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)
- Google OAuth Redirect URIs:
- Web / App Login (PocketBase built-in OAuth2):
https://scribe.akyonlabs.com/api/oauth2-redirecthttp://127.0.0.1:8090/api/oauth2-redirecthttp://localhost:8090/api/oauth2-redirecthttp://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/callbackhttp://127.0.0.1:8090/api/openscribe/calendar/callbackhttp://localhost:8090/api/openscribe/calendar/callback
- Web / App Login (PocketBase built-in OAuth2):
- Google Scopes Required:
- Sign-in:
openid,email,profile - Calendar connection:
openid,email,profile,https://www.googleapis.com/auth/calendar.readonly
- Sign-in:
- 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_superusersinitial bootstrap when_superuserscount is 0. Required before browser installer can run)GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRETPUBLIC_URL(e.g.https://scribe.akyonlabs.comorhttp://127.0.0.1:8090)OPENSCRIBE_TOKEN_ENCRYPTION_KEY(Optional 32-byte hex or base64 key for AES-256-GCM encryption ofcalendar_accountsOAuth refresh tokens. Falls back to a deterministic SHA-256 derivation fromGOOGLE_CLIENT_SECRET+ app secret in local dev if unset)ELEVENLABS_API_KEYOPENAI_API_KEYANTHROPIC_API_KEYGOOGLE_API_KEYTYPESAFE_API_KEY(Optional server-held key for boundedjev-1.14.0System One candidate scoring onGET /api/openscribe/search/suggestions)SMTP_URL,MAIL_FROM(Optional SMTP configuration)
- Static Frontend Serving:
- In production, the Go binary serves
../web/dist(or./pb_publicif present) on port8090with SPA fallback.
- In production, the Go binary serves
2. Authentication & Session Contract (Web & Native macOS)
2.1 Web Authentication (PocketBase Built-in)
- Standard PocketBase
usersauth collection endpoints:GET /api/collections/users/auth-methodsPOST /api/collections/users/auth-with-oauth2POST /api/collections/users/auth-refresh
- Calling
GET /api/openscribe/auth/mewithAuthorization: Bearer <pb_token>returns the current user, theiradminflag, and their connectedcalendar_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.adminis server-managed. Only verified Google accounts listed inOPENSCRIBE_ADMIN_EMAILcan 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_EMAILremains 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:
POST /api/openscribe/auth/device/start(Guest)- Request:
{ "device_label": "Fatih's MacBook Pro" } - Response
200:{ "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_urlautomatically uses the incoming request host (http://127.0.0.1:8090orhttp://localhost:8090) when invoked over local loopback or SSH port forwarding, even ifPUBLIC_URLpoints to a production domain.
- Request:
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 pendingcodelookup whenidis omitted) - Response
204
- Request:
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 } }
- Request:
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.
users(Auth collection)name(text),avatar(file),admin(bool, server-managed)
calendar_accounts(Base collection)owner(relation ->users, required)google_sub(text, required): stable Google subject IDemail(email, required): Google account emaildisplay_name(text)calendar_id(text, required, default"primary")scopes(json): granted OAuth scopesencrypted_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)
meeting_series(Base collection)owner(relation ->users, required)calendar_account(relation ->calendar_accounts, required)calendar_id(text, required): e.g."primary"or specific calendar emailrecurring_event_id(text, required): Google CalendarrecurringEventId(never grouped by title alone)title(text, required)summary(text): inferred series summarymanual_summary(text): authoritative user overrideprojects(json): inferred project namesmanual_projects(json): authoritative user overridedeadlines(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)
tags(Base collection)owner(relation ->users, required)name(text, required)description(text)color(text)- Unique index on
(owner, name)
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-meetingparticipant_device_idvalues are scoped bymeeting_idand never merged across meetings; cross-meeting matching only occurs on actually suppliedemailor separately provenstable_id)display_name(text): inferred/observed namemanual_name(text): authoritative user overrideinferred_metadata(json):{ "country": "", "company": "", "job_title": "", "responsibilities": "", "linkedin_url": "", "scholar_url": "", "links": [], "sources": [] }manual_metadata(json): same structure, authoritative override when non-emptymerged_into(relation ->people_profiles, optional): supports explicit profile merge/deduplication
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 instanceidrecurring_event_id(text): Google CalendarrecurringEventIdtitle(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 wheneverlanguage_modeis"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 overrideaction_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 }(recoverabledefaults tofalseuntil a verified local bundle is attached)verification_mode(select:"real","controlled_test","fixture"): explicitly distinguishes real recordings/provider calls from synthetic/fixture tests
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 timedisplay_name(text): observed display name (may arrive after initial audio chunks)email(email): only set when actually verified from calendar/platform identity, otherwise empty (nullin API)attribution_mode(select:"one_mapped_source","mixed_sources","unmapped","local_mic")is_self(bool)- Unique index on
(meeting, platform, participant_device_id)
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/renegotiationstatus(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 millisecondsreceive_epoch_ms(number): observer UTC epoch ms when audio chunk was receiveddecoded_sample_offset(number, int): compact Opus decoder sample position for lossless replay alignmentrtp_timestamp(number, int): original RTP timestampwords(json): array of word items mapped to meeting timeline:[ { "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
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/descriptionurl(text): populated whenkind == "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 alonetimestamp_ms(number, int): optional meeting offset when note/link was captured
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 mswindow_title(text)source_app(text): e.g."Google Chrome","zoom.us","Finder"extracted_text(text)extracted_links(json)
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": "..." }
- Returns
GET /api/openscribe/auth/me(Auth)- Returns
{ "user": { "id": "...", "email": "...", "name": "...", "admin": true }, "calendar_accounts": [...] }
- Returns
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 includesencrypted_token).
- Lists the user's connected
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 currentuser.Idand PKCE verifier, requestinghttps://www.googleapis.com/auth/calendar.readonlywithaccess_type=offlineandprompt=consent.
- Request:
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, upsertscalendar_accounts, and redirects toreturn_to(or returns JSON ifAccept: application/json).
- Exchanges
DELETE /api/openscribe/calendar/accounts/{id}(Auth, Owner)- Removes the connected calendar account.
GET /api/openscribe/calendar/events(Auth)- Query params:
account_ids: comma-separatedcalendar_accountsIDs (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 upsertmeeting_series(for events withrecurringEventId) and extract description URLs into series/occurrencemeeting_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".
- Query params:
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_KEYis configured and ambiguous unmatched candidates remain, evaluates only the typed query and bounded owner-scoped candidate labels (max 30, never meeting transcripts) viaPOST https://api.typesafe.ai/v1/systemone(model: "jev-1.14.0", structurednoulquestions, short deadline). Unknown or out-of-range classifier answers are ignored, and provider errors or cancellations never block search or leak secrets. - Response
200:{ "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" }
- Resolves owner-scoped filter suggestions across canonical People (
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 canonicalpersonentity IDs (or linkedpeople_profilesIDs). Matches meetings via linkedmeeting_participants(including merged profiles), assignedknowledge_tasks, orknowledge_detail_evidence.project_ids: comma-separated canonicalprojectentity IDs. Matches meetings viaknowledge_detail_evidence, projectknowledge_tasks, exactserieslinkage, or project slug match.tag_ids: comma-separatedtagsIDs. Matches both meeting-widemeetings.tagsand timestampedknowledge_tag_mentions.account_ids: comma-separatedcalendar_accountIDs (multiselect filter)emails: comma-separated participant/account emails (multiselect filter)series_id: filter occurrences belonging to ameeting_seriesq: text search across title, summary, transcriptverification_mode: optional filter ("real","controlled_test","fixture")
- Returns
{ "items": [ ... ], "series": [ ... ] }, sorted by-started_at, -created. Relation fields emit both unsuffixed and_idsuffixed keys (calendar_account&calendar_account_id,series&series_id,meeting&meeting_id,participant&participant_id).
- Query params (OR within each category, AND across categories; cross-owner IDs are validated and never match):
POST /api/openscribe/meetings(Auth)- Creates a new meeting (and links/creates
meeting_seriesifcalendar_account_id+recurring_event_idare provided). - Request:
{ "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.
- Creates a new meeting (and links/creates
GET /api/openscribe/meetings/{id}(Auth, Owner)- Returns full meeting detail including:
meeting: meeting recordparticipants:meeting_participantslist with linkedpeople_profilessegments: orderedtranscript_segmentswith word-level timestampsnotes:meeting_notes(both occurrence-specific and inheritedseriesnotes/links)attachments:meeting_attachmentswith short-lived?token=protectedfile_urland owner-scopeddownload_urlseries:meeting_seriesrecord (if part of a recurring series)occurrences: prior and siblingmeetingsin the samemeeting_series(ordered bystarted_at DESC) with their summaries and action items so the UI can switch occurrences cleanly.
- Returns full meeting detail including:
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.
- Updates state (
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, andlocal_micattributions. 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'schunk_start_ms/receive_epoch_msrather than assuming a compact decoder clock is wall time. - Request:
{ "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.
- The native macOS companion accepts notes, dropped screenshots/files, and links while an active meeting is running (and finishes queued uploads for their original
POST /api/openscribe/meetings/{id}/notes(Auth, Owner)- Creates a note or link on the meeting (or on its
seriesif"scope": "series"is passed). - Saved links default to
ingested_status: "url_only"unless authorized content was explicitly fetched. - Request:
{ "scope": "meeting", "kind": "link", "content": "Sprint 6 Tracker", "url": "https://github.com/fcakyon/openscribe/issues/1", "provenance": "manual", "timestamp_ms": 45000 }
- Creates a note or link on the meeting (or on its
DELETE /api/openscribe/notes/{noteId}(Auth, Owner)POST /api/openscribe/meetings/{id}/attachments(Auth, Owner)multipart/form-datawithfile,kind("screenshot","file","audio_chunk","replay_bundle"— auto-detects"screenshot"for.png/.jpg/.jpeg/.webp/.gifand"file"for other extensions when omitted),captured_at_ms,window_title(defaults to the original uploaded filename forkind == "file"),source_app,extracted_text,extracted_links.- Returns serialized attachment with
file_url(signed with a short-lived PocketBase file token?token=...) anddownload_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).
- Streams raw attachment bytes with non-executing headers (
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=jsonorAccept: application/json(withoutimage/) is passed.
- Serves raw image bytes by default for
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
activetranscription configuration,active_capabilities, verifiedcatalog(elevenlabs,openai_realtime,gemini_realtime,openai_compatible),providers_configured, andcan_edit.
- Returns the administrator's
PUT /api/openscribe/admin/transcription(Auth, Admin only)- Updates the authoritative transcription
provider,model_id, and optionalbase_url.
- Updates the authoritative transcription
POST /api/openscribe/transcription/session(Auth, Owner whenmeeting_idis supplied)- Provider-neutral transcription session endpoint that strictly uses the administrator's
ActiveConfig(any caller-suppliedprovider,model_id, orbase_urloverride returns403 Forbidden). - For
"elevenlabs", mints a single-userealtime_scribetoken (POST https://api.elevenlabs.io/v1/single-use-token/realtime_scribe). - For
"openai_realtime", returnstoken: ""andwebsocket_url: "/api/openscribe/transcription/openai-realtime?..."so the native client connects to OpenScribe's server-owned WebSocket relay using its appAuthorization: Bearer <pb_auth_token>header without exposing OpenAI credentials or allowing clientsession.updatemodel switches. - For
"gemini_realtime", mints a single-use constrained token (uses: 1) viaPOST /v1beta/auth_tokenswithbidiGenerateContentSetuplocked to the administrator-selected model (fieldMaskomitted). - For
"openai_compatible", returnstoken: ""andhttp_transcription_url: "/api/openscribe/transcription/audio". - Request:
{ "meeting_id": "<optional_meeting_id>", "participant_device_id": "device-554", "audio_format": "pcm_48000", "language_mode": "auto", "language_code": "", "commit_strategy": "vad" }
- Provider-neutral transcription session endpoint that strictly uses the administrator's
GET /api/openscribe/transcription/openai-realtime(Auth, Owner whenmeeting_idis supplied, WebSocket upgrade)- Server-owned OpenAI Realtime transcription WebSocket relay (
wss://api.openai.com/v1/realtime?intent=transcription). Requires an authenticated app user and verifiesmeeting_idownership before dialing upstream. Enforces the administrator-selected OpenAI transcription model (gpt-4o-mini-transcribe,gpt-4o-transcribe, orgpt-live-transcribe) on initial session setup and all subsequent clientsession.updatemessages while preserving authorized language changes, 24 kHz PCM audio frames (input_audio_buffer.append), and commits (input_audio_buffer.commit).
- Server-owned OpenAI Realtime transcription WebSocket relay (
POST /api/openscribe/transcription/audio(Auth, Owner whenmeeting_idis supplied)- Server-brokered multipart audio proxy for
openai_compatibletranscription endpoints. Enforces the administrator-selectedmodel_idandbase_url.
- Server-brokered multipart audio proxy for
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-emptyname, 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.
- Updates
POST /api/openscribe/people/{id}/merge(Auth, Owner)- Merges duplicate profile
{ "target_person_id": "..." }and updatesmeeting_participantsreferences.
- Merges duplicate profile
PATCH /api/openscribe/series/{id}(Auth, Owner)- Updates
manual_summary,manual_projects,manual_deadlines.
- Updates
4.8 Admin AI Settings & Meeting Intelligence / Handoff Hooks
GET /api/openscribe/admin/models(Auth, any signed-in user can view active models, onlyadmin == truecan mutate)PUT /api/openscribe/admin/models(Auth, Admin only - enforced server-side viar.Auth.GetBool("admin"))- Request:
{ "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" } ] }
- Request:
POST /api/openscribe/meetings/{id}/ask(Auth, Owner)- Delegates to
server/intelligencewhen 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.
- Delegates to
POST /api/openscribe/meetings/{id}/summarize(Auth, Owner)- Generates/updates
summary, user-focusedaction_items(withevidence_segment_idsandevidence_quote),suggested_tags, and copyablefollowup_email_draft.
- Generates/updates
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.
- Request:
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 detailsstatus: optional"active"(default),"provisional","archived","all"project_id: optional filter returning people/details linked to a projecttag_id: optional filter returning entities linked to a tag
- Response
200:{ "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": [ ... ] }
- Query params:
POST /api/openscribe/knowledge/entities(Auth, Owner)- Creates an owner-scoped
personorprojectentity. Whenkind == "person", links to an existing ownedperson_profile_idor creates/links a matchingpeople_profilesrow (never merging by display name alone). - Request:
{ "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 asGET /api/openscribe/knowledge/entities/{id}).
- Creates an owner-scoped
GET /api/openscribe/knowledge/entities/{id}(Auth, Owner)- Accepts either a
knowledge_entities.idOR an existingpeople_profiles.id(automatically resolving the migrated 1:1 person entity for backward compatibility). - Response
200:{ "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": [ ... ] }
- Accepts either a
PATCH /api/openscribe/knowledge/entities/{id}(Auth, Owner)- Updates
manual_name,canonical_name,email(forperson),manual_brief,status("active","archived"),pinned. Syncsmanual_nameandemailwith the linkedpeople_profilesrow whenkind == "person". - Response
200: updated full entity detail payload.
- Updates
4.9.2 Bounded Structured Entity Details (note | role | responsibility | location | skill | link | deadline | relation)
Allowed kind values on knowledge_details:
"note": short rich-text note/brief block (value_textmax 4000 chars, optionallabel, optionalrelated_project_id/related_person_id/related_tag_id)."role": role/title (value_textmax 300 chars, e.g."Lead","Advisor","Product Designer"). On aperson, optionally binds torelated_project_idso the same person can hold distinct roles across different projects concurrently (e.g. Lead on Project A, Advisor on Project B), orlabelfor organization/company context."responsibility": scoped responsibility (value_textmax 1000 chars, optionalrelated_project_id,related_tag_id)."location": country/city/timezone (value_textmax 300 chars)."skill": domain skill or area of expertise (value_textmax 200 chars, optionalrelated_tag_idorrelated_project_id)."link": arbitrary labeled website URL (labelmax 300 chars,urlmax 2000 chars, any validhttp://orhttps://website URL allowed without predefined LinkedIn/Scholar-only slot restrictions;ingested_statusis always"url_only"unless fetched by an authorized connector; never fetches external URLs from URL alone)."deadline": project or person deadline (value_textmax 500 chars,due_atISO-8601/RFC3339 date,deadline_state"upcoming"|"completed"|"rescheduled"|"canceled"|"past_due_unconfirmed", optionalrelated_project_id)."relation": explicit typed connection to an existing ownedrelated_person_id,related_project_id, orrelated_tag_id(label/value_textmax 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 inhistory. - Request:
{ "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": { ... } }
- Creates a manual structured detail block (
PATCH /api/openscribe/knowledge/details/{detailId}(Auth, Owner)- Updates a detail block and locks it as authoritative (
is_manual: true). Supports updatinglabel,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": { ... } }
- Updates a detail block and locks it as authoritative (
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
- Deletes or retracts the detail block (marking extracted details
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.
- Query params:
GET /api/openscribe/knowledge/evidence(Auth, Owner)- Query params:
entity_id,detail_id,meeting_id,tag_id - Returns
{ "evidence": [ ... ], "tag_mentions": [ ... ] }withsource_meeting_id,evidence_segment_ids,evidence_start_ms,evidence_end_ms,evidence_speaker, andevidence_quote.
- Query params:
POST /api/openscribe/meetings/{id}/tag-mentions(Auth, Owner)- Attaches an owned
tagsrecord to a bounded meeting timestamp range (start_ms,end_ms, optionalsegment_ids,quote,entity_id). - Request:
{ "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": "..." } }
- Attaches an owned
DELETE /api/openscribe/tag-mentions/{mentionId}(Auth, Owner)- Response
204
- Response
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:{ "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" } ] }
- Query params:
POST /api/openscribe/knowledge/tasks(Auth, Owner)- Creates a task linked to optional
assignee_person_id,project_id,tag_ids,source_meeting_id, andevidence_segment_ids/evidence_start_ms/evidence_end_ms/evidence_quote. Syncs back tomeetings.action_itemsifsource_meeting_idis set. - Response
201:{ "task": { ... } }
- Creates a task linked to optional
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. Marksis_manual = trueso subsequent meeting re-extractions never overwrite user edits. Syncs back tomeetings.action_items. - Response
200:{ "task": { ... } }
- Updates
DELETE /api/openscribe/knowledge/tasks/{taskId}(Auth, Owner)- Deletes/retracts the task and removes it from
meetings.action_items. - Response
204
- Deletes/retracts the task and removes it from
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, andevidence_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 reviewableknowledge_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:{ "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" } ] }
- Query params:
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": { ... } }
- Request:
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:{ "meeting_id": "mtg_1", "job_id": "job_mtg_1_1794233500", "status": "queued", "content_hash": "sha256:..." }
- Schedules bounded, idempotent living-knowledge extraction asynchronously in the background (
GET /api/openscribe/meetings/{id}/extract-knowledge/status(Auth, Owner)- Returns the latest extraction job status and result summary for
meeting_id:{ "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" }
- Returns the latest extraction job status and result summary for
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.Routermounts/api/openscribeinserver/routes.go.server/intelligenceandserver/mcpbridgecan call core service methods (ListMeetingsForUser,GetMeetingDetailForUser,AskMeeting,SummarizeMeeting,GetActiveModelForWorkload) which enforceowner = userIdon every lookup.