A target in Styx is a place an agent can change things outside the repo: a Vercel project, an AWS account, a GCP
project, a Supabase database, a GitHub repo, or an SSH host. Each target belongs to a project and has a provider,
an environment (prod, staging, preview or scm), a policy, and a credentialRef that points at a secret in the
OS keychain. Agents never hold the stored credential up front. When an agent runs the provider's CLI (say
vercel deploy --prod), a Styx shim catches the call and works out what it needs (read, write, deploy or
delete). Styx then asks the user, with Touch ID or Windows Hello for prod writes, issues a short-lived credential
for that grant only, and audits every step. When you add a provider, users can connect it from the Connect target
modal (preferably by reusing the login its own CLI already holds), and agents get gated, audited access to it.
This guide follows the current code. Where the design docs or the provider-adapter skill disagree with it, the code
wins.
How it fits together
Data flows through these parts in this order.
- The name.
providerSchemaandPROVIDER_LABELinpackages/core/src/model/common.ts. Targets, policies, IPC commands and.styx/project.jsonall derive from this enum. - The adapter. A class implementing
ProviderAdapterfromapps/desktop/src/main/providers/types.ts, one file per provider inapps/desktop/src/main/providers/. It getsAdapterDeps: the credential vault,fetch, a clock, and aCliRunner(cli-runner.ts) that runs provider CLIs on the login-shellPATHand never logs their output. Shared helpers for the "use the CLI's own login" mode are incli-auth.ts.ProviderRegistryinproviders/index.tsholds one instance per provider and maps shim tool names to adapters (forTool). The registry is built inapps/desktop/src/main/container.ts. - Connect. The renderer's
ConnectModal.tsxshows the provider grid (PROVIDERSinmodals.ts). The primary path callstarget.connect.cliStatus→adapter.cliStatus(),target.connect.cliLogin→adapter.cliLoginCommand()(run in a terminal the user can see), andtarget.connect.cliSave→adapter.connect({ method: 'cli', … }). Under Advanced,target.connect.start/saveToken/saveKey/saveSshlead toadapter.connect()with a pasted secret. The commands are declared inpackages/core/src/ipc/contract.ts, handled inapps/desktop/src/main/ipc/commands/target.ts, and carried out byTargetServiceintarget-service.ts.connect()writes any secret to the vault and returns only{ credentialRef, config, label }. - Health.
RefreshScheduler(refresh-scheduler.ts) callsTargetService.checkHealth(), which callsadapter.health().expired: truemarks the target expired and raises the auth-expired banner with a Reconnect action. - The agent runs the CLI.
writeShims()inshim-service.tswrites a script for each name inSHIM_TOOLSinto<userData>/bin, which is first on every agent'sPATH. The script runsstyx wrap <tool> …(packages/cli/src/wrap.ts), which calls the broker'sexec_authorize. - Authorise. The broker host (
apps/desktop/src/main/broker/host.ts) finds the adapter withproviders.forTool(tool)and classifies the call withadapter.scopeOfCommand(argv, tool). It picks the project's target for that provider and either reuses a live grant that covers the scopes or opens a request withGrantService.request(). Agents can also ask directly through the MCP toolsrequest_accessandget_credential(packages/broker/src/mcp-stdio.ts). - Decide.
GrantServiceingrant-service.tsruns the pure policy engine (evaluatePolicies()inpackages/core/src/policy/engine.ts) and the grant state machine (packages/core/src/machines/grant.ts). If the user has to decide, the grant sheet opens (grant-sheet.ts).approve()recomputesrequireMfain main and asks the OS for biometric verification when needed. - Issue.
GrantServicecallsadapter.issue(grant, target). The adapter returns anIssuedCredential: env vars (or an SSH agent socket), an expiry, and whether it is trulyscoped. The broker sends the env back to the shim, which runs the real CLI with it and reports the exit code (exec_report). Every request, decision, use and revoke is written to the append-only audit log byGrantServiceand the host. Adapters never write audit rows. - Expire and revoke. Grant timers (duration, 1 h idle, session end) and manual revokes call
adapter.revoke(issued). - Deploy button (optional).
DeployService(deploy-service.ts) runs either the target's savedconfig.deployCommandoradapter.deployCommand(), under a grant.deploy-detect.tssuggests a command from the repo.
Design background: ADR-0001 (the local broker) and ADR-0007 (keychain only).
Worked example: Supabase
Supabase is the example because it is the smallest adapter that implements every required method plus the CLI login
mode. It uses one bearer token, has no per-grant minting and no deploy verb, and its scope classifier is short. Vercel
is the same shape with one extra: a built-in deployCommand.
1. Name and labels
export const providerSchema = z.enum(['vercel', 'aws', 'gcp', 'supabase', 'github', 'ssh']);PROVIDER_LABEL.supabase and copy.providers.supabase are both 'Supabase'.
2. The adapter class
supabase.ts starts with the static facts:
export class SupabaseAdapter implements ProviderAdapter {
readonly provider = 'supabase' as const;
readonly authMethod = 'oauth' as const;
readonly tools = ['supabase'];
constructor(private readonly deps: AdapterDeps) {}tools lists the binaries this provider owns. The broker uses it to route styx wrap supabase … here.
3. Connect
The Advanced path checks the pasted token against the API, writes it to the vault, and returns a reference:
const projects = await this.projects(input.token.trim());
const ref = makeCredentialRef('supabase', targetId, 'oauth');
await this.deps.vault.set(ref, JSON.stringify({ token: input.token.trim() }));The returned config holds only non-secret settings (the project ref). The token never leaves the vault.
The CLI path (connectCli) stores no secret at all. The vault entry only names the account:
const ref = makeCredentialRef('supabase', targetId, 'cli');
const account = input.account.trim() || 'cli';
await this.deps.vault.set(ref, JSON.stringify({ kind: 'cli', account }));At use time, cliToken() reads the token the supabase CLI keeps (~/.supabase/access-token, or the CLI's own OS
keyring entry through deps.cli.readKeychain) and throws CliAuthError(…, true) when the CLI is logged out.
readCliEntry() from cli-auth.ts tells the two modes apart by the :cli suffix on the credentialRef.
cliStatus() reports whether the binary is installed, its version and its accounts, using cliInstall() and
semver(). cliLoginCommand() returns { bin: 'supabase', args: ['login'] }, which Styx runs in a visible terminal.
4. Test and health
test() returns { ok: true, identity } or { ok: false, error }. health() separates "the login is gone" from
"the network blipped": in CLI mode a CliAuthError carries expired, and in token mode testToHealth() treats a
401/403 as expired. Only an expiry raises the Reconnect banner.
5. Issue and revoke
Supabase has no API for minting a narrower token, so issue() hands over the stored token as env and says so:
const token = await this.token(target);
const env: Record<string, string> = { SUPABASE_ACCESS_TOKEN: token };
if (typeof target.config['ref'] === 'string') env['SUPABASE_PROJECT_REF'] = target.config['ref'];
return { kind: 'env', env, expiresAt: grant.expiresAt, scoped: false };scoped: false, and no issuesScoped() method, is the honest answer. It makes GrantService require biometric
verification for any grant on a prod Supabase target, even "read", because the agent gets the whole token. Adapters
that can mint narrower credentials implement issuesScoped(): AWS (STS session policies) and GCP (for
read-only grants). revoke() is empty because Supabase issued nothing new. An adapter that mints a credential should
revoke it there when the provider allows it, as GCP does.
6. Scope classification
scopeOfCommand(argv) maps an invocation to scopes. It reads only the command words (commandHead() skips leading
flags and stops at the first flag), checks the most dangerous classes first, and treats anything it does not know as
a write:
if (cmd === 'functions' && sub === 'deploy') return ['deploy'];
// …
return ['write']; // unknown verbs fail closedThe tests in providers.test.ts include a "scope
classification fails closed (M2)" block that every provider is held to.
7. Registration, shim and the rest
new SupabaseAdapter(deps)is in theProviderRegistrylist inproviders/index.ts.'supabase'is inSHIM_TOOLSinshim-service.ts, so agents'supabasecalls go throughstyx wrap.SUPABASE_ACCESS_TOKENis inSTRIPPED_ENVincli-runner.ts, so a token in Styx's own environment never reaches an agent or a CLI child.sbp_…tokens are a pattern inSECRET_SHAPESinlogger.ts, so they are redacted from logs, audit detail and terminal logs.- The renderer knows Supabase's grantable scopes (
PROVIDER_SCOPESingrant-sheet.ts), its kind tag, its CLI name (CLI_OFinmodals.ts), and its token page (TOKEN_PAGESintarget-service.ts). deploy-detect.tssuggestssupabase db pushandcopy.deploy.placeholders.supabaseis the placeholder for a custom deploy command.
Adding yours
Below, netlify stands for your provider id. Lowercase, no spaces.
Before you start, check that the provider has a CLI that agents already use. The shim model gates CLI calls; a
provider reached only through raw HTTP gets nothing from the shim and must rely on request_access and
get_credential. Also find out whether the provider can mint short-lived or narrower tokens. That decides scoped.
The compiler finds some of these places: anything typed Record<Provider, …> fails pnpm typecheck until you add
the key. The plain arrays, the second Provider type in the adapter folder, SQL, regexes and prose do not. Work
through the whole list.
Required: the provider exists and is gated
- Core enum and label. Add
'netlify'toproviderSchemaandPROVIDER_LABELinpackages/core/src/model/common.ts. - The adapter's own
Providertype.providers/types.tsdeclares its ownProviderunion. Add the id there too. - Database.
targets.providerhas a SQLCHECK. Add the id to theenuminapps/desktop/src/main/db/schema.ts, and write a new migration that rebuildstargetsthe way0002_auth_method_cli.sqldoes, with the current column list. See.claude/skills/db-migration/SKILL.md. - The adapter. Create
apps/desktop/src/main/providers/netlify.tswith a class implementingProviderAdapter:- Header comment: the auth modes, which env vars
issue()sets, whether the credential is scoped, and how read/write/deploy/delete map to provider permissions. provider,authMethod('oauth'for token paste,'key', or'ssh'), andtools(every binary name the shim should own).connect(input, targetId): handlemethod: 'cli'(store only{ kind: 'cli', account }) and your Advanced method. Verify the credential against the provider before storing it. Store secrets only withdeps.vault.set(makeCredentialRef('netlify', targetId, …), …). Return non-secretconfigand alabel.test(), andhealth()that setsexpiredonly for real auth failures (useCliAuthError,cliFailure()andtestToHealth()fromcli-auth.ts).issue(grant, target): mint the narrowest, shortest-lived credential the provider supports, capped withexpiryFor(). Setscopedhonestly. If you can mint narrower credentials, implementissuesScoped().revoke(issued): revoke anything you minted. Keep ahandlein the issued credential if you need one.scopeOfCommand(argv, tool): build oncommandHead(),isHelp(),hasVerb()andshortFlags()fromtypes.ts. Check delete, then deploy, then write, then an explicit read list. Return['write']for anything unknown. Never classify by flag values.- CLI mode:
cliStatus()andcliLoginCommand(). Read the CLI's own token store read-only and on every use; never copy it into the vault. Put any path helper next tovercelAuthPaths()incli-auth.ts. - Optional:
deployCommand(target)if the provider has one obvious deploy verb.
- Header comment: the auth modes, which env vars
- Registry. Add
new NetlifyAdapter(deps)to the list inproviders/index.ts. - Shim. Add each tool name to
SHIM_TOOLSinshim-service.ts. Add them toCLOUD_CLI_BY_PATHinstream-runner.ts, which notices an agent calling the real binary by full path to skip the shim. - Environment hygiene. Add the provider's token env vars to
STRIPPED_ENVincli-runner.ts. If the CLI prompts or prints update notices, add a quiet setting toQUIET_ENV. - Redaction. If the provider's tokens have a recognisable prefix, add it to
SECRET_SHAPESinlogger.ts. If its CLI takes a secret through a flag that is not inSECRET_FLAGS, add that flag. - Connect flow. In
target-service.ts:AUTH_METHODand, for token paste,TOKEN_PAGES. Inmodals.ts:PROVIDERS(the grid order; it also feeds onboarding),CLI_OF, andmethodOf()(it repeats the auth method rule). Add the id toPROVIDERSinPolicyRuleModal.tsx. - Grant sheet. Add
PROVIDER_SCOPES(the scopes a user can grant) andPROVIDER_KIND(the tag next to the env) ingrant-sheet.ts. - Copy. In
packages/core/src/copy.ts:providers.netlifyanddeploy.placeholders.netlify. Add the CLI to the tool lists inagentPrompt.shimsandagentPrompt.learnDeploy. These tell agents which commands are wrapped. - MCP tool description. Add the provider and its CLI to the
request_accessdescription inpackages/broker/src/mcp-stdio.ts. The broker protocol typesprovideras a string, so nothing else changes there. - Fixture credentials.
fixtureSecretFor()inapps/desktop/src/main/db/seed-vault.tsgives fixture targets a fake{ token: 'FIXTURE-…' }. Add acaseif yourconnect()stores a different shape. Every fake value must sayFIXTURE.
Optional
- Deploy. For a built-in verb, implement
deployCommand()and add the id toDEPLOYABLE_PROVIDERSinpackages/core/src/selectors/palette.ts. For suggestions read from the repo, add acasetodetectDeployCommands()indeploy-detect.ts. Its suggestions must never run anything that changes state. - Demo fixture. Add a target to
demoAcmeTargets()inpackages/core/src/fixtures/demo.ts, using the sameconfigkeys your adapter reads. - File tree. If the provider's CLI writes a cache folder into repos, add it to
TREE_IGNORED_DIRSinpackages/core/src/model/project.ts(.netlifyis already there). - Icons and colours. There are none per provider. Targets are named in text everywhere; do not add an icon set.
Docs and website
- README. "Works with", the opening lines, and the shim list in "How does an agent ask?" in
README.md. - Website.
targetsinapps/website/components/Compat.tsx,deployinapps/website/lib/compare/styx.ts, andapps/website/public/llms.txt. - Discrepancy log. The provider grid now differs from the handoff prototype. Add the next numbered row to
docs/handoff-discrepancies.md(| # | Where | Prototype | Spec | Resolution |). Maintainers may renumber it on merge.
You do not need to add IPC commands. The target.* commands take providerSchema, and TargetService calls the
adapter through the registry.
Testing it
First-time setup is in the README's "Build from source" section (pnpm install, pnpm tokens:build).
pnpm typecheck # finds missing Record<Provider, …> keys
pnpm -F @styx/desktop test src/main/providers # adapters, CLI auth, registry
pnpm -F @styx/desktop test src/main/broker # exec_authorize, request_access, get_credential
pnpm -F @styx/desktop test src/main/services # grants, targets, deploy, shims
pnpm -F @styx/core test # policy engine, selectors, copy
pnpm lintWrite tests in providers.test.ts, or a
netlify.test.ts next to the adapter, with the same tools:
MemoryVaultfromcredential-vault.tsinstead of the keychain.- A stubbed
fetchthat answers by URL prefix (thedeps()helper at the top ofproviders.test.ts). Never call the real provider. FakeCliRunnerfromcli-runner.tsfor CLI mode:.install(bin),.on(bin, argsPrefix, result),.file(path, contents), andkeychainentries.
Cover at least:
- connect with each method stores exactly one vault entry, and no secret appears in
configorlabel; - in CLI mode, the vault entry is
{ kind: 'cli', account }and the CLI token is read per use, not stored; issue()returns the expected env andscopedvalue, andrevoke()undoes anything it minted;scopeOfCommand()as a table: every delete, deploy and write verb,--help, an unknown verb (must bewrite), and tricks like a read-looking word in a flag value. Add your provider to the "fails closed (M2)" block;health()returnsexpired: truefor a 401 or a logged-out CLI andexpired: falsefor a timeout or a missing binary;- the
ProviderRegistrytest'stoHaveLength(6)andforTool()expectations, updated.
End-to-end tests drive the built Electron app with Playwright (pnpm build, then pnpm e2e).
apps/desktop/e2e/launch.ts puts
apps/desktop/e2e/fixtures/bin/ first on PATH and starts the app with
STYX_KEYCHAIN=memory. Fixture targets then get fake FIXTURE credentials. If your test makes the app run the
provider's CLI (a cliStatus probe, a deploy), add a fake fixtures/bin/netlify Node script (executable, with a
.cmd twin for Windows, copied from gh.cmd), like the fake gh. Set
STYX_MFA=auto in the launch env to pass the biometric step, as
grant-flow.spec.ts does. 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 and scheduled health checks off. Connect your provider from Settings › Targets with a real login: the secret stays in memory and is gone when you quit. Then spawn a shell session in that project and run your CLI. The call should open a grant request; approving it should run the command with the issued env, and Approvals › Audit log should show the request, grant and use. Try a prod target to see the biometric prompt.
A good PR includes:
- the adapter with its header comment, its tests, and the migration;
- a scope table in the PR description (command → scope) and an honest note on
scoped; pnpm typecheck && pnpm lint && pnpm testpassing;- the provider CLI version you checked against;
- screenshots of the provider grid, the connect step and a grant sheet;
- the README "Works with" line and a discrepancy-log row;
- a request for a security review. The
security-revieweragent in.claude/agents/is the checklist maintainers use.
Security rules
These apply to every target. They are the reason targets exist.
- Secrets only in the OS keychain. Store credentials only through
CredentialVault, behind acredentialRefmade bymakeCredentialRef(). Never put a secret in SQLite (including targetconfig), logs, IPC payloads, renderer state,.styx/project.json, fixtures or the repo. Error messages reach audit detail and banners: quote the CLI's stderr diagnostics (cliFailure()does this), never its stdout, and never a response body that could echo a token. - Reuse the CLI login without copying it. In CLI mode the vault holds only the account name. Read the CLI's token
store read-only, on every use. Read its OS keyring entry through
CliRunner.readKeychain()so the keychain prompt names Styx. - Short-lived, scoped credentials. Prefer minting a credential limited to the granted scopes and the grant's
lifetime, capped with
expiryFor(). If the provider cannot do that, returnscoped: falseand do not implementissuesScoped(). Styx then forces biometric verification on prod for every scope. Never claimscoped: truefor a credential that can do more than the grant says. - Fail closed.
scopeOfCommand()returns['write']for anything it does not recognise. A misclassified deploy or delete is a security bug, not a usability bug. - Biometric for prod is computed in main.
requireMfacomes from the policy engine,requiresMfa()in the grant machine andGrantService.approve(), from database rows. Do not add a code path that takes it from the renderer or the agent, or that issues a prod write, deploy or delete credential without going throughapprove(). - Everything is audited. Request, grant, deny, use and revoke are recorded with actor, session, worktree and the
triggering command (redacted with
redactArgv()). You get this by going throughGrantServiceand the broker. Never hand out a credential any other way.audit_entriesis append-only; a revoke inserts a new row. - Agents get credentials only for the grant's lifetime. Through the shim env or
get_credential, never persistently, and never in the agent's launch env. SSH-style providers hand over a forwarded agent socket, never a key file (seessh.tsandssh-agent.ts). - Inputs are hostile. Account names travel into argv: validate them with
ACCOUNT_PATTERN. Targetconfigcan be seeded from a committed.styx/project.json: only compose plain identifiers into commands, asdetectDeployCommands()does withSAFE_ID. - Tests never touch real providers. Use
MemoryVault, a stubbedfetchandFakeCliRunner. Fixture credentials sayFIXTURE.