Smart Agent Teams

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

RequirementWhy
The sat CLIRun 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 PATHclaude (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.
gitOnly for agents whose task belongs to a project with a repository. See Workspaces.
A personal API keyThe runner refuses a password session. See below.
Network access to the SAT APIPlus 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 run

Use 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 NoteFlow

Create a key and store it for the profile

sat apikey create grace-laptop-runner --store

The 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 start

Start the runner

sat runner start

sat 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 process

Flags

FlagDefaultMeaning
--agents <list>all agentsOnly 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 machineOnly claim runs for agents with these adapters. Values: claude_code, codex, echo, cursor, gemini, opencode, http.
--concurrency <n>2Maximum runs executing at once in this process. A positive integer.
--workdir <dir>$SAT_WORKDIR, else $XDG_DATA_HOME/sat/workspaces, else ~/.local/share/sat/workspacesWhere repositories, worktrees and scratch folders live.
--onceoffClaim 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>30Safety-net poll interval. Values under 5 are raised to 5.
--on-success <status>in_reviewWhere a task moves after a successful run: in_review, done or none. See Follow-up rules.
--simulateoffUse the built-in echo adapter for every agent. No model calls, no cost beyond 1 cent per run.
--echo-delay <ms>1500Length of a simulated run.
--echo-fail-rate <0-1>0Fraction of simulated runs that fail.
--claude-permission-mode <mode>acceptEditsClaude Code permission mode: acceptEdits, bypassPermissions, plan or default.
--claude-model <model>Claude Code's defaultModel passed to claude --model.
--max-turns <n>no limitPassed to claude --max-turns.
--claude-settings <file-or-json>nonePassed to claude --settings, for example to let the agent's sandbox reach the SAT API.
--codex-sandbox <mode>workspace-writeCodex sandbox: read-only, workspace-write or danger-full-access.
--codex-model <model>Codex's defaultModel passed to codex exec --model.
--with-scheduleroffAlso 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:

  1. Writes Grace picked up NF-12 in <workspace path> to the transcript.
  2. Writes step 1/3 to step 3/3, spread over --echo-delay.
  3. Reports 100 input tokens, 50 output tokens and 1 cent, so simulated runs count against budgets.
  4. Succeeds with Echo: NF-12 Fix login redirect done., or fails with echo 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 300

Run 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.

.github/workflows/agents.yml
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:

  1. Stops claiming and closes the live stream.
  2. Stops every agent process (SIGTERM, SIGKILL after 5 seconds).
  3. Posts Run failed: Runner stopped before the run finished on each in-flight run's task.
  4. Finishes each in-flight run as failed with the error Runner stopped before the run finished.
  5. 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 status

Lists 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 adapters on your runners.
  • Is the agent paused or pending_approval? See sat inbox.
  • Does the agent already have a running run?

sat runner adapters

sat runner adapters

Shows 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.

/etc/systemd/system/sat-runner.service
[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.target
/etc/sat-runner.env (mode 0600, owned by root)
SAT_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 -f

Sizing

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: --concurrency agent 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 --agents or --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.created arrives. Without it, within --poll seconds.

On this page