Adding a provider
A provider wraps one vendor's agent CLI. Adding one is a file in src/providers/, a line in the registry and a passing contract suite.
Just want your own CLI in the rotation? A command provider needs no code, only a few lines of config. The rest of this page is for shipping a built-in.
Research first
Before code, add the provider to Providers with each claim tagged [V] (vendor docs), [C] (community) or [I] (inference), and settle these with a real run:
- It runs headless with the user's own subscription login, through the vendor's unmodified binary. awaywork never calls a backing API with a subscription credential; a provider that only works that way is out.
- The terms allow unattended use, or at least don't forbid it.
- Where its quota can be read, and what one unit costs.
- Where it looks for skills and project instructions.
Every [C] or [I] claim the code depends on needs a burn test, recorded in Real runs.
The contract
src/core/types.ts:
interface Provider {
id: string
configSchema: ZodObject // strict; `budget` is added to it
credential: 'static' | 'rotating' // a token, or a login file the CLI rewrites
quota: 'window' | 'credits' // expires unused, or monthly credits
unattended: 'allowed' | 'grey' | 'forbidden'
install?: { npm: string } | { script: string } // how a GitHub-hosted runner gets the binary
secrets: string[] // env var names it reads unattended
unavailable?(env): string | undefined // why it can't run here, if it can't
detect(): Promise<{ installed: boolean; version?: string }>
probe(ctx): Promise<Quota | null> // null = fixed budget (fallback)
run(job: Job, signal: AbortSignal): AsyncIterable<WorkerEvent>
prepare?(job: Job): Promise<void> // writes what run reads in the clone, first
skillsDir?: string // where skills go in the clone; unset: job.plugin
instructionsFile: string // CLAUDE.md, AGENTS.md, …
bin?: string // the binary's name, when it isn't the id
repoConfig?: string[] // clone files hidden on someone else's PR
}runspawns the binary injob.dirwithjob.prompt, underjob.maxTurns, withjob.envas its whole environment (extendEnv: false), forge tokens and other providers' and sources' secrets already removed. It yieldsloglines,quotawhenever the stream has one, and exactly onedone.- Spawn with
launch(src/core/launch.ts), notexeca: every process running repo code goes through it, so it gets the run's isolation. PasssignalascancelSignal;launchcloses the binary's output a second after it stopped, so a process that left its group can't keep your loop reading. - The binary reaches only
job.env, the clone andjob.plugin: on a GitHub-hosted runner it's another user with its ownHOME, without the runner's home; underisolation: containera container of the repo's image mounting only the clone andjob.plugin, with only the unit's secrets and the env names inAWAYWORK_ENV, so put a variable your provider sets on that list. job.configis the provider's config entry, paths relative tojob.configDir. Without askillsDir, skills come as a plugin folder injob.plugin.
- Spawn with
prepareruns before eachrun, while the clone is still yours under every isolation (underuserandcontainerit's the worker's oncerunstarts), so write files the binary reads here, like Cursor'sHOMEin.away/agent-home.launch'shomeoption then gives the binary thatHOMEwhatever the isolation.repoConfignames clone files that make the binary run commands or allow it more, like.cursor/hooks.json: awaywork removes them on someone else's PR, git keeping the committed version, and for a gate's judge, since an agent wrote its clone, restoring them after the judge on your own PR.probereads windows as shares from 0 to 1,shortfor the ~5 h session,longfor the week or month;nullwhen unreadable, and awaywork appliesfallback.- A turn cap the CLI lacks is enforced by counting tool calls and aborting, as
codex.tsdoes. installis how the action puts the binary on a GitHub-hosted runner: an npm package joins its onenpm install -g; a bashscriptruns as the runner's user, with sudo, before the worker user exists, as Cursor's does. Install outside the runner's home and nowhere the worker can write. Withoutinstall, a provider isunavailablethere.unavailablereturns a one-line reason where it can't run, as Codex and command providers on a hosted runner:planandrunroute around it,doctorsays why, andschedule install --with github-actionsleaves it out.
claude.ts is the shortest example; codex.ts shows a rotating credential and a per-machine lock; cursor.ts a HOME of its own, written by prepare.
Register it
// src/providers/index.ts
export const providers: Record<string, Provider> = { claude, codex, cursor, mine }The config schema, init, doctor, plan and schedule install --with github-actions pick it up from the registry.
Test it
- A fake binary in
test/fakes/<id>.mjsthat replays recorded output fromtest/fixtures/<id>/, andproviderContract(yourProvider(fakeBin), job)fromtest/contracts/provider.contract.ts. A check that can't apply goes in its third argument,exempt(the command provider's config can't be empty, for one). - Unit tests for the event parser against the recorded streams.
- A
test/real-<id>.test.tsthat spends a few turns of a real subscription, skipped unlessAWAYWORK_REAL=1.
Then a burn test: one real unit with awaywork run --unit, recorded in Real runs with what the quota did.