Self-Hosting & OpenShip Deployment Guide
OpenScribe packages its Go + PocketBase backend (server/), React web interface (web/dist/), public documentation (web/dist/docs/), and remote Model Context Protocol (MCP) server into a single stateless container image that stores all persistent state on one mounted volume (/pb/pb_data).
You can run OpenScribe locally from a clean Git checkout, inside a standalone Docker container, or on a self-hosted OpenShip instance.
1. Environment Variables Reference (.env & .env.required)
All runtime secrets are injected via environment variables. The repository root includes .env.required listing the expected environment variable names. When running locally, create .env and restrict file permissions to owner-only (chmod 600 .env). Never commit .env to version control.
| Variable | Required | Default | Description |
|---|---|---|---|
PUBLIC_URL | Yes | http://127.0.0.1:8090 | Canonical external origin without a trailing slash (for example https://scribe.example.com). Used to construct Google OAuth callback URLs, device pairing verification links (/pair), MCP OAuth metadata issuers, and canonical URLs in /docs/, /robots.txt, and /sitemap.xml. |
PORT | No | 8090 | TCP port where the Go + PocketBase HTTP server listens inside the process or container. |
PB_DATA_DIR | No | ./pb_data (local) / /pb/pb_data (Docker) | Directory path holding data.db, auxiliary.db, and uploaded meeting attachments. Must be backed by a persistent volume in container deployments. |
OPENSCRIBE_ADMIN_EMAIL | Yes | None | Comma-separated Google email allowlist. Only verified accounts on this list can create an account or sign in. An empty list denies application access. Calendar connections may use other Google accounts. |
PB_ADMIN_EMAIL | Yes | Falls back to the first address in OPENSCRIBE_ADMIN_EMAIL | Email address for the PocketBase _superusers account (/_/). Bootstrapped automatically on startup by openscribe prepare and ensureSuperuserBootstrap (server/auth.go). |
PB_ADMIN_PASSWORD | Yes | None (10+ characters) | Password for the PocketBase _superusers account (minimum 10 characters). Setting both PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD before first boot prevents PocketBase from exposing an unauthenticated web installer token. |
PB_ENCRYPTION_KEY | Yes (Docker / prepare) | None (exact 32 characters) | Exact 32-character ASCII key passed via --encryptionEnv=PB_ENCRYPTION_KEY so PocketBase encrypts OAuth client secrets and application settings at rest inside SQLite (server/auth.go and docker-entrypoint.sh). |
OPENSCRIBE_TOKEN_ENCRYPTION_KEY | Recommended | Derived fallback in dev | 32-byte key (64 hex characters from openssl rand -hex 32 or 43-44 base64 characters) used for AES-256-GCM encryption of calendar_accounts.encrypted_token (server/crypto.go). Set explicitly in production so rotating GOOGLE_CLIENT_SECRET never invalidates stored calendar refresh tokens. |
GOOGLE_CLIENT_ID | Yes (for OAuth & Calendar) | None | Google OAuth 2.0 Web Application Client ID (*.apps.googleusercontent.com). Synced into the PocketBase users collection on startup (applyRuntimeSettings in server/auth.go) and used by /api/openscribe/calendar/connect. |
GOOGLE_CLIENT_SECRET | Yes (for OAuth & Calendar) | None | Google OAuth 2.0 Web Application Client Secret (GOCSPX-...). Kept strictly on the server. |
ELEVENLABS_API_KEY | Optional (default ASR) | None | Server-held ElevenLabs API key used by POST /api/openscribe/transcription/session (server/elevenlabs.go and server/transcription/) to mint short-lived, single-use realtime_scribe tokens (scribe_v2_realtime). Never sent to clients. |
OPENAI_API_KEY | Optional | None | Server-held OpenAI API key for AI workloads assigned to the openai provider (default gpt-6-luna for tagging and profile_enrichment) and for openai_realtime (relayed via GET /api/openscribe/transcription/openai-realtime with token = "" so OpenAI credentials never leave the server) or openai_compatible transcription when selected. |
ANTHROPIC_API_KEY | Optional | None | Server-held Anthropic API key for AI workloads assigned to the anthropic provider (default claude-sonnet-5-5 for qa and summary). |
GOOGLE_API_KEY | Optional | None | Server-held Google Gemini API key (GEMINI_API_KEY also accepted for ASR) for AI workloads assigned to the google provider (default gemini-3.8-flash for action_items) and for gemini_realtime transcription tokens (uses: 1, model-locked bidiGenerateContentSetup). |
OPENAI_COMPATIBLE_TRANSCRIPTION_BASE_URL | Optional | None | Base URL (for example http://127.0.0.1:8080/v1) for an OpenAI-compatible /v1/audio/transcriptions endpoint proxied by POST /api/openscribe/transcription/audio (data_destination = "server_proxy_http"). Optional bearer token via OPENAI_COMPATIBLE_TRANSCRIPTION_API_KEY. |
TYPESAFE_API_KEY | Optional | None | Server-held Typesafe key for bounded jev-1.14.0 search suggestions. Only the query and candidate labels are sent. Local matching works without it. |
SMTP_URL | Optional | None | Optional SMTP connection URL for transactional emails. |
MAIL_FROM | Optional | None | Optional sender address when SMTP_URL is configured. |
Generating Encryption Keys
Generate PB_ENCRYPTION_KEY (exact 32 hex characters) and OPENSCRIBE_TOKEN_ENCRYPTION_KEY (64 hex characters, or 32 decoded bytes) with OpenSSL:
# 32-character ASCII key for PB_ENCRYPTION_KEY
openssl rand -hex 16
# 64-hex-character (32-byte) key for OPENSCRIBE_TOKEN_ENCRYPTION_KEY
openssl rand -hex 322. Running Locally from a Clean Checkout
You can build and run OpenScribe directly on Linux or macOS without any private analysis directories or external scripts.
Step 1: Prepare .env
touch .env
chmod 600 .envEdit .env (referring to .env.required) and set PUBLIC_URL=http://127.0.0.1:8090, OPENSCRIBE_ADMIN_EMAIL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_ENCRYPTION_KEY, OPENSCRIBE_TOKEN_ENCRYPTION_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and your AI provider keys.
Step 2: Build the Public Documentation and React Frontend
The Go server automatically serves static files from ../web/dist (or ./pb_public inside Docker). Running npm run build in web/ automatically executes npm run build:docs (node ../docs/build-docs.mjs) to generate /docs/, /robots.txt, and /sitemap.xml before bundling the React frontend:
cd web
npm ci
PUBLIC_URL=http://127.0.0.1:8090 npm run build
cd ..Step 3: Build and Start the Go + PocketBase Server
Always pass --dev=false so PocketBase never prints SQL statements or sensitive parameters to standard output:
cd server
go build -o openscribe .
./openscribe prepare --dev=false --dir="${PB_DATA_DIR:-./pb_data}" --encryptionEnv=PB_ENCRYPTION_KEY
./openscribe serve --dev=false --http="127.0.0.1:${PORT:-8090}" --dir="${PB_DATA_DIR:-./pb_data}" --encryptionEnv=PB_ENCRYPTION_KEY3. Standalone Docker Deployment
The root Dockerfile performs a reproducible multi-stage build:
frontend(node:22-alpine): CopiesREADME.md,docs/,server/API.md, andweb/, installs frontend dependencies (npm ci), removes any prebuiltpublic/docs,public/robots.txt, andpublic/sitemap.xml, runsnpm run build(build:docs && tsc -b && vite build) usingARG PUBLIC_URL=http://__openscribe_public_url__.invalidby default, and verifies thatdist/docs/index.html,dist/robots.txt, anddist/sitemap.xmlwere generated.backend(golang:1.27-alpine): Compiles the static Go binary (CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /openscribe .).- Runtime (
alpine:3.23): Installsca-certificatesandtzdata, copies/pb/openscribe,/pb/pb_public(containing the built web app and/docs/), and/pb/docker-entrypoint.sh, exposes port8090, and defines a containerHEALTHCHECKagainsthttp://127.0.0.1:8090/api/openscribe/config.
Build and Run with Docker
docker build -t openscribe:latest .
docker volume create openscribe-data
docker run -d \
--name openscribe \
--restart unless-stopped \
-p 127.0.0.1:8090:8090 \
--env-file .env \
-e PB_DATA_DIR=/pb/pb_data \
-v openscribe-data:/pb/pb_data \
openscribe:latestHow docker-entrypoint.sh Bootstraps the Server
On every container start, docker-entrypoint.sh:
- Sets
DATA_DIR(${PB_DATA_DIR:-/pb/pb_data}) andPORT(${PORT:-8090}). - Verifies that
PB_ENCRYPTION_KEYis exactly 32 characters,PB_ADMIN_EMAIL(falling back to the first address inOPENSCRIBE_ADMIN_EMAIL) is set, andPB_ADMIN_PASSWORDis at least 10 characters. - Reads the build-time origin sentinel (
BUILD_ORIGIN) fromSitemap: <BUILD_ORIGIN>/sitemap.xmlinsidepb_public/robots.txtand rewritesBUILD_ORIGINto the runtimeTARGET_ORIGIN(${PUBLIC_URL:-http://127.0.0.1:${PORT}}without trailing slash) acrosspb_public(*.html,robots.txt, andsitemap.xml). - Runs database migrations, applies OAuth and app settings, and upserts the bootstrap superuser via:
./openscribe prepare --dev=false --dir="$DATA_DIR" --encryptionEnv=PB_ENCRYPTION_KEY - Replaces the shell process (
exec) with:exec ./openscribe serve --dev=false --http="0.0.0.0:${PORT}" --dir="$DATA_DIR" --encryptionEnv=PB_ENCRYPTION_KEY
4. Deploying with OpenShip (openship.json)
OpenScribe includes a validated openship.json manifest at the repository root:
{
"$schema": "https://openship.io/openship.schema.json",
"framework": "docker",
"runtime": "docker",
"port": 8090,
"volumes": [
"openscribe-data:/pb/pb_data"
],
"domains": [
{
"domain": "scribe.akyonlabs.com",
"port": 8090
}
]
}Adapting openship.json and .openship/project.json for Your Own Domain
When deploying OpenScribe to your own OpenShip instance:
- Edit
openship.jsonand replace"scribe.akyonlabs.com"underdomainswith your own domain (for example"scribe.example.com"). - Bind
.openship/project.jsonto your own OpenShip project usingopenship project createandopenship init --force --project <your-project-id>as shown below.
Step-by-Step OpenShip CLI Workflow
1. Verify OpenShip Context and Validate openship.json
openship --json context list
openship --json status
openship --json config validate2. Create and Bind Your OpenShip Project
If you are deploying to your own OpenShip server for the first time, create a project and bind .openship/project.json to your project ID:
openship project create --name openscribe
openship project list
openship init --force --project <your-project-id>3. Configure Secret Environment Variables
Use openship project env set <your-project-id> --secret so API keys and OAuth secrets are stored encrypted in OpenShip:
openship project env set <your-project-id> \
--set PUBLIC_URL=https://scribe.example.com \
--set PORT=8090 \
--set PB_DATA_DIR=/pb/pb_data \
--set OPENSCRIBE_ADMIN_EMAIL=you@example.com \
--set PB_ADMIN_EMAIL=you@example.com
openship project env set <your-project-id> --secret \
--set PB_ADMIN_PASSWORD="$PB_ADMIN_PASSWORD" \
--set PB_ENCRYPTION_KEY="$PB_ENCRYPTION_KEY" \
--set OPENSCRIBE_TOKEN_ENCRYPTION_KEY="$OPENSCRIBE_TOKEN_ENCRYPTION_KEY" \
--set GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" \
--set GOOGLE_CLIENT_SECRET="$GOOGLE_CLIENT_SECRET" \
--set ELEVENLABS_API_KEY="$ELEVENLABS_API_KEY" \
--set OPENAI_API_KEY="$OPENAI_API_KEY" \
--set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
--set GOOGLE_API_KEY="$GOOGLE_API_KEY"Verify the configured environment keys (secret values remain masked):
openship project env get <your-project-id>4. Attach and Verify Your Custom Domain
Point your DNS A / AAAA or CNAME record for scribe.example.com to your OpenShip host, then register and verify the domain:
openship domain add scribe.example.com --project <your-project-id>
openship domain list --project <your-project-id>
openship domain verify <domain-id>5. Deploy and Watch Build Logs
Trigger the OpenShip deployment (which builds the Dockerfile, runs npm run build:docs during the frontend stage, and updates canonical URLs at container startup via docker-entrypoint.sh):
openship deploy --watchCheck deployment history or inspect failure logs at any time:
openship deployment list --project <your-project-id>
openship deployment logs <deployment-id>5. Persistent Storage & Backup Policy (/pb/pb_data)
Because openship.json mounts "openscribe-data:/pb/pb_data", all stateful files survive container rebuilds and redeployments:
/pb/pb_data/data.db(+-waland-shm): Primary SQLite database containingusers,calendar_accounts(with AES-256-GCMencrypted_token),oauth_transactions,meetings,meeting_series,meeting_participants,transcript_segments,meeting_notes,meeting_attachments,people_profiles,tags,ai_settings,device_logins, and MCP OAuth tables (mcp_oauth_clients,mcp_oauth_codes,mcp_oauth_tokens)./pb/pb_data/auxiliary.db: PocketBase internal logs and metrics./pb/pb_data/storage/: Uploaded meeting screenshots, attachments, and replay bundles.
Always back up /pb/pb_data together with both PB_ENCRYPTION_KEY and OPENSCRIBE_TOKEN_ENCRYPTION_KEY. Without those keys, encrypted PocketBase settings and encrypted Google Calendar refresh tokens in calendar_accounts cannot be decrypted after a restore.
6. Post-Deploy Verification Checklist
Run the following checks against your deployed PUBLIC_URL (replace https://scribe.example.com with your domain or http://127.0.0.1:8090 locally):
1. Public Config & Readiness (GET /api/openscribe/config)
OpenScribe exposes its health and capability status at GET /api/openscribe/config (used by the Dockerfile HEALTHCHECK):
curl -sS https://scribe.example.com/api/openscribe/configExpected 200 OK JSON response:
{
"app_name": "OpenScribe",
"public_url": "https://scribe.example.com",
"google_oauth_enabled": true,
"google_client_id": "123456789012-abc.apps.googleusercontent.com",
"elevenlabs_configured": true,
"ai_providers": {
"openai": true,
"anthropic": true,
"google": true
}
}2. Public Documentation, robots.txt, and sitemap.xml
Verify that /docs/, /robots.txt, and /sitemap.xml are publicly reachable without authentication and that unknown paths under /docs/ return HTTP 404:
curl -sS -o /dev/null -w "%{http_code}\n" https://scribe.example.com/docs/
curl -sS -o /dev/null -w "%{http_code}\n" https://scribe.example.com/docs/self-hosting/
curl -sS -o /dev/null -w "%{http_code}\n" https://scribe.example.com/robots.txt
curl -sS -o /dev/null -w "%{http_code}\n" https://scribe.example.com/sitemap.xml
curl -sS -o /dev/null -w "%{http_code}\n" https://scribe.example.com/docs/nonexistent-pageExpected status codes: 200, 200, 200, 200, and 404.
3. Admin AI Workload & Transcription Routing (GET / PUT /api/openscribe/admin/*)
Once signed in with an authorized Google account listed in OPENSCRIBE_ADMIN_EMAIL, inspect or update the AI provider and model ID for each workload (tagging, profile_enrichment, qa, summary, action_items) as well as the default transcription provider and model (elevenlabs, openai_realtime, gemini_realtime, or openai_compatible):
curl -sS https://scribe.example.com/api/openscribe/admin/models \
-H "Authorization: Bearer $OPENSCRIBE_USER_TOKEN"
curl -sS https://scribe.example.com/api/openscribe/transcription/providers \
-H "Authorization: Bearer $OPENSCRIBE_USER_TOKEN"7. Remote MCP Server (POST /api/openscribe/mcp)
OpenScribe includes a built-in Streamable HTTP Model Context Protocol (MCP) server (server/mcpbridge/ and server/mcpauth/) secured with OAuth 2.1 + PKCE so AI assistants (Claude, ChatGPT, Cursor, Codex, and custom MCP clients) can query your meetings with owner-scoped permissions.
Copy the MCP server URL from Settings → Apps & connections in the web workspace. Add that URL in your assistant’s connector settings, then sign in to OpenScribe when it asks. Web connectors need a publicly reachable HTTPS address. A localhost workspace works with local clients on the same machine.
Discovery & OAuth 2.1 Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/.well-known/oauth-protected-resource | GET | RFC 9728 Protected Resource Metadata pointing to <PUBLIC_URL>/api/openscribe/mcp. |
/.well-known/oauth-protected-resource/api/openscribe/mcp | GET | Path-scoped RFC 9728 Protected Resource Metadata. |
/.well-known/oauth-authorization-server | GET | RFC 8414 OAuth 2.1 Authorization Server Metadata (code_challenge_methods_supported: ["S256"]). |
/api/openscribe/oauth/register | POST | RFC 7591 Dynamic Client Registration for public MCP clients (token_endpoint_auth_method: "none"). |
/api/openscribe/oauth/authorize | GET | OAuth 2.1 authorization endpoint (requires code_challenge and code_challenge_method=S256). |
/api/openscribe/oauth/consent | POST | Authenticated user approval or denial for an MCP client authorization request. |
/api/openscribe/oauth/token | POST | Exchanges authorization_code + code_verifier or refresh_token for an owner-scoped MCP Bearer token. |
/api/openscribe/oauth/revoke | POST | Revokes an issued MCP access or refresh token. |
/api/openscribe/oauth/grants | GET / DELETE | Lists or revokes active MCP client grants for the signed-in user. |
/api/openscribe/mcp | POST / GET / DELETE | Streamable HTTP MCP JSON-RPC 2.0 endpoint. Returns 401 Unauthorized with WWW-Authenticate: Bearer resource_metadata="<PUBLIC_URL>/.well-known/oauth-protected-resource" when called without a valid token. |
Available MCP Tools (server/mcpbridge/bridge.go)
Every MCP tool enforces owner = <authenticated_user_id> and blocks sensitive financial or compensation queries (CheckNoFinancialData in server/intelligence/):
meetings_list(MeetingsListInput): List and filter meetings owned by the authenticated user usingaccount_ids,emails,tag_ids,series_id,q,verification_mode, andlimit(1-200).meetings_search(MeetingsSearchInput): Search across meeting titles, summaries, and speaker-attributed transcript segments usingquery,series_id,account_ids,tag_ids, andlimit(1-100).meeting_read(MeetingReadInput): Read a specific meeting occurrence (meeting_id), returning transcript segments, participants, notes, links, screenshot OCR text, and recurring series occurrences (with MCP App UI resourceui://openscribe/meeting-viewer.html).meeting_screenshot_read(MeetingScreenshotReadInput): Read a specific meeting-window screenshot attachment (meeting_id,attachment_id), returning the image content and metadata (dimensions, timestamp, window title, source app, and OCR text).meeting_ask(MeetingAskInput): Ask a grounded question against a specific meeting (meeting_id,question,include_series_history,include_screenshots,attachment_ids,max_images) and receive an answer with transcript segment and screenshot citations.context_metadata(ContextMetadataInput): Read recurring series metadata, participant profiles (with manual overrides and source provenance), and user-defined tags owned by the authenticated user (series_id,person_id,email).