This guide covers setting up the Refactor platform for local development and production deployment.
- Rust toolchain (
rustup+ stable) - PostgreSQL 14+ (see README.md for DB setup)
cargo,sea-orm-cli2.0.3 (matchessea-orm-migration)- ngrok with a reserved domain (for Recall.ai webhooks; see section 4)
Follow the database setup and backend startup instructions in README.md first. The steps below layer on the credentials needed for meeting transcription.
All OAuth tokens are encrypted at rest. This key must be set before any OAuth flow works.
# Generate a 64-hex-character key (32 random bytes, hex-encoded):
openssl rand -hex 32
openssl rand -hex 32outputs 32 bytes encoded as 64 hex characters — this is correct forENCRYPTION_KEY.
ENCRYPTION_KEY=<64-hex-char output from above>- Go to console.cloud.google.com and create a new project (or use an existing one).
- Enable these two APIs:
- Google Meet API (
meet.googleapis.com) - Google People API (
people.googleapis.com)
- Google Meet API (
- Navigate to APIs & Services → OAuth consent screen:
- User Type: External
- Fill in App name, support email, developer contact
- Add scopes:
openidemailprofilehttps://www.googleapis.com/auth/meetings.space.created
- Add your Google account as a Test user (required while the app is in "Testing" status)
- Navigate to APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID:
- Application type: Web application
- Authorized redirect URI:
http://localhost:4000/oauth/google/callback - Download or copy the Client ID and Client Secret
GOOGLE_CLIENT_ID=<client-id>.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=<client-secret>
GOOGLE_REDIRECT_URI=http://localhost:4000/oauth/google/callback
# Note: this var has NO `GOOGLE_` prefix even though it's used by the Google OAuth flow.
# A typo like `GOOGLE_OAUTH_SUCCESS_REDIRECT_URI=...` is silently ignored (the code
# falls back to the default `http://localhost:3000/settings`).
OAUTH_SUCCESS_REDIRECT_URI=http://localhost:3000/settings
# These have working defaults — only set if you need to override:
# GOOGLE_OAUTH_AUTH_URL=https://accounts.google.com/o/oauth2/v2/auth
# GOOGLE_OAUTH_TOKEN_URL=https://oauth2.googleapis.com/token
# GOOGLE_USERINFO_URL=https://www.googleapis.com/oauth2/v2/userinfo
# GOOGLE_MEET_API_URL=https://meet.googleapis.com/v2Recall.ai sends recording and transcript events to POST /webhooks/recall_ai, so during local development it has to reach your machine. Recall has no API for webhook endpoints, and every endpoint in a workspace receives every event in that workspace. So each developer uses their own Recall development workspace with one endpoint pointing at their own reserved ngrok domain. Set it up once; nothing needs editing afterwards.
-
Recall workspace. In the Recall dashboard, create a development workspace for yourself (never the production one; a shared one would also deliver other developers' bot events to you). Note its region (
us-west-2for ours). -
API key and signing secret. In that workspace, create an API key, then under Developers > API Keys & Secrets create the workspace secret (starts with
whsec_). -
ngrok domain. Install ngrok, run
ngrok config add-authtoken <token>, and reserve a static domain in the ngrok dashboard (one is included on the free plan). -
.env:NGROK_DOMAIN=<your-reserved-domain> # e.g. jim-dev.ngrok.app RECALL_AI_API_KEY=<key from your workspace> RECALL_AI_REGION=us-west-2 # your workspace's region RECALL_AI_WEBHOOK_SECRET=whsec_<your workspace secret>
-
Open the tunnel once with the backend running (see Development Flow below):
scripts/recall_webhook_tunnel.sh
It prints your endpoint URL and the events to subscribe to.
-
Recall endpoint. In your workspace, Webhooks > Add Endpoint: paste the URL the script printed (
https://<your-domain>/webhooks/recall_ai) and subscribe to every event it lists. The/webhooks/recall_aipath is required: the bare host answers POSTs with 405, because unmatched paths fall through to a static-file handler.
Start the backend, then run scripts/recall_webhook_tunnel.sh in another terminal and leave it running; Ctrl-C stops the tunnel. Before opening the tunnel it checks:
NGROK_DOMAIN,RECALL_AI_REGION, and thewhsec_form ofRECALL_AI_WEBHOOK_SECRET;- that Recall accepts your key in that region (a key from another region or workspace is rejected here, rather than surfacing later as transcripts that never arrive);
- that the backend answers on
http://localhost:$PORT/health(PORTdefaults to 4000); - that
GOOGLE_REDIRECT_URIpoints at the local backend (a warning only).
Once the tunnel is up it sends an unsigned POST /webhooks/recall_ai through it. A 401 from the handler proves the route reaches the backend; anything else is reported with what to fix. Because that check is unsigned, the backend logs one Svix validation error ... Missing svix-id/webhook-id header warning each time the tunnel opens. That warning is expected; real Recall deliveries always carry the signature headers. scripts/recall_webhook_tunnel.sh --check does all of this and then closes the tunnel, which is handy for verifying a setup.
If ngrok reports the domain is already online, another ngrok (often a forgotten earlier run) holds it; stop that one first.
When a coaching session transcript completes, the backend sends the speaker-labelled transcript to the AssemblyAI LLM Gateway and stores the commitments it finds as suggested Actions that the coach and coachee review. Nothing becomes an Action until one of them accepts it. See docs/architecture/transcript_suggestions.md for how it works.
ASSEMBLY_AI_API_KEY=<your AssemblyAI API key>
# These have working defaults; only set them to override:
# ASSEMBLY_AI_LLM_MODEL=claude-sonnet-4-6
# ASSEMBLY_AI_LLM_BASE_URL=https://llm-gateway.assemblyai.com/v1Without ASSEMBLY_AI_API_KEY transcripts still complete and no suggestions are made. Surrounding whitespace, such as a trailing newline, is trimmed; a key with any other character a header cannot carry is ignored with a startup warning. Only transcripts that complete while the key is set are analyzed. To test locally, record a real session through the Recall webhook tunnel (section 4) and follow docs/test-plans/transcript_suggestions_manual_testing.md.
Images pasted into a coaching note are uploaded to object storage; the note itself stores only an
image id and resolves the URL at render time. OBJECT_STORE_BACKEND picks the backend.
Each upload is spooled through the system temp directory (TMPDIR, else /tmp) and streamed to
storage from there, so the server's memory does not grow with image size. Keep that directory on
disk: mounting it as tmpfs would put every in-flight upload back in RAM.
Local development needs no DigitalOcean Spaces account. The default local backend writes to
OBJECT_STORE_LOCAL_PATH, a gitignored directory under the repo, and the read endpoint streams the
bytes back instead of redirecting to a presigned URL.
OBJECT_STORE_BACKEND=local
OBJECT_STORE_LOCAL_PATH=./.local-object-store
COACHING_SESSION_IMAGE_MAX_BYTES=10485760 # 10 MB upload cap
COACHING_SESSION_IMAGE_PRESIGN_TTL_SECONDS=900 # lifetime of a presigned image GET URL
COACHING_SESSION_IMAGE_GRACE_PERIOD_HOURS=168 # how long a removed image survives undo
COACHING_SESSION_IMAGE_PURGE_POLL_MINUTES=60 # how often the purge job sweepsOnly needed to exercise the production path, and required in PR previews and production.
- Create a Space at cloud.digitalocean.com/spaces and note
its region (e.g.
nyc3) and name. - Under Settings → Spaces Keys, generate an access key pair. The secret is shown once — store it in your secrets manager.
- Set
OBJECT_STORE_BACKEND=spacesplus the variables below. The regional endpoint must matchSPACES_REGION.
OBJECT_STORE_BACKEND=spaces
SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
SPACES_REGION=nyc3
SPACES_BUCKET=<space-name>
SPACES_ACCESS_KEY_ID=<access-key-id>
SPACES_SECRET_ACCESS_KEY=<secret-access-key>If the backend is spaces but any of these is missing, the app still boots — it logs a warning and
the image endpoints return 503 rather than failing startup.
To verify the whole pipeline end to end, follow docs/test-plans/coaching_session_images_manual_testing.md.
# ==============================
# Encryption (required for OAuth token storage)
# ==============================
ENCRYPTION_KEY=<output of: openssl rand -hex 32>
# ==============================
# Google OAuth
# ==============================
GOOGLE_CLIENT_ID=<client-id>.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=<client-secret>
GOOGLE_REDIRECT_URI=http://localhost:4000/oauth/google/callback
OAUTH_SUCCESS_REDIRECT_URI=http://localhost:3000/settings
# ==============================
# Recall.ai
# ==============================
NGROK_DOMAIN=<your-reserved-ngrok-domain>
RECALL_AI_API_KEY=<key from your own Recall development workspace>
RECALL_AI_REGION=us-west-2
RECALL_AI_WEBHOOK_SECRET=whsec_<your workspace signing secret>
# ==============================
# Suggested Actions (AssemblyAI LLM Gateway)
# ==============================
ASSEMBLY_AI_API_KEY=<your AssemblyAI API key>
ASSEMBLY_AI_LLM_MODEL=claude-sonnet-4-6
ASSEMBLY_AI_LLM_BASE_URL=https://llm-gateway.assemblyai.com/v1
# ==============================
# Coaching note images
# ==============================
OBJECT_STORE_BACKEND=local
OBJECT_STORE_LOCAL_PATH=./.local-object-store
COACHING_SESSION_IMAGE_MAX_BYTES=10485760
COACHING_SESSION_IMAGE_PRESIGN_TTL_SECONDS=900
COACHING_SESSION_IMAGE_GRACE_PERIOD_HOURS=168
COACHING_SESSION_IMAGE_PURGE_POLL_MINUTES=60
# ==============================
# Collaborative notes (docs-collab-server)
# ==============================
TIPTAP_URL=http://localhost:1234
TIPTAP_AUTH_KEY=<any shared secret; the launcher passes it to both servers>
TIPTAP_JWT_SIGNING_KEY=<any shared secret; the launcher passes it to both servers>Coaching-session notes sync through the self-hosted docs-collab-server, not TipTap Cloud. The frontend has no Cloud fallback: if its collab URL is set but nothing is listening, the editor opens local-only with no error, and notes silently never sync. So the server has to be running whenever you work on notes.
scripts/run_backend.sh builds and starts it alongside the app server, deriving everything from .env:
JWT_SIGNING_KEYandMANAGEMENT_AUTH_KEYcome fromTIPTAP_JWT_SIGNING_KEYandTIPTAP_AUTH_KEY, so they match the app by construction.- It uses its own local database,
refactor_collab, built from thePOSTGRES_*values and created on first run. This mirrors production and PR preview, and means notes survivescripts/rebuild_db.sh. Override withCOLLAB_DATABASE_URLif you want it elsewhere. - It binds
127.0.0.1:1234(override withCOLLAB_BIND_ADDR). If you change the bind address or port, the app-facing URL follows it (http://localhost:<port>, or setCOLLAB_URLexplicitly), and the frontend'sNEXT_PUBLIC_DOCS_COLLAB_URLmust be changed to match by hand; nothing validates that side. - It exits with the status of the first binary that dies, so a startup failure such as a port already in use is reported as a failure, not a clean exit.
One-time .env change so the app talks to the local server instead of Cloud:
TIPTAP_URL=http://localhost:1234The script warns at startup if this is still pointing at Cloud. On the frontend side, .env.local needs NEXT_PUBLIC_DOCS_COLLAB_URL="ws://localhost:1234".
Sanity check once it's up:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:1234/health # 200If notes open but never sync or show presence, check that the collab server is running before anything else.
- Generate and set
ENCRYPTION_KEY. - Start the backend:
scripts/run_backend.sh(app server plus collab server). - Open the Recall webhook tunnel:
scripts/recall_webhook_tunnel.sh(one-time Recall endpoint setup in section 4). - Start the frontend.
- In the frontend, connect Google Meet via the settings page — this triggers the OAuth flow to
GOOGLE_REDIRECT_URI. - Once connected, starting a coaching session with a Google Meet link will dispatch a Recall.ai bot. Bot events arrive at
/webhooks/recall_aiand are verified usingRECALL_AI_WEBHOOK_SECRET.
Generate a key on the production host and store it in your secrets manager (never commit it):
openssl rand -hex 32Set ENCRYPTION_KEY in your environment or secret store. All existing encrypted tokens become unreadable if this key changes, so treat it as permanent once the service is live.
Use the same Google Cloud project as development, or create a dedicated production project.
Key differences from local setup:
- Redirect URI: Set to your production domain, e.g.
https://api.myrefactor.com/api/auth/google/callback - OAuth consent screen status: Submit for Google verification to move out of "Testing" mode (required for non-test users to authorize)
- Authorized redirect URIs: Add your production URI in the OAuth 2.0 Client ID settings
GOOGLE_CLIENT_ID=<client-id>.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=<client-secret>
GOOGLE_REDIRECT_URI=https://api.myrefactor.com/api/auth/google/callback
OAUTH_SUCCESS_REDIRECT_URI=https://myrefactor.com/settings- Webhook URL: Set to your production endpoint, e.g.
https://api.myrefactor.com/webhooks/recall_ai - No tunnel required — the production host is directly reachable
- Use production API keys and signing secrets, not development ones
- Retention: every bot asks Recall to delete its recording (audio, video and Recall's copy of the transcript) after 7 days, which is within Recall's free retention. The platform keeps only the transcript, in its own database. Bots created before this setting keep the account's default retention; delete their media through Recall's API if that default is forever
RECALL_AI_API_KEY=<production-api-key>
RECALL_AI_REGION=us-east-1 # or eu-west-2
RECALL_AI_WEBHOOK_SECRET=whsec_<production-signing-secret>Session conversations are sent to AssemblyAI and, through the LLM Gateway, to the model vendor. Walk through this checklist before setting ASSEMBLY_AI_API_KEY in production.
-
Own account and key. Use the platform's own AssemblyAI account on a paid plan and a production key. Set
ASSEMBLY_AI_API_KEYas a GitHubproductionenvironment secret. -
Opt out of model training. In the AssemblyAI dashboard, open Settings > Data Controls and turn on "Opt Out of Data Sharing for Model Improvement Program". Only an Owner or Admin can do this, it needs a paid plan, it applies going forward only, and it forgoes the program discount. Alternative: email data-opt-out@assemblyai.com from the account's address and keep the confirmation.
-
Model vendors do not train on LLM Gateway traffic. AssemblyAI states it has opted out of training with every LLM Gateway provider. Nothing to configure.
-
Short retention. In Data Controls, set Time-to-live (TTL) to 1 day. It covers LLM Gateway inputs and outputs as well as audio and transcripts sent to AssemblyAI directly, and deletion runs hourly once it has passed. The privacy policy promises deletion within one day, so change both together.
-
Keep an Anthropic model. Leave
ASSEMBLY_AI_LLM_MODELon an Anthropic model (defaultclaude-sonnet-4-6). AssemblyAI runs Claude on Amazon Bedrock, which stores nothing, falling back to Anthropic's own API with zero-day retention. Zero data retention for every request is available for Anthropic and Google models under a signed BAA, if it is ever required. -
Provider toggles. Claude runs through AWS Bedrock, which is enabled by default. In Data Controls, leave the opt-in providers (Together AI, Fireworks AI, DigitalOcean) off.
-
Pricing row (optional, for cost tracking). Without it, analyses still run but no cost row is recorded. Insert one rate for provider
llm_gateway, metricllm_tokens:cost_per_unit_lowis the model's input price per token andcost_per_unit_highits output price per token. Each analysis is priced exactly from its input and output token counts, socost_per_unit_avgis not used for tokens; it only has to be filled in, for example with a blend of the two. claude-sonnet-4-6 on AssemblyAI is $3 / $15 per million tokens, and LLM Gateway requests are billed with its 10% regional surcharge, which is 0.0000033 / 0.0000165 per token:INSERT INTO refactor_platform.cost_pricing_config (provider, metric, unit, cost_per_unit_low, cost_per_unit_high, cost_per_unit_avg, effective_from) VALUES ('llm_gateway', 'llm_tokens', 'tokens', 0.0000033, 0.0000165, 0.0000051857, NOW());
The average above, (6 x 0.0000033 + 1 x 0.0000165) / 7, is informational only. When prices change, insert a new row with a later
effective_fromrather than editing this one. -
Re-check the terms. AssemblyAI's terms change. Read their current pages before enabling: data retention and model training, data controls, LLM Gateway providers.
-
Privacy policy. The platform's privacy policy (
/privacyin the frontend) names Recall.ai, AssemblyAI and Anthropic, and states the training opt-out, the one-day TTL and Recall's 7-day retention. Keep it in step with these settings.
In addition to the variables in README.md (database, Tiptap, Resend), add:
# Encryption
ENCRYPTION_KEY=<64-hex-char key from secrets manager>
# Google OAuth
GOOGLE_CLIENT_ID=<client-id>.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=<client-secret>
GOOGLE_REDIRECT_URI=https://api.myrefactor.com/api/auth/google/callback
OAUTH_SUCCESS_REDIRECT_URI=https://myrefactor.com/settings
# Recall.ai
RECALL_AI_API_KEY=<production-api-key>
RECALL_AI_REGION=us-east-1
RECALL_AI_WEBHOOK_SECRET=whsec_<production-signing-secret>
# Suggested Actions (see "Suggested Actions: private AssemblyAI setup")
ASSEMBLY_AI_API_KEY=<production-api-key>
ASSEMBLY_AI_LLM_MODEL=claude-sonnet-4-6
ASSEMBLY_AI_LLM_BASE_URL=https://llm-gateway.assemblyai.com/v1
# Coaching note images (see "Coaching Note Images" under Development Setup)
OBJECT_STORE_BACKEND=spaces
SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
SPACES_REGION=nyc3
SPACES_BUCKET=<space-name>
SPACES_ACCESS_KEY_ID=<access-key-id from secrets manager>
SPACES_SECRET_ACCESS_KEY=<secret-access-key from secrets manager>
COACHING_SESSION_IMAGE_MAX_BYTES=10485760
COACHING_SESSION_IMAGE_PRESIGN_TTL_SECONDS=900
COACHING_SESSION_IMAGE_GRACE_PERIOD_HOURS=168
COACHING_SESSION_IMAGE_PURGE_POLL_MINUTES=60See docs/cicd/production-deployment.md for the full deployment process.