Skip to content

Latest commit

 

History

History
1224 lines (990 loc) · 66.4 KB

SETUP.md

File metadata and controls

1224 lines (990 loc) · 66.4 KB

Talyn Setup Checklist

Actions you (Tom) need to take outside this repo. Everything that can be automated by Claude Code is being automated — this doc covers the things that require your credentials, accounts, or browser approval.

Legend:

  • ⚡ Now — needed for current dev loop
  • 🔜 Soon — needed for Phase 18 (hosted backend)
  • 🧰 Nice-to-have — dev ergonomics, do when convenient

⚡ Required now

0. Local dev stack — Supabase local + dev-only GitHub OAuth apps

Why this exists (June 2026): local dev used to point at the prod Supabase DB and the prod GitHub OAuth app. That cross-talk silently revoked prod's GitHub token: every reconnect (dev or prod) minted a new token against the same OAuth app until GitHub's 10-tokens-per-user/app/scope cap revoked the oldest, and any backend still holding the revoked token in memory would 401 and delete the shared integrations row. Dev and prod must never share a DB, an OAuth app, or a TALYN_TOKEN_KEY.

Local dev runs the full stack on your machine via the Supabase CLI (Docker required):

npm run dev:db        # supabase start (db + auth + api; heavyweight services excluded)
npm run dev           # backend (migrations auto-apply on boot) + desktop
npm run dev:db:stop   # when you're done

Local endpoints (stable across restarts):

  • API / auth: http://127.0.0.1:54321
  • Postgres: postgresql://postgres:postgres@127.0.0.1:54322/postgres
  • Studio: http://127.0.0.1:54323
  • Keys: npx supabase status prints the local anon + service_role keys

One dev-only classic GitHub OAuth app backs desktop login. Create it once in the browser (GitHub has no API for it) under GitHub → Settings → Developer settings → OAuth Apps → New OAuth App:

Field Value
Application name anything, e.g. talyn-dev
Homepage URL anything, e.g. your fork's GitHub URL
Redirect URI http://127.0.0.1:54321/auth/v1/callback
Allow wildcard matching off
Enable Device Flow off
Expire user access tokens off

Copy the client id, generate a client secret, and put them in the gitignored supabase/.env as SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID / SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET. The provider is wired up in supabase/config.toml. The dev desktop deep link fastowl-dev://auth-callback is already in additional_redirect_urls (dev builds use the fastowl-dev:// scheme so the OAuth callback reopens your dev build instead of an installed production FastOwl.app).

This app is login only. Connecting a workspace to GitHub (onboarding step 1, Settings → Integrations → GitHub) needs a dev GitHub App — see §3b. The classic-OAuth connect flow and its /api/v1/github/callback route are gone, so a second OAuth app, or a GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET pair in packages/backend/.env, no longer connects anything.

packages/backend/.env and apps/desktop/.env must point at the local stack — never at Railway/prod values (those live only in Railway service variables).

1. Anthropic API key — NOT needed to run Talyn

Nothing in the backend or either front end reads ANTHROPIC_API_KEY. Talyn never calls Anthropic on a first-party key: the fleet runs on the workspace's own subscription credential (sk-ant-oat…, §6c) and PostHog Code runs on PostHog's token. That is the whole point of the fleet — see docs/CLOUD_PROVIDERS.md.

This section used to say the key powered packages/backend/src/services/ai.ts for auto-generating task titles. That file was deleted in the cloud-only refactor and the instruction outlived it, so anyone following this guide was creating a billable key for a feature that no longer exists.

The only things that read it today are optional and developer-facing:

  • scripts/check-model-catalog.mjs — one read-only GET /v1/models to report catalogue drift (see §6d). Skips cleanly when the key is absent.
  • scripts/spikes/spike-claude.ts — a hand-run spike, never shipped.

2. claude CLI on every environment

Talyn spawns claude (interactive mode) via node-pty on the chosen environment. The binary must be in the PATH of whichever shell gets spawned.

  • Local: npm install -g @anthropic-ai/claude-cli (or whatever the current install command is), log in via claude login
  • VMs: same, on the remote user's shell

Verify by running claude --version as the shell user Talyn will use.

One-time MCP trust approval (only if you run autonomous tasks in strict mode on this environment — i.e., the env's "Allow unattended Claude runs to bypass permission prompts" toggle is OFF):

Talyn's repo root ships a .mcp.json registering the Supabase MCP server. On first encounter, Claude Code prompts you to trust it. Autonomous runs can't answer that prompt, so do it once interactively:

cd ~/path/to/fastowl    # or wherever the clone lives on this env
claude                  # opens the TUI
# → prompted: "New MCP server found in .mcp.json: supabase — use this?"
# → pick "Use this and all future MCP servers in this project"
# → Ctrl-D to exit

The approval lands in your user-level Claude config and sticks. You don't need to do this on daemon envs that have "bypass permissions" enabled — they skip all prompts by design.

3. GitHub OAuth app (already scaffolded in backend)

Used by the GitHub integration (connect GitHub → PR monitoring, PR actions, repo listing).

Use a classic OAuth App, NOT a GitHub App. The connect flow in services/github.ts is the classic OAuth web flow (scope=repo read:user read:org, exchange code → long-lived user token). A GitHub App grants access by installation, so it can't read a repo you personally have access to (e.g. posthog/posthog) without being installed on that org — the wrong model here. A classic OAuth App's token acts as you, so it reads any public repo and any private repo your account can reach (private org repos may still need the org to approve the app under its third-party-access policy). You can tell the two apart by the client ID: classic OAuth = bare hex / Ov23…; a GitHub App is Iv1.… / Iv23… — if you see that prefix, you made the wrong app type.

  1. https://github.com/settings/developers → OAuth Apps tab → New OAuth App (do not use the "GitHub Apps" tab)
  2. Application name: Talyn (Dev) (make a separate prod one later)
  3. Homepage URL: http://localhost:4747
  4. Authorization callback URL: http://localhost:4747/api/v1/github/callback (must match GITHUB_REDIRECT_URI exactly)
  5. Create, then Generate a new client secret
  6. Export before running the backend:
    export GITHUB_CLIENT_ID=Ov23xxxxx        # classic OAuth App id (NOT Iv1./Iv23 — that's a GitHub App)
    export GITHUB_CLIENT_SECRET=xxxxx
    export GITHUB_REDIRECT_URI=http://localhost:4747/api/v1/github/callback

No scopes are configured on the app itself — the backend requests repo read:user read:org at authorize time.

Without these, the "Connect GitHub" button in Settings will fail loudly.

3b. GitHub App (webhooks and user authorization)

The App supplies webhooks across replicas. All workspace repository operations use the connected user's token, including automated writes. Installation credentials do not authorize workspace access. Repository registration and webhook processing verify the user's current repository access. Classic OAuth connections remain supported. User-to-server tokens use the user's rate budget, not a separate installation budget.

Make TWO Apps — "Talyn App Dev" (slug talyn-app-dev) and "Talyn App" (slug talyn-app). A GitHub App has exactly one webhook URL, so it can't serve localhost and Railway at once. Dev's creds go in packages/backend/.env; prod's go in Railway variables. The backend just reads whatever GITHUB_APP_* / GITHUB_WEBHOOK_SECRET env it finds — no code difference. Use a separate private key + secret per App.

  1. https://github.com/settings/apps → New GitHub App.
  2. Webhook URL: https://<your-backend>/api/v1/webhooks/github.
    • Prod: https://prod.talyn.dev/api/v1/webhooks/github.
    • Local: tunnel it — npx smee-client --url https://smee.io/<channel> --target http://localhost:4747/api/v1/webhooks/github, and use the smee.io/<channel> URL here. Webhook secret: generate one → GITHUB_WEBHOOK_SECRET.
  3. Permissions (read-only unless noted): Pull requests R/W (merge/auto-merge write paths), Checks R, Commit statuses R, Contents R, Metadata R, Members R (team-based review-request resolution).
  4. Subscribe to events: pull_request, pull_request_review, pull_request_review_comment, issue_comment, check_run, check_suite, status, push, installation, installation_repositories. (push is what lets us catch a PR becoming conflicting because its base branch advanced — GitHub sends no per-PR event for that; on a push we refresh every open PR based on the pushed branch.)
  5. Enable "Request user authorization (OAuth) during installation", and set the Callback URL to http://localhost:4747/api/v1/github/app/callback (prod: the Railway host equivalent — an App can list multiple callback URLs).
  6. Expiring user tokens are supported through refresh-token rotation. The backend checks persisted credentials before use. Concurrent rotation cannot overwrite a disconnected or replaced integration. An expired refresh grant requires reconnection.
  7. Generate a private key (.pem), base64 it, and set the env vars (see the .env block below): GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET.
  8. Set REDIS_URL (webhooks are enqueued onto a Redis Stream; without it the receiver acks-and-drops and the reconcile sweep keeps PRs fresh).
    • Optional: WEBHOOK_STREAM_MAXLEN caps the ingest stream length (default 50000, approximate trim). A backlog only forms if the worker can't keep up; raise it for more runway before the oldest deliveries are dropped, but size it to your Redis memory — each entry holds a full webhook payload (~10-30KB), so 50k ≈ up to ~1GB worst case.

Connecting (two steps):

  1. Install the App on each account/org whose repos you want to track — GitHub → the App's page → Install (or Configure to add repos). One-time per account; a user can install it on their personal account and several orgs.
  2. In the desktop, Settings → Integrations → GitHub → Connect GitHub. This runs the OAuth user-authorization flow (/login/oauth/authorize), which always redirects back to /github/app/callback with a signed state — whether or not the App is already installed. (We deliberately do NOT use /installations/new, which dead-ends on the "configure" page for an existing install.) The callback exchanges the code for the user token, discovers all the user's installations via GET /user/installations, records them, and bulk-refreshes.

Data-plane reads resolve the installation per repo owner, so one workspace can span repos across multiple installed accounts/orgs. A repo only delivers webhooks once the App is installed on its owner with that repo selected. The reconcile sweep (~5 min) + manual refresh are the safety net for missed deliveries.


🔜 Needed for Phase 18 (hosted backend)

Do these when we're ready to stand up the hosted infrastructure — not urgent yet, but creating the accounts early is free and removes friction when we get there.

4. Supabase project

For Postgres + auth when Phase 18.1/18.2 lands.

  1. Create account at https://supabase.com
  2. New project (free tier is fine for dev) — pick closest region
  3. From the project dashboard, grab:
    • SUPABASE_URL (Project settings → API → Project URL)
    • SUPABASE_ANON_KEY (same page → anon public)
    • SUPABASE_SERVICE_ROLE_KEY (same page → service_role) — never expose to the desktop app, backend only
    • DATABASE_URL (Project settings → Database → Connection string → URI, with pooling for runtime)
  4. Enable GitHub OAuth in Supabase Auth (Authentication → Providers → GitHub). You'll need to create a separate GitHub OAuth app for Supabase auth (the one from #3 is for workspace-level GitHub integration):
    • https://github.com/settings/developers → New OAuth App
    • Homepage URL: your Supabase project URL
    • Authorization callback URL: https://<project-ref>.supabase.co/auth/v1/callback
    • Paste the client ID/secret into Supabase Authentication → Providers → GitHub
  5. Add fastowl://auth-callback to Redirect URLs in Supabase (Authentication → URL Configuration → Redirect URLs). Without this, the desktop deep-link flow fails silently with an "invalid redirect URL" error.
  6. Storage bucket for session work (required for session checkpoints and archives). In Storage → New bucket, create workbench-work with Public bucket OFF. Leave the file size limit at the default: Talyn writes parts of 2 MiB. Add no storage policies: only the backend reads and writes it, with SUPABASE_SERVICE_ROLE_KEY, which bypasses RLS. No new env var. Without the bucket, checkpoints fail (logged, never a failed turn) and an archive with unsaved work keeps its sandbox and says the save failed. Local dev gets the bucket from supabase/config.toml ([storage.buckets.workbench-work]) after npm run dev:db.

Single-user allow-list (optional, recommended while Talyn is pre-invites)

Talyn doesn't ship an invite flow yet — anyone with a GitHub account can sign in to an instance. To lock a self-hosted instance to just you, set on the backend:

TALYN_ALLOWED_EMAILS=you@example.com

Multiple emails are comma-separated. Unauthorised callers get a 403 on first request. Once invite flows land (TODO in ROADMAP Phase 19) this can go away.

Leaving it unset allows everyone — it is a lock, not a default. Setting it now also applies to MCP tokens and internal impersonation: a token whose owner is off the list stops validating and returns 401, which looks to the user like a bad token. Include every MCP-token owner's email, not only the people who sign in.

Feature flags (PostHog)

Every feature gate is a PostHog flag. The register of them — their keys, their break-glass env vars and their per-flag fallbacks — is packages/shared/src/featureFlags.ts, and the backend reads them through packages/backend/src/services/featureFlags.ts. Nothing gates on process.env at the call site.

Flag (PostHog key) Gates Break glass Fallback Availability
workflows Workflows — user-defined PR automation WORKFLOWS_ENABLED=false ON general
loops Loops — recurring prompts on a cron schedule LOOPS_ENABLED=false OFF general
talyn-fleet Talyn Fleet — the Firecracker microVMs FLEET_ALLOWED=false OFF general
public-api The public API (/v1) and personal access tokens PUBLIC_API_ENABLED=false OFF gated

Public API (/v1)

The backend serves the public API at /v1 on its normal host. Customers use https://api.talyn.dev/v1: add api.talyn.dev as a Railway custom domain on the backend service and point a CNAME at it. No path rewrite is needed. Evaluate the public-api flag against the token OWNER (the Supabase user id, with the email as a person property). Set PUBLIC_API_ENABLED=true to use the API locally with no PostHog project. Rate limits use REDIS_URL when it is set, and count in each process when it is not. See PUBLIC_API.md.

availability is a separate question from fallback: it says whether the "What's new" modal may describe the feature yet. general does NOT mean everybody has it — the PostHog audience still decides that. It means the notes may talk about it, so a flag can be general while its rollout is still a percentage. Flipping it replays the withheld backlog to everyone who missed it.

Every flag is general as of 2026-09-16, so nothing is withheld today. Note what that means for talyn-fleet: its availability and fallback now disagree on purpose. The fleet is announced to everybody, and a PostHog outage still refuses it, because an announcement does not change the fact that the fleet is finite hardware running on a workspace's own agent subscription.

The fallbacks are deliberately opposite, and they are per flag. The fallback is the answer when PostHog is not configured, is unreachable, or has never heard of the key — so it is chosen by asking "if the flag service is down, what is the safe answer?" Workflows is released, so a PostHog outage must not hide a page that exists. The fleet is one box running on somebody's own subscription, so an outage must not open it to everybody. Loops reads like workflows but answers like the fleet, and the difference is worth stating: a workflow acts when a webhook arrives, so there is a person at the other end of every firing, while a loop acts because time passed — "PostHog is unreachable, so arm every scheduler" spends the workspace's money and its agent subscription at 3am with nobody watching. A single shared default would silently flip whichever of these it did not describe.

Running Loops locally needs no PostHog project at all: set LOOPS_ENABLED=true and the override wins over the flag service. Without it the scheduler still arms — the boot log says so — but every firing is refused per workspace, which is the same thing as off.

Backend configuration

TALYN_POSTHOG_KEY=phc_...              # absent ⇒ every flag answers its fallback
TALYN_POSTHOG_PERSONAL_API_KEY=phx_... # optional, but see below
TALYN_POSTHOG_HOST=https://us.i.posthog.com

Set the personal API key in production. With it, posthog-node polls the flag DEFINITIONS every 30s and evaluates them in-process, so a gate costs no network at all. Without it every gate is an HTTP round trip to PostHog — cached here for 30s to keep it survivable, but the workflows gate runs once per webhook delivery per watching workspace, and the engine already dropped a users join from that path for being too expensive. The boot log says which mode the process is in ([flags] …).

The key needs the feature flag read scope and nothing else.

Audiences

A flag's audience is a PostHog release condition. The backend evaluates against the Supabase user id as the distinct id — the same one analytics.ts captures against, so a person targeted in PostHog is the same person in their own funnels — and passes their email as a person property. So the literal thing the old FLEET_ALLOWED_EMAILS did is a release condition of email ∈ {…}; unlike the env var it can also be a percentage, a cohort, or everybody-except-one-account, and changing it is a save rather than a restart.

Which subject is used depends on the flag:

  • workflows is asked about the caller at the routes and at GET /api/v1/features, and about the workspace owner in the engine (a webhook delivery has no caller).
  • talyn-fleet is always asked about the workspace owner. A task can be dispatched by a webhook, the poller or a sweep — none of which has a user attached — and a gate that passes when it cannot identify a caller is not a gate.

The break-glass variables

Each flag keeps one env var that overrides PostHog entirely, in both directions. Anything other than false/0/off/no reads as ON, so a typo turns a feature on rather than silently off — the safer failure, because "it stopped working and nobody knows why" is harder to notice than the thing you were trying to stop.

They exist for two cases a remote flag service cannot serve:

  • Stopping something in a hurry. Workflows comment on, label and merge other people's pull requests. When that goes wrong, "the flag service is down" must never be why it stays on.
  • Local development and CI, which have no project key. FLEET_ALLOWED=true is how you work on the fleet locally.

Pulling WORKFLOWS_ENABLED=false stops the engine, 403s every /api/v1/workflows route, and makes GET /api/v1/features answer { workflows: false } so both clients hide the nav item rather than leaving it pointing at routes that refuse.

Note that setting one of these to true disables the flag. An override wins whether it says yes or no, so WORKFLOWS_ENABLED=true in a deployment's env means PostHog is never consulted for that flag. Leave them unset unless you are breaking glass.

Retired variables

FLEET_ALLOWED_EMAILS and WORKFLOWS_ALLOWED_EMAILS are gone. Nothing reads them; delete them from any deployment that still has them. The backend warns at boot ([env] … no longer does anything) rather than refusing to start, because the deployment that still has them set is by definition the one mid-migration.

Adding a flag

  1. Add an entry to FEATURE_FLAGS in packages/shared/src/featureFlags.ts, picking fallback by the "if PostHog is down" question above, and availability by a different question: may the "What's new" modal talk about this feature yet? A new gate is 'gated'. Set releaseScopes to the commit scopes that belong to it (['loops']).
  2. Create the flag in PostHog under the same posthogKey.
  3. Read it through services/featureFlags.ts — never process.env at the call site, or the override precedence gets reimplemented per gate.
  4. If a client needs to DRAW something from it, add the key to ACCOUNT_FEATURE_FLAGS so GET /features answers it.

Releasing a gated feature

Set availability: 'general' (or delete the flag). That one edit is what announces it: every release highlight that was withheld under the flag becomes visible, and each client replays the backlog it was never shown. Nothing else to remember, and nothing to remember it in the same commit as.

Enforce it where the work happens. A hidden nav item is a decoration that the CLI, the MCP server and plain curl all walk straight past.

Talyn Fleet (required to use the Firecracker fleet)

The selfhosted provider — "Talyn Fleet" everywhere in the UI; the wire and DB value stays selfhosted because it is persisted in environments.type and integrations.type — runs tasks on hardware we own — one box, with a memory budget that fits a couple of concurrent runs. Two separate things gate it, and both are needed:

FLEET_ENABLED=true     # this deployment has fleet hardware to talk to

plus the talyn-fleet PostHog flag turned on for the workspace owner (see Feature flags above).

FLEET_ENABLED stays an env var on purpose. It is not an audience question: it says whether this deployment has fleet hardware and gateway tokens to reach, and it is read once at boot to decide whether to register the provider at all. No flag can conjure a machine, and a boot-time registration should not wait on a network call. Unregistered is also a stronger off than a runtime branch — with it unset, getCloudProvider('selfhosted') returns null, so no dispatch path can reach the fleet at all.

The audience check runs at dispatch and at credential-write, not in the UI — the settings screen also hides the provider, but that is cosmetic, and the CLI, the MCP server and curl never render it. A task dispatched by a workspace outside the audience is failed with the reason attached rather than left silently queued.

The gate still matters now the fleet is the DEFAULT provider. selfhosted heads CLOUD_PROVIDER_ORDER (services/prCloudFix.ts), but resolveCloudEnvChain drops that link entirely for a workspace that may not use the fleet — so a workspace outside the audience gets exactly the behaviour it had before: the chain heads at PostHog Code, no fleet card is offered, and a credential write is 403'd. The fleet is one box; widen the audience as capacity allows rather than removing the gate.

Connecting the backend to a self-hosted fleet (Tailscale)

A fleet host binds loopback and is never exposed publicly (fleet spec §16.4), so the backend needs a private path to it. The fleet spec asks for a WireGuard mesh (§15 risk 5); a tailnet is the same idea with key distribution already solved.

Both ends run Tailscale in userspace mode. That is not a preference:

  • On the backend, a PaaS container has no NET_ADMIN and cannot create a TUN device. tailscaled exposes a local HTTP proxy instead and the fleet client dials through it; nothing else in the backend's networking changes.
  • On a fleet host, a TUN-mode tailscaled programs its own nftables rules. hetzner-64 runs an Incus-managed guest for a different workload, with table inet incus beside our table inet fleet — adding a third party's firewall rules to that is how you break a co-tenant nobody warned. Userspace mode creates no interface and writes no firewall rules at all; inbound connections are terminated by tailscaled and proxied to fleetd's loopback port by tailscale serve.

1. Tailnet + keys

In the Tailscale admin console, generate reusable, pre-approved, tagged auth keys. Tag them so ACLs can name groups rather than machines:

tag:talyn-backend   the backend container(s)
tag:talyn-fleet     the fleet hosts

An ACL that lets only the backend reach fleet hosts, on the fleet port:

"acls": [
  { "action": "accept", "src": ["tag:talyn-backend"], "dst": ["tag:talyn-fleet:8080"] }
]

That matters — without it, every device on the tailnet can reach a machine that runs untrusted code.

2. Each fleet host

From the talyn-fleet repo:

sudo provision/tailscale.sh --authkey tskey-auth-... --hostname fleet-hetzner-64

It prints the tailnet address. Add to /etc/fleet/secrets.env and restart fleetd:

FLEET_ADVERTISE_ENDPOINT=http://100.x.y.z:8080
FLEET_REPORT_URL=https://prod.talyn.dev/api/v1/fleet/report
FLEET_REPORT_TOKEN=<same value as the backend's>

FLEET_ADVERTISE_ENDPOINT is what the backend dials. It is advertised by the host because the report arrives from a NAT'd source address that is not somewhere you can connect back to, and only the host knows which of its addresses is. Leave it unset and the host registers as observable but not dispatchable — a real state, not a broken one.

3. The backend

TS_AUTHKEY=tskey-auth-...              # absent = Tailscale never starts
TS_HOSTNAME=talyn-backend
FLEET_HTTP_PROXY=http://localhost:1055 # what the fleet client dials through
FLEET_REPORT_TOKEN=<shared with every fleet host>
FLEET_API_TOKEN=<shared with every fleet host>
FLEET_ENABLED=true
TS_DEBUG_MTU=1000                      # optional; the entrypoint defaults to this

On the tunnel MTU, because the failure it prevents does not look like an MTU problem. Tailscale's default tunnel MTU is 1280, and on at least one Railway↔Hetzner path that black-holes: anything fitting in a single segment arrives, anything larger never does. /healthz and /v1/capacity answered in 20ms throughout, so the link looked healthy — while dispatch timed out at 20s and transcripts could not be read at all. It presents as "the fleet is unreachable", and every small-payload check you would reach for to test that passes.

The entrypoint defaults TS_DEBUG_MTU to 1000. Raise it only with a measurement in hand: fetch something over a few KB (/v1/sandboxes, /metrics) through the proxy and check the byte count, not just the status code.

The yas control plane hit the identical wall from its own Railway container and 1000 is now measured on both sides of the cliff there: at 1280 a 1 KB exec response took 196 s and 20 KB never arrived at all, while at 1000 the same sandbox returned 1,000,000 bytes in 1.29 s. 1280 was re-applied afterwards and the hang came back, so the number is the cause and not the restart that applied it. See yas cmd/yasctl/docker-entrypoint.sh.

The two fleet tokens run in opposite directions and are not interchangeable:

direction must match
FLEET_REPORT_TOKEN host → backend (POST /fleet/report) every host's FLEET_REPORT_TOKEN
FLEET_API_TOKEN backend → host (dispatch, follow, cancel) every host's FLEET_API_TOKEN

FLEET_API_TOKEN is deployment config, not a workspace setting. It authenticates one service to another and is identical for every workspace, so the settings UI does not ask for it — a user cannot be custodian of a secret they neither own nor can rotate, and asking them got it wrong in the obvious way (a token right for one host and wrong for the next). Unset, the backend refuses to store fleet credentials at all and says so, rather than accepting a Claude token against a fleet it cannot talk to.

The Talyn Fleet card asks for one thing per AGENT, and nothing else. Nothing else on it was the workspace's to give — which host a run lands on is answered by the registry from reports seconds old, and nobody using the product can see which box is least loaded or which stopped reporting four minutes ago.

  • Claude subscription — an OAuth token from claude setup-token (sk-ant-oat…), or a Console API key (sk-ant-api…) to be billed per token.
  • Codex (ChatGPT) subscription — on the desktop, one "Sign in with ChatGPT" button; the whole OAuth flow runs in the app's main process because OpenAI's Codex client redirects to http://localhost:1455/auth/callback, which only a process on the user's own machine can answer. On app.talyn.dev there is no loopback and no filesystem, so it takes a paste of ~/.codex/auth.json after codex login. Either way the backend stores the access AND refresh token and renews them itself — a subscription access token is short-lived and a fleet run outlives it.

Keeping the model catalogues current. FLEET_MODELS is hand-curated (ordering, blurbs, deliberate exclusions), so it rots silently — a legacy model keeps working, it just stops being the best one on offer. Two things watch it, split by what each vendor actually exposes:

  • Anthropic publishes GET /v1/models, so .github/workflows/model-catalog.yml diffs it against ours daily and reports both directions (offered-but-gone, served-but-not-offered). It needs an optional ANTHROPIC_API_KEY repo secret and skips cleanly without one. It reports; a human still picks the label.
  • Codex has no equivalent. OpenAI's GET /v1/models answers for an API KEY, while the fleet runs on the user's own ChatGPT subscription, and the gap between those is exactly what took every Codex run down. So that half is learned at run time: services/selfHosted/withdrawnModels.ts reads the vendor's own refusal out of the failure, stops dispatching at that id, and migrates the workspace's stored choice onto the agent's default — no deploy.

Either agent alone is enough. A workspace with only Codex connected is fully configured, and its runs default to gpt-6.1-terra rather than to a Claude model it would then be refused for. Which agent runs a given task is decided by the MODEL — Settings → Talyn Fleet → Model for the workspace default, or the per-task agent menu (right-click the robot on any PR row).

A dispatch never falls back to the gateway's own credentials. The gateway fills an absent or blank anthropicKey/openaiKey from its tenant's sealed custody, so Talyn always sends the workspace's own key for the vendor being run and suppresses the other with policy.credentials. No credential for that vendor is a refusal naming the fix, never a blank field.

Dispatching through the sandbox gateway

FLEET_PINNED_ENDPOINT=https://api.yetanothersandbox.dev
FLEET_GATEWAY_TOKEN=yas_sk_...              # blank/unset = use the registry

This is the normal path as of 2026-08-24. The variable keeps its old name — it began as a debugging pin and renaming a deployed variable is a coordinated restart to change a string — but what it points at is now the yas control plane, which does the placing itself.

Read that as the opposite of the old warning rather than a repeat of it. Pinned at a HOST it really was dangerous: one box, chosen once, no idea whether it is draining or offline, and a stale value silently routed every task to a machine that had been down for a week. Pinned at the GATEWAY it is safer than the registry, because the gateway holds the placement index and can refuse to offer an AGENTIC id to a second host when a create's answer is lost. This backend cannot: a duplicated sandbox is idle capacity, a duplicated agent is a second LLM bill for the same work, and only the party holding the index can prevent it.

The two tokens are different trust domains and are not interchangeable. FLEET_API_TOKEN is a deployment secret every host shares; a gateway key is a per-tenant key the gateway minted and can revoke on its own. Sending either to the other 401s. Both stay set, because both destinations are still in use: the operator console keeps dialling hosts DIRECTLY for capacity, stats, metrics, goldens and drain, which are host questions the gateway does not serve.

Cut over when nothing is in flight. A run this backend dispatched straight to a host carries the EMPTY tenant on that host's record, and the gateway refuses to adopt an unowned record — correctly, since an unowned record cannot prove the caller may read it. So an in-flight run at the moment of the switch becomes a 404 on the next poll, which the poller reads as a vanished run and fails. Check GET /api/v1/fleet/hosts (or the host's /v1/capacity) for runsLive: 0 first.

To force every run onto one BOX while debugging it, the same variable still works — point it at http://100.x.y.z:8080 and leave FLEET_GATEWAY_TOKEN unset so it falls back to FLEET_API_TOKEN. Unset it when you are done.

FLEET_REPORT_TOKEN unset means the report endpoint refuses everything. An open one lets anyone invent a host, and an invented host with an apiEndpoint is a dispatch target — a route to sending customer work to a stranger's server, not merely bad telemetry.

With no TS_AUTHKEY the daemon never starts and the image behaves exactly as it did before; a deployment with no self-hosted fleet should not be running a networking daemon it has no use for.

The backend's key must be EPHEMERAL (tick "Ephemeral" when generating it). The container has no persistent volume, so every deploy starts with empty tailscaled state and registers as a new node. With a normal key the old one lingers and you accumulate talyn-backend-1, -2, -3… one per deploy — all tagged, all apparently valid ACL sources, and none of them the live one. An ephemeral node removes itself shortly after it goes offline, which is exactly the container lifecycle.

Fleet hosts are the opposite: their keys should NOT be ephemeral, because a host that reboots should come back as the same node with the same tailnet address — which is what FLEET_ADVERTISE_ENDPOINT has been told to publish.

4. Check it

GET /api/v1/fleet/hosts (admin only) lists every host that has reported, with online and dispatchable. A host that is online but not dispatchable has not advertised an endpoint, is draining, or is at capacity.

5. Railway account (deployed)

Hosted backend lives at https://prod.talyn.dev (project Talyn, service fastowl-backend, env production). Auto-deploy on push to main via .github/workflows/deploy-backend.yml — needs a RAILWAY_TOKEN GitHub secret:

  1. Railway dashboard → Project Settings → Tokens → create a project token (account-scoped works too) → copy.
  2. GitHub repo → Settings → Secrets and variables → Actions → New repository secret → name RAILWAY_TOKEN, paste value.

Supabase connection: Railway can't route IPv6, so the backend uses the transaction pooler (aws-1-eu-west-2.pooler.supabase.com:6543) not the direct connection. If you rotate the DB password, update DATABASE_URL on Railway too (railway variables --set 'DATABASE_URL=...').

GitHub OAuth callback: the workspace-integration GitHub OAuth app (the one from section #3 above, used for PR monitoring) has its authorization callback URL pointing at localhost. Once you actually start using GitHub integration against the hosted backend, update the OAuth app's callback to https://prod.talyn.dev/api/v1/github/callback. The Supabase-auth GitHub OAuth app (for user sign-in) is separate and already points at Supabase's domain, not ours.

5b. todiex inbox (optional — phone notifications)

The cross-product inbox at https://todiex.com. Talyn posts the handful of moments worth interrupting someone for — a signup, a cloud provider being connected, a workspace's first task, a subscription starting, cancelling or going past_due — and each one arrives as a push notification. Everything else stays in PostHog: a phone that buzzes for every task dispatch is a phone that gets silenced.

Two variables on the fastowl-backend Railway service, both or neither:

railway variables --service fastowl-backend \
  --set 'TODIEX_URL=https://todiex.com' \
  --set 'TODIEX_TOKEN=tdx_...'
  • TODIEX_URL — the inbox base URL. No trailing slash needed.
  • TODIEX_TOKEN — a bearer token minted in todiex (pnpm exec tsx scripts/mktoken.ts products, or the New token button on /app/build-in-public/settings). One token is shared across every product; the source field in the payload is what tells them apart, not the token.

Every event carries the names behind its ids, not just the ids: the workspace's name, its owner's email, GitHub handle and plan; for a subscription, the product, the price and the renewal (or cancellation) date. The ids are still there — workspace_id, user_id, subscription_id — but the notification reads on a lock screen without a database to hand (services/todiexContext.ts). The lookup runs inside the fire-and-forget POST and is cached per workspace for five minutes, so no webhook, login or dispatch waits on it, and one that fails degrades to the id-only event rather than losing the notification.

Leave both unset and every call is a no-op — which is the right state for local dev. A partial config is a boot error (services/validateEnv.ts): a URL without a token posts nothing but 401s and a token without a URL posts nothing at all, both silently, because the client swallows its own failures by design.

Nothing here can break a request. The client is fire-and-forget with a 5s timeout, and todiex commits the event before it attempts the push — so even a notification that never arrives leaves the event in the feed.

See packages/backend/src/services/todiex.ts.

5c. Polar billing (usage pricing)

Polar is the merchant of record. It sells Pro, the credit packs and the team seats, and it charges auto top-ups. Talyn keeps the usage ledger and the credit balance. Do these steps in the sandbox org (sandbox.polar.sh) first, then again in the production org.

Do steps 1 to 6 before tom/workbenches merges into main. The backend refuses to boot with the old Polar env (the Unlimited ids only), because the core group now includes the Pro and credit-pack ids. Railway's health gate then keeps the old build serving, so nothing breaks, but nothing deploys.

  1. Pro products. Create two recurring products:

    • "Talyn Pro (monthly)": fixed price, $40, every month.
    • "Talyn Pro (annual)": fixed price, $400, every year.

    Copy the two ids to POLAR_PRODUCT_ID_PRO_MONTHLY and POLAR_PRODUCT_ID_PRO_ANNUAL.

  2. Credit packs. Create three one-time products, each with a fixed price (not "pay what you want"): $25, $100 and $500. Copy the ids to POLAR_PRODUCT_ID_CREDITS_25, POLAR_PRODUCT_ID_CREDITS_100 and POLAR_PRODUCT_ID_CREDITS_500. Use one product per pack, not one product with a custom amount: Polar's off-session orders (auto top-up) accept only a one-time fixed-price product, and the product id is how the webhook knows which pack was bought. The bonus credit (+5% on $100, +10% on $500) is Talyn's (CREDIT_PACKS in packages/shared/src/pricing.ts); do not add a benefit for it in Polar. Turn off discount codes on these products.

  3. Old Unlimited products. Keep them as they are. Do not archive them, because existing subscriptions renew on them. Hide them from any public checkout link. Keep POLAR_PRODUCT_ID_MONTHLY and POLAR_PRODUCT_ID_ANNUAL set: the backend reads them to put those subscribers on Legacy Pro.

  4. Team seats. Create two new seat-based products, $40 a seat a month and $400 a seat a year (volume tiers, minimum 1 seat). Copy the ids to POLAR_PRODUCT_ID_TEAM_PRO_MONTHLY and POLAR_PRODUCT_ID_TEAM_PRO_ANNUAL. Do NOT re-price the old team products: a team's tier comes from its product id, so a re-priced old product would make new $40 teams read as Legacy Pro. Keep the old team products and their two env vars; their teams stay on Legacy Pro terms. New teams buy the Pro team product as soon as its two vars are set.

  5. Off-session charges (auto top-up). Ask Polar to enable the off-session charges preview on the org (it is the off_session_charges_enabled feature on Polar's side, on a paid Polar plan). Until it is on, every auto top-up records "Automatic charges are not available on this account yet" and the app shows the "buy credits" prompt. Nothing else changes, so this step does not block the release.

  6. Access token. Create an organization access token with these scopes: checkouts:write, customer_sessions:write, customers:read, orders:read, orders:write, products:read, subscriptions:write. orders:write is new (auto top-up). Put it in POLAR_ACCESS_TOKEN.

  7. Webhook. Endpoint <backend>/api/v1/webhooks/polar, format "Raw". Subscribe to subscription.created, subscription.active, subscription.updated, subscription.canceled, subscription.uncanceled, subscription.revoked, order.paid and order.refunded. The two order.* events are new: order.paid grants a credit pack, order.refunded takes back what is left of it. Put the signing secret in POLAR_WEBHOOK_SECRET.

  8. Return URLs. Checkout returns to WEB_APP_URL + /settings/billing (?checkout=success&kind=plan|credits on success). POLAR_SUCCESS_URL, when set, replaces the success page. With neither, Polar shows its own receipt page.

How the pieces behave:

  • Free → Pro is a hosted checkout (POST /billing/checkout). A user who already has a live subscription gets a 409 already_subscribed.
  • Legacy Pro → Pro changes the live subscription (POST /billing/change-plan) with proration_behavior: invoice: Polar charges the prorated difference for the rest of the period at once. The subscription.updated webhook writes the new product id; the tier follows.
  • Credits are a hosted checkout (POST /billing/credits/checkout). The order.paid webhook grants the pack. Free accounts cannot buy credits.
  • Auto top-up runs from the usage meter when the balance falls below the owner's trigger. It creates a draft order and finalizes it against the saved payment method. A paid answer grants the credits at once; the later order.paid grants nothing more (both are keyed on the order id). A refused charge records the reason, and the app shows "buy credits" until the user saves the auto top-up settings again. Nothing retries a declined card by itself. A partial refund of a credit pack changes no credit: adjust it by hand from the operator console.

6. PostHog project

Single source of truth for analytics + error tracking + logs (Phase 18.8).

  1. Create project at https://posthog.com (or self-host)
  2. From Project settings grab:
    • POSTHOG_PROJECT_API_KEY (write key, used in server + desktop)
    • POSTHOG_PERSONAL_API_KEY (read key, used by CI / MCP server / dashboards)
    • POSTHOG_HOST (https://us.i.posthog.com or https://eu.i.posthog.com or self-hosted URL)
  3. In the project, enable Error tracking and Session replay features
  4. Create a feature flag called fastowl_debug (off by default) that we can flip for verbose logging per user

Where the write key goes (both use the same project key):

  • Desktop — baked in at webpack build time. The project key is committed (apps/desktop/.erb/configs/posthogKey.ts) — a project write key is public by design, and defaulting it to '' meant any build made outside CI emitted no client analytics at all. TALYN_POSTHOG_KEY still overrides it (that is how you point a build at another project); a blank value falls through to the default, and TALYN_ANALYTICS_DISABLED=1 is the opt-out. The renderer also bakes TALYN_APP_VERSION: a CI-stamped release reports its semver, and every other build reports dev+<sha> — identifiable, but never mistakable for a release (.erb/configs/appVersion.ts, renderer/lib/appVersion.ts).
  • Backend (Railway env) — TALYN_POSTHOG_KEY / TALYN_POSTHOG_HOST enable server-side task-lifecycle events (task_dispatched / task_completed / task_failed), attributed to the workspace owner. Unset ⇒ server analytics is a no-op (see packages/backend/src/services/analytics.ts). The same key also powers feature flags — unset there means every flag answers its built-in fallback. See Feature flags (PostHog) above, and add TALYN_POSTHOG_PERSONAL_API_KEY so flags evaluate locally.

6b. "Connect with PostHog" — the PostHog Code OAuth app

Nothing to register anywhere. PostHog supports CIMD (OAuth Client ID Metadata Document), so Talyn's client_id IS a URL we host, and PostHog fetches it during the flow to learn our name, logo and redirect URIs. There is no client secret.

Two backend env vars, and they are all-or-nothing — with either missing, the OAuth option is not offered anywhere in the UI and the personal-API-key path is the only way to connect (that is the local-dev default, and the prod kill switch):

Var Value Why
POSTHOG_OAUTH_CLIENT_ID https://www.talyn.dev/oauth-client The CIMD document, served by apps/marketing/app/oauth-client/route.ts
POSTHOG_OAUTH_REDIRECT_URI https://prod.talyn.dev/api/v1/posthog/oauth/callback Where PostHog sends the browser back

PostHog API destinations default to https://us.posthog.com, https://eu.posthog.com and https://app.posthog.com (the legacy US cloud domain, kept so workspaces connected before the regional split keep working without configuration). For trusted self-hosted instances, set POSTHOG_ALLOWED_ORIGINS to a comma-separated list of exact HTTPS origins. For example: POSTHOG_ALLOWED_ORIGINS=https://posthog.example.com,https://posthog.internal:8443. Paths, queries, fragments, userinfo, wildcards, and noncanonical addresses are rejected. Credential-bearing requests never follow redirects. These entries authorize credential delivery, including to private addresses. Operators must trust each endpoint and its DNS configuration. Existing integrations outside this list stop making requests until the operator approves their origin. Run docs/rollout/find_posthog_hosts.sql before deploying to list every workspace this affects — including http:// hosts and noncanonical spellings, which the allowlist cannot rescue and which need the stored value fixed instead. A malformed entry is ignored with a warning and reported at boot, rather than refusing every PostHog request.

Three ways to get this wrong, all of which fail on PostHog's side with an error the user sees and we don't:

  1. The client_id must be byte-identical to the URL the document is served from — PostHog string-compares them. talyn.dev 308-redirects to www, so the www form is the only usable one. Don't "tidy" a trailing slash onto either value.
  2. POSTHOG_OAUTH_REDIRECT_URI must appear verbatim in the CIMD document's redirect_uris. They are two copies of one fact; the pair existing is what stops anyone (including us) nominating a different destination for an authorization code. Change one → change the other in the same push.
  3. The marketing site must be deployed before the env vars are set. PostHog fetches the document at /authorize time, so pointing at a 404 breaks the flow for everyone.

Scopes requested: openid task:read task:write, narrowed to the single project the user picks on PostHog's consent screen (required_access_level=project). No analytics, flags, or query access is asked for, and none is granted.

Running the flow against a LOCAL backend

You can, and you don't need to host anything. Don't point the local backend at the production client_id: its CIMD document lists only the prod callback, so PostHog would reject a localhost redirect_uri — and adding localhost to the published document would widen the production client for everyone.

Use Dynamic Client Registration instead (RFC 7591, POST /oauth/register). It hands back an opaque client_id and hosts nothing, which is exactly the shape a laptop needs. POSTHOG_OAUTH_CLIENT_ID accepts either form for this reason — a URL is validated as a CIMD document, anything else is passed through as an opaque id.

curl -s -X POST https://us.posthog.com/oauth/register/ \
  -H 'Content-Type: application/json' \
  -d '{
    "client_name": "Talyn (local dev)",
    "redirect_uris": ["http://localhost:4747/api/v1/posthog/oauth/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "scope": "task:read task:write"
  }' | jq -r .client_id

Put the result in packages/backend/.env:

POSTHOG_OAUTH_CLIENT_ID=<the client_id from above>
POSTHOG_OAUTH_REDIRECT_URI=http://localhost:4747/api/v1/posthog/oauth/callback

http is fine here and nowhere else: PostHog permits it for loopback hosts only (is_loopback_host in posthog/models/oauth.py), and so do we. Port 4747 is the backend's default (PORT in packages/backend/src/index.ts) — match it to whatever yours actually serves on, in both places.

Two things to expect on a dev client: the consent screen says unverified application and names it whatever you registered, and the flow authorizes your real PostHog account against a real project, so tasks it dispatches really run. Point it at a scratch project.

The same recipe works against a self-hosted or locally-running PostHog — swap us.posthog.com for its origin and set the workspace's host to match in the Settings card. (CIMD is the wrong tool there: PostHog fetches the document server-side with SSRF screening, so a document served from your laptop is unreachable to PostHog Cloud and refused by a local instance.)

And the boring option remains: leave both unset and use a personal API key. That is the default, and no OAuth UI appears at all.


6c. Slack sessions

People start Talyn sessions from Slack: a trigger emoji reaction, an @mention of the bot, or a DM. Talyn never posts in Slack. It shows status only as reactions on the triggering message. Slack sessions use the workbenches flag. The flag is OFF by default. Set WORKBENCHES_ENABLED=true to enable it locally. Set WORKBENCHES_ENABLED=false to disable it everywhere.

One Slack app ("Talyn Internal") serves this and the Slack MCP connector.

Variable Value Notes
SLACK_APP_CLIENT_ID / SLACK_APP_CLIENT_SECRET The app's client id and secret Optional. When unset, Talyn uses SLACK_MCP_CLIENT_ID / SLACK_MCP_CLIENT_SECRET. Set these only for a separate app.
SLACK_SIGNING_SECRET Basic Information → Signing Secret Verifies every Events API request. Without it the events endpoint answers 500.
SLACK_OAUTH_REDIRECT_URI https://prod.talyn.dev/api/v1/slack/oauth/callback Must match the app's redirect URL exactly. Both flows (install and "Sign in with Slack") use it. Without it, the install and link buttons answer 503.

App settings (api.slack.com/apps → the app):

  • OAuth & Permissions → Redirect URLs: https://prod.talyn.dev/api/v1/slack/oauth/callback.
  • Bot token scopes: app_mentions:read, reactions:read, reactions:write, channels:history, groups:history, im:history, mpim:history, channels:read, groups:read, users:read, emoji:read (shows the workspace's custom emoji in the trigger picker). No chat:write.
  • User token scopes: openid, profile (for "Sign in with Slack").
  • Token rotation: ON. Bot tokens then expire after about 12 hours. Talyn refreshes them with oauth.v2.access (grant_type=refresh_token) and stores each new refresh token, because a refresh token works once.
  • PKCE: ON. Talyn sends an S256 challenge on every authorize request.
  • App Home → Messages tab: ON, with "Allow users to send messages" ticked, so people can DM the bot.
  • Event Subscriptions: ON. Request URL https://prod.talyn.dev/api/v1/webhooks/slack. Bot events: app_mention, reaction_added, message.im, app_uninstalled, tokens_revoked.

Notes:

  • Installing links the installer's Slack account too. Other people link theirs with "Sign in with Slack" in Settings. A link to a Slack workspace without the app installed is refused.
  • "Remove" in Talyn only forgets the bot token. To uninstall the app, remove it in Slack. Slack then sends app_uninstalled, and Talyn deletes the token and every link in that workspace.
  • Events are stored in slack_events (one row per Slack event_id) and deleted 7 days after processing.

🧰 Nice-to-have: MCP servers for Claude Code

Wiring these up lets Claude Code answer questions about GitHub state, DB schema, PostHog events without manual copy-pasting. Add them to ~/.claude/mcp_servers.json or via claude mcp add.

GitHub MCP

claude mcp add github -- npx -y @modelcontextprotocol/server-github

Needs a GITHUB_PERSONAL_ACCESS_TOKEN env var. Create one at https://github.com/settings/tokens with repo + read:org scopes.

Supabase MCP (once #4 is set up)

Follow https://github.com/supabase-community/supabase-mcp for the install command. Needs SUPABASE_ACCESS_TOKEN (Supabase account level) and the project ref.

PostHog MCP (once #6 is set up)

Not yet officially released but community versions exist — search posthog mcp on GitHub. Will use POSTHOG_PERSONAL_API_KEY + POSTHOG_HOST.

Railway MCP (once #5 is set up)

Official Railway MCP server — see https://docs.railway.com for the current install command. Uses a Railway account token (same one you minted in #5). Lets Claude Code create projects, deploy services, read logs, and manage variables without leaving the editor.

Talyn MCP (hosted)

Talyn serves its own MCP server from the backend at /api/v1/mcp. There is nothing to build or run locally. Mint a personal token in the app (Settings → MCP server), then:

claude mcp add --transport http talyn http://localhost:4747/api/v1/mcp \
  --header "Authorization: Bearer <your-token>"

See docs/MCP_SERVER.md for the tool list and the production URL.

Session sandboxes get the same server as talyn. For that the backend must know its public origin: set TALYN_PUBLIC_API_URL (for example https://prod.talyn.dev). Without it, the backend reads the origin of POSTHOG_OAUTH_REDIRECT_URI or SLACK_OAUTH_REDIRECT_URI. It must be https on a public host, so a local backend gives its sessions no talyn server.

After adding any MCP server, restart Claude Code. Verify with /mcp in the prompt.


GitHub secrets (for CI)

Once the accounts above exist, add these to Repo Settings → Secrets and variables → Actions so CI can use them:

Secret Purpose
ANTHROPIC_API_KEY OPTIONAL — the daily model-catalogue check only (GET /v1/models). Absent = the check skips. Nothing else reads it.
GITHUB_TOKEN Already provided by Actions
RAILWAY_TOKEN Deploy the backend on merges to main
DATABASE_URL drizzle-kit migrate step
SUPABASE_SERVICE_ROLE_KEY Server-side admin operations in migrations
POSTHOG_PROJECT_API_KEY Ship error events + build metrics
APPLE_ID / APPLE_TEAM_ID / APPLE_ID_PASS macOS notarization (already in publish.yml)
CSC_LINK / CSC_KEY_PASSWORD Code signing (same)

Local .env convention

Backend reads env vars on startup. To avoid exporting them every terminal, create packages/backend/.env:

# NOTE: no ANTHROPIC_API_KEY here — the backend does not read one (see §1).

# Workspace-level GitHub integration (for PR monitoring)
GITHUB_CLIENT_ID=Iv1.xxx
GITHUB_CLIENT_SECRET=xxx
GITHUB_REDIRECT_URI=http://localhost:4747/api/v1/github/callback

# Database + auth (Phase 18)
DATABASE_URL=postgres://...supabase.co:6543/postgres?pgbouncer=true
SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...    # service role, bypasses RLS
# TALYN_ALLOWED_EMAILS=you@example.com   # optional single-user lock

# Redis — cross-replica WebSocket fan-out + the GitHub webhook ingest queue.
# Optional: leave unset to run single-process; the app degrades to local-only
# delivery and webhooks are not enqueued (the reconcile sweep keeps PRs fresh).
# Start the container with `npm run dev:redis` (see docker-compose.yml).
# REDIS_URL=redis://localhost:6379

# GitHub App (webhooks + installation auth). Optional until you create the App;
# when unset, the app stays on the OAuth connect flow. See the GitHub App
# section below. GITHUB_APP_PRIVATE_KEY is the .pem, base64-encoded.
# GITHUB_APP_ID=123456
# GITHUB_APP_SLUG=talyn-app
# GITHUB_APP_CLIENT_ID=Iv1.xxxxxxxx
# GITHUB_APP_CLIENT_SECRET=xxxxxxxx
# GITHUB_APP_PRIVATE_KEY=<base64 of the downloaded .pem>
# GITHUB_WEBHOOK_SECRET=<the secret you set on the App's webhook>

POSTHOG_PROJECT_API_KEY=phc_xxx     # optional, for when 18.8 lands
POSTHOG_HOST=https://us.i.posthog.com

# Polar billing (usage pricing: Free / Pro / Legacy Pro, credits, auto
# top-up). Optional as a group: when ALL core vars are unset, limits are not
# enforced. A PARTIAL core group is a boot error. Full steps: § 5c below.
# POLAR_ACCESS_TOKEN=polar_oat_...
# POLAR_WEBHOOK_SECRET=<the standard-webhooks secret from the webhook config>
# POLAR_ENVIRONMENT=sandbox            # or production
# POLAR_PRODUCT_ID_PRO_MONTHLY=<uuid of Pro, $40/month>
# POLAR_PRODUCT_ID_PRO_ANNUAL=<uuid of Pro, $400/year>
# POLAR_PRODUCT_ID_CREDITS_25=<uuid of the $25 one-time credit pack>
# POLAR_PRODUCT_ID_CREDITS_100=<uuid of the $100 one-time credit pack>
# POLAR_PRODUCT_ID_CREDITS_500=<uuid of the $500 one-time credit pack>
# POLAR_SUCCESS_URL=https://www.talyn.dev/checkout-success   # optional
#
# Optional pairs on top of the core group (both or neither):
# POLAR_PRODUCT_ID_MONTHLY=<uuid of the OLD $15/mo Unlimited product>   # Legacy Pro
# POLAR_PRODUCT_ID_ANNUAL=<uuid of the OLD $150/yr Unlimited product>   # Legacy Pro
# POLAR_PRODUCT_ID_TEAM_PRO_MONTHLY=<uuid of the $40/seat monthly team product>
# POLAR_PRODUCT_ID_TEAM_PRO_ANNUAL=<uuid of the $400/seat annual team product>
# POLAR_PRODUCT_ID_TEAM_MONTHLY=<uuid of the OLD seat-based monthly team product>
# POLAR_PRODUCT_ID_TEAM_ANNUAL=<uuid of the OLD seat-based annual team product>

# Release notes ("What's new" modal). The nightly publish workflow POSTs each
# release's generated highlights to <backend>/api/v1/release-notes with this
# value in an X-Talyn-Release-Secret header. UNSET MEANS THE INGEST ENDPOINT
# 404s — an ingest without the secret configured is an unauthenticated write
# into a modal every user sees on launch. Set the same value as the
# TALYN_RELEASE_INGEST_SECRET repo secret in GitHub Actions. The read side
# (GET /api/v1/release-notes) needs no configuration and works regardless.
# TALYN_RELEASE_INGEST_SECRET=<a random 32+ byte string, shared with CI>

# --- Browser apps (app.talyn.dev, admin.talyn.dev) -------------------------
# All three are unset for a desktop-only deployment, which is the default.
#
# ALLOWED_ORIGINS: comma-separated CORS/WS allowlist, EXACT string match (no
# globs — a pattern rule is how "https://app.talyn.dev.evil.com" gets in).
# Loopback and a missing Origin are always allowed, so the desktop app, the
# CLI, and the MCP server never need an entry. Set this the moment a browser
# client exists, or every one of its requests is blocked.
# ALLOWED_ORIGINS=https://app.talyn.dev,https://admin.talyn.dev
#
# It is READ ONCE AT BOOT, so adding an origin needs a restart. The failure
# mode when you forget is a CORS error in the browser with NO server-side log
# — worth checking first if a new front end can't talk to the backend.
#
# --- Operator console (admin.talyn.dev) ------------------------------------
#
# TALYN_ADMIN_EMAILS grants admin access only when a user is first inserted.
# It does not change existing users or reverse an explicit revocation.
# Grant access to existing users through the protected console or operator SQL.
# TALYN_ADMIN_EMAILS=you@example.com
#
# TALYN_ADMIN_GRANT_ENABLED: set to exactly "1" to allow granting/revoking
# operator access FROM the console. Default off, deliberately. Granting admin
# is the one mutation that permanently widens the blast radius of every other
# one, so with this unset a stolen operator session can read and comp — bad,
# but auditable and reversible — and cannot mint a second operator to survive
# the first being revoked. Use operator SQL for existing users instead.
# TALYN_ADMIN_GRANT_ENABLED=1
#
# The console also needs, in Supabase (dashboard, not config.toml):
#   Authentication → URL Configuration → Redirect URLs
#     += https://admin.talyn.dev/auth/callback
# Client-side OAuth means Supabase owns that allowlist, not our backend.
#
# It reaches the fleet through the backend, so it needs no fleet secret of its
# own — FLEET_API_TOKEN and FLEET_HTTP_PROXY (below) are already set for
# dispatch and are what the console's proxy uses.

# WEB_APP_URL: where the browser app lives. Must be https (localhost is
# allowed for dev); a malformed or http:// value is a BOOT error, because it
# becomes the GitHub App callback's redirect target and a bad one strands the
# user on the API origin. Only ever read from env, never from a request.
# WEB_APP_URL=https://app.talyn.dev
#
# PREVIEW_BASE_DOMAIN: the domain for hosted session previews. A session port
# gets the host `<random id>-<port>.<domain>`. Unset turns hosted previews off,
# and the ticket route answers 404 `preview_links_unavailable`. It needs a
# wildcard DNS record and a wildcard TLS certificate on the backend service.
# A `*.localhost` value (with a port) works for local dev over http. Never put a
# preview host in ALLOWED_ORIGINS; the backend ignores one if you do.
# See docs/WORKBENCHES.md § Previews.
# PREVIEW_BASE_DOMAIN=preview.talyn.dev
#
# TALYN_ALLOW_NULL_ORIGIN_WS: kill switch, defaults ON. The packaged desktop
# renderer loads from file://, whose WS handshake Chromium reports as the
# opaque `null`, so the upgrade accepts it. `null` is forgeable by ANY page
# (sandboxed iframe, data: URL), which is harmless while WS auth is a Bearer
# JWT in the first frame — there are no ambient credentials to hijack. Set to
# 0 the same day anything moves to cookie auth, or it becomes a live
# cross-site WebSocket hijack.
# TALYN_ALLOW_NULL_ORIGIN_WS=0

Supabase redirect URLs for the browser app

Client-side OAuth means Supabase — not the backend — owns the allowed redirects. In the dashboard (Authentication → URL Configuration) add https://app.talyn.dev/auth/callback to Redirect URLs and keep fastowl://auth-callback there for the desktop. Locally the same list lives in supabase/config.toml (additional_redirect_urls).

Vercel preview deployments get random hostnames, so they can't be allowlisted without a glob — point previews at a staging backend or accept that sign-in only works on the production hostname. Don't add a wildcard.

To comp an account onto Unlimited without paying (e.g. your own):

UPDATE users SET plan_override = 'unlimited' WHERE email = 'you@example.com';

Desktop app env (set in the shell before npm run build or npm start):

TALYN_SUPABASE_URL=https://<ref>.supabase.co
TALYN_SUPABASE_ANON_KEY=eyJ...    # anon key, safe to bundle
TALYN_API_URL=http://localhost:4747   # default if unset

CLI uses ~/.fastowl/token (populated via fastowl token set — copy the current token from desktop → Settings → Account → Copy CLI token). MCP server expects TALYN_AUTH_TOKEN in its spawn env.

And ensure .gitignore has packages/backend/.env. (If the backend doesn't yet load .env automatically, we'll add a dotenv import in Phase 18 cleanup — not critical yet.)


What Claude Code cannot do for you

Everything in this doc that requires an account, a browser approval, or a credential you own. Specifically:

  • Create Anthropic / GitHub / Supabase / Railway / PostHog accounts
  • Approve OAuth apps
  • Generate API keys
  • Add GitHub repo secrets
  • Install claude CLI binaries on remote VMs (we can automate this via the Phase 18.3 remote install flow once that ships, but not yet)

Everything else — schema, migrations, deploy configs, Dockerfiles, CI YAML — Claude Code can scaffold. Just share the credentials above when each phase starts.

Slack MCP sign-in

Slack requires a registered app. It does not support automatic client registration. Use an internal Slack app for one organization. Public distribution requires a Marketplace app. See Slack's MCP requirements.

  1. Register a Slack app for Talyn in Slack app settings.
  2. Add the exact callback URL: <WEB_APP_URL>/mcp/callback.
  3. Configure user scopes for the tools you need. Talyn requests the scopes from Slack's resource metadata. The app must support those scopes. Review Slack's scope list.
  4. Enable PKCE and token rotation for the app. Slack's PKCE setting cannot be reversed without Slack support.
  5. Set SLACK_MCP_CLIENT_ID and SLACK_MCP_CLIENT_SECRET on the Talyn backend. Keep the secret out of frontend variables.
  6. Restart the backend. Select Slack in the MCP catalog, then select Connect account.

Slack displays its workspace selection and consent page. Talyn does not select a workspace for the user. The backend encrypts the client secret with the stored grant. The browser receives only the authorization URL. Without these credentials, Talyn displays a deployment setup error instead of trying automatic registration. An administrator may need to approve the Slack app before users can connect.