Build your own runner
The runner protocol call by call, with curl examples and a tested Python reference implementation.
sat runner is one implementation of a small HTTP protocol. You can write your own runner in any language, for example to execute agents with a tool SAT does not support, inside your own job system, or behind a firewall. This page specifies each call, shows it with curl, and ends with a complete Python runner that has been run against the API. Read How runs work first for the rules behind the calls.
Before you start
Every call goes to /api/companies/{company_id}/... on the API origin, with a bearer credential. Use a personal API key: it does not expire unless you ask, so an unattended runner keeps working.
export SAT_API_URL=https://app.yarlis.com
export SAT_API_KEY=sat_live_...
# Find the company id
curl -sS "$SAT_API_URL/api/companies" -H "Authorization: Bearer $SAT_API_KEY"
export COMPANY_ID=6bdb77a8-288a-44d5-ae29-f7beda9fd580
export RUNNER_ID="build-01:$$"Pick a runner_id of 1 to 100 characters that is unique to the process. The API uses it to tell your runner's lease from another runner's.
The protocol
| Step | Call | Notes |
|---|---|---|
| 1 | POST /runs/claim | Take the oldest runnable queued run, or 204 |
| 2 | PATCH /tasks/{task_ref} with run_id | Optional: move the task to in_progress |
| 3 | POST /runs/{run_id}/heartbeat | Repeatedly, well inside lease_seconds |
| 4 | PATCH /runs/{run_id} with append_transcript | Optional: stream progress. Also extends the lease |
| 5 | POST /tasks/{task_ref}/comments, PATCH /tasks/{task_ref}, POST /tasks, each with run_id | Follow-ups, as the agent, while the run is still running |
| 6 | PATCH /runs/{run_id} with status | Finish: succeeded or failed, with totals |
Optionally subscribe to GET /events (server-sent events) to claim as soon as run.created arrives and to stop as soon as a run you hold is cancelled. Polling alone is correct, only slower.
1. Claim a run
curl -sS -X POST "$SAT_API_URL/api/companies/$COMPANY_ID/runs/claim" \
-H "Authorization: Bearer $SAT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"runner_id\": \"$RUNNER_ID\", \"adapters\": [\"claude_code\"]}" \
-w '\nHTTP %{http_code}\n'In a script, make the claim like this instead, to keep the run id for the next calls (an empty RUN_ID means 204):
export RUN_ID=$(curl -sS -X POST "$SAT_API_URL/api/companies/$COMPANY_ID/runs/claim" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"runner_id\": \"$RUNNER_ID\"}" | jq -r '.run.id // empty')agent_ids and adapters are optional filters. A 204 with an empty body means there is nothing for you; wait and try again. A 200 returns the run context, already marked running under your runner_id:
{
"run": {
"id": "3f2a6c1e-8d4b-4c1e-9a57-0b1f2c3d4e5f",
"agent_id": "a1c2...",
"task_id": "7d9e...",
"trigger": "assignment",
"status": "running",
"runner_id": "build-01:777",
"transcript": [
{"ts": "2026-10-05T10:42:06+00:00", "role": "system", "text": "Assigned NF-12"},
{"ts": "2026-10-05T10:42:07+00:00", "role": "system", "text": "Claimed by runner build-01:777"}
]
},
"agent": {"id": "a1c2...", "name": "Grace", "title": "Senior Engineer", "adapter": "claude_code", "instructions": "..."},
"manager": {"id": "b7f0...", "name": "Linus", "title": "CTO"},
"company": {"id": "6bdb...", "name": "NoteFlow", "mission": "...", "task_prefix": "NF"},
"task": {"id": "7d9e...", "key": "NF-12", "title": "Fix login redirect", "status": "todo", "priority": "high"},
"comments": [],
"goals": [{"id": "c3d4...", "title": "Ship v1 login"}],
"project": {"id": "e5f6...", "name": "Web app", "repository": "github.com/noteflow/web"},
"lease_seconds": 300
}The example is shortened; the full schemas are in Claim a run. task, manager and project can be null.
2. Start the task
curl -sS -X PATCH "$SAT_API_URL/api/companies/$COMPANY_ID/tasks/NF-12" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"status\": \"in_progress\", \"run_id\": \"$RUN_ID\"}"task_ref is the task key or id. run_id makes the agent the actor in the activity log.
3. Heartbeat
curl -sS -X POST "$SAT_API_URL/api/companies/$COMPANY_ID/runs/$RUN_ID/heartbeat" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"runner_id\": \"$RUNNER_ID\"}"Send one at a steady interval much shorter than lease_seconds; sat runner uses 30 seconds against the default 300. A 200 returns the run. A 409 RUN_LEASE_LOST means stop: kill the agent and send nothing more for this run.
{"error": {"code": "RUN_LEASE_LOST", "message": "Run is cancelled", "details": {}}}4. Stream the transcript
curl -sS -X PATCH "$SAT_API_URL/api/companies/$COMPANY_ID/runs/$RUN_ID" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"runner_id\": \"$RUNNER_ID\", \"append_transcript\": [
{\"role\": \"agent\", \"text\": \"Reproduced the redirect on staging.\"},
{\"role\": \"tool\", \"text\": \"→ Bash npm test\"}
]}"Entries are appended in order. role is agent, tool, system or user (default agent); ts is optional and defaults to the server's time. Batch entries: sat runner sends every 2 seconds. Every PATCH also renews the lease.
5. Follow-ups as the agent
Do these before you finish the run. Once it is finished, run_id is rejected with 400 RUN_NOT_RUNNING.
# The agent's final reply, as a comment
curl -sS -X POST "$SAT_API_URL/api/companies/$COMPANY_ID/tasks/NF-12/comments" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"body\": \"Fixed the OAuth callback URL; tests pass.\", \"kind\": \"comment\", \"run_id\": \"$RUN_ID\"}"
# Hand the task to the board for review
curl -sS -X PATCH "$SAT_API_URL/api/companies/$COMPANY_ID/tasks/NF-12" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"status\": \"in_review\", \"run_id\": \"$RUN_ID\"}"To match sat runner, apply the follow-up rules: a reply starting with BLOCKED: becomes a question comment and the task moves to blocked; a failure becomes a Run failed: ... comment; a success moves the task to in_review only if it is still todo or in_progress.
6. Finish the run
curl -sS -X PATCH "$SAT_API_URL/api/companies/$COMPANY_ID/runs/$RUN_ID" \
-H "Authorization: Bearer $SAT_API_KEY" -H "Content-Type: application/json" \
-d "{\"runner_id\": \"$RUNNER_ID\", \"status\": \"succeeded\",
\"summary\": \"Fixed the OAuth callback URL; tests pass.\",
\"tokens_in\": 48210, \"tokens_out\": 3920, \"cost_cents\": 42}"For a failure, send "status": "failed" and an error message. tokens_in, tokens_out and cost_cents are totals for the run in integers (cost in US cents) and replace any earlier values. The API then frees the agent and checks its monthly budget.
Errors to handle
| Answer | When | What your runner does |
|---|---|---|
| 204 on claim | Nothing matches your filters | Wait, then claim again |
409 RUN_LEASE_LOST | Heartbeat for a run that is no longer running or held by another runner; PATCH with a different runner_id | Stop the agent. Report nothing more |
400 RUN_FINISHED | PATCH or cancel on a finished run, for example one cancelled or expired while you worked | Same as 409 |
400 RUN_NOT_RUNNING | A task write with run_id after the run left running | Same as 409 |
400 INVALID_STATE | PATCH with status: "queued" | Bug in your runner |
404 NOT_FOUND | Wrong company, run or task id, or the key's owner is not a member | Check ids and the key |
| 401 | Missing, revoked or expired key | Stop and alert |
422 VALIDATION_ERROR | Request body fails validation, for example a missing runner_id | Bug in your runner; message names the first bad field and details.errors lists them all |
Network errors and 5xx answers on heartbeats are not fatal; retry on the next tick. If your runner cannot report for lease_seconds, the API fails the run and frees the agent. See Errors.
Rules for a correct runner
- Start runs only with
POST /runs/claim. NeverPATCHa run torunning. - Send your
runner_idon every heartbeat and runPATCH. Without it, the API skips the lease check, which is how cancel works, so a stale runner could overwrite a run another runner holds. - Treat 409,
RUN_FINISHEDandRUN_NOT_RUNNINGas "stop now". Kill the agent process. - Do follow-ups before the finishing
PATCH. - Report totals, not deltas, for tokens and cost.
- For concurrency, claim again while you have free slots. The API never gives one agent two running runs.
Reference implementation in Python
This runner claims one run at a time, executes a command with the prompt on standard input, heartbeats in a background thread, applies the follow-up rules and finishes the run. It needs Python 3.9+ and requests. It has been run against the API, covering success, BLOCKED:, failure, a missing binary, cancellation and lease errors.
pip install requests
export SAT_API_URL=https://app.yarlis.com SAT_API_KEY=sat_live_... SAT_COMPANY_ID=6bdb77a8-...
export SAT_ADAPTERS=my_tool # claim only agents whose adapter is "my_tool"
export AGENT_CMD="my-tool --non-interactive" # reads the prompt on stdin
python sat_runner.pySAT_ADAPTERS is a comma-separated list. Leave it empty to claim runs for every agent; do that only if no other runner serves this company. The default AGENT_CMD is cat, which echoes the prompt back as the agent's reply, so you can try the loop safely.
"""A minimal SAT runner: claim queued runs, execute a command, report back."""
import os, shlex, socket, subprocess, tempfile, threading
import requests
API = os.environ.get("SAT_API_URL", "http://localhost:8080").rstrip("/")
BASE = f"{API}/api/companies/{os.environ['SAT_COMPANY_ID']}"
RUNNER_ID = f"{socket.gethostname()}:{os.getpid()}"
ADAPTERS = [a for a in os.environ.get("SAT_ADAPTERS", "").split(",") if a] or None
AGENT_CMD = shlex.split(os.environ.get("AGENT_CMD", "cat")) # gets the prompt on stdin
http = requests.Session()
http.headers["Authorization"] = f"Bearer {os.environ['SAT_API_KEY']}"
class LeaseLost(Exception):
"""The run was cancelled, expired or taken over: stop without reporting."""
def call(method, path, body=None):
res = http.request(method, BASE + path, json=body, timeout=30)
if res.status_code == 204:
return None
if res.status_code >= 400:
try:
code = res.json()["error"]["code"]
except (ValueError, KeyError, TypeError):
code = None
if res.status_code == 409 or code in ("RUN_FINISHED", "RUN_NOT_RUNNING"):
raise LeaseLost(code)
res.raise_for_status()
return res.json()
def heartbeat(run_id, done, lost):
while not done.wait(30):
try:
call("POST", f"/runs/{run_id}/heartbeat", {"runner_id": RUNNER_ID})
except LeaseLost:
lost.set()
return
except requests.RequestException:
pass # transient: the lease lasts lease_seconds, so retry next tick
def prompt_for(ctx):
agent, task = ctx["agent"], ctx.get("task")
if not task:
return f"You are {agent['name']}. This is a heartbeat: review your open work."
return f"You are {agent['name']}.\nTask {task['key']}: {task['title']}\n\n{task.get('description') or ''}"
def follow_up(ctx, ok, text):
task_id, run_id = ctx["task"]["id"], ctx["run"]["id"]
def comment(body, kind="comment"):
call("POST", f"/tasks/{task_id}/comments", {"body": body, "kind": kind, "run_id": run_id})
if text.upper().startswith("BLOCKED:"):
comment(text, "question")
call("PATCH", f"/tasks/{task_id}", {"status": "blocked", "run_id": run_id})
elif not ok:
comment(f"Run failed: {text[-2000:] or 'no output'}")
else:
if text:
comment(text)
if call("GET", f"/tasks/{task_id}")["status"] in ("todo", "in_progress"):
call("PATCH", f"/tasks/{task_id}", {"status": "in_review", "run_id": run_id})
def execute(ctx):
run_id, task = ctx["run"]["id"], ctx.get("task")
done, lost = threading.Event(), threading.Event()
threading.Thread(target=heartbeat, args=(run_id, done, lost), daemon=True).start()
try:
if task and task["status"] in ("backlog", "todo"):
call("PATCH", f"/tasks/{task['id']}", {"status": "in_progress", "run_id": run_id})
env = {**os.environ, "SAT_RUN_ID": run_id, "SAT_AGENT_ID": ctx["agent"]["id"]}
with tempfile.TemporaryFile("w+") as out:
proc = subprocess.Popen(AGENT_CMD, stdin=subprocess.PIPE, stdout=out,
stderr=subprocess.STDOUT, text=True, env=env)
proc.stdin.write(prompt_for(ctx))
proc.stdin.close()
while proc.poll() is None:
if lost.wait(1): # cancelled or expired: stop the agent, report nothing
proc.terminate()
proc.wait()
return
out.seek(0)
text = out.read()[-8000:].strip()
ok = proc.returncode == 0
if task:
follow_up(ctx, ok, text) # before finishing: run_id only works while running
report = {"runner_id": RUNNER_ID, "status": "succeeded" if ok else "failed",
"summary": text[:2000] or None, "tokens_in": 0, "tokens_out": 0, "cost_cents": 0,
"append_transcript": [{"role": "agent", "text": text or "(no output)"}]}
if not ok:
report["error"] = f"{AGENT_CMD[0]} exited with {proc.returncode}"
call("PATCH", f"/runs/{run_id}", report)
except LeaseLost:
pass
except Exception as err: # anything else: fail the run so the agent is freed
try:
call("PATCH", f"/runs/{run_id}", {"runner_id": RUNNER_ID, "status": "failed",
"error": str(err)[:2000]})
except Exception:
pass # the server expires the run after lease_seconds
finally:
done.set()
def main(wake=threading.Event()):
while True:
try:
ctx = call("POST", "/runs/claim", {"runner_id": RUNNER_ID, "adapters": ADAPTERS})
except requests.RequestException as err:
print(f"claim failed: {err}")
ctx = None
if ctx:
print(f"run {ctx['run']['id']} for {ctx['agent']['name']}")
execute(ctx)
continue
wake.wait(30) # nothing queued: poll again in 30 s (or sooner, see SSE below)
wake.clear()
if __name__ == "__main__":
main()What it leaves out, compared with sat runner: concurrency, streaming the transcript while the agent works (it uploads the output once, at the end), workspaces, token and cost parsing, and graceful shutdown. Each is a small addition on top of the same calls.
Optional: react to live events
Polling every 30 seconds is correct. To claim as soon as work is queued, run a listener that wakes the loop on run.created, run.updated and agent.updated. The stream needs the same bearer header, which is why this uses requests rather than an EventSource.
import json, threading
import requests
from sat_runner import BASE, http, main
def listen(wake):
"""Wake the claim loop as soon as something changes (SSE)."""
while True:
try:
with http.get(BASE + "/events", stream=True, timeout=(10, 60)) as res:
res.raise_for_status()
for line in res.iter_lines(decode_unicode=True):
if line and line.startswith("data:"):
event = json.loads(line[5:])
if event["type"] in ("run.created", "run.updated", "agent.updated"):
wake.set()
except (requests.RequestException, ValueError):
pass
threading.Event().wait(5) # reconnect after a pause
if __name__ == "__main__":
wake = threading.Event()
threading.Thread(target=listen, args=(wake,), daemon=True).start()
main(wake)On Firebase Hosting origins the stream is buffered and events arrive late or not at all; point the listener at the API's direct origin. See Live events.