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
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
integrationsrow. Dev and prod must never share a DB, an OAuth app, or aTALYN_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 doneLocal 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 statusprints 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).
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-onlyGET /v1/modelsto report catalogue drift (see §6d). Skips cleanly when the key is absent.scripts/spikes/spike-claude.ts— a hand-run spike, never shipped.
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 viaclaude 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 exitThe 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.
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.tsis 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 isIv1.…/Iv23…— if you see that prefix, you made the wrong app type.
- https://github.com/settings/developers → OAuth Apps tab → New OAuth App (do not use the "GitHub Apps" tab)
- Application name:
Talyn (Dev)(make a separate prod one later) - Homepage URL:
http://localhost:4747 - Authorization callback URL:
http://localhost:4747/api/v1/github/callback(must matchGITHUB_REDIRECT_URIexactly) - Create, then Generate a new client secret
- 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.
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" (slugtalyn-app). A GitHub App has exactly one webhook URL, so it can't serve localhost and Railway at once. Dev's creds go inpackages/backend/.env; prod's go in Railway variables. The backend just reads whateverGITHUB_APP_*/GITHUB_WEBHOOK_SECRETenv it finds — no code difference. Use a separate private key + secret per App.
- https://github.com/settings/apps → New GitHub App.
- 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 thesmee.io/<channel>URL here. Webhook secret: generate one →GITHUB_WEBHOOK_SECRET.
- Prod:
- 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).
- Subscribe to events:
pull_request,pull_request_review,pull_request_review_comment,issue_comment,check_run,check_suite,status,push,installation,installation_repositories. (pushis 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.) - 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). - 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.
- Generate a private key (.pem), base64 it, and set the env vars (see the
.envblock below):GITHUB_APP_ID,GITHUB_APP_SLUG,GITHUB_APP_CLIENT_ID,GITHUB_APP_CLIENT_SECRET,GITHUB_APP_PRIVATE_KEY,GITHUB_WEBHOOK_SECRET. - 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_MAXLENcaps the ingest stream length (default50000, 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.
- Optional:
Connecting (two steps):
- 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.
- 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/callbackwith a signedstate— 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 viaGET /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.
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.
For Postgres + auth when Phase 18.1/18.2 lands.
- Create account at https://supabase.com
- New project (free tier is fine for dev) — pick closest region
- 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 onlyDATABASE_URL(Project settings → Database → Connection string → URI, with pooling for runtime)
- 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
- Add
fastowl://auth-callbackto Redirect URLs in Supabase (Authentication → URL Configuration → Redirect URLs). Without this, the desktop deep-link flow fails silently with an "invalid redirect URL" error. - Storage bucket for session work (required for session checkpoints and archives). In Storage → New bucket, create
workbench-workwith 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, withSUPABASE_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 fromsupabase/config.toml([storage.buckets.workbench-work]) afternpm run dev:db.
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.
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 |
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.
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.
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:
workflowsis asked about the caller at the routes and atGET /api/v1/features, and about the workspace owner in the engine (a webhook delivery has no caller).talyn-fleetis 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.
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=trueis 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.
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.
- Add an entry to
FEATURE_FLAGSinpackages/shared/src/featureFlags.ts, pickingfallbackby the "if PostHog is down" question above, andavailabilityby a different question: may the "What's new" modal talk about this feature yet? A new gate is'gated'. SetreleaseScopesto the commit scopes that belong to it (['loops']). - Create the flag in PostHog under the same
posthogKey. - Read it through
services/featureFlags.ts— neverprocess.envat the call site, or the override precedence gets reimplemented per gate. - If a client needs to DRAW something from it, add the key to
ACCOUNT_FEATURE_FLAGSsoGET /featuresanswers it.
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.
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.
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_ADMINand cannot create a TUN device.tailscaledexposes 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
tailscaledprograms its own nftables rules.hetzner-64runs an Incus-managed guest for a different workload, withtable inet incusbeside ourtable 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 bytailscaledand proxied to fleetd's loopback port bytailscale serve.
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:
That matters — without it, every device on the tailnet can reach a machine that runs untrusted code.
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.
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. Onapp.talyn.devthere is no loopback and no filesystem, so it takes a paste of~/.codex/auth.jsonaftercodex 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.ymldiffs it against ours daily and reports both directions (offered-but-gone, served-but-not-offered). It needs an optionalANTHROPIC_API_KEYrepo secret and skips cleanly without one. It reports; a human still picks the label. - Codex has no equivalent. OpenAI's
GET /v1/modelsanswers 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.tsreads 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.
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.
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.
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:
- Railway dashboard → Project Settings → Tokens → create a project token (account-scoped works too) → copy.
- 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.
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; thesourcefield 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.
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.
-
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_MONTHLYandPOLAR_PRODUCT_ID_PRO_ANNUAL. -
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_100andPOLAR_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_PACKSinpackages/shared/src/pricing.ts); do not add a benefit for it in Polar. Turn off discount codes on these products. -
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_MONTHLYandPOLAR_PRODUCT_ID_ANNUALset: the backend reads them to put those subscribers on Legacy Pro. -
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_MONTHLYandPOLAR_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. -
Off-session charges (auto top-up). Ask Polar to enable the off-session charges preview on the org (it is the
off_session_charges_enabledfeature 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. -
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:writeis new (auto top-up). Put it inPOLAR_ACCESS_TOKEN. -
Webhook. Endpoint
<backend>/api/v1/webhooks/polar, format "Raw". Subscribe tosubscription.created,subscription.active,subscription.updated,subscription.canceled,subscription.uncanceled,subscription.revoked,order.paidandorder.refunded. The twoorder.*events are new:order.paidgrants a credit pack,order.refundedtakes back what is left of it. Put the signing secret inPOLAR_WEBHOOK_SECRET. -
Return URLs. Checkout returns to
WEB_APP_URL+/settings/billing(?checkout=success&kind=plan|creditson 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 409already_subscribed. - Legacy Pro → Pro changes the live subscription
(
POST /billing/change-plan) withproration_behavior: invoice: Polar charges the prorated difference for the rest of the period at once. Thesubscription.updatedwebhook writes the new product id; the tier follows. - Credits are a hosted checkout (
POST /billing/credits/checkout). Theorder.paidwebhook 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.paidgrants 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.
Single source of truth for analytics + error tracking + logs (Phase 18.8).
- Create project at https://posthog.com (or self-host)
- 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.comorhttps://eu.i.posthog.comor self-hosted URL)
- In the project, enable Error tracking and Session replay features
- 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_KEYstill overrides it (that is how you point a build at another project); a blank value falls through to the default, andTALYN_ANALYTICS_DISABLED=1is the opt-out. The renderer also bakesTALYN_APP_VERSION: a CI-stamped release reports its semver, and every other build reportsdev+<sha>— identifiable, but never mistakable for a release (.erb/configs/appVersion.ts,renderer/lib/appVersion.ts). - Backend (Railway env) —
TALYN_POSTHOG_KEY/TALYN_POSTHOG_HOSTenable server-side task-lifecycle events (task_dispatched/task_completed/task_failed), attributed to the workspace owner. Unset ⇒ server analytics is a no-op (seepackages/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 addTALYN_POSTHOG_PERSONAL_API_KEYso flags evaluate locally.
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:
- The
client_idmust be byte-identical to the URL the document is served from — PostHog string-compares them.talyn.dev308-redirects towww, so thewwwform is the only usable one. Don't "tidy" a trailing slash onto either value. POSTHOG_OAUTH_REDIRECT_URImust appear verbatim in the CIMD document'sredirect_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.- The marketing site must be deployed before the env vars are set. PostHog
fetches the document at
/authorizetime, 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.
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_idPut 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/callbackhttp 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.
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). Nochat: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 Slackevent_id) and deleted 7 days after processing.
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.
claude mcp add github -- npx -y @modelcontextprotocol/server-githubNeeds a GITHUB_PERSONAL_ACCESS_TOKEN env var. Create one at https://github.com/settings/tokens with repo + read:org scopes.
Follow https://github.com/supabase-community/supabase-mcp for the install command. Needs SUPABASE_ACCESS_TOKEN (Supabase account level) and the project ref.
Not yet officially released but community versions exist — search posthog mcp on GitHub. Will use POSTHOG_PERSONAL_API_KEY + POSTHOG_HOST.
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 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.
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) |
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
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.)
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
claudeCLI 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 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.
- Register a Slack app for Talyn in Slack app settings.
- Add the exact callback URL:
<WEB_APP_URL>/mcp/callback. - 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.
- Enable PKCE and token rotation for the app. Slack's PKCE setting cannot be reversed without Slack support.
- Set
SLACK_MCP_CLIENT_IDandSLACK_MCP_CLIENT_SECRETon the Talyn backend. Keep the secret out of frontend variables. - 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.