An agent in Styx is a coding-agent command-line tool that Styx finds on your computer and runs for you: Claude Code, Codex, Gemini CLI, Cursor agent, or a plain shell. Styx does not ship its own model. It starts the CLI you already have and are signed in to, inside a git worktree of the project, with Styx's MCP server and command shims wired in. When you add one, users can pick it in the Spawn agent modal and on the Tasks board. Its chat shows in the workspace, its permission prompts become Allow / Deny cards, and every attempt it makes to reach a deploy target goes through a scoped, expiring grant.
This guide follows the current code. Where the design docs and the code disagree, the code wins.
How it fits together
Data flows through these parts in this order.
-
The name.
agentSchemainpackages/core/src/model/common.tsis the list of agent ids. Every IPC command, row schema and.styx/project.jsonfield that names an agent derives from it.AGENT_LABELin the same file is the short display name. -
Detection.
DetectServiceinapps/desktop/src/main/services/detect-service.tslooks for the binary (login-shellPATH, vendor install folders, editor extension bundles, a manual "Locate binary" pick). It runs--version, reads--helpinto acapabilitiesmap (streamJson,printMode,appServer,acp, …), and guesses sign-in state from the CLI's own files. The login shell is asked about every binary name inSHELL_WHICH_NAMESinapps/desktop/src/main/services/pty-service.ts. Results are stored asCliInstallrows and refreshed by thedetect.cliscommand. -
Runner choice.
runnerFor()inapps/desktop/src/main/services/session-service.tsturns the agent and its capabilities into'pty'(the CLI's own TUI in an xterm terminal) or'stream'(Styx drives the CLI over pipes and renders a native chat). -
Launch.
SessionServicebuilds anAgentLaunchContext(worktree path, first message, model, permission mode, the session env withSTYX_SESSION_ID,STYX_BROKER,STYX_TOKENand the shim directory first onPATH) and callsbuildAgentLaunch()inapps/desktop/src/main/agents/index.ts. That switches to a per-agent function, such asgeminiLaunch()inapps/desktop/src/main/agents/gemini.ts, which returns anAgentLaunch: command, args, extra env, how the first message is delivered, an optionalstreamkind, and acleanup(). The types and shared helpers are inapps/desktop/src/main/agents/types.ts. -
The process. A
ptylaunch goes toPtyService. Styx sees the session as working while output flows and idle afterQUIET_MS(3 s) of silence. Astreamlaunch goes to theRunnerMuxinapps/desktop/src/main/services/runner-mux.ts, which picks a backend bystream.kind:stdin/argv:StreamRunnerinstream-runner.ts(Claude Code's stream-json, and Cursor's--print --output-format stream-jsonfallback).app-server:AppServerRunnerinapp-server-runner.ts(Codex only).acp:AcpRunnerinacp-runner.ts, a generic Agent Client Protocol client (Gemini CLI and Cursor agent today).
Each backend turns the CLI's events into
StreamEffects: transcript rows, tool steps, permission asks, usage, and the quiet signal that ends a turn. The backends are registered inapps/desktop/src/main/container.ts. -
Talking back to Styx. The agent reaches Styx through the
styx mcpserver (tools such asrequest_access,ask_user,report_status) and through the shims (gh,vercel,aws,gcloud,supabase,ssh) on itsPATH. Both go over the broker protocol inpackages/broker/src/protocol.tsto the host inapps/desktop/src/main/broker/host.ts. How the MCP server reaches the CLI depends on the runner: ACP and app-server hand it over in the protocol; the other launches write it into a config file (writeWorktreeMcpConfig(), or a file underconfigDirfor flags like Claude's--mcp-config). CLIs with lifecycle hooks can also callstyx hook <agent>(packages/cli/src/hook.ts), which lands inSessionService.onHook(). -
Instructions. CLIs without a system-prompt flag are listed in
PREAMBLE_AGENTSinagents/types.ts. They getagentPreamble()(use the shims, peers exist, which lane you are on) as text ahead of their first turn. -
Setup and sign-in.
AgentServiceinapps/desktop/src/main/services/agent-service.tsbacks the Connect agent modal (status probe, CLI login, install guide).AgentSetupServiceinapps/desktop/src/main/services/agent-setup-service.tsbacks the onboarding "Which AI do you use?" cards (install, sign in, one test message). Install commands are inpackages/core/src/model/agent-install.ts. -
The renderer. It never sees the process. It reads the mirrored store and lists agents from
SPAWN_AGENTSinapps/desktop/src/renderer/features/modals/modals.ts, names them throughpackages/core/src/copy.ts, and colours them withAgentDotfrompackages/ui/src/primitives/AgentDot/.
Design background: ADR-0010 (pty vs stream),
ADR-0016 (Codex app-server), ADR-0017 (ACP), and
docs/research/agent-parity.md.
Worked example: Gemini CLI
Gemini is the example because its launch adapter is the smallest real one (47 lines) and it shows both paths a new CLI is likely to take: ACP over pipes when the CLI supports it, and the TUI in a terminal when it does not.
1. Detection
DetectService has one row per agent, with the binary names to look for:
const CLIS: { agent: AgentKind; label: string; bins: string[] }[] = [
// …
{ agent: 'gemini', label: 'Gemini CLI', bins: ['gemini'] },Once a binary is found, its --help is matched against a few patterns. The one that matters for Gemini:
acp: /(^|\s)(--acp|acp)\b/.test(help),Sign-in state is read from the CLI's own files, never from the secrets in them:
case 'gemini':
return exists(h, '.gemini', 'oauth_creds.json') || !!this.deps.env['GEMINI_API_KEY']
? 'signed-in'
: 'signed-out';AGENT_MARKERS in the same file (gemini: /gemini/i) stops a user from pointing "Locate binary" at the wrong CLI.
2. Runner choice
runnerFor() sends Gemini to the stream runner only when the CLI advertised ACP:
// Gemini and Cursor: the Agent Client Protocol; Cursor's print mode is the fallback (no approvals there).
if (agent === 'gemini' && caps['acp'] === true) return 'stream';Everything else falls through to 'pty'. The table test is
runner-for.test.ts.
3. The launch adapter
The ACP branch is short because the protocol carries everything else (the MCP server, the first message, mode changes):
if (ctx.runner === 'stream' && ctx.capabilities['acp'] === true) {
args.push('--acp');
if (ctx.model) args.push('-m', ctx.model);
return {
command: ctx.binary,
args,
env: {},
typeFirstMessage: false,
stream: { kind: 'acp' },
cleanup: async () => undefined,
};
}The pty fallback has to give the TUI the styx MCP server through a file. It merges an entry into the worktree's
.gemini/settings.json, keeps that file out of git with .git/info/exclude, and restores it on cleanup:
const written = await writeWorktreeMcpConfig(file, ctx, io, dir);
await excludeLocally(ctx.worktreePath, '.gemini/settings.json');
if (ctx.model) args.push('-m', ctx.model);
if (ctx.firstMessage) args.push('-i', ctx.firstMessage);
return { command: ctx.binary, args, env: {}, typeFirstMessage: false, cleanup: () => written.restore() };writeWorktreeMcpConfig() uses styxMcpServerInherit(), which has no env block. That matters: STYX_TOKEN must
never be written into the worktree. The CLI starts styx mcp with its own session env, which already has the token.
buildAgentLaunch() in agents/index.ts has one case per agent that calls the adapter.
4. The ACP runner
Gemini needs no Gemini-specific runner. AcpRunner sends initialize, session/new (with the styx MCP server in
mcpServers), then session/prompt per turn. It answers session/request_permission with the user's Allow / Deny,
and turns session/update into transcript and tool rows. Two small per-agent tables live there:
GEMINI_MODESmaps Styx's permission modes onto Gemini's own mode ids (auto_edit,yolo,plan).resolveAcpMode()tries the Gemini table, then the Cursor table, then a match by name.pickAuthMethod()picks an ACPauthenticatemethod that needs no browser (gemini-api-keywhenGEMINI_API_KEYis set).
The runner tests are in acp-runner.test.ts.
5. Preamble, sign-in and setup
PREAMBLE_AGENTSincludes'gemini', because Gemini has no flag for extra system-prompt text.AgentServicehas no status command for Gemini, soprobeGemini()reads~/.gemini/oauth_creds.jsonandgoogle_accounts.json.LOGIN_ARGS.geminiis[]: Gemini signs in on its first run.RECIPES.geminiinagent-install.tsinstalls it with Homebrew or npm.AgentSetupServicedownloads a private Node.js first when there is no npm (thenotInstalledGeminicopy says so).
6. Names, colour, copy
AGENT_LABEL.gemini = 'Gemini'(core), and incopy.ts:agents.gemini,agentProducts.gemini = 'Gemini CLI',agentSetup.*.gemini,skills.hosts.gemini, andsession.permissionModeHintsByAgent.gemini(what each permission mode means for this CLI).- The colour is the
agentGeminitoken incolorLane(dark and light) inpackages/tokens/tokens.json. The build script emits it as--agent-gemini, andAgentDot.module.cssuses it fordata-agent='gemini'.
7. Tests
gemini.test.tschecks both launches: the ACP args, and the pty fallback's settings file (with no token in it, removed on cleanup).- The fake CLI
e2e/fixtures/bin/geminianswers--versionand--help(which lists--acp) and speaks a scripted ACP session.acp-session.spec.tsre-detects so the fake wins, spawns a Gemini session, seespong, allows a permission ask, and switches mode.
Adding yours
Pick the transport first. In order of preference:
- ACP. If the CLI speaks the Agent Client Protocol (check its
--helpfor anacpsubcommand or--acpflag), you reuseAcpRunnerand get a native chat with approvals, mode switching and model lists for very little code. Your adapter looks like Gemini's ACP branch. - Claude-style stream-json. If the CLI prints Claude Code's
stream-jsonevents, reuseStreamRunnerwith{ kind: 'stdin' }or{ kind: 'argv', resumeFlag }, as Cursor's print fallback does. - A new protocol. Write a backend that implements
StreamRunnerLikeand emits the sharedeffect/exitevents, add aStreamInputkind inagents/types.ts, and register it incontainer.ts. Look atAppServerRunnerfirst. This is a large change; open an issue before you start. - pty only. Always works as a fallback. Users get the CLI's own TUI in a terminal, approvals happen inside the
TUI, and background tasks (
session.purpose, which need a stream runner) are refused with a note.
Then change these places. Below, opencode stands for your agent id. Lowercase, no spaces.
The compiler finds some of these for you: anything typed Record<Agent, …> or an exhaustive switch fails
pnpm typecheck until you add the new key. Plain lists, zod enums outside core, SQL and regexes do not. Work through
the whole list.
Required: the agent exists and runs
- Core enum and label. Add
'opencode'toagentSchemaand an entry toAGENT_LABELinpackages/core/src/model/common.ts. - Duplicated agent types. These are separate lists of the same ids. Add yours to each:
AgentKindindetect-service.ts.HookAgentinsession-service.ts.AgentKindinpackages/ui/src/primitives/AgentDot/AgentDot.tsx.SessionBrief.agentandhook.params.agentinpackages/broker/src/protocol.ts. The broker client zod-checks thehelloreply, so if you miss this one,styx mcpand every shim fail for your agent's sessions.- The list in
hook()inpackages/cli/src/hook.ts.
- Database.
sessions.agentandcli_installs.agenthave SQLCHECKconstraints. Add the id to bothenumlists inapps/desktop/src/main/db/schema.ts, then write a new migration inapps/desktop/src/main/db/migrations/that rebuilds both tables. SQLite cannot alter aCHECK. Follow0008_user_pause.sql, but copy the current column list: later migrations added columns tosessions. See thedb-migrationskill in.claude/skills/db-migration/SKILL.md. - Detection. In
detect-service.ts: a row inCLIS(label and every binary name), an entry inAGENT_MARKERS(a regex the CLI's--versionoutput matches), and acaseinauthState()that checks the CLI's credential files or env var without reading secrets. If the CLI needs a capability flag the existing patterns do not cover, add it to thecapabilitiesmap. Add the binary names toSHELL_WHICH_NAMESinpty-service.ts. Add the id to therefreshClis()loop insession-service.ts, or a manual "Locate binary" pick is never read back. Optional:EXTENSION_PREFIXESif an editor extension bundles the binary. - Runner choice. Add a line to
runnerFor()insession-service.ts, and rows torunner-for.test.ts. - Launch adapter. Create
apps/desktop/src/main/agents/opencode.tsexportingopencodeLaunch(ctx), and add acasetobuildAgentLaunch()inagents/index.ts. Rules:- Use
ctx.binary. Never hard-code a path. - Any config file written into the worktree goes through
writeWorktreeMcpConfig()(no env block) andexcludeLocally(), andcleanup()restores it. Files that must carry the token go underctx.configDirand usestyxMcpServer(), asclaude.tsdoes. - If the CLI has no system-prompt flag, add it to
PREAMBLE_AGENTSinagents/types.ts. - If the first message cannot be passed as an argument, set
typeFirstMessage: trueand Styx types it into the terminal, asshell.tsdoes.
- Use
- Runner wiring (ACP). Nothing is required, since unknown modes fall back to
MODE_HINTS. For an exact mapping, add a mode table next toGEMINI_MODESand include it in the loop inresolveAcpMode(). If the CLI has anauthenticatemethod that needs no browser, add it topickAuthMethod(). Both are inacp-runner.ts. - Sign-in and status. In
agent-service.ts: addLOGIN_ARGS,INSTALL_GUIDES, and eitherSTATUS_ARGSplus a parser inprobe(), or a file-based probe likeprobeGemini(). Probes return an identity label, never a credential. - Install. Add
RECIPES.opencode(darwin, linux, win32) inagent-install.ts, andCLI_INSTALL_URLS.opencode(https only) inpackages/core/src/selectors/discovery.ts. - Copy. In
packages/core/src/copy.ts:agents,agentProducts(this also adds the row to Settings › Agents), andsession.permissionModeHintsByAgentwhen your CLI's modes differ from the generic hints. Write it in the same plain style as the existing entries. - Pickers. Add the id to
SPAWN_AGENTSinmodals.ts. This feeds the Spawn modal, the New task form, the Tasks board and the design canvas. Add it toAGENT_OPTIONSinapps/desktop/src/renderer/screens/Settings/rows.ts(default agent setting). - Colour. Add
agentOpencodeto bothcolorLanethemes inpackages/tokens/tokens.json, map it inlaneShortinpackages/tokens/scripts/build.mjs, runpnpm tokens:build, and add a.dot[data-agent='opencode']rule toAgentDot.module.cssand its story. Without this, the dot falls back to the shell colour. A new colour is a design decision, so say so in the PR.
Optional: features that are per agent
- One-click setup (onboarding cards). Add the id to
setupAgentSchemainpackages/core/src/model/agent-setup.ts. Then fillTEST_ARGS,SIGNIN_ARGS,PLAN_PAGESandPLAN_NAMESinagent-setup-service.ts, andagentSetup.names,plans,sitesandaccountsincopy.ts. - Skills. If the CLI reads
SKILL.mdfolders, add a host toskillHostSchemaandINSTALLABLE_SKILL_HOSTSinpackages/core/src/model/discovery.ts,SKILL_HOST_DIRSandHOSTSinskills-service.ts, andskills.hosts/hostsShortincopy.ts. - Publish drafts.
agentInvocation()inpublish-service.tsruns the agent headless to draft a commit or PR message. Returnnullto use the file-list fallback, or add a one-shot invocation and parser. - Hooks. If the CLI has lifecycle hooks, point them at
styx hook opencodeand map the events inSessionService.onHook(). - Images, effort, steering.
acceptsImages()insession-service.tsandtakesEffort()insession-controls.tsname specific agents. ACP sessions report image support frominitialize, so most new agents need nothing here. - Demo fixture. Add a
CliInstallrow (and a session, if you want one on screen) topackages/core/src/fixtures/demo.ts.
Docs and website
- README. Add the agent to the "Works with" list and the opening lines in
README.md. - Website.
agentsinapps/website/components/Compat.tsx,apps/website/lib/compare/styx.tsandapps/website/public/llms.txt. Maintainers may prefer to do this at release. - Discrepancy log. A new agent adds UI the handoff prototype does not show (a spawn tile, a colour). Add the
next numbered row to
docs/handoff-discrepancies.md(| # | Where | Prototype | Spec | Resolution |) saying what you added and why. Maintainers may renumber it on merge.
You do not need to touch packages/core/src/ipc/contract.ts. Its
schemas use agentSchema, so new ids flow through.
Testing it
First-time setup is in the README's "Build from source" section (pnpm install, pnpm tokens:build).
Unit tests and types:
pnpm typecheck # every package; finds missing Record<Agent, …> keys
pnpm -F @styx/desktop test src/main/agents # launch adapters
pnpm -F @styx/desktop test src/main/services # detection, runners, runnerFor, agent services
pnpm -F @styx/core test # schemas, selectors, copy
pnpm -F @styx/broker test
pnpm lintWrite at least:
apps/desktop/src/main/agents/opencode.test.ts, likegemini.test.ts. Cover each launch branch, check the exact args, and check that nothing in the worktree containsSTYX_TOKENand thatcleanup()removes what it wrote.runnerForrows for every capability combination you rely on.- A detection test in
detect-service.test.tsfor the binary name, the version marker andauthState(). - If you added a protocol backend, tests against a scripted fake process, like
acp-runner.test.ts. Never call a real CLI or a real model in tests.
End-to-end tests drive the built Electron app with Playwright. pnpm e2e runs them, after pnpm build.
apps/desktop/e2e/launch.tsputsapps/desktop/e2e/fixtures/bin/first onPATH, so a test never starts a real agent or spends anyone's usage.- Add a fake
fixtures/bin/opencode: a Node script with a#!/usr/bin/env nodeline, marked executable (chmod +x). It must answer--versionand--help, and the help text must list the flags your capabilities look for. For ACP, copy the scripted session infixtures/bin/gemini. - Add
fixtures/bin/opencode.cmdfor Windows. Copygemini.cmdand change the script name on its last line. - The demo fixture's CLI rows point at paths like
/opt/homebrew/binand are never re-detected on their own. Your spec should calldetect.clisfirst, asacp-session.spec.tsdoes, then spawn through the UI. - Run one spec with
pnpm e2e -- --grep "<test name>".
To try it by hand:
STYX_FIXTURE=demo STYX_KEYCHAIN=memory pnpm devThis opens sample projects in a temporary database, with an in-memory keychain. Go to Settings › Agents and press
Rescan so your real CLI is detected (fixture rows are not). Then spawn the agent from the Spawn agent modal. Check
that the chat works, an approval shows as a card, and asking it to run gh auth status makes a grant request appear.
A good PR includes:
- the launch adapter and its test, the detection changes and their test, and the migration;
- the fake CLI (POSIX and
.cmd) and an e2e spec that spawns a session and gets one reply; pnpm typecheck && pnpm lint && pnpm testpassing;- the CLI version you checked the flags against, written in the adapter's header comment. Existing adapters mark
flags they could not check against a real install as
UNVERIFIED; do the same; - a screenshot of the Spawn modal and a running chat;
- the README "Works with" line and a discrepancy-log row.
Security rules
- No secrets in the worktree.
STYX_TOKENauthenticates the session to the broker. It may sit in the process env and in files underctx.configDir(per session, outside the repo), never in a file inside the worktree. UsestyxMcpServerInherit()/writeWorktreeMcpConfig()for anything in the repo. - No agent credentials in Styx. Styx reuses the CLI's own login. Detection and probes check whether a credential file exists or read an identity label (an email, "API key"). They never read, copy or store the token. Agent credentials never go into SQLite, logs, IPC payloads or renderer state.
- Shims first on
PATH. The session env puts the shim directory ahead of the loginPATH. Do not reorderPATHor reset it in your launchenv. The preamble (or Claude's system prompt) tells the agent to use the shims.cloudCliByFullPath()instream-runner.tsflags commands that call a cloud CLI by full path to get around them. - Approvals stay with the user. Map Styx permission modes conservatively. Plain
defaultmust ask before edits and commands. Never map a mode to the CLI's "approve everything" setting unless the user pickedbypassPermissionsordontAsk, and say so inpermissionModeHintsByAgent. - Logs. Never log CLI stdout or stderr that could carry tokens. Use
loggerfromlogger.ts, which redacts secret-shaped values. Terminal output saved to disk goes throughPtyLog, which masks them too. - Access to targets is not the agent's business. Your adapter must not add provider env vars (
GH_TOKEN,VERCEL_TOKEN, …) to the launch env. Agents get provider credentials only through a grant, for the grant's lifetime, through the shims orget_credential. That path is audited for you. - Ask for a review from the
security-revieweragent (.claude/agents/) or a maintainer when you touch the launch env, the broker or config files.