Skip to content
OpenScribe brand logo Docs

OpenScribe Backend API & Schema Contract

Module path: github.com/fcakyon/openscribe/server

Default local listen address: http://127.0.0.1:8090 (PocketBase serve) Production URL: https://scribe.akyonlabs.com


1. Cloud & OAuth Setup Requirements (for Cloud / Deployment Agent)

  1. Google OAuth Redirect URIs:
    • Web / App Login (PocketBase built-in OAuth2):
      • https://scribe.akyonlabs.com/api/oauth2-redirect
      • http://127.0.0.1:8090/api/oauth2-redirect
      • http://localhost:8090/api/oauth2-redirect
      • http://localhost:5173/api/oauth2-redirect (if proxied in Vite dev)
    • Google Calendar Read-Only Connection (Multiple accounts per user):
      • https://scribe.akyonlabs.com/api/openscribe/calendar/callback
      • http://127.0.0.1:8090/api/openscribe/calendar/callback
      • http://localhost:8090/api/openscribe/calendar/callback
  2. Google Scopes Required:
    • Sign-in: openid, email, profile
    • Calendar connection: openid, email, profile, https://www.googleapis.com/auth/calendar.readonly
  3. Environment Variables (.env.required):
    • OPENSCRIBE_ADMIN_EMAIL (Comma-separated allowlist of verified Google login emails. Only these users can create an account or sign in. An empty list denies application access.)
    • PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (PocketBase _superusers initial bootstrap when _superusers count is 0. Required before browser installer can run)
    • GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
    • PUBLIC_URL (e.g. https://scribe.akyonlabs.com or http://127.0.0.1:8090)
    • OPENSCRIBE_TOKEN_ENCRYPTION_KEY (Optional 32-byte hex or base64 key for AES-256-GCM encryption of calendar_accounts OAuth refresh tokens. Falls back to a deterministic SHA-256 derivation from GOOGLE_CLIENT_SECRET + app secret in local dev if unset)
    • ELEVENLABS_API_KEY
    • OPENAI_API_KEY
    • ANTHROPIC_API_KEY
    • GOOGLE_API_KEY
    • TYPESAFE_API_KEY (Optional server-held key for bounded jev-1.14.0 System One candidate scoring on GET /api/openscribe/search/suggestions)
    • SMTP_URL, MAIL_FROM (Optional SMTP configuration)
  4. Static Frontend Serving:
    • In production, the Go binary serves ../web/dist (or ./pb_public if present) on port 8090 with SPA fallback.

2. Authentication & Session Contract (Web & Native macOS)

2.1 Web Authentication (PocketBase Built-in)

  • Standard PocketBase users auth collection endpoints:
    • GET /api/collections/users/auth-methods
    • POST /api/collections/users/auth-with-oauth2
    • POST /api/collections/users/auth-refresh
  • Calling GET /api/openscribe/auth/me with Authorization: Bearer <pb_token> returns the current user, their admin flag, and their connected calendar_accounts.
  • Google OAuth2 is the only application login method. The server rejects non-allowlisted accounts before creating user or external-auth records. Password login, OTP and public signup are disabled.
  • users.admin is server-managed. Only verified Google accounts listed in OPENSCRIBE_ADMIN_EMAIL can authenticate. Existing user records remain stored when access is removed, while their application, native pairing, protected file and MCP access is denied.
  • Secondary Google Calendar accounts do not need to appear in the login allowlist. PB_ADMIN_EMAIL remains a separate PocketBase superuser account for server administration.

2.2 Native macOS Device Pairing / Handoff (POST /api/openscribe/auth/device)

Allows the native macOS app to obtain a PocketBase user auth token stored in macOS Keychain without embedding OAuth secrets:

  1. POST /api/openscribe/auth/device/start (Guest)
    • Request: { "device_label": "Fatih's MacBook Pro" }
    • Response 200:
      json
      {
        "id": "<request_id>",
        "code": "ABCD-1234",
        "user_code": "ABCD-1234",
        "secret": "<high_entropy_secret>",
        "device_code": "<high_entropy_secret>",
        "verify_url": "<request_base_url>/pair?id=<request_id>&code=ABCD-1234",
        "verification_uri": "<request_base_url>/pair",
        "verification_uri_complete": "<request_base_url>/pair?id=<request_id>&code=ABCD-1234",
        "expires": "2026-11-09T14:30:00Z",
        "expires_in": 900,
        "interval": 2
      }
    • Note: verify_url automatically uses the incoming request host (http://127.0.0.1:8090 or http://localhost:8090) when invoked over local loopback or SSH port forwarding, even if PUBLIC_URL points to a production domain.
  2. POST /api/openscribe/auth/device/approve (Authenticated User)
    • Request: { "id": "<request_id>", "code": "ABCD-1234" } (also accepts "user_code" and optional "id", falling back to pending code lookup when id is omitted)
    • Response 204
  3. POST /api/openscribe/auth/device/poll (Guest / Native App)
    • Request: { "id": "<request_id>", "secret": "<high_entropy_secret>" } (also accepts "device_code" and optional "id")
    • Response 200 (while pending): { "status": "pending" }
    • Response 200 (once approved, single-use): { "status": "approved", "token": "<pb_auth_jwt>", "user": { "id": "...", "email": "...", "name": "...", "admin": true } }

3. PocketBase Collections & Ownership Rules

All user collections enforce @request.auth.id != '' && owner = @request.auth.id for ListRule and ViewRule (read/realtime access), while direct PocketBase collection mutations (CreateRule, UpdateRule, DeleteRule) are disabled (nil) on app entities so all writes go through the validated /api/openscribe/* endpoints.

  1. users (Auth collection)
    • name (text), avatar (file), admin (bool, server-managed)
  2. calendar_accounts (Base collection)
    • owner (relation -> users, required)
    • google_sub (text, required): stable Google subject ID
    • email (email, required): Google account email
    • display_name (text)
    • calendar_id (text, required, default "primary")
    • scopes (json): granted OAuth scopes
    • encrypted_token (text, hidden): AES-256-GCM encrypted OAuth2 token JSON (never returned to clients)
    • sync_status (select: "connected", "error", "revoked")
    • last_synced_at (date)
    • Unique index on (owner, email, calendar_id)
  3. meeting_series (Base collection)
    • owner (relation -> users, required)
    • calendar_account (relation -> calendar_accounts, required)
    • calendar_id (text, required): e.g. "primary" or specific calendar email
    • recurring_event_id (text, required): Google Calendar recurringEventId (never grouped by title alone)
    • title (text, required)
    • summary (text): inferred series summary
    • manual_summary (text): authoritative user override
    • projects (json): inferred project names
    • manual_projects (json): authoritative user override
    • deadlines (json): array of { "title": "...", "due_at": "...", "evidence": "...", "manual": false }
    • manual_deadlines (json): authoritative user override array
    • Unique index on (owner, calendar_account, calendar_id, recurring_event_id)
  4. tags (Base collection)
    • owner (relation -> users, required)
    • name (text, required)
    • description (text)
    • color (text)
    • Unique index on (owner, name)
  5. people_profiles (Base collection)
    • owner (relation -> users, required)
    • email (email): optional/nullable. Never fabricated, and people without a known email stay distinct.
    • platform_ids (json): array of { "platform": "google_meet", "participant_id": "...", "meeting_id": "...", "stable_id": "..." } (temporary per-meeting participant_device_id values are scoped by meeting_id and never merged across meetings; cross-meeting matching only occurs on actually supplied email or separately proven stable_id)
    • display_name (text): inferred/observed name
    • manual_name (text): authoritative user override
    • inferred_metadata (json): { "country": "", "company": "", "job_title": "", "responsibilities": "", "linkedin_url": "", "scholar_url": "", "links": [], "sources": [] }
    • manual_metadata (json): same structure, authoritative override when non-empty
    • merged_into (relation -> people_profiles, optional): supports explicit profile merge/deduplication
  6. meetings (Base collection)
    • owner (relation -> users, required)
    • calendar_account (relation -> calendar_accounts, optional/nullable if ad-hoc local recording, preserved when selected)
    • series (relation -> meeting_series, optional)
    • calendar_event_id (text): Google Calendar instance id
    • recurring_event_id (text): Google Calendar recurringEventId
    • title (text, required)
    • platform (select: "google_meet", "zoom_native", "teams_native", "teams_browser", "slack_huddle", "local_mic", "other")
    • state (select: "preview", "waiting", "joined", "recording", "ended", "failed")
    • capture_capability (select: "per_participant_pcm", "single_stream_verified", "mixed_audio", "metadata_only", "unverified", default "unverified")
    • meeting_url (text)
    • language_mode (select: "auto", "manual")
    • language_code (text): e.g. "en", "tr", or "" for auto (automatically cleared whenever language_mode is "auto")
    • detected_languages (json): array of { "language_code": "en", "detected_at_ms": 12000, "source": "elevenlabs" }
    • started_at (date)
    • ended_at (date)
    • tags (relation -> tags, multi)
    • suggested_tags (json): array of { "tag_id": "...", "name": "...", "reason": "..." }
    • summary (text)
    • manual_summary (text): authoritative override
    • action_items (json): array of { "id": "...", "text": "...", "assignee_email": "...", "is_owner": true, "evidence_segment_ids": ["..."], "evidence_quote": "...", "completed": false }
    • followup_email_draft (json): { "subject": "...", "body": "...", "generated_at": "..." } (copyable draft only, never auto-sent)
    • projects (json)
    • deadlines (json)
    • local_recording_meta (json): { "local_bundle_path": "...", "sample_rate": 48000, "channels": 1, "recoverable": false, "time_origin_epoch_ms": 1794233220000 } (recoverable defaults to false until a verified local bundle is attached)
    • verification_mode (select: "real", "controlled_test", "fixture"): explicitly distinguishes real recordings/provider calls from synthetic/fixture tests
  7. meeting_participants (Base collection)
    • owner (relation -> users, required)
    • meeting (relation -> meetings, required, cascade delete)
    • person (relation -> people_profiles, optional)
    • platform (text, required): e.g. "google_meet"
    • participant_device_id (text, required): platform participant/device ID (kept separate from transport stream/SSRC IDs and display names)
    • transport_source_ids (json): array of observed CSRC/stream IDs mapped to this participant over time
    • display_name (text): observed display name (may arrive after initial audio chunks)
    • email (email): only set when actually verified from calendar/platform identity, otherwise empty (null in API)
    • attribution_mode (select: "one_mapped_source", "mixed_sources", "unmapped", "local_mic")
    • is_self (bool)
    • Unique index on (meeting, platform, participant_device_id)
  8. transcript_segments (Base collection)
    • owner (relation -> users, required)
    • meeting (relation -> meetings, required, cascade delete)
    • participant (relation -> meeting_participants, optional/nullable for unmapped or mixed streams)
    • participant_device_id (text): raw platform participant device ID ("" if unmapped/mixed)
    • speaker_name_snapshot (text): display name at time of utterance (never used as primary key)
    • attribution (select: "one_mapped_source", "mixed_sources", "unmapped", "local_mic")
    • source_ids (json): array of contributing source IDs (e.g. ["554"])
    • unmapped_contributors (json): array of unmapped CSRC values (e.g. ["42"])
    • stream_generation (number, int): increments on rejoin/renegotiation
    • status (select: "partial", "committed")
    • text (text, required)
    • language_code (text)
    • start_ms (number, int): meeting-timeline start offset in milliseconds (mapped via receiver/wall clock accounting for DTX/gaps, NOT raw compact decoder sample offset)
    • end_ms (number, int): meeting-timeline end offset in milliseconds
    • receive_epoch_ms (number): observer UTC epoch ms when audio chunk was received
    • decoded_sample_offset (number, int): compact Opus decoder sample position for lossless replay alignment
    • rtp_timestamp (number, int): original RTP timestamp
    • words (json): array of word items mapped to meeting timeline:
      json
      [
        {
          "text": "Hello",
          "start_ms": 1200,
          "end_ms": 1520,
          "provider_start_s": 0.0,
          "provider_end_s": 0.32,
          "type": "word",
          "speaker_id": "device-554"
        }
      ]
    • provider (text): e.g. "elevenlabs", "local_whisper"
    • external_segment_id (text): client/session idempotency key for updating partial -> committed
  9. meeting_notes (Base collection)
    • owner (relation -> users, required)
    • meeting (relation -> meetings, optional, cascade delete)
    • series (relation -> meeting_series, optional, cascade delete)
    • kind (select: "note", "link")
    • content (text, required): markdown note or link title/description
    • url (text): populated when kind == "link"
    • link_type (select: "webpage", "github", "slack", "google_drive", "other")
    • provenance (select: "manual", "calendar_description", "meeting_chat", "screenshot_ocr")
    • ingested_status (select: "url_only", "authorized_content"): never claims private Drive/Slack/GitHub content was ingested from URL alone
    • timestamp_ms (number, int): optional meeting offset when note/link was captured
  10. meeting_attachments (Base collection)
    • owner (relation -> users, required)
    • meeting (relation -> meetings, required, cascade delete)
    • file (file, protected, max 50MB, arbitrary file types allowed including images, documents, archives, and audio bundles)
    • kind (select: "screenshot", "file", "audio_chunk", "replay_bundle")
    • captured_at_ms (number, int): meeting timeline offset in ms
    • window_title (text)
    • source_app (text): e.g. "Google Chrome", "zoom.us", "Finder"
    • extracted_text (text)
    • extracted_links (json)
  11. ai_settings (Base collection, Admin write only)
    • workload (select, unique: "tagging", "profile_enrichment", "qa", "summary", "action_items")
    • provider (select: "openai", "anthropic", "google", "local")
    • model_id (text, required)
    • updated_by (relation -> users)

4. Custom /api/openscribe Endpoints

All endpoints under /api/openscribe/* (except /api/openscribe/config, /api/openscribe/calendar/callback, and device pairing guest routes) require Authorization: Bearer <pb_auth_token>.

4.1 Config & Auth

  • GET /api/openscribe/config (Public)
    • Returns { "google_oauth_enabled": true, "elevenlabs_configured": true, "ai_providers": { "openai": true, "anthropic": true, "google": true }, "public_url": "..." }
  • GET /api/openscribe/auth/me (Auth)
    • Returns { "user": { "id": "...", "email": "...", "name": "...", "admin": true }, "calendar_accounts": [...] }
  • POST /api/openscribe/auth/device/start (Public)
  • POST /api/openscribe/auth/device/approve (Auth)
  • POST /api/openscribe/auth/device/poll (Public)

4.2 Google Calendar Accounts & Events

  • GET /api/openscribe/calendar/accounts (Auth)
    • Lists the user's connected calendar_accounts (never includes encrypted_token).
  • POST /api/openscribe/calendar/connect (Auth)
    • Request: { "return_to": "/settings" }
    • Returns { "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?..." } with signed state binding the current user.Id and PKCE verifier, requesting https://www.googleapis.com/auth/calendar.readonly with access_type=offline and prompt=consent.
  • GET /api/openscribe/calendar/callback (Public callback with signed state)
    • Exchanges code, verifies user identity via Google userinfo/ID token, encrypts refresh/access token with AES-256-GCM, upserts calendar_accounts, and redirects to return_to (or returns JSON if Accept: application/json).
  • DELETE /api/openscribe/calendar/accounts/{id} (Auth, Owner)
    • Removes the connected calendar account.
  • GET /api/openscribe/calendar/events (Auth)
    • Query params:
      • account_ids: comma-separated calendar_accounts IDs (optional, defaults to all connected accounts of the user)
      • time_min: RFC3339 (defaults to 7 days ago)
      • time_max: RFC3339 (defaults to 14 days ahead)
      • sync: "true" to fetch live from Google Calendar API v3 (singleEvents=true) and automatically upsert meeting_series (for events with recurringEventId) and extract description URLs into series/occurrence meeting_notes (provenance: "calendar_description", ingested_status: "url_only").
    • Returns { "events": [...], "accounts": [...] }. Each event includes both "start"/"start_at", "end"/"end_at", and "calendar_email"/"account_email".

4.3 Meetings, Search Suggestions & Series

  • GET /api/openscribe/search/suggestions?q=<text> (Auth, Owner)
    • Resolves owner-scoped filter suggestions across canonical People (kind = "person"), Projects (kind = "project"), Tags (kind = "tag"), and Connected Accounts (kind = "account").
    • Exact ID, prefix, and lexical matches resolve immediately from local owner-scoped data (source = "match").
    • When TYPESAFE_API_KEY is configured and ambiguous unmatched candidates remain, evaluates only the typed query and bounded owner-scoped candidate labels (max 30, never meeting transcripts) via POST https://api.typesafe.ai/v1/systemone (model: "jev-1.14.0", structured noul questions, short deadline). Unknown or out-of-range classifier answers are ignored, and provider errors or cancellations never block search or leak secrets.
    • Response 200:
      json
      {
        "suggestions": [
          {
            "kind": "person",
            "id": "ent_person_1",
            "label": "Dr. Ada Lovelace",
            "subtitle": "ada@example.org · Leads compiler architecture for Project Analytical.",
            "confidence": 0.96,
            "source": "match"
          },
          {
            "kind": "project",
            "id": "ent_proj_1",
            "label": "Project Analytical",
            "subtitle": "Realtime audio capture and symbol graph",
            "confidence": 0.91,
            "source": "jev"
          }
        ],
        "query": "compiler"
      }
  • GET /api/openscribe/meetings (Auth, Owner)
    • Query params (OR within each category, AND across categories; cross-owner IDs are validated and never match):
      • person_ids: comma-separated canonical person entity IDs (or linked people_profiles IDs). Matches meetings via linked meeting_participants (including merged profiles), assigned knowledge_tasks, or knowledge_detail_evidence.
      • project_ids: comma-separated canonical project entity IDs. Matches meetings via knowledge_detail_evidence, project knowledge_tasks, exact series linkage, or project slug match.
      • tag_ids: comma-separated tags IDs. Matches both meeting-wide meetings.tags and timestamped knowledge_tag_mentions.
      • account_ids: comma-separated calendar_account IDs (multiselect filter)
      • emails: comma-separated participant/account emails (multiselect filter)
      • series_id: filter occurrences belonging to a meeting_series
      • q: text search across title, summary, transcript
      • verification_mode: optional filter ("real", "controlled_test", "fixture")
    • Returns { "items": [ ... ], "series": [ ... ] }, sorted by -started_at, -created. Relation fields emit both unsuffixed and _id suffixed keys (calendar_account & calendar_account_id, series & series_id, meeting & meeting_id, participant & participant_id).
  • POST /api/openscribe/meetings (Auth)
    • Creates a new meeting (and links/creates meeting_series if calendar_account_id + recurring_event_id are provided).
    • Request:
      json
      {
        "title": "Team Sync",
        "calendar_account_id": "",
        "calendar_id": "primary",
        "calendar_event_id": "",
        "recurring_event_id": "",
        "platform": "google_meet",
        "state": "recording",
        "capture_capability": "per_participant_pcm",
        "meeting_url": "https://meet.google.com/abc-defg-hij",
        "language_mode": "auto",
        "language_code": "",
        "verification_mode": "real",
        "local_recording_meta": {
          "sample_rate": 48000,
          "channels": 1,
          "recoverable": true,
          "time_origin_epoch_ms": 1794233220000
        }
      }
    • Response 201: full meeting object.
  • GET /api/openscribe/meetings/{id} (Auth, Owner)
    • Returns full meeting detail including:
      • meeting: meeting record
      • participants: meeting_participants list with linked people_profiles
      • segments: ordered transcript_segments with word-level timestamps
      • notes: meeting_notes (both occurrence-specific and inherited series notes/links)
      • attachments: meeting_attachments with short-lived ?token= protected file_url and owner-scoped download_url
      • series: meeting_series record (if part of a recurring series)
      • occurrences: prior and sibling meetings in the same meeting_series (ordered by started_at DESC) with their summaries and action items so the UI can switch occurrences cleanly.
  • PATCH /api/openscribe/meetings/{id} (Auth, Owner)
    • Updates state (preview, waiting, joined, recording, ended), language_mode, language_code, title, manual_summary, action_items, tags, calendar_account_id, ended_at, local_recording_meta.

4.4 Realtime Participant & Transcript Ingestion

  • POST /api/openscribe/meetings/{id}/transcript (Auth, Owner)
    • Upserts participants and ingests one or more partial or committed transcript segments with word-level timestamps.
    • Preserves one_mapped_source, mixed_sources, unmapped, and local_mic attributions. Never fabricates emails or merges participants by display name alone.
    • Maps provider word timestamps (provider_start_s, provider_end_s) onto the meeting timeline (start_ms, end_ms) using the chunk's chunk_start_ms / receive_epoch_ms rather than assuming a compact decoder clock is wall time.
    • Request:
      json
      {
        "participants": [
          {
            "platform": "google_meet",
            "participant_device_id": "device-554",
            "display_name": "Host Speaker",
            "email": null,
            "transport_source_ids": ["554"],
            "attribution_mode": "one_mapped_source",
            "is_self": false
          }
        ],
        "segments": [
          {
            "external_segment_id": "seg-device-554-001",
            "participant_device_id": "device-554",
            "speaker_name_snapshot": "Host Speaker",
            "attribution": "one_mapped_source",
            "source_ids": ["554"],
            "unmapped_contributors": [],
            "stream_generation": 1,
            "status": "committed",
            "text": "Hello everyone.",
            "language_code": "en",
            "start_ms": 1200,
            "end_ms": 2100,
            "receive_epoch_ms": 1794233221200,
            "decoded_sample_offset": 57600,
            "rtp_timestamp": 160000,
            "provider": "elevenlabs",
            "words": [
              {
                "text": "Hello",
                "start_ms": 1200,
                "end_ms": 1600,
                "provider_start_s": 0.0,
                "provider_end_s": 0.4,
                "type": "word",
                "speaker_id": "device-554"
              },
              {
                "text": "everyone.",
                "start_ms": 1620,
                "end_ms": 2100,
                "provider_start_s": 0.42,
                "provider_end_s": 0.9,
                "type": "word",
                "speaker_id": "device-554"
              }
            ]
          }
        ]
      }
    • Response 200: { "participants": [...], "segments": [...] }

4.5 Notes, Related Links & Attachments (Live & Post-Meeting)

  • Meetings are never globally locked after state == "ended":
    • The native macOS companion accepts notes, dropped screenshots/files, and links while an active meeting is running (and finishes queued uploads for their original meeting_id).
    • The web meeting detail page continues to accept notes, links, screenshots, and arbitrary files after the meeting ends.
  • POST /api/openscribe/meetings/{id}/notes (Auth, Owner)
    • Creates a note or link on the meeting (or on its series if "scope": "series" is passed).
    • Saved links default to ingested_status: "url_only" unless authorized content was explicitly fetched.
    • Request:
      json
      {
        "scope": "meeting",
        "kind": "link",
        "content": "Sprint 6 Tracker",
        "url": "https://github.com/fcakyon/openscribe/issues/1",
        "provenance": "manual",
        "timestamp_ms": 45000
      }
  • DELETE /api/openscribe/notes/{noteId} (Auth, Owner)
  • POST /api/openscribe/meetings/{id}/attachments (Auth, Owner)
    • multipart/form-data with file, kind ("screenshot", "file", "audio_chunk", "replay_bundle" — auto-detects "screenshot" for .png/.jpg/.jpeg/.webp/.gif and "file" for other extensions when omitted), captured_at_ms, window_title (defaults to the original uploaded filename for kind == "file"), source_app, extracted_text, extracted_links.
    • Returns serialized attachment with file_url (signed with a short-lived PocketBase file token ?token=...) and download_url (/api/openscribe/meetings/{id}/attachments/{attachmentId}/download).
  • GET /api/openscribe/meetings/{id}/attachments/{attachmentId}/download (Auth, Owner)
    • Streams raw attachment bytes with non-executing headers (Content-Disposition: attachment; filename="...", X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cache-Control: private, no-store).
  • GET /api/openscribe/meetings/{id}/attachments/{attachmentId}/image (Auth, Owner)
    • Serves raw image bytes by default for <img> rendering, or returns the JSON base64 envelope when ?format=json or Accept: application/json (without image/) is passed.
  • DELETE /api/openscribe/attachments/{attachmentId} (Auth, Owner)

4.6 Provider-Neutral Realtime Transcription Session & Providers

  • GET /api/openscribe/transcription/providers (Auth)
    • Returns the administrator's active transcription configuration, active_capabilities, verified catalog (elevenlabs, openai_realtime, gemini_realtime, openai_compatible), providers_configured, and can_edit.
  • PUT /api/openscribe/admin/transcription (Auth, Admin only)
    • Updates the authoritative transcription provider, model_id, and optional base_url.
  • POST /api/openscribe/transcription/session (Auth, Owner when meeting_id is supplied)
    • Provider-neutral transcription session endpoint that strictly uses the administrator's ActiveConfig (any caller-supplied provider, model_id, or base_url override returns 403 Forbidden).
    • For "elevenlabs", mints a single-use realtime_scribe token (POST https://api.elevenlabs.io/v1/single-use-token/realtime_scribe).
    • For "openai_realtime", returns token: "" and websocket_url: "/api/openscribe/transcription/openai-realtime?..." so the native client connects to OpenScribe's server-owned WebSocket relay using its app Authorization: Bearer <pb_auth_token> header without exposing OpenAI credentials or allowing client session.update model switches.
    • For "gemini_realtime", mints a single-use constrained token (uses: 1) via POST /v1beta/auth_tokens with bidiGenerateContentSetup locked to the administrator-selected model (fieldMask omitted).
    • For "openai_compatible", returns token: "" and http_transcription_url: "/api/openscribe/transcription/audio".
    • Request:
      json
      {
        "meeting_id": "<optional_meeting_id>",
        "participant_device_id": "device-554",
        "audio_format": "pcm_48000",
        "language_mode": "auto",
        "language_code": "",
        "commit_strategy": "vad"
      }
  • GET /api/openscribe/transcription/openai-realtime (Auth, Owner when meeting_id is supplied, WebSocket upgrade)
    • Server-owned OpenAI Realtime transcription WebSocket relay (wss://api.openai.com/v1/realtime?intent=transcription). Requires an authenticated app user and verifies meeting_id ownership before dialing upstream. Enforces the administrator-selected OpenAI transcription model (gpt-4o-mini-transcribe, gpt-4o-transcribe, or gpt-live-transcribe) on initial session setup and all subsequent client session.update messages while preserving authorized language changes, 24 kHz PCM audio frames (input_audio_buffer.append), and commits (input_audio_buffer.commit).
  • POST /api/openscribe/transcription/audio (Auth, Owner when meeting_id is supplied)
    • Server-brokered multipart audio proxy for openai_compatible transcription endpoints. Enforces the administrator-selected model_id and base_url.

4.7 Tags, People Profiles & Series Metadata

  • GET /api/openscribe/tags / POST /api/openscribe/tags / PATCH /api/openscribe/tags/{id} / DELETE /api/openscribe/tags/{id} (Auth, Owner)
    • PATCH /api/openscribe/tags/{id} accepts { "name"?: "...", "description"?: "...", "color"?: "..." }, enforces owner scope and non-empty name, and returns the updated serialized tag ({ "id", "name", "description", "color", "created", "updated" }).
  • GET /api/openscribe/people (Auth, Owner)
  • PATCH /api/openscribe/people/{id} (Auth, Owner)
    • Updates manual_name, manual_metadata (countries, companies, job titles, project responsibilities, LinkedIn/Scholar URLs, custom links). Never accesses or stores salary/income/tax/contract fields.
  • POST /api/openscribe/people/{id}/merge (Auth, Owner)
    • Merges duplicate profile { "target_person_id": "..." } and updates meeting_participants references.
  • PATCH /api/openscribe/series/{id} (Auth, Owner)
    • Updates manual_summary, manual_projects, manual_deadlines.

4.8 Admin AI Settings & Meeting Intelligence / Handoff Hooks

  • GET /api/openscribe/admin/models (Auth, any signed-in user can view active models, only admin == true can mutate)
  • PUT /api/openscribe/admin/models (Auth, Admin only - enforced server-side via r.Auth.GetBool("admin"))
    • Request:
      json
      {
        "settings": [
          { "workload": "tagging", "provider": "openai", "model_id": "gpt-6-luna" },
          { "workload": "profile_enrichment", "provider": "openai", "model_id": "gpt-6-luna" },
          { "workload": "qa", "provider": "anthropic", "model_id": "claude-sonnet-5-5" },
          { "workload": "summary", "provider": "anthropic", "model_id": "claude-sonnet-5-5" },
          { "workload": "action_items", "provider": "google", "model_id": "gemini-3.8-flash" }
        ]
      }
  • POST /api/openscribe/meetings/{id}/ask (Auth, Owner)
    • Delegates to server/intelligence when registered or uses the built-in server-side provider adapter (OpenAI / Anthropic / Google) with owner-scoped meeting transcript, notes, screenshot extracted text, and series history.
  • POST /api/openscribe/meetings/{id}/summarize (Auth, Owner)
    • Generates/updates summary, user-focused action_items (with evidence_segment_ids and evidence_quote), suggested_tags, and copyable followup_email_draft.
  • POST /api/openscribe/meetings/{id}/handoff (Auth, Owner)
    • Request: { "target": "claude" | "chatgpt" | "codex" | "t3" }
    • Returns verified handoff descriptor with concise copyable context bundle, MCP resource URI, and target launch URL without putting full private transcripts in URL query parameters.

4.9 Living Knowledge (People, Projects, Structured Details, Tasks, Evidence & Suggestions)

The Living Knowledge API extends people_profiles, meeting_series, tags, and meetings.action_items with bounded, owner-scoped living pages for People (kind = "person") and Projects (kind = "project"), structured editable detail blocks with source history, unified tasks/todos, timestamped tag/source evidence, and reviewable extraction suggestions.

All endpoints require Authorization: Bearer <pb_auth_token> and enforce owner = r.Auth.Id on every entity, detail, relation (related_person_id, related_project_id, related_tag_id), meeting, segment, note, attachment, and task reference. Cross-owner IDs are rejected (400/404). Direct PocketBase collection mutations remain disabled (CreateRule = UpdateRule = DeleteRule = nil). Income, salary, pay, tax, bank statement, and paid employment contract data are strictly forbidden (403 Forbidden).

4.9.1 Entities (person | project)

  • GET /api/openscribe/knowledge/entities (Auth, Owner)

    • Query params:
      • kind: optional "person" or "project"
      • q: optional case-insensitive search across name, brief, email, and active details
      • status: optional "active" (default), "provisional", "archived", "all"
      • project_id: optional filter returning people/details linked to a project
      • tag_id: optional filter returning entities linked to a tag
    • Response 200:
      json
      {
        "entities": [
          {
            "id": "ent_person_1",
            "owner": "usr_1",
            "kind": "person",
            "canonical_name": "Dr. Ada Lovelace",
            "manual_name": "",
            "effective_name": "Dr. Ada Lovelace",
            "email": "ada@example.org",
            "person_profile_id": "prof_1",
            "status": "active",
            "current_brief": "Leads compiler architecture for Project Analytical.",
            "manual_brief": "",
            "effective_brief": "Leads compiler architecture for Project Analytical.",
            "pinned": false,
            "last_observed_at": "2026-11-09T18:00:00Z",
            "active_details_count": 5,
            "pending_suggestions_count": 1,
            "open_tasks_count": 2,
            "project_roles": [
              {
                "detail_id": "det_1",
                "kind": "role",
                "value_text": "Lead Architect",
                "related_project_id": "ent_proj_1",
                "related_project_name": "Project Analytical",
                "is_manual": true
              }
            ],
            "created": "2026-11-09T18:00:00Z",
            "updated": "2026-11-09T18:00:00Z"
          }
        ],
        "people": [ ... ],
        "projects": [ ... ]
      }
  • POST /api/openscribe/knowledge/entities (Auth, Owner)

    • Creates an owner-scoped person or project entity. When kind == "person", links to an existing owned person_profile_id or creates/links a matching people_profiles row (never merging by display name alone).
    • Request:
      json
      {
        "kind": "project",
        "name": "OpenScribe Web Workspace",
        "manual_name": "OpenScribe Web Workspace",
        "email": "",
        "person_profile_id": "",
        "manual_brief": "Bot-free meeting recorder and living knowledge workspace.",
        "pinned": true,
        "tag_ids": ["tag_1"],
        "series_id": ""
      }
    • Response 201: full entity detail payload (same shape as GET /api/openscribe/knowledge/entities/{id}).
  • GET /api/openscribe/knowledge/entities/{id} (Auth, Owner)

    • Accepts either a knowledge_entities.id OR an existing people_profiles.id (automatically resolving the migrated 1:1 person entity for backward compatibility).
    • Response 200:
      json
      {
        "entity": {
          "id": "ent_person_1",
          "owner": "usr_1",
          "kind": "person",
          "canonical_name": "Dr. Ada Lovelace",
          "manual_name": "",
          "effective_name": "Dr. Ada Lovelace",
          "email": "ada@example.org",
          "person_profile_id": "prof_1",
          "status": "active",
          "current_brief": "Leads compiler architecture for Project Analytical.",
          "manual_brief": "",
          "effective_brief": "Leads compiler architecture for Project Analytical.",
          "pinned": false,
          "last_observed_at": "2026-11-09T18:00:00Z",
          "created": "2026-11-09T18:00:00Z",
          "updated": "2026-11-09T18:00:00Z"
        },
        "details": [
          {
            "id": "det_1",
            "entity_id": "ent_person_1",
            "kind": "role",
            "label": "Lead Architect",
            "value_text": "Lead Architect",
            "url": "",
            "ingested_status": "",
            "valid_from": "2026-11-09T15:00:00Z",
            "valid_to": "",
            "due_at": "",
            "deadline_state": "",
            "related_person_id": "",
            "related_person_name": "",
            "related_project_id": "ent_proj_1",
            "related_project_name": "Project Analytical",
            "related_tag_id": "",
            "related_tag_name": "",
            "status": "active",
            "is_manual": true,
            "cardinality": "single_valued",
            "sort_order": 1,
            "first_observed_at": "2026-11-09T15:00:00Z",
            "last_observed_at": "2026-11-09T15:00:00Z",
            "superseded_at": "",
            "superseded_by": "",
            "evidence": [
              {
                "id": "ev_1",
                "detail_id": "det_1",
                "source_type": "meeting_segment",
                "source_meeting_id": "mtg_1",
                "source_meeting_title": "Architecture Sync",
                "source_note_id": "",
                "source_attachment_id": "",
                "observed_at": "2026-11-09T15:02:00Z",
                "evidence_segment_ids": ["seg_1"],
                "evidence_start_ms": 12000,
                "evidence_end_ms": 18500,
                "evidence_speaker": "Dr. Ada Lovelace",
                "evidence_quote": "I will lead the compiler architecture for Project Analytical.",
                "source_url": ""
              }
            ]
          }
        ],
        "history": [ ... ],
        "relations": [
          {
            "detail_id": "det_1",
            "direction": "outgoing",
            "kind": "role",
            "label": "Lead Architect",
            "source_entity_id": "ent_person_1",
            "source_entity_kind": "person",
            "source_entity_name": "Dr. Ada Lovelace",
            "target_entity_id": "ent_proj_1",
            "target_entity_kind": "project",
            "target_entity_name": "Project Analytical",
            "related_tag_id": "",
            "related_tag_name": "",
            "status": "active",
            "is_manual": true,
            "valid_from": "2026-11-09T15:00:00Z",
            "valid_to": "",
            "evidence": [ ... ]
          }
        ],
        "members": [ ... ],
        "projects": [ ... ],
        "tags": [ ... ],
        "tasks": [ ... ],
        "meetings": [ ... ],
        "suggestions": [ ... ],
        "evidence": [ ... ]
      }
  • PATCH /api/openscribe/knowledge/entities/{id} (Auth, Owner)

    • Updates manual_name, canonical_name, email (for person), manual_brief, status ("active", "archived"), pinned. Syncs manual_name and email with the linked people_profiles row when kind == "person".
    • Response 200: updated full entity detail payload.

Allowed kind values on knowledge_details:

  • "note": short rich-text note/brief block (value_text max 4000 chars, optional label, optional related_project_id/related_person_id/related_tag_id).
  • "role": role/title (value_text max 300 chars, e.g. "Lead", "Advisor", "Product Designer"). On a person, optionally binds to related_project_id so the same person can hold distinct roles across different projects concurrently (e.g. Lead on Project A, Advisor on Project B), or label for organization/company context.
  • "responsibility": scoped responsibility (value_text max 1000 chars, optional related_project_id, related_tag_id).
  • "location": country/city/timezone (value_text max 300 chars).
  • "skill": domain skill or area of expertise (value_text max 200 chars, optional related_tag_id or related_project_id).
  • "link": arbitrary labeled website URL (label max 300 chars, url max 2000 chars, any valid http:// or https:// website URL allowed without predefined LinkedIn/Scholar-only slot restrictions; ingested_status is always "url_only" unless fetched by an authorized connector; never fetches external URLs from URL alone).
  • "deadline": project or person deadline (value_text max 500 chars, due_at ISO-8601/RFC3339 date, deadline_state "upcoming" | "completed" | "rescheduled" | "canceled" | "past_due_unconfirmed", optional related_project_id).
  • "relation": explicit typed connection to an existing owned related_person_id, related_project_id, or related_tag_id (label / value_text max 300 chars).

Endpoints:

  • POST /api/openscribe/knowledge/entities/{id}/details (Auth, Owner)

    • Creates a manual structured detail block (is_manual: true, status: "active"). If replacing a single-valued active detail for the same (entity, kind, related_project_id, slot), marks the prior active detail "superseded" and preserves it in history.
    • Request:
      json
      {
        "kind": "role",
        "label": "Advisor",
        "value_text": "Technical Advisor",
        "url": "",
        "valid_from": "2026-11-09",
        "valid_to": "",
        "due_at": "",
        "related_person_id": "",
        "related_project_id": "ent_proj_2",
        "related_tag_id": "",
        "source_meeting_id": "mtg_1",
        "evidence_segment_ids": ["seg_2"],
        "evidence_start_ms": 25000,
        "evidence_end_ms": 31000,
        "evidence_quote": "Happy to advise on Project B."
      }
    • Response 201: { "detail": { ... }, "entity": { ... } }
  • PATCH /api/openscribe/knowledge/details/{detailId} (Auth, Owner)

    • Updates a detail block and locks it as authoritative (is_manual: true). Supports updating label, value_text, url, valid_from, valid_to, due_at, deadline_state, related_person_id, related_project_id, related_tag_id, status ("active", "superseded", "archived", "retracted"), sort_order.
    • Response 200: { "detail": { ... } }
  • DELETE /api/openscribe/knowledge/details/{detailId} (Auth, Owner)

    • Deletes or retracts the detail block (marking extracted details "retracted" so re-extraction does not recreate a user-deleted block, or deleting manual blocks and rebuilding the entity projection).
    • Response 204

4.9.3 Relations, Evidence & Timestamped Tag Mentions

  • GET /api/openscribe/knowledge/relations (Auth, Owner)

    • Query params: entity_id, project_id, person_id, tag_id, status (default "active")
    • Returns { "relations": [ ... ] } linking owned people, projects, and tags with evidence.
  • GET /api/openscribe/knowledge/evidence (Auth, Owner)

    • Query params: entity_id, detail_id, meeting_id, tag_id
    • Returns { "evidence": [ ... ], "tag_mentions": [ ... ] } with source_meeting_id, evidence_segment_ids, evidence_start_ms, evidence_end_ms, evidence_speaker, and evidence_quote.
  • POST /api/openscribe/meetings/{id}/tag-mentions (Auth, Owner)

    • Attaches an owned tags record to a bounded meeting timestamp range (start_ms, end_ms, optional segment_ids, quote, entity_id).
    • Request:
      json
      {
        "tag_id": "tag_1",
        "start_ms": 12000,
        "end_ms": 24000,
        "segment_ids": ["seg_1"],
        "quote": "We need to finalize the release checklist."
      }
    • Response 201: { "tag_mention": { "id": "...", "meeting_id": "...", "tag_id": "tag_1", "tag_name": "...", "start_ms": 12000, "end_ms": 24000, "segment_ids": ["seg_1"], "quote": "..." } }
  • DELETE /api/openscribe/tag-mentions/{mentionId} (Auth, Owner)

    • Response 204

4.9.4 Unified Tasks / Action Items (knowledge_tasks <-> meetings.action_items)

Meeting and entity pages edit the same owner-scoped knowledge_tasks records. The meeting response projects each canonical ID as action_items[].task_id. Use that ID for task endpoints. Legacy action_items[].id values belong to one meeting and are not accepted as task IDs.

  • GET /api/openscribe/knowledge/tasks (Auth, Owner)

    • Query params: person_id (assignee), project_id, meeting_id, tag_id, state ("open", "in_progress", "done", "canceled", "all")
    • Response 200:
      json
      {
        "tasks": [
          {
            "id": "tsk_1",
            "owner": "usr_1",
            "text": "Publish owner-scoped living knowledge API contract",
            "state": "open",
            "completed": false,
            "due_at": "2026-11-12",
            "assignee_person_id": "ent_person_1",
            "assignee_name": "Dr. Ada Lovelace",
            "assignee_email": "ada@example.org",
            "is_owner": true,
            "project_id": "ent_proj_1",
            "project_name": "Project Analytical",
            "tag_ids": ["tag_1"],
            "source_meeting_id": "mtg_1",
            "source_meeting_title": "Architecture Sync",
            "evidence_segment_ids": ["seg_1"],
            "evidence_start_ms": 12000,
            "evidence_end_ms": 18500,
            "evidence_quote": "I will publish the API contract by Thursday.",
            "is_manual": false,
            "created": "2026-11-09T18:00:00Z",
            "updated": "2026-11-09T18:00:00Z"
          }
        ]
      }
  • POST /api/openscribe/knowledge/tasks (Auth, Owner)

    • Creates a task linked to optional assignee_person_id, project_id, tag_ids, source_meeting_id, and evidence_segment_ids / evidence_start_ms / evidence_end_ms / evidence_quote. Syncs back to meetings.action_items if source_meeting_id is set.
    • Response 201: { "task": { ... } }
  • PATCH /api/openscribe/knowledge/tasks/{taskId} (Auth, Owner)

    • Updates text, state ("open" | "in_progress" | "done" | "canceled"), completed (bool alias: true <-> "done", false <-> "open"), due_at, assignee_person_id, project_id, tag_ids. Marks is_manual = true so subsequent meeting re-extractions never overwrite user edits. Syncs back to meetings.action_items.
    • Response 200: { "task": { ... } }
  • DELETE /api/openscribe/knowledge/tasks/{taskId} (Auth, Owner)

    • Deletes/retracts the task and removes it from meetings.action_items.
    • Response 204

4.9.5 Pending Suggested Changes & Post-Meeting Extraction (knowledge_suggestions)

When a meeting completes (state == "ended") and committed transcript segments arrive (including late-drained native ASR segments after ended), OpenScribe schedules a bounded, non-blocking, idempotent extraction job (WorkloadProfileEnrichment / Engine.ExtractMeetingKnowledge).

  • Non-conflicting supported facts/details/links/project connections with verified identity and owner-scoped segment citations are applied automatically with evidence_segment_ids, evidence_start_ms, and evidence_end_ms.
  • Ambiguous speaker/person identities (e.g. display-name-only match or unmapped/mixed_sources), candidate new-project merges, conflicting claims, or updates that would touch a manual user edit (is_manual == true) are recorded as reviewable knowledge_suggestions (status = "pending") and never overwrite manual content.

Endpoints:

  • GET /api/openscribe/knowledge/suggestions (Auth, Owner)

    • Query params: entity_id, meeting_id, status (default "pending", or "accepted", "rejected", "all")
    • Response 200:
      json
      {
        "suggestions": [
          {
            "id": "sug_1",
            "owner": "usr_1",
            "status": "pending",
            "suggestion_type": "detail_conflict",
            "reason": "Conflicts with manual role on Project Analytical",
            "target_entity_id": "ent_person_1",
            "target_entity_kind": "person",
            "target_entity_name": "Dr. Ada Lovelace",
            "candidate_entity_id": "",
            "proposed_kind": "role",
            "proposed_label": "Advisor",
            "proposed_value_text": "Stepping back to Advisor role",
            "proposed_url": "",
            "proposed_due_at": "",
            "related_person_id": "",
            "related_project_id": "ent_proj_1",
            "related_project_name": "Project Analytical",
            "related_tag_id": "",
            "source_meeting_id": "mtg_2",
            "source_meeting_title": "Q4 Planning",
            "evidence_segment_ids": ["seg_9"],
            "evidence_start_ms": 42000,
            "evidence_end_ms": 48000,
            "evidence_speaker": "Dr. Ada Lovelace",
            "evidence_quote": "I am moving to an advisor role on Project Analytical next month.",
            "created": "2026-11-09T18:05:00Z"
          }
        ]
      }
  • POST /api/openscribe/knowledge/suggestions/{suggestionId}/resolve (Auth, Owner)

    • Request: { "action": "accept" | "reject" }
    • On "accept", applies the proposed detail, project creation/link, or person link as an authoritative user-confirmed update (is_manual: true), superseding any prior conflicting active detail while preserving history. On "reject", marks the suggestion "rejected" and records negative constraints so identical retries do not re-propose it.
    • Response 200: { "suggestion": { ... }, "entity": { ... } }
  • POST /api/openscribe/meetings/{id}/extract-knowledge (Auth, Owner)

    • Schedules bounded, idempotent living-knowledge extraction asynchronously in the background (202 Accepted) so HTTP requests and active recorders are never blocked by long model calls.
    • Response 202:
      json
      {
        "meeting_id": "mtg_1",
        "job_id": "job_mtg_1_1794233500",
        "status": "queued",
        "content_hash": "sha256:..."
      }
  • GET /api/openscribe/meetings/{id}/extract-knowledge/status (Auth, Owner)

    • Returns the latest extraction job status and result summary for meeting_id:
      json
      {
        "meeting_id": "mtg_1",
        "job_id": "job_mtg_1_1794233500",
        "status": "completed",
        "content_hash": "sha256:...",
        "entities_updated": ["ent_person_1", "ent_proj_1"],
        "details_upserted": ["det_1"],
        "tasks_upserted": ["tsk_1"],
        "suggestions_created": ["sug_1"],
        "updated_at": "2026-11-09T18:05:02Z"
      }

5. Extension Hooks for server/intelligence and server/mcpbridge

The core server exposes clean Go registration functions and a typed Service struct so server/intelligence and server/mcpbridge can plug in without duplicating database queries or access control:

  • e.Router mounts /api/openscribe in server/routes.go.
  • server/intelligence and server/mcpbridge can call core service methods (ListMeetingsForUser, GetMeetingDetailForUser, AskMeeting, SummarizeMeeting, GetActiveModelForWorkload) which enforce owner = userId on every lookup.