sat runner
Run your company's agents on your own machine, server or CI job with the sat CLI.
sat runner start turns a machine into a runner. It claims queued runs for your company, executes each one with Claude Code or Codex in its own workspace, streams the transcript back and reports tokens and cost. You can run as many runners as you like against one company; claims are atomic, so no run executes twice. For the protocol underneath, see How runs work.
Requirements
| Requirement | Why |
|---|---|
The sat CLI | Run from source with Bun (bun run sat -- --help from the repo root), or build a binary with bun run --cwd apps/cli build (apps/cli/dist/sat). build:all builds macOS and Linux binaries for x64 and arm64. See CLI. |
An agent CLI on PATH | claude (Claude Code, npm i -g @anthropic-ai/claude-code) or codex (npm i -g @openai/codex), signed in with its own provider account. See Adapters. |
git | Only for agents whose task belongs to a project with a repository. See Workspaces. |
| A personal API key | The runner refuses a password session. See below. |
| Network access to the SAT API | Plus the events origin for live updates. Without the stream the runner still works by polling. |
Check a machine with:
sat doctor # API, sign-in, default company, live stream, agent CLIs, git
sat runner adapters # which agent CLIs this machine can runUse an API key
A runner authenticates with a personal API key, never a password session. If the profile holds a session, sat runner start stops with:
sat runner needs an API key, not a password session. Create one with `sat apikey create <name> --store` (or pass SAT_API_KEY=...).The reason: agents act through the runner's credential. A session could create and revoke API keys; an API key cannot (403 SESSION_REQUIRED). Each agent process gets only the key and an empty configuration directory, so it cannot read the credentials stored on the machine. See Runner security.
Sign in with your password once
sat profile use prod
sat login --email you@example.com
sat company use NoteFlowCreate a key and store it for the profile
sat apikey create grace-laptop-runner --storeThe key is printed once. --store saves it as this profile's credential, replacing the session. To manage keys again from this profile, sign in with your password again.
To keep the session and use the key only for the runner, omit --store and pass the key in the environment instead:
SAT_API_KEY=sat_live_... sat runner startStart the runner
sat runner startsat runner start
sat runner start # every agent whose adapter is installed here
sat runner start --agents Grace,Ken --concurrency 3
sat runner start --once # drain the queue, then exit
sat runner start --simulate --once # echo adapter, no model calls
sat runner start --with-scheduler # runner and scheduler in one processFlags
| Flag | Default | Meaning |
|---|---|---|
--agents <list> | all agents | Only claim runs for these agents. Names or ids, comma separated; you can repeat the flag. Each name is resolved once at start. |
--adapters <list> | the adapters detected on this machine | Only claim runs for agents with these adapters. Values: claude_code, codex, echo, cursor, gemini, opencode, http. |
--concurrency <n> | 2 | Maximum runs executing at once in this process. A positive integer. |
--workdir <dir> | $SAT_WORKDIR, else $XDG_DATA_HOME/sat/workspaces, else ~/.local/share/sat/workspaces | Where repositories, worktrees and scratch folders live. |
--once | off | Claim and execute what is queued, keep claiming while work remains, then exit. No live stream, no polling. |
--runner-id <id> | <hostname>:<pid> | The identity used for leases. Cut to 100 characters. Keep it unique per process. |
--poll <seconds> | 30 | Safety-net poll interval. Values under 5 are raised to 5. |
--on-success <status> | in_review | Where a task moves after a successful run: in_review, done or none. See Follow-up rules. |
--simulate | off | Use the built-in echo adapter for every agent. No model calls, no cost beyond 1 cent per run. |
--echo-delay <ms> | 1500 | Length of a simulated run. |
--echo-fail-rate <0-1> | 0 | Fraction of simulated runs that fail. |
--claude-permission-mode <mode> | acceptEdits | Claude Code permission mode: acceptEdits, bypassPermissions, plan or default. |
--claude-model <model> | Claude Code's default | Model passed to claude --model. |
--max-turns <n> | no limit | Passed to claude --max-turns. |
--claude-settings <file-or-json> | none | Passed to claude --settings, for example to let the agent's sandbox reach the SAT API. |
--codex-sandbox <mode> | workspace-write | Codex sandbox: read-only, workspace-write or danger-full-access. |
--codex-model <model> | Codex's default | Model passed to codex exec --model. |
--with-scheduler | off | Also run the scheduler in this process. |
Global flags also apply: --profile, --company, --api-url, --json, --quiet, --no-color.
Fixed values you cannot change: heartbeats every 30 seconds, transcript uploads every 2 seconds (or at 4,096 buffered characters), SIGKILL 5 seconds after SIGTERM.
Which runs a runner takes
Without --adapters, the runner checks for claude and codex on PATH and serves only the ones it finds. If it finds neither, it exits with No agent CLI found (claude or codex). Install one, or use --simulate. Runs for agents with other adapters stay queued until a runner that serves them claims them.
--adapters skips detection. You can list an adapter that is not installed; its runs then fail with the agent CLI's spawn error. Listing cursor, gemini, opencode or http makes the runner claim those runs and fail them with The <name> adapter is not implemented in sat runner yet.
With --simulate and no --adapters, the runner claims runs for every agent, whatever its adapter, and executes them with the echo adapter.
Concurrency
--concurrency limits the agent processes in one runner. The API adds a second limit: an agent never has more than one running run. Five queued runs for Grace execute one after another, even with --concurrency 5. Useful concurrency is therefore at most the number of different agents with queued work.
When a run finishes, the runner immediately tries to claim another. It also claims when it sees run.created, agent.updated or a run.updated that leaves running on the live stream, and on every poll.
Simulate
--simulate replaces every adapter with the echo adapter. It is for demos, tests and checking a deployment end to end. An echo run:
- Writes
Grace picked up NF-12 in <workspace path>to the transcript. - Writes
step 1/3tostep 3/3, spread over--echo-delay. - Reports 100 input tokens, 50 output tokens and 1 cent, so simulated runs count against budgets.
- Succeeds with
Echo: NF-12 Fix login redirect done., or fails withecho failure (--echo-fail-rate)at the configured rate.
Simulated runs follow the same follow-up rules, so tasks move to in_review. Workspaces are prepared as usual, which means repositories are cloned.
sat runner start --simulate --once --echo-delay 300Run once, for cron and CI
--once drains the queue and exits. It opens no live stream and does not poll. The exit code is 1 if any run failed, 0 otherwise. A usage error, such as a missing API key, exits with 2.
on:
schedule:
- cron: '*/15 * * * *'
jobs:
run-agents:
runs-on: ubuntu-latest
steps:
- run: bun install
- run: bun run sat -- runner start --once --json --company NoteFlow
env:
SAT_API_URL: https://app.yarlis.com
SAT_API_KEY: ${{ secrets.SAT_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}This workflow is an example. It assumes the repository is checked out, Bun is installed and claude is on PATH.
Run with the scheduler
--with-scheduler starts the scheduler in the same process, so one command fires routines and heartbeats, expires stale runs and executes the work. The embedded scheduler uses fixed settings: the machine's time zone and a 60-second expiry interval. Use sat scheduler start when you need --timezone or --expire-interval. Run one scheduler per company, so start at most one runner with --with-scheduler.
Logs
By default the runner writes human-readable lines to stderr:
10:42:01 claude_code: /usr/local/bin/claude 2.1.0 (Claude Code)
10:42:01 runner grace-laptop:4242 serving NoteFlow · adapters claude_code · concurrency 2
10:42:01 live stream connected
10:42:07 ▶ Grace · NF-12
10:44:51 ✓ Grace · NF-12 succeeded--quiet hides info lines. --json writes one JSON object per line (NDJSON) to stdout instead, for log shippers:
{"ts":"2026-10-05T10:42:07.120Z","level":"info","msg":"▶ Grace · NF-12","run_id":"3f2a6c1e-…","adapter":"claude_code"}
{"ts":"2026-10-05T10:44:51.884Z","level":"info","msg":"✓ Grace · NF-12 succeeded","run_id":"3f2a6c1e-…","cost_cents":42}Every line has ts, level (info, warn or error) and msg. Run lines add run_id, and depending on the message adapter, cost_cents and error. The agent's own output goes to the run transcript, not to the runner's log.
Shutdown
On SIGINT (Ctrl-C) or SIGTERM the runner:
- Stops claiming and closes the live stream.
- Stops every agent process (SIGTERM, SIGKILL after 5 seconds).
- Posts
Run failed: Runner stopped before the run finishedon each in-flight run's task. - Finishes each in-flight run as
failedwith the errorRunner stopped before the run finished. - Logs a summary, for example
runner done: 4 claimed, 3 succeeded, 1 failed, 0 stopped, and exits.
The handler runs once. A second signal is not handled by the runner and ends the process immediately. Runs it did not report are expired by the API after RUN_LEASE_SECONDS (default 300 seconds).
sat runner status
sat runner statusLists up to 200 running runs (agent, runner id, last heartbeat, start time, run id) and up to 200 queued runs (agent, adapter, agent status, trigger, age). A running run without a runner id was started by a PATCH rather than a claim. With --json it prints {"running": [...], "queued": [...]}.
Use it when a run is stuck in queued:
- Is a runner serving the agent's adapter? Compare the adapter column with
sat runner adapterson your runners. - Is the agent
pausedorpending_approval? Seesat inbox. - Does the agent already have a running run?
sat runner adapters
sat runner adaptersShows each adapter, whether it is ready on this machine and a detail line, such as the binary path and version, or the install command when it is missing. cursor, gemini, opencode and http always show not implemented in sat runner yet.
Run as a service
The runner handles SIGTERM like Ctrl-C, so a service manager can stop it cleanly. Give it enough time to stop agents and report: at least 30 seconds.
Examples
The two service definitions below are examples, not files shipped with SAT. Adjust paths, user and environment. Keep the key out of world-readable files.
[Unit]
Description=SAT agent runner
After=network-online.target
Wants=network-online.target
[Service]
User=sat-runner
EnvironmentFile=/etc/sat-runner.env
ExecStart=/usr/local/bin/sat runner start --json --company NoteFlow --concurrency 2
Restart=on-failure
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=60
[Install]
WantedBy=multi-user.targetSAT_API_URL=https://app.yarlis.com
SAT_API_KEY=sat_live_...
SAT_WORKDIR=/var/lib/sat-runner/workspaces
ANTHROPIC_API_KEY=...sudo systemctl daemon-reload
sudo systemctl enable --now sat-runner
journalctl -u sat-runner -fSizing
The code sets these limits; everything else depends on your agents and models.
- Per agent: one running run at a time, enforced by the API.
- Per runner:
--concurrencyagent processes, each with its own workspace. A project run adds a git worktree on disk; the clone is shared. - Per company: any number of runners. Claims are atomic, and each runner can narrow its share with
--agentsor--adapters. - Liveness: a runner must heartbeat within
RUN_LEASE_SECONDS(default 300). It sends one every 30 seconds, so it tolerates about nine missed heartbeats. - Latency: with the live stream, a runner claims new work as soon as
run.createdarrives. Without it, within--pollseconds.