agent.run()
DOCUMENTATION / PLATFORM REFERENCE

Based on AgentRun release · Synced .

@parcha/agentrun-serve

AgentRun Server: the Agentrun v2 API on your own machine, or in one container. agentrun-serve serves the same /v2 contract (agentrun.v2/0) as the hosted API, over one agent store of @parcha/pi-durable-agent in a directory: many agents, each with revisions written once (an agentic prompt, or an agentrun-dsl workflow), and every run pinned to the revision it ran. Each run is a @parcha/pi-durable-disk run directory beside the store. An SDK pointed at it works the way it works against the hosted API; the capabilities document says what this server does not offer.

Published to npm from the next release (@latest): npx @parcha/agentrun-serve then starts it with nothing else installed, its console included. Until then, run it from a checkout (below) or the container.

Quick start (no key, no network)

Node 22.19 or later on 22, or 24.2 or later. In a checkout of this repository:

npm ci --ignore-scripts && npm run build
cat > faux.mjs <<'JS'
export default (context, faux) => faux.message("The answer is 42.");
JS
node packages/serve/dist/cli.js --faux faux.mjs

Installed, agentrun-serve is a launcher that needs a POSIX sh with a builtin printf (Linux, macOS; not Windows). It hands the server its keys and token over a pipe, so none is in any process's environment, the run's processes included. Started as node dist/cli.js, as above, the server reads them from its own environment. GET /v2/capabilities' secrets says how they are held, and what that does not cover. Started with npx @parcha/agentrun, your shell and npm keep the keys you exported in their environment, as they always do; the server itself, and its runs, hold none. For the strictest custody, start this launcher directly, with each key set for that one command and not exported: once the launcher has handed a key to the server, no process holds it in its environment. The launcher takes keys from its environment only, not from a file or standard input.

It prints where it listens and, at the start that made it, the token:

agentrun-serve on http://127.0.0.1:7340/v2 (agent store in ~/.local/share/agentrun/agents)
AGENTRUN_BASE_URL=http://127.0.0.1:7340/v2
AGENTRUN_API_KEY=ar_local_...
Open http://127.0.0.1:7340/
Boxes: docker (on by default; set AGENTRUN_BOXES to change).
Set your password: http://127.0.0.1:7340/setup (the first visit sets it)

When the box image is not built yet, a line after the banner says its first build is under way (Preparing the Docker box image (first start only, a few minutes); agents can be made now, and their first run waits for it.), and another when it ends (The Docker box image is ready., or why it failed). npx @parcha/agentrun prints the Boxes: line and these two on stderr, after its own line on stdout. On a machine where Docker is not running, the Boxes: line says so instead (Boxes: none, so new agents run on the runner. Docker isn't running on this machine (no docker command on PATH): install or start Docker, then restart AgentRun.); see Boxes.

Open is the console, which takes your password. For a client, export the two lines before it, then make an agent, start a run, and read its timeline until a page says final:

H="authorization: Bearer $AGENTRUN_API_KEY"
curl -s "$AGENTRUN_BASE_URL/agents" -H "$H" -H 'content-type: application/json' \
  -d '{"definition":{"id":"calc","name":"Calc","instructions":"Answer the question."}}'
run=$(curl -s "$AGENTRUN_BASE_URL/agents/calc/runs" -H "$H" -H 'content-type: application/json' \
  -d '{"input":{"content":"What is six times seven?"}}' | node -p 'JSON.parse(require("fs").readFileSync(0)).run_id')
curl -s "$AGENTRUN_BASE_URL/runs/$run/timeline?wait_ms=5000" -H "$H"
curl -s "$AGENTRUN_BASE_URL/runs/$run" -H "$H"
curl -s "$AGENTRUN_BASE_URL/runs" -H "$H"

An edit is a new revision: PUT $AGENTRUN_BASE_URL/agents/calc/definition with {"definition":{"instructions":"Answer in one word."}} makes revision 2 current, and the next run runs it.

Flag Default What
--data DIR ~/.local/share/agentrun/agents The agent store (DIR/store.sqlite) and its runs (DIR/disk/agents/<agent>/runs/<id>/). The token is DIR/.token, mode 0600, made once and kept, unless AGENTRUN_TOKEN gives it: then a DIR/.token from an earlier start is removed.
--listen ADDR 127.0.0.1 The address to listen on. On any other, every Host name is served and the token is the only lock: put TLS in front of it.
--port N the first free port from 7340 The port. 0 is any free port.
--open off Open the console in this machine's browser once the server listens; not in CI, nor on Linux with no display.
--faux MODULE none Answer every model request from the module's default export (below).
--faux-browser MODULE none With --faux only: the browser tool's provider and driver from the module's default export, a fake browser with no vendor and no key (below). The console's real-browser tests run on it.
--allow-host NAME none Another Host name to accept on a loopback address, on any port. Repeat it.
--max-runs N AGENTRUN_MAX_RUNS, else as many pi runs as fit The most runs at once, beside the memory each run's harness reserves (capacity). Over either, a new run is 429 rate_limited.
--documents DIR AGENTRUN_SERVE_DOCUMENTS A read-only directory of documents a workflow's pdf_pages calls read by file name, confined to it.
--allow-code-nodes off (AGENTRUN_SERVE_CODE_NODES=1 turns it on) Run workflows' code nodes. They run in the run's process through the DSL's new Function transform, which is no sandbox, so only turn it on for workflows you wrote. Off, a workflow with a code node saves with a not_set_up_here warning and its runs are 409 agent_not_runnable with details.code: true. A call via: "shell" or "executor" is refused either way.
--learning on|off on (AGENTRUN_SERVE_LEARNING) Whether runs learn. On, a run of an agent whose definition says learning: true starts from what that agent learned and adds to it; off, no run does, whatever its agent says. Any other value refuses the start. Capabilities report it as features.learning.
--learning-budget BYTES 8000 (AGENTRUN_SERVE_LEARNING_BUDGET) How much of what an agent learned its runs' instructions hold under # Learned, in UTF-8 bytes: every note whole while they fit, else the index and the notes changed last, whole, while they fit (learning). 0 gives the index alone. A value that is not a whole number of bytes refuses the start.
--drain-timeout D 30s (the image: 6s) How long a busy run may finish its turn when the server stops (8s, 500ms, 1m). A turn cut off resumes at the next start; its model request is sent again. The server keeps its data directory until its runs' processes have exited (up to this and a second), and says so as it stops; a server started on the same --data meanwhile waits for it, saying so, then starts.

AGENTRUN_TOKEN, when set, is the token (32 characters or more), and the server prints $AGENTRUN_TOKEN in its place: a token from the environment stays out of the logs. DIR/.token's token is printed only by the start that made it; a later start prints AGENTRUN_API_KEY=<in DIR/.token>, to read it there. With AGENTRUN_ACCOUNTS=0 the Open link carries the token (?token=<in DIR/.token> on a later start); it encodes the token, and the server also takes one whose + arrived as a space. AGENTRUN_PUBLIC_URL, when set, is the address people open (a container's published port, or a proxy's URL): the printed Open link and AGENTRUN_BASE_URL use it.

Sandbox in 60 seconds

An agent with a shell, in a Docker container of its own: on a machine with Docker, in a checkout built as above, with a model key. Start the server; the Docker box is on by default wherever Docker answers:

export AGENT_MODEL=openrouter/anthropic/claude-sonnet-5.5 OPENROUTER_API_KEY=...
node packages/serve/dist/cli.js

Its log says event=boxes available=docker, and its banner's last line Boxes: docker (on by default; set AGENTRUN_BOXES to change). docker boxes reach only pypi.org, files.pythonhosted.org, registry.npmjs.org, registry.yarnpkg.com, github.com, and codeload.github.com. (on Linux; elsewhere it says why the box has no network: Sandbox). The first start builds the box's image (python 3.12, git, curl, node, npm and rg, about 300 MB, a minute or two, the build alone on the network): its banner then says Boxes: docker is preparing (building the box image (python, git, curl, node, npm, rg))., and the log event=box_image ... state=preparing, then state=ready with how long it took; a later start finds it built. A run that needs its box sooner waits for it, with a row on its timeline saying so. In a second shell, export the two AGENTRUN_ lines it printed (a later start prints where the key is: export AGENTRUN_API_KEY=$(cat ~/.local/share/agentrun/agents/.token)). Then make a sandbox agent, run it, watch it, and read the file it wrote:

H="authorization: Bearer $AGENTRUN_API_KEY"; J='content-type: application/json'
curl -s "$AGENTRUN_BASE_URL/agents" -H "$H" -H "$J" -d '{"definition":{"id":"boxed","name":"Boxed","instructions":"Do each step with bash.","place":{"mode":"sandbox","box":"docker"},"tools":["bash","write","read"]}}'
run=$(curl -s "$AGENTRUN_BASE_URL/agents/boxed/runs" -H "$H" -H "$J" -d '{"input":{"content":"Save what cat /etc/os-release prints in out.txt."}}' | node -p 'JSON.parse(require("fs").readFileSync(0)).run_id')
curl -sN "$AGENTRUN_BASE_URL/runs/$run/timeline" -H "$H" -H 'accept: text/event-stream' | grep -o '"summary":"[^"]*"'
curl -s "$AGENTRUN_BASE_URL/runs/$run/files" -H "$H"; curl -s "$AGENTRUN_BASE_URL/runs/$run/files/out.txt" -H "$H"

The place above is what a new agent gets anyway on a server with docker, so a definition that names only its name and instructions lands in the box too, with bash as its one tool (Place).

The timeline ends when the run does. out.txt names the image's Debian, whatever this machine runs, and it is in the run's work/, the one directory the container binds. No key? Start the server with --faux box.mjs, a faux model that makes one bash call and then answers, and run the same commands:

cat > box.mjs <<'JS'
export default (context, faux) => context.messages.some((m) => m.role === "toolResult") ? faux.message("Saved out.txt.")
  : faux.message(faux.toolCall("bash", { command: "cat /etc/os-release | tee out.txt" }), { stopReason: "toolUse" });
JS
node packages/serve/dist/cli.js --faux box.mjs

The box has python 3.12, git, curl, node, npm and rg. On Linux it reaches Python's and npm's package registries and GitHub, and nothing else: pip install and npm install work in it. The box's image says how to give it more. The rest is under Sandbox.

Logs

stdout gets one line per event, at=<time> event=<name> key=value ... (a value with a space is quoted):

at=2026-10-25T21:04:05.120Z event=request method=POST path=/v2/agents/calc/runs status=202 ms=41
at=2026-10-25T21:04:05.121Z event=run_started run=01a1... agent=calc kind=agentic
at=2026-10-25T21:04:06.802Z event=run_ended run=01a1... agent=calc status=completed cost_usd=0.0012
at=2026-10-25T21:04:09.010Z event=auth_failed route=/v2/runs reason=wrong_token

A request's path has no query. auth_failed names the route and why (no_credential, wrong_token, wrong_link_token, wrong_password, sign_in_table_full), never the token given nor any part of it. AGENTRUN_LOG=quiet leaves out the request lines; the run and auth_failed lines stay. A run whose process failed at its start says why in its error.message, in words. One line at start, event=boxes, says which boxes this server has (Boxes), and with a Docker box, event=box_image says whether its image is ready, preparing (being built or pulled) or unavailable and why, again when that ends; quiet keeps them too. event=harness_offers why=start ms=<n> says how long the server took to check what each harness offers (each CLI's package and pins; harnesses), the bulk of its start; quiet keeps it too. It is made at the start only: a PUT of /settings/tools and a save or delete of a tool key change no harness's offer, so they remake capabilities at once without checking again (a key some harness reads would, with why=key).

In a container

The image is public: ghcr.io/parcha-ai/agentrun-server (:beta, or an exact version such as :v0.1.0-beta.27). compose.yaml at the repository's root is the one way to run it: the server, its data on a named volume, and the host's Docker socket. In this release a server in a container can't start a Docker sandbox yet (its folder and the computer daemon are mounted by paths only the server's container has, and it sets no host list), so agents there get file tools and web_fetch, no shell. For a sandbox, run the server on the host, or give the agent a Daytona or E2B computer with workspaces on Archil.

  1. Start it (below).
  2. Open the console and set your password.
  3. Sign in with it on any browser; the token in the log stays for scripts and the CLI.

On a fresh server with no AGENTRUN_PUBLIC_URL, whoever reaches the console first sets the password, so set it right after the start, before the port is reachable by anyone else. With AGENTRUN_PUBLIC_URL set, the setup code the server prints still guards it.

On a Linux host with Docker Engine 26 or later, with no checkout:

curl -fsSLO https://raw.githubusercontent.com/Parcha-ai/agentrun/main/compose.yaml
export AGENT_MODEL=openrouter/anthropic/claude-sonnet-5.5 OPENROUTER_API_KEY=...
echo "DOCKER_GID=$(stat -c %g /var/run/docker.sock)" >> .env
echo "AGENTRUN_SEAL_KEY=$(openssl rand -base64 32)" >> .env && chmod 600 .env   # once; keep it
docker compose up -d
docker compose logs agentrun | grep Open

The .env beside compose.yaml keeps the socket's group (DOCKER_GID) for every later docker compose command, in any shell: compose reads it each time, and compose.yaml needs it to be read at all.

This compose.yaml doesn't pass AGENTRUN_SEAL_KEY to the server yet (#2164), so the server keeps its seal key inside the container, outside /data: recreating the container loses every saved key and sign-in. Until that's fixed, use deploy/compose.yaml, which requires the key, or don't recreate the container.

From a checkout, the same command in its root, with --build to build the image from it. packages/serve/docker/Dockerfile builds that image: the server and the open packages it runs, on Node 22 and Debian 13 (trixie), with git 2.47 for an agent's learning folder and the Docker client. Everything it keeps is under /data: the agent store, the runs and the token.

The token is in the log of the start that made it. On a stop the server gives a busy run 6 s (--drain-timeout 6s) to finish its turn, so a busy docker stop ends in about 7.5 s, inside Docker's default 10 s grace, and Compose's stop_grace_period is 15 s; a turn cut off resumes at the next start. Compose publishes the port on this machine's loopback only (port 7340, or AGENTRUN_PORT; set AGENTRUN_PUBLIC_URL with it, so the printed link names that port), and the volume agentrun-data keeps /data across down and up. In the container the server listens on 0.0.0.0, so setup takes the code the start prints (Set up this server: <url>/setup#code=<code>), and every route but GET /v2/capabilities and GET /v2/health still needs the token. Compose's healthcheck and render.yaml's healthCheckPath are GET /v2/health.

render.yaml at the repository's root is a Render blueprint for the same image: one web service on the 1 CPU / 2 GB plan with a 10 GB disk at /data. Render generates AGENTRUN_TOKEN, which scripts and the CLI read in the service's Environment tab, and AGENTRUN_SEAL_KEY, which seals the keys, MCP server credentials and webhook trigger secrets the server saves (keep a copy: a service that loses it loses them). With no AGENTRUN_PUBLIC_URL the server takes Render's own RENDER_EXTERNAL_URL as its public address, so setup takes AGENTRUN_TOKEN from the Environment tab, or the code the start prints in the service's log (Set up this server: ...). On another host, set AGENTRUN_PUBLIC_URL to the address people open before the first start; without it, whoever opens the console first sets the password. Set AGENT_MODEL and the provider's key there too. Render waits 30 s after SIGTERM, above the image's 6 s drain; it does not let a service with a disk change that.

The disk, and earlier data directories

The data directory has two parts. Its own files, the control plane, hold the agent store (DIR/store.sqlite, where each run's row keeps its claim: an epoch and its holder), the token and the server's settings; only the server opens them. Everything the agents make is on the disk, DIR/disk/: one folder per agent, DIR/disk/agents/<agent>/ (its id in lower case, a dash and 16 hex digits of its SHA-256), with its learning folder (learning/) and its runs (runs/<id>/, each with store/run.sqlite, its log, and work/, where its tools work). Nothing is copied in or out of a run's folder: where it works, it works in that folder.

A run's own process writes its run.sqlite in WAL, under an exclusive lock, with no automatic checkpoint, and ends with its WAL in place. The server reads it with a fresh read-only open per read, never shows a read older than one before, and never opens it for writing while the run's process holds it. Once that process is gone the server takes the run back (its epoch bumped, the server its holder) and checkpoints its WAL into the database file.

A data directory an earlier version wrote, with its runs under DIR/runs/ (or a store of store version 1), is refused at start, saying so: this version neither reads nor moves it. Keep it with that version, or start this one on a new data directory (an empty folder, or a new volume).

When a run's folder is on the owner's Archil disk and the run has no computer, a command runs through Archil's serverless exec, for at most 295 s. A known limit: a command, once sent, cannot be stopped. Stopping the call answers at once, but the command runs on to its own deadline, and the run's next command, or a write to its folder, waits until it has ended. A command not yet sent when the call is stopped is never sent.

Upgrading from 0.1.0-beta.26

0.1.0-beta.27 keeps every agent in one store, DIR/store.sqlite, with its revisions (and this version each run on the disk, above). 0.1.0-beta.26 and earlier kept one agent home per agent, DIR/<agent>/agent.sqlite, and this version does not read those. So it refuses to start on a data directory that holds one, and names each home it found, instead of serving it as if it had no agents.

To keep those agents and their runs, keep the earlier version on that data directory. To start this version, give it a new data directory (a new volume), or move the old homes out of the one you have. Then create the agents again, here or through POST /v2/agents. Their earlier runs stay readable where you moved them, with the earlier version.

Capacity and health

Each run is a process of its own, and how much memory it takes depends on its harness. The server admits runs by memory: each run that holds a live process (working, or waiting for an answer) reserves its harness's size out of the container's limit when it has one (cgroup), else the machine's, less the server's own 128 MiB.

Harness Reserved per run Measured per run
pi 128 MiB up to 106 MB
codex 192 MiB up to 143 MB
claude-agent 288 MiB up to 215 MB
opencode 704 MiB up to 524 MB

The measure is a short task (write a file, read it back, answer) on a real server in a container of its own; the table in src/harness.ts says how it was taken. So a 512 MB instance holds 3 pi runs, 2 codex runs or 1 claude-agent run at once (or 1 codex and 1 pi), 1 GB holds 7, 4 or 3, and 8 GB 63, 42 or 28; an opencode run, OpenCode's own process with it, needs a 1 GB instance for one and 2 GB for two. A start that does not fit in what is left is 429 rate_limited with retry-after, its message naming the harness and the memory, and nothing starts: there is no queue, so the client tries again once a run ends. A harness whose one run needs more than all the memory for runs (claude-agent on a host under 416 MB, opencode under 832 MB) is unavailable: capabilities say why in its harnesses entry, and its runs are 409 agent_not_runnable with that reason. pi always has one place, however small the host.

--max-runs N or AGENTRUN_MAX_RUNS caps the count as well (by default, as many as pi runs fit), and both limits apply. Capabilities report pi's count in limits.max_concurrent_runs, and each harness's own in its harnesses entry (max_concurrent_runs, beside run_memory_mib). A message to a run already going is never refused for a place, nor is the relaunch of one whose process went, and a run's process the next server adopts after a restart holds its harness's memory there. A run whose harness can never fit (the server restarted on a smaller host) is not started again: it fails with why if it was working, and a message to it is 409 agent_not_runnable.

A data directory serves one AgentRun Server at a time: two servers on a shared disk are not supported (each counts only its own runs, and a run could be relaunched twice).

GET /v2/health, with no credential like capabilities, answers 200 {status: "ok", checks: {store: "ok", disk: "ok"}} when the agent store opens for a read and the data directory's disk has at least 64 MiB free (AGENTRUN_MIN_FREE_MB), and 503 with status: "failing" and which check failed (store: "does_not_open", disk: "below_floor", or disk: "cannot_check" when the free space cannot be read) otherwise. It names no path, size or version. One route serves as both liveness and readiness: any answer says the server is up, and a 200 says it can work, and the platforms here take one path.

A run whose end the agent store cannot take (a full disk) still ends: a cancel makes it cancelled, with its cost from its rows, and the server writes the end to the store again every 30 s until it lands; a restart before then reads the run as the store last had it. A run whose process was killed is opened again on its next message at once: the run's row names its process (its pid, that process's start and this boot), and a process that is gone holds nothing. One server serves a data root, and the agent disk under it (<data>/disk): a second server on the same root is refused at start (server.lock), so the row's claim never has two servers claiming it at once.

Sandbox

A sandbox agent's bash runs in a box, a machine apart from this server: today a Docker container made for each run, which binds the run's work/ and nothing else, or a Daytona sandbox, which gets a copy of it. Neither holds a key. A Docker box on Linux reaches the package registries and GitHub through a proxy, and nothing else; a Daytona box reaches them through Daytona's domain list. Its read, write and edit work on that same work/: on this server, where the box binds it and this server holds directory handles (Docker on Linux), else in the box. The agent's loop, its log, its model calls and every key stay on the server. An agent says so in its place; the server says which boxes it has; a run in a Docker box is what the container gets, and a run in a Daytona box what a Daytona sandbox gets; the box's image is what it starts from. Sandbox in 60 seconds is all of it end to end. The published image hosts Docker boxes through the host's Docker socket.

Place

A definition may name where its runs execute: {"mode": "runner"} or {"mode": "sandbox", "box": ...}, the box one of the providers under Computers. place is the v2 view of the agent's computer setting: runner is kind none, and sandbox a sandbox on that provider. On the runner a run has no shell where its folder is on this server's disk (Mode 1), and bash on Archil's serverless exec where it is on Archil (Mode 2) (where bash runs). In a sandbox the agent's bash runs in the named box and its file tools (read, write, edit) work on the work/ the box has (a run in a Docker box), while the agent's loop, its log and every key stay on the server, whatever its harness. Any other shape is 422 input_invalid on definition.place, whose fix is the change to make: the field to remove, or the place it most likely meant, to copy ("place": {"mode": "sandbox", "box": "docker"}). One that names a place keeps it, runner included. An edit that names no place keeps the agent's.

The box is a new agent's default machine. A new agentic agent whose definition names no place is saved in the Docker box ({"mode": "sandbox", "box": "docker"}) wherever this server has docker, ready or still preparing its image (its first run then waits for the image, with one timeline row). The place is written on the agent's first revision, so its answers show it. If that definition names no tools either, it gets ["bash", "bash_background", "bash_output", "bash_input", "bash_signal", "bash_list"], so the agent has a shell and can run a command in the background (background processes). A definition that names its tools keeps exactly those, an empty list included. Where docker is unavailable, a new agent is saved with no place. So is one whose tools name one the box refuses (browser, runner-only for now). A workflow is never given a box.

capabilities.places.default_place is the place a new agent gets here, and where a revision that names no place runs. It is worked out again at each run, the same way as at create: an agentic revision with no place runs in the Docker box wherever this server has docker now, and on the runner where it does not. A workflow, and a revision whose tools name one the box refuses, run on the runner. A save checks a revision with no place against where it would run, so bash with no place saves where docker is there and is 422 where it is not. When that is the runner, default_reason says why in docker's own words, such as new agents run on the runner, because the docker box is unavailable here: Docker isn't running on this machine (no docker command on PATH): install or start Docker, then restart AgentRun. The console's new-agent form says docker's reason too, under its place picker. The console's new-agent form starts on default_place, with bash ticked when that place is a sandbox. What a person picks or unticks there is what is sent.

A run runs in its revision's place, else where default_place says at that run (a workflow, and a revision whose tools name one the box refuses, on the runner). A run request names no place: POST /agents/{id}/runs with place is 422 input_invalid, as with any field the body does not keep. The run keeps the place it resolved to, and GET /v2/runs/{id} shows it as place. A harness's own tools in the box, when they come, will be an option of sandbox (such as native_tools: true), never a third mode.

bash may be named only by an agentic definition whose place is a sandbox (422 on a runner, named or by default, with the sandbox place as its fix), and a workflow's place is runner or none (422 on a sandbox, with the runner place as its fix). In capabilities.tools, bash is available: false with a reason (no box is set up on this server: …, each box's own reason) when no box this server has can run it.

GET /v2/capabilities lists places: the modes, the default, a new agent's place, and each box with available, its state (ready, preparing while the server builds or pulls its image, or unavailable) and, when it is not ready, the reason. A definition naming a box this server does not have saves with a warning on definition.place, and a run of it is 409 agent_not_runnable with details.box. GET /v2/openapi.json marks definition.place, the run's place and capabilities.places x-agentrun-serve: "place/1", default_reason "place/3", and default_place "place/4" (it replaced places.default).

A run in a Docker box

With docker on (by default, when AGENTRUN_BOXES is not set; or listed in it) and a Docker daemon this server's user may use, a run whose place is {"mode": "sandbox", "box": "docker"} gets one container, made on its first bash call (a run with none gets no container). Those four tools are pi's own, on every harness. bash runs in the container. read, write and edit run on this server, on the work/ the container binds, because a docker exec costs half a second or more per call: they follow no link anywhere on the way (one pointing inside work/ included), take only a regular file with one hard link, and write through a temporary file renamed over the old one, keeping its mode (a new file is 0644). A refusal says so and names the box's path; bash, in the container, can still reach the file. Where this server reaches directories by path (no /proc/self/fd, as on macOS), or a box binds nothing, all four run in the container, made on the first of them. web_fetch, web_search and the monid.* tools stay on this server, and so do the run's loop, its log, its model calls and every key. What the server itself files in work/ (monid's long answers, the browser's evidence) never goes through a link a box's command planted there: each directory is held open on the way (/proc/self/fd). On a host without it (macOS), where a link swapped in mid-walk could still turn a write, a sandbox run's work/ gets none of these files: monid's long answers say they could not be saved, and the browser's evidence is not filed. browser is runner-only for now (422 in a sandbox place): its downloads go through work/ by path.

  • The container binds the run's work/ alone, read-write, at /workspace, where the tools start. Nothing else of the data directory enters it. read, write and edit take a path in that directory, relative or absolute, or under the run's own work/ on this machine, which names the same file there (another harness's cwd may name one); any other (another path of this machine, .. out of it, ~) is refused as outside the workspace, and their errors name the box's paths, never this machine's. A mount source that is, or passes through, a link below the data directory is refused: the tool call fails, and no container is made. The data directory is resolved to its real path once, at start.
  • It has no network of Docker's (--network none). On Linux, with a Docker daemon of this machine's own, it reaches a list of hosts alone, on ports 80 and 443, through the run's egress proxy. The list, by default: pypi.org, files.pythonhosted.org, registry.npmjs.org, registry.yarnpkg.com, github.com and codeload.github.com. AGENTRUN_BOX_ALLOW replaces it with a comma list of host names, such as pypi.org,files.pythonhosted.org,registry.npmjs.org,registry.yarnpkg.com,github.com,codeload.github.com,deb.debian.org to add one (*.example.com names the hosts below example.com); AGENTRUN_BOX_ALLOW=none is the box offline, as it was: no proxy, no network at all. *, an address or a CIDR refuses the start.
    • How: the run's process starts the proxy with the list on a Unix socket in a directory of its own under /tmp/agentrun-egress-<uid>/, bound into the box read-only at /run/agentrun/egress/. A socat in the box relays 127.0.0.1:3128 to it, and HTTP_PROXY and HTTPS_PROXY (and their lowercase twins) name that port, with NO_PROXY for loopback. pip, npm, git and curl read them. A program that skips them has no route out.

    • A host off the list, an address and any other port are refused before any lookup, in words: 403 example.com is not on this box's network list: use web_fetch for web pages, or ask the operator to add it to AGENTRUN_BOX_ALLOW. So is a listed name whose address is private, this machine's or a cloud's metadata. The proxy holds no key and adds none. Its decisions are in the run's process log (box_egress: host, port, address, class).

    • Only a Docker daemon on this same Linux machine can reach the proxy's socket. The server checks it once, at start, and fails closed: the box has the list only when this machine is Linux, the docker CLI's endpoint is a local unix socket, and docker info answers within 5 s with Linux containers on this machine's own kernel, keeping its data in a directory this server sees. The kernel is the evidence, not the runtime's name:

      • a Docker whose kernel differs from this server's (Docker Desktop on a Mac, OrbStack, Colima) sees a socket bound in from the host but cannot connect to it, so its box has no network;
      • a Docker on this server's kernel passes whatever it calls itself, if its data directory is one this server sees: Docker Desktop with WSL2 shares the kernel string, but keeps its data outside the distro, so it gets none unless the distro has that directory (/var/lib/docker); where it passes, the proxy enforces the list as anywhere;
      • a server in a container given the host's docker socket shares the host's kernel but not its files (docker info's DockerRootDir is not there), so its box has no network rather than every tool call failing.

      Capabilities and the banner say why, naming what was found: docker boxes have no network: this Docker (OrbStack) runs in a VM (its kernel is "6.15.11-orbstack-…", this machine's "6.1.0-…") that can't reach this server's egress proxy yet. At each start the server removes the proxy directories of runs that are gone (a socket that refuses a connect, over a minute old); a live run's, of any server of this user's, stays.

    • On a Mac, where Docker runs in a VM, a socket bound in from the Mac does not connect, so the proxy runs inside the VM instead: one container per server, agentrun-egress-<12 hex of the data root>, labelled agentrun.egress. It is node:22-bookworm-slim pinned by digest, with the kernel's two proxy files and a short entry bound read-only from <data>/egress/, the list as its one variable, a read-only root, no capability, the server's uid:gid, 256 MiB and 256 processes. It listens on a Unix socket in a tmpfs volume of the same name, owned by the server's uid, and the box mounts that volume read-only at /run/agentrun/egress/. The box keeps --network none, its socat and its proxy variables; the proxy container is the only container with a network (Docker's default), and it publishes no port. The server turns this on only when docker info names a VM runtime it knows, on a kernel that is not the Mac's: Operating System: Docker Desktop (Docker Desktop), OrbStack (OrbStack), or a host name of colima or colima-<profile> (colima). Anything else on a Mac (Rancher Desktop, a Podman machine, a colima whose host name was changed, a daemon elsewhere) has no network, and network_reason says what was seen. At each start, before any run gets the volume's name, the server makes the volume if it is missing; a volume named exactly this server's agentrun-egress-<12 hex> that lacks its label (Docker made it with its defaults when a box mounted it first) is replaced, and no other volume is touched. It removes every container of its own label, pulls the image once if Docker lacks it, starts the proxy and waits up to 10 s for it to say it is ready; until then, and if it never does, the box's network is none with why. Docker restarts a proxy that dies (--restart unless-stopped); the server starts one that is gone again within about 10 s; its stop removes it, by the id Docker gave it, never by its name. Linux keeps the path above, unchanged.

      • The proxy's code is bound from the data directory (<data>/egress/), so Docker Desktop must share that path with its VM. The default data directory, under your home in /Users, is shared. One elsewhere (XDG_DATA_HOME or --data outside Docker Desktop's shared paths) makes the bind fail: the box's network is then none, and network_reason says the proxy did not start. Add the path under Docker Desktop's Settings > Resources > File sharing, or keep the data directory under /Users.
    • The proxy container's own threat model, on a Mac. It has a network: Docker's bridge, the VM's NAT, then the Mac, its LAN and the internet. Nothing reaches it but through the socket in the volume: no TCP listener, no published port, no Docker socket, no key, and a variable that is the list alone; its entry refuses to start with no list, since a proxy with none would reach every public host. The host list comes first, so an unlisted name is never looked up and an address is never dialled: host.docker.internal, gateway.docker.internal, host.orb.internal, host.lima.internal and the VM's own addresses cannot even be asked for. A listed name that resolves to the VM or the Mac is refused by the same address rule as on Linux: OrbStack's Mac host and gateway (seen at addresses in 0.0.0.0/8, which the rule refuses as unspecified; OrbStack's docs do not name them), Docker Desktop's VM network and colima's host address (both in RFC 1918 private space), the Docker bridge, a home LAN, a Tailscale address, cloud metadata and IPv6 unique local. What stays: the rule checks the container's own interfaces, not the Mac's, so a listed name that DNS points at the Mac's public address, or at the router through hairpin NAT, would be dialled; and the cap of 256 connections open at once is the server's, shared by all its runs, not each run's, so one run that holds many open can starve the others until its connections close.

    • Capabilities give the list as places.allow, and each box's network: allowlist or none, with network_reason when an available box has none. An image with no socat gets no network, and the run's timeline says so once.

    • What it allows, and what it does not: a box reaches no cloud metadata address, nothing of this machine or its network, and no resolver, so no name leaves this machine but a listed one. It can send what its run has to a listed host with the agent's own credentials (an npm publish, a git push), as web_fetch can send to any public host. Docker still binds its copies of this machine's /etc/resolv.conf, /etc/hostname and /etc/hosts into it, read-only, so a box's command can read this machine's resolver settings (its nameservers and search domains), with no network to use them. Its environment is LANG=C.UTF-8 and HOME=/tmp and the image's own: never this server's, so no key or token is in it. It runs as this server's uid:gid, so what it writes in work/ stays this server's. It starts from the box's image. It may use 2 CPUs' time, 4 GiB of memory, swap included, and 1,024 processes and threads (--cpu-period 100000 --cpu-quota 200000 --memory 4g --memory-swap 4g --pids-limit 1024), so a runaway build or a fork bomb stops in the box. AGENTRUN_BOX_CPUS (at least 0.01), AGENTRUN_BOX_MEMORY (with its unit, such as 8g, and at least 6m) and AGENTRUN_BOX_PIDS (at most 4,194,304) change them; any other value refuses the start. AGENTRUN_BOX_CPUS caps CPU time, as a CFS quota (the cap --cpus sets), so it holds whatever CPU count Docker's VM has, and where the kernel gives Docker no CPU controller (a rootless Docker without one delegated), Docker ignores it with a warning.

  • bash takes pi's {command, timeout?}, timeout in seconds, 300 when the call names none; a timeout kills the command's process group in the container. Its answer is its output's last 2,000 lines or 50 KB. Each call is a fresh shell in work/.
  • A command that should outlive one call (a server, a watcher, a long build) goes in the background instead (background processes).
  • It has no capability and can gain none (--cap-drop ALL, --security-opt no-new-privileges), so even as root (a server run as uid 0) it cannot make a device node in work/. Its Docker daemon must be this machine's: where DOCKER_HOST, or the docker context, names another machine (tcp://, ssh://), no box is made, since that daemon would bind its own paths, and the refusal says which setting to undo.
  • The container is labelled agentrun.run=<run id>, and the label is its only record. A run's process that starts again after a crash finds it by the label and first kills the commands the dead process left running. It is destroyed when the run ends (completed, failed or cancelled: a follow-up after a completed run gets a fresh one, with the same work/) and when the run is archived. A start destroys every such container whose run has ended or is archived; one of a run this data directory does not know is left to its 24-hour lifetime.

Background processes

An agent whose place is a sandbox may also name bash_background, bash_output, bash_input, bash_signal and bash_list, to run a command that outlives one call: a dev server, a watcher, a long build, a program that reads its input as it goes. Each call is one short command in the run's box, on the same path as bash, so they work on every box bash runs on, and nothing new runs on this server.

The permission gate holds all five to bash's setting (none is a settings key of its own, so no setting of one lifts bash's), and capabilities.tools and an agent's tools show each as the gate takes it, permission_from being where bash's setting came from:

  • bash: deny refuses all five, as it refuses bash, with no shell at all.

  • bash: ask parks only the calls that start or feed a process: bash_background and bash_input (and bash itself), each under its own name, so an approval runs that call. bash_output, bash_list and bash_signal run unasked: they reach only the processes this run started, by its own ids (another run's id, or one never started, is answered "there is no background process"), so watching an approved process costs no approval per page. They run so on a server with accounts off too, where an ask is refused. Only an ask set as written lets them: where bash asks because its setting is a value not offered, or because the settings file never read, they ask too. A save of the settings as served keeps that stand-in, and the file keeps the value as written; only a different permission sets it.

  • bash: allow runs all five.

  • bash_background {command} starts the command from work/, in a fresh shell (bash, else sh) under a session of its own (setsid, its runner under nohup), and answers its id ("1", then "2", and on) at once. Its stdout and stderr go together to work/background/<id>/output.log, beside command, started, pid (its process group) and, once it ends, exit (its code; 128 + n for signal n). The run's files show them (GET /v2/runs/{id}/files), so the console and the API show each log, during the run and after it.

  • Those files are for people to read: the run's own file tools can write anything in work/, so no call trusts one. The group a signal goes to, the exit code, the start time, the command and the run's-end mark are kept in the box's own directory (its TMPDIR, else /tmp), out of work/. A copy in work/ is written from inside work/background/<id>, entered and checked by its real path, to a new file of a name no one can guess, created and written in one exclusive open, then renamed over its name; so a link planted at the name or at the directory is never followed, and a real directory standing at the name skips that copy (as does a failed check). At the run's end every copy (command and started among them) is written again, so a link planted at one since its start does not stay. output.log is opened first and then checked as opened (through /proc: the file reached is work/background/<id>/output.log itself, by its real path, and a regular file), and every read is of that open file, so a link swapped in after the check is never read. A link planted at it is refused, saying so; a box with no /proc to check by refuses every log, saying that instead. A directory made in work/background/ that this box did not start reads as "not started in this box", and is never signalled.

  • A process runs until no live process of its group is left: a child the command starts with & (a server, its output sent elsewhere) keeps it running, counted against the cap, signalled with it and ended at the run's end; its exit code is the shell's.

  • A child that leaves the process group (one that calls setsid, a daemon that detaches) is not tracked: it is not counted against the cap, no bash_signal reaches it, and the run's end does not end it. Only the box's end (its destroy at the run's end) or its 24-hour lifetime ends it; the box's process limit (AGENTRUN_BOX_PIDS) only bounds how many processes there can be.

  • A process whose runner ended before it did (its box was replaced, or the runner was killed) reads as gone, its exit code not known.

  • At most 8 run at once in a run's box: a ninth start is refused, saying so, until one ends. Each log keeps the first 16 MiB of the output; later output is dropped while the process runs on, and bash_output says so.

  • bash_background {command, tty: true} runs the command on a terminal instead: script -qfec (util-linux) in the box gives it a pty, so a program that needs one works (a prompt, a REPL, a progress display). Paging, input and signals are the same calls: the log holds what the terminal shows (input echoed, lines ending in \r\n), bash_input types into the terminal, and bash_signal reaches the terminal's processes: INT goes to its foreground job, as Ctrl-C does, so an interactive shell's running command stops and the shell runs on; TERM and KILL go to every process group of the terminal's session. That session is tracked as the process: a child its command leaves running on the terminal keeps the process running, counted against the cap, reached by bash_signal and ended at the run's end, after script itself is gone. A box with no script, or with another script than util-linux's (busybox's takes no -e, and without it always exits 0, so the command's exit code would be lost), refuses tty: true, saying so, and starts nothing. bash_list marks such a process (tty).

  • bash_output {id, offset?, max_bytes?} reads the log from a byte offset (0 when absent), at most 48 KiB a call (the default), never splitting a character (a page too small for the character it starts at is refused, saying so): whether the process still runs, its exit code once it ended, the log's size, and the offset to read from next. Once it says the process ended, the log is whole.

  • bash_input {id, text, close?} writes text to the process's stdin as given (end a line with \n), at most 64 KiB a call; close: true closes its stdin after it, so the process reads the end of its input. The stdin is a fifo in the box's TMPDIR (else /tmp), never in work/.

  • bash_signal {id, signal} sends the process's group TERM, INT or KILL, and answers its state after up to 2 s.

  • bash_list names each process: running or how it ended, its output's size, when it started and its command's start (every running one, then the newest that ended, 40 at most).

  • The run's end ends them: before its box is copied back and destroyed, the server sends each running group TERM, then KILL after 3 s, and marks each with ended beside its log, which stays in the run's files; the server's log says background_ended with how many. A follow-up's box starts with none running, and its ids go on from the last.

A run in a Daytona box

With AGENTRUN_BOXES=daytona and DAYTONA_API_KEY, a run whose place is {"mode": "sandbox", "box": "daytona"} gets one Daytona sandbox, made on its first read, write, edit or bash call, with the same tools, path rules, environment, bash and labels as a Docker box. What differs (its read, write and edit run in the sandbox, as its bash does, since nothing binds work/ there):

  • The key is this server's alone. The server makes, copies to and from, and destroys the sandbox itself. The run's process holds no key: it reaches its sandbox only through the server's box relay, on loopback, which takes a call for a run only with that run's own door token (DIR/disk/agents/<agent>/runs/<id>/door-token, handed to that run's process alone) and adds the key to the calls a run's tools make of their own sandbox (a read of it, and its toolbox's programs and files). It refuses every other call (a create, a listing, a stop, a delete, another run's sandbox or token, any other host), and it never follows a redirect, nor does the server's own Daytona client: a redirect would carry the key.
  • It reaches the hosts AGENTRUN_BOX_ALLOW names, and no other: Daytona's domain list (domainAllowList), which Daytona's egress proxy enforces. Unset or empty, the list is the package registries and GitHub: pypi.org, files.pythonhosted.org, registry.npmjs.org, registry.yarnpkg.com, github.com and codeload.github.com. A comma list of hosts (a *. wildcard allowed, at most 100) replaces it, so to add one, copy this list and extend it. AGENTRUN_BOX_ALLOW=none blocks its network (networkBlockAll), as before the list. *, an IP address, a CIDR, a port or a URL refuses the start. Capabilities show the hosts in places.allow, and the box's network (allowlist or none) in its places.boxes entry. Daytona's proxy re-signs each TLS connection with Daytona's own CA, which the sandbox's environment names to pip, npm, git and curl (REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS, GIT_SSL_CAINFO, CURL_CA_BUNDLE, SSL_CERT_FILE).
  • A sandbox whose own read does not carry the list it was asked for is deleted. Daytona takes a domain list on its tier 3 and 4 only: where it keeps none, or refuses it, the server makes the run's box again with its network blocked, never open, and makes every later Daytona box that way until it starts again. The log says so once, with Daytona's own words (event=box_network box=daytona network=none refused=<code> message="…"), and capabilities show the box's network as none with its network_reason. A box the server finds is kept when it has no network, or the list the setting names now (the same hosts, in any order, as Daytona reads them back); any other (a wider or narrower list, a list when the setting is none) is destroyed and made again, so a changed list holds for a run's box after a restart.
  • A sandbox Daytona does not report blocked, when blocked was asked, is deleted, and the tool call fails saying so. An organization on Daytona's tier 1 or 2 keeps its own network policy, which a sandbox cannot override, so a run there gets no Daytona box unless that policy blocks it.
  • A sandbox cannot bind work/. The server copies work/ into it at /workspace when it makes it, and copies what changed back at each turn's end (a read of the run's files waits for that copy) and once more before the run's end is recorded. Daytona's answer is never trusted: the copy back writes regular files and directories only, never a link and nothing outside work/, and a file this server changed since the last copy stays as this server has it. A file the box removed is removed here too. What the server files in work/ during the run (monid's long answers) is copied into the sandbox as well. The copy's mark is box-sync.json beside the run, which the server alone writes; the run's process notes each file it copied in, in box-mirrored.jsonl beside it, and a copy back counts those as held by both sides, so an edit the box makes to one comes back.
  • The sandbox is destroyed after the last copy back, and only once that copy is whole: when the run ends, and when it is archived (a copy that fails at an archive keeps the sandbox for the run's end). A copy that fails at the run's end is tried again, and the end waits for it. One that stops for good (an archive the server cannot read, or a limit a copy that brought nothing reached) keeps the sandbox, says so in the log (event=box_kept with its id), and lets the run end with what did come back. A start pulls a run it finds waiting for the person, and copies back, then destroys, the sandbox of every run that has ended or is archived. One whose copy fails is kept for the next start.
  • A kept sandbox has Daytona's auto-delete turned off (a sandbox is made with auto-delete at 0, which deletes it as it stops) and is set to stop after 15 minutes without activity, so once stopped it costs its disk alone (3 GiB on Daytona's default snapshot, at Daytona's storage price) and no compute; its files stay. The next start wakes it, copies back again, and destroys it once that copy is whole; else its 24-hour lifetime ends it. To remove one sooner, delete the sandbox in Daytona's dashboard or API: it carries the labels agentrun.box=run and agentrun.run=<run id>.
  • A run's sandbox that Daytona stopped, paused (a sandbox class that pauses when idle, while the run waits for its person) or archived (a stopped one it moved away) is started again, an archived one restored, when the server next needs it: for the run's next tool call, a copy back, or the start's sweep. One still stopping, pausing or archiving is waited for (a minute at most) first. It is never replaced by a new one, so what only it holds stays.
  • The sandbox is Daytona's default snapshot, made as its user, with sudo -n making /workspace.

A run in a Modal sandbox

With modal listed in AGENTRUN_BOXES, its token set and its SDK installed, a run whose place is {"mode": "sandbox", "box": "modal"} gets one Modal sandbox, made on its first read, write, edit or bash call. It is Modal's default sandbox runtime (gVisor), not a virtual machine: the server does not ask Modal for its VM runtime. It works like a Daytona box: the same tools, path rules, environment and labels, and its read, write and edit run in the sandbox too. What differs:

  • The token and the SDK's client are this server's alone. The server makes the sandbox, copies work/ into it at /workspace, copies what changed back at each turn's end and once more before the run's end is recorded, and ends it after that last copy, at the run's end, a cancel included, and at an archive. The copy rules (no link, nothing outside work/, a file this server changed stays as it has it) are Daytona's.
  • The run's process holds no token. Its calls to its sandbox go through the server's box relay, on loopback, with that run's own door token. The relay makes only the calls a run's tools make of their own sandbox, with the server's client: finding it, reading its state and tags, and starting a program in it with that program's input, output and exit. It refuses everything else: a create, a listing, an end, a tag change, or another run's sandbox or program.
  • It has no network, whatever AGENTRUN_BOX_ALLOW names. The provider takes a domain list when a sandbox is made, but its SDK cannot read a sandbox's list back, so this server cannot check that a box holds the list it asked for (as it does for Daytona). Each box is made with its network blocked, never with the provider's open default. Capabilities show the box's network as none, with network_reason, unless the setting is none.
  • A sandbox that is gone (it ended, or its lifetime ran out) is made again on the run's next tool call and seeded with the run's work/. The call that met the gone box fails, saying so.
  • Each Modal sandbox reserves 2 CPUs and 4 GiB of memory by default, which the provider bills for while the box lives: the Docker box's defaults, asked of the provider as the sandbox's CPU and memory. AGENTRUN_BOX_CPUS and AGENTRUN_BOX_MEMORY change both boxes alike (such as AGENTRUN_BOX_CPUS=1 AGENTRUN_BOX_MEMORY=1g). The provider has no process limit, so AGENTRUN_BOX_PIDS does not apply. Its lifetime is 24 hours, as every box's is.
  • A start copies back, then ends, the sandbox of every run that has ended or is archived.

The box's image

A Docker box starts from this server's own image by default: agentrun-box, python 3.12 (python:3.12-slim, pinned by its digest) with git, curl, node and npm (Debian's nodejs and npm), rg (ripgrep), socat (the relay to the box's network proxy, when it has one) and their certificates, about 360 MB. Its recipe is BOX_RECIPE in src/box-image.ts, its base BOX_BASE there. The server builds it on first use (docker build, at most 10 minutes, its context a private directory under TMPDIR holding the recipe alone as its Dockerfile, removed after), tags it with the recipe's hash (agentrun-box:<12 hex>) and labels it with the whole hash (agentrun.box-image), and builds again only when the recipe changes: a start that finds the image with this recipe's hash uses it. To move the base, read the tag's index digest again from Docker Hub (the comment beside BOX_BASE says how) and put it there: the recipe's hash changes, and so does the image's tag, so the next start builds it. The build reaches the network, for the base image and apt; a box made from it never does through Docker's (--network none). A build that fails leaves the box unavailable with the reason (Boxes). Images of older recipes stay until you remove them: docker image ls --filter label=agentrun.box-image lists them all, and docker image rm agentrun-box:<12 hex> removes one.

A box reaches its package registries (on Linux), so pip install and npm install work in it, but not apt-get: the box runs as this server's user, with no root. For other tools, name another image in AGENTRUN_DOCKER_IMAGE: the server then pulls it when Docker has none (at most 10 minutes) and builds nothing. This image, its build and the wait for it are the Docker box's alone: a Daytona box is made by the server from Daytona's own image, and a Daytona run never waits on this one.

AGENTRUN_DOCKER_IMAGE=python:3.12-bookworm AGENTRUN_BOXES=docker node packages/serve/dist/cli.js

python:3.12-bookworm adds compilers and the build tools of Debian's buildpack-deps. To bring your own, build one FROM the base you want, tag it, and name the tag. An image based on Debian has what a box needs to run commands: sh, sleep and setsid, and bash for the bash tool when there is one (else sh). The image's own entrypoint never runs; the box's first process is a sleep of its lifetime.

In the published image

The published image, ghcr.io/parcha-ai/agentrun-server, runs Docker boxes on the Docker of the host it runs on, through that Docker's socket, as compose.yaml at the repository's root starts it (in a container). The image has the Docker client (Debian's docker-cli) and no daemon; group_add gives the image's node user the socket's group (DOCKER_GID), so the server never runs as root.

  • The socket is root on the host. Whatever can reach /var/run/docker.sock can start a privileged container and do anything on the host: the server, and every run's process it starts, since a run makes its own box. It is the same trust as a server on a host whose user is in the docker group. Give the server the socket only where you would give it root. A box never gets it.
  • A box is a container beside the server's. It is made as on a Linux host (--network none, every capability dropped, no new privileges, the server's uid, the box limits).
  • No host list from a container yet. A Docker sandbox's egress proxy refuses the Docker host's own addresses, so it must know them, and a provider that runs in a container (/.dockerenv, /run/.containerenv or a container's cgroup) can't read them from its own interfaces. The computer package's Docker provider takes them as its egress.hostAddresses option (IP addresses, the host's public ones and any NAT'd or elastic IP; its README), but this server sets no such option and has no setting for it. So in a container, a Docker sandbox asked for a host list (AGENTRUN_BOX_ALLOW, whose default is a list) is refused before anything is made: "this provider runs in a container (…), so this provider's own interfaces are not the Docker host's, and the egress proxy could not refuse the host's own addresses: pass them as egress.hostAddresses, or ask for network none or open". With AGENTRUN_BOX_ALLOW=none a sandbox asks for no network, which this check does not refuse.
  • Its files. The daemon has none of the server's paths, only what it mounted into the server's container. At its start the server reads its own container's id from /proc/self/mountinfo, asks Docker for that container's mounts (docker inspect), and takes the named volume that holds the data directory. A box mounts that run's work/ from it, and the run's egress proxy's directory (<data>/box-egress/<16 hex>/, read-only), as the volume's subpaths (--mount type=volume,...,volume-subpath=disk/agents/<key>/runs/<run>/work), never the rest. The run's files and the box's are the same files, as on a host.
  • Any other layout (the data directory not on a read-write named volume of the server's own container, the socket of another Docker than the one that runs the server, a container whose id /proc/self/mountinfo does not name) makes docker unavailable at the start, its reason in capabilities and the event=box_paths line: run AgentRun in a container with docker compose up from compose.yaml (its named data volume is how boxes reach a run's files). On an older engine: Docker Engine <26 can't mount a named volume's subpath: upgrade to Docker 26+.
  • On a host the server finds no container of its own and asks Docker nothing: a box binds this machine's paths and its proxy's directory is under /tmp, as before.

Computers

An agent's computer is one setting, computer: {kind, provider} in its definition, set in the console under Settings → Computers. The kind says what the agent is given, and the provider who runs it:

Kind What the agent gets Providers
none The run's folder alone: no computer, and no provider ({"kind": "none"})
sandbox A Linux shell, with files and processes docker, daytona, modal, e2b
desktop A screen, a pointer and keys (desktop_screen, desktop_input), and a shell where its provider mounts the run's folder docker (with an image that has an X server), e2b, orgo
phone A rented Android: its screen, touch and apps (the phone_* tools) phone-harness
browser A hosted browser (the browser tools) browserbase, kernel

A kind with no provider, a provider that does not serve the kind, or a provider whose required keys are not saved is refused before anything starts, in words that name the fix in Settings → Computers. GET /v2/computers lists each kind, whether this server offers it now, the tools it brings, and each provider with its key names and whether each is saved (never a value). PUT /v2/computers/{provider}/keys saves a provider's keys, sealed under the server's seal key, from the owner's signed-in session only (the API token is 403), and POST /v2/computers/{provider}/test makes one read at the provider with them and starts nothing. A key set in the server's environment wins over the saved one. The keys are DAYTONA_API_KEY (with DAYTONA_API_URL and DAYTONA_TARGET optional), MODAL_TOKEN_ID and MODAL_TOKEN_SECRET, E2B_API_KEY, ORGO_API_KEY, PHONE_HARNESS_API_KEY, BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID, and KERNEL_API_KEY; Docker takes none. The vendors bill their computers by time on their own pages: this server meters no computer time. The older place is this setting's v2 view: {"mode": "runner"} is kind none, and {"mode": "sandbox", "box": X} is a sandbox on provider X. Writing either sets both.

Where the run's folder lives. A server keeps its runs' folders in one of two modes:

  • Mode 1 (the default): on this server's own disk. Only a provider that binds a folder of this machine can hold it, so a sandbox runs on Docker only; a Daytona, Modal or E2B sandbox is refused ("Mode 1 runs a sandbox only on Docker").
  • Mode 2: on your own Archil disk. PUT /v2/workspace/archil turns it on with the disk's id, its region, its S3 URL (and an exec image pinned by digest, if you want one other than Archil's default), and the account key, ARCHIL_API_KEY; DELETE turns it off. A Daytona, Modal or E2B sandbox mounts the run's folder from the disk with a mount credential minted for that run alone; Docker's sandbox is refused, since it cannot mount the disk.

A desktop, a phone and a browser run in either mode. The run's folder is at /workspace in every computer that mounts it: a sandbox always, and a desktop whose provider mounts the mode's folder (Docker's in Mode 1, E2B's in Mode 2). A phone and a browser never mount it.

Where bash runs. It follows the mount:

  • A computer that mounts the run's folder runs bash and the background tools (bash_background, bash_output, bash_input, bash_signal, bash_list). A call with no timeout runs 120 s, and none runs past 600 s.
  • With no computer that mounts it (kind none, a phone, a browser, or a desktop that does not mount), in Mode 1 the run has no shell at all: the folder is one of this server's own, and a command there would run on the server, able to read its data and its keys. The run gets read, write and edit, confined to its folder; bash and the background tools are not offered, and a bash call answers "bash is not available in this run: it has no computer to run commands in, and commands never run on the server itself." So an agent with no computer on a server without Docker gets the file tools and no shell.
  • With no computer that mounts it in Mode 2, bash runs on Archil's serverless exec: a fresh container that holds the run's folder alone, never a process of this server. Only bash: the background tools answer that background programs need a computer. bash is held to its own setting, as on a computer. Archil's limits hold for it: a command runs at most 295 s (Archil cuts a call at 300 s, and a longer timeout is refused, not cut); once sent it can't be stopped, so a cancel answers at once while the command runs on to its own deadline, holding the folder (later writes and commands wait for it); and its output comes back whole when it ends, not as it runs. Through Archil's file API a write does not keep a file's mode (a 0755 file is 0644 after a write), and a symbolic link a command made is never followed: reading one is refused, a write through a linked folder is refused, and bash refuses a working directory with a link on the way.

An agent with no setting. With no computer and no place, the server decides at each run's start: in Mode 1, the Docker sandbox where this server's Docker answers, else no computer; in Mode 2, no computer. A workflow always runs with no computer.

What a computer reaches. A Docker sandbox reaches the hosts of AGENTRUN_BOX_ALLOW (by default the package registries and GitHub) through its egress proxy, and nothing else; AGENTRUN_BOX_ALLOW=none gives it no network. A server that runs in a container can't start a Docker sandbox yet (in a container). A Daytona, Modal or E2B sandbox has no network of its own (loopback only). A phone and a browser are on their vendor's own network, whatever the list says.

Docker. A Docker computer runs where this server's Docker answers. With AGENTRUN_BOXES not set at all (a plain npx @parcha/agentrun), the server probes docker at its start (within 5 s) and offers it when Docker answers (ready, or preparing while its image is built or pulled, as the box's image says); AGENTRUN_BOXES, when set, lists what it probes, and AGENTRUN_BOXES=none probes nothing. One log line says what it found:

event=boxes available=<ids or none> unavailable=<ids> <id>="<why>" ...

A missing docker command says Docker isn't running on this machine (no docker command on PATH): install or start Docker, then restart AgentRun; a daemon that does not answer says to start Docker or add this user to the docker group, then restart AgentRun, since the probe runs once. A run whose agent names a Docker computer here is then refused at its start in those words. Every other provider needs only its keys. A Docker desktop has no default image: it needs one with an X server, and AGENTRUN_DOCKER_IMAGE names the image of every Docker computer, sandbox and desktop alike.

The keys belong to the server alone. A provider is handed its declared keys only when a run's computer starts or its connection is tested, and no run's process gets them, in its environment or on its pipe.

Models

An agentic revision names its model as provider/model-id ("model" in its definition), or names none and runs on this host's default, AGENT_MODEL. With no AGENT_MODEL, a model provider's key, the server's own or saved as a tool key, gives the default, the first of: anthropic/claude-sonnet-5-5 with ANTHROPIC_API_KEY, openai/gpt-6.1-sol with OPENAI_API_KEY, openrouter/anthropic/claude-sonnet-5.5 with OPENROUTER_API_KEY. Each is its provider's capable everyday model, not its dearest ($2 in and $10 out per million tokens); set AGENT_MODEL for another. claude-agent takes only the Anthropic one and codex only the OpenAI one. A workflow revision names no model: each model node names a tier (fast, default or strong), and the host maps it, fast to AGENT_MODEL_FAST and strong to AGENT_MODEL_STRONG, each falling back to AGENT_MODEL. Capabilities show the mapping in models ({source, route_prefix, fast, default, strong}): source is gateway, providers or faux (the published document marks these as models/2, beyond the contract's managed, chatgpt, env and faux), and route_prefix is the routing prefix this server puts before a gateway's model ids (agent-model), which a client takes off to show the gateway's own id, and null with any other source. models also says ready, whether a revision that names no model runs here, and when it does not, needs: key (no model provider's key, or none for the default's provider) or model (AGENT_MODEL names one this server does not know); and priced, whether that model has a price here, which a run's max_cost_usd needs (false for a gateway model with no price set that the catalog does not list in any form; null while ready is false, with no model to price). Where the models come from:

  • --faux MODULE: every model is the module's (below), with no key and no network: faux-1, or for a tier the model id its variable names.
  • AGENT_MODEL_BASE_URL: every model id goes to that one OpenAI Responses endpoint, a gateway, at <base>/v1/responses with one trailing /v1 taken off the base first, as for every harness (OpenRouter's is https://openrouter.ai/api or https://openrouter.ai/api/v1): a revision's provider/model-id is sent whole, as a gateway like OpenRouter names its models vendor/model, and AGENT_MODEL, AGENT_MODEL_FAST and AGENT_MODEL_STRONG are sent as they are. For a gateway that wants the id alone, AGENT_MODEL_GATEWAY_IDS=bare sends a revision's model without its provider. AGENT_MODEL_API_KEY is its key when it needs one. AGENT_MODEL_PRICE is input,output,cacheRead[,cacheWrite] in USD per million tokens, so a run's cost estimate has a price; AGENT_MODEL_FAST_PRICE and AGENT_MODEL_STRONG_PRICE price their tier's model, and default to it. With no price set, a model id pi-ai's catalog knows (gpt-6-luna, say) takes the catalog's list price; one it lists only with its version's separators written the other way (a gateway's gpt-5-4-mini for the catalog's gpt-5.4-mini, claude-haiku-4.5 for claude-haiku-4-5) takes that entry's price, only when every entry of that form lists one price (else none), and the id sent to the gateway stays as named; any other has no price: its answers' cost is unknown, so the run's cost.usd is null, never $0. A model the catalog lists at $0, or one AGENT_MODEL_PRICE=0,0 prices, is a known $0. A revision's provider/model-id takes that provider's entry when it is paid; a bare id, a provider the catalog does not list for it, or one it lists at $0 takes the highest price any provider lists (one entry's whole price, the most input plus output; a $0 entry never wins over a paid one), as the gateway does not say whose model answers. A run with max_cost_usd on a model with no price is refused before it starts: 409 agent_not_runnable, whose error.fields entry names definition.model, the reason not_set_up_here, "The model has no price here, so a run's spend cap cannot be kept on it." and the fix naming AGENT_MODEL_PRICE, as every 409 agent_not_runnable names each thing that keeps a run from starting; an agent saved with a default cap on one gets that as a warning. One without a cap runs, on every harness, with each request's cost unknown: codex's and opencode's proxy books it unknown, never $0, so the run's cost.usd is null and /usage counts it in unpriced_runs.
  • Otherwise pi-ai's providers serve them, each with its key from the environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY, Bedrock's AWS_* keys, ...), and its catalog prices the run. The AGENT_MODEL variables are then provider/model-id too. The launcher holds every key pi-ai reads, as it holds the rest, and a run's process is handed a model key only when its model needs it: a pi run its model's provider's (a run on Gemini GEMINI_API_KEY, one on openrouter/... OPENROUTER_API_KEY; a workflow each of its tiers'), or AGENT_MODEL_API_KEY through the gateway; a codex run OPENAI_API_KEY, or AGENT_MODEL_API_KEY through the gateway; a claude-agent run ANTHROPIC_API_KEY. No run gets another's. A workflow's decision nodes get their providers' keys on a descriptor of their own, whatever its model, and only those (Safety).

A definition is checked for what keeps it from running here when it is saved, with the same checks a run makes before it starts:

  • What no setting of this host fixes refuses the save, 422 input_invalid by field, and nothing is written: a tool this host does not bind (definition.tools[i]), bash on an agent whose place is not a sandbox, a model pi-ai does not know when no gateway is set (definition.model, saying why), empty instructions (definition.instructions), and a workflow that needs a tool call this host does not run, code, or a node tool other than read, write and edit (definition.workflow, one field per need).
  • What a setting of this host fixes saves, and the 201 or 200 carries it in warnings ([{field, reason: "not_set_up_here", message}], empty when there is none): no key for the model's provider, no default model for a revision that names none, no model for a tier a workflow's nodes name, no key for the decision provider a workflow's typed questions name (decision workflows). A decision provider this server does not have refuses the save.

A run is refused before it starts, 409 agent_not_runnable, saying which, for any of these: a warning the host has not fixed yet, or a revision saved before these checks. A run needs only the tiers it uses: an agentic one its model or the default, a workflow the tiers its nodes name, so a workflow of fast nodes runs with AGENT_MODEL_FAST and no AGENT_MODEL. A run thinks at its effort (low, medium, high), else at its agentic revision's thinking (low to max), else at the host's AGENT_THINKING, else at medium; it is never off, and a model node that names its own thinking level keeps it. An agentic run started with an effort also gets a short note on how to work, right after its instructions, on every harness (src/effort-guidance.ts): low answers directly, medium plans briefly and checks once, high plans each part, fills the gaps and reviews. A run started with none gets no note.

Tools

A revision names its tools from one closed table (src/tools.ts): each name is how the run's process binds it and the variables of this server's environment it needs. Nothing outside the table can be named, and no tool is loaded from anywhere else. GET /capabilities lists every one in tools, available: true or false with the variables it is missing (their names, never their values), nodes for whether a workflow's model node may name it, and calls for whether a workflow's call node via: "tool" may run it. A run of a revision that names a tool outside the table, or one whose variables are not set, is refused before it starts (409 agent_not_runnable, with details.unbound and details.missing).

Tool What it does Needs A workflow's model node may name it, a call node run it
read Read a file in the run's work/; in a box, also an image (png, jpg, gif, webp, by its bytes) at any absolute path in the box, such as one rendered to /tmp, read through the box and never from the host nothing yes, no
write Write a file in the run's work/ nothing yes, no
edit Edit a file in the run's work/ nothing yes, no
bash Run a shell command in the run's box, from its work/: pi's {command, timeout?}, 120 s when the call names no timeout and at most 600 s (a longer one runs at 600 s and says so; longer work is bash_background) an agentic definition whose place is a sandbox (Sandbox) no, no
bash_background Start a shell command in the background in the run's box, from its work/, and answer its id at once: {command, tty?} in, tty: true for a terminal. Its stdout and stderr go to work/background/<id>/output.log, the first 16 MiB; at most 8 run at once (background processes) an agentic definition whose place is a sandbox no, no
bash_output Read a background process's output from a byte offset: {id, offset?, max_bytes?} in (at most 48 KiB a call), its state, exit code, the log's size and the next offset out an agentic definition whose place is a sandbox no, no
bash_input Write to a running background process's stdin: {id, text, close?} (at most 64 KiB a call) an agentic definition whose place is a sandbox no, no
bash_signal Send a background process's group TERM, INT or KILL: {id, signal} an agentic definition whose place is a sandbox no, no
bash_list List the run's background processes: id, state or exit code, output size, start time and command an agentic definition whose place is a sandbox no, no
ask_person Ask the person who started the run; the turn ends until a message answers nothing no, no
web_fetch Read one web page from a public host through the guarded direct fetch, or through Parallel's extract where Parallel is picked (search and fetch), a part at a time: {url, offset?, max_chars?} in, a Page out, {ok, url, status, title, text, chars, truncated?, original_chars?, beyond_read?, offset?, next_offset?, html_chars?, converted?, links, links_omitted?, via, parallel?, refused?, error?} nothing (PARALLEL_API_KEY for Parallel, one billed extract a page a run) yes, yes
web_search Search the web, natively where the harness or the model's provider can, else through Parallel where the server holds its key, else keyless through Exa, or through the provider picked (search and fetch): {query, n?} in, {provider, query, results: [{url, title, snippet, published?}], text?} or {error} out nothing (PARALLEL_API_KEY for Parallel) yes, yes
browser A hosted browser, Browserbase's or Kernel's, on pi-browser's durable adapter: snapshot, run, screenshot, browser_read, browser_release, browser_relaunch, browser_downloads and browser_upload (into and from the run's work/); Kernel keeps no downloads, so a run on Kernel has no browser_downloads BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID, or KERNEL_API_KEY yes, no
monid.discover Search monid's catalog of paid data endpoints in plain words: {query, limit?, minScore?} in, {results: [{provider, endpoint, description, score, price}]} out MONID_API_KEY no, no
monid.inspect One monid endpoint before it runs: {provider, endpoint} in, the JSON Schemas of its input (pathParams, queryParams, body), its price, notes and documentation out MONID_API_KEY no, no
monid.run Run one monid endpoint, paid per call from the monid wallet: {provider, endpoint, input} in, the run's output, price and cost out MONID_API_KEY no, no
pdf_pages A workflow call only: {path, maxPages?, dpi?} in, {name, pageCount, dpi, pages: [{page, image}], text} out. Draws a document's first pages (3 by default, at 110 dpi) as PNGs under the run's work/pages/<stem>/<dpi>/ (<stem>: the document's path below its directory made one name, with a short hash, so two documents of one file name never share pages), the dpi lowered so a page's long side is at most 1568 px, with its text (pdftotext, cut at 20,000 characters). A PNG or JPEG passes through as one page, scaled down to 1568 px. path is under --documents or the run's work/, never outside pdftoppm, pdftotext no, yes
llm A workflow call only: {prompt, system?, maxTokens?, tier?} in, {text, model, usage, cost_usd} out. One completion on the run's model of the tier (default; a tier the state fills in, such as "{requestedTier}", may be any of the three, and a tier with no model fails the call), no tools and no loop; cost_usd is null when the model has no price, never 0 a model no, yes

bash and the background tools run only in a box: a runner agent, and a workflow, get no shell on this machine (see Safety). In a sandbox place read, write and edit run in the box too.

A call tool that fails on its own (an unreadable PDF, a model's refusal) fails its call node; inside a map with onItemFailure: "collect" it fails that item, and the other items go on. A cancel or the node's deadline still stops the run.

web_fetch's direct fetch reaches public hosts only, through pi-browser's guard (publicFetch): each redirect hop is resolved, every address it resolves to is checked, and the connection is dialled at the address checked, so a loopback, private, link-local, carrier-grade or metadata address (127.0.0.1, 10.x, 169.254.169.254, fc00::, ::ffff:127.0.0.1 and the like), a name such as localhost, a public name that resolves to one of them and a redirect to one are all refused, as is any scheme but http(s). A URL on a port other than 80 or 443, or longer than 4,096 characters, is refused before the guard is asked. A body is at most 5 MiB and a fetch at most 30 s. A Page holds a part of the page's text: about max_chars characters from offset (20,000 by default, at least 200, at most 50,000; a quote or a line break counts two, as the JSON answer holds it, and a part holds one character at least, so it always advances), its length as chars. A part that is not the whole page says so: truncated while more follows, next_offset where it begins, original_chars the page's whole length where it is known, and for an agent a line after the JSON says which characters these are and the call that reads on. A page whose HTML is longer than 1,000,000 characters (scripts and styles aside) is cut before it is read: every part gives html_chars, the page's whole length, and no original_chars, its text is the start of the page, and the last part's line says The page was cut at 1,000,000 characters of HTML before reading; the rest wasn't read. converted: "text" says the text is the page's plain text, its conversion to markdown having run out of time. A page is read to its first 1,000,000 characters: an offset at or past them is refused, and the last part a read reaches of a longer page has beyond_read. The links, absolute http(s) only, come with the part at offset 0: at most 200, in 20,000 characters, with links_omitted counting those left out. A page is fetched once a run, at the first call for its URL, through either provider, and the run keeps what it read of its last 8 pages by the URL asked for: every later part of that URL comes from it, with no request, so a page's parts are always one read's. Through Parallel that is one billed extract a page a run, asked for its first 1,000,001 characters (the extract has no offset: its text always starts at the page's start). A refusal or a failure is a Page with ok: false and refused or error, never a thrown error: an agent reads why, and a workflow can branch on it. An agent gets the Page as JSON text; a workflow's call node via: "tool", tool: "web_fetch" gets it as its output (args: {url, offset?, max_chars?}, 50,000 characters of text by default, out a schema of that shape). A call node naming a tool the table cannot run that way is refused before the run starts (409).

On another harness, every host tool's result reaches the model within the host's bound of 120,000 characters (HOST_RESULT_MAX_CHARS, @parcha/agentrun-kernel): the kernel's tool bridge cuts a longer one, its start with a line saying where it was cut, and flags the call truncated. Each CLI adapter tells its CLI to pass a result that long to the model whole: the Claude Agent CLI would otherwise save a result past 50,000 characters to a file and tell the model to read it, which a run's confinement refuses, Codex keeps only the ends of a result past 10,000 tokens, and OpenCode would cut one past 2,000 lines or 51,200 bytes and save the rest to a file the session has no tool to read. A web_search answer past 100,000 characters leaves out its least relevant results, omitted counting them.

Search and fetch

web_search and web_fetch work on every harness with only a model key. Each resolves, for a run, in one order: (1) the provider the user picked, optional: the agent's own (definition.tool_settings) over the server's (PUT /settings/tools); (2) the native tool, the harness's own or its model provider's hosted one (on claude-agent only where it is picked: auto there skips Claude's own search, an explicit native pick); (3) for web_search, Parallel, where the server holds a Parallel key (PARALLEL_API_KEY in its environment, or one saved on Tool keys): auto then uses that key and bills it, each search booked as the run's cost ($0.001 a search); (4) the kernel's keyless fallback. A revision's tools (and a pack's grant) names the capability, never a provider. On codex, auto is Codex's own search, except behind a gateway, on OpenRouter (which runs neither Codex's nor Claude's own search, and counts no searches to price) or under a spend cap (its request has no input bound, which a cap cannot admit), where it is Parallel on the server's key, else keyless Exa. off turns web_search off: nothing is searched, and an agent naming it is refused (409) until it is turned on again. A pick of exa-keyless is keyless Exa alone, the key held or not. A workflow's model nodes may each run on another tier's model, so a workflow's web_search never searches natively: it is Parallel on the server's key, else keyless Exa, or the provider picked, and a pick of native alone is unavailable for a workflow.

claude-agent codex pi
web_search, auto (the default) Parallel on the server's key, else keyless Exa: Claude's own WebSearch is the native pick Codex's own search, each search booked; behind a gateway, on OpenRouter or under a spend cap, Parallel on the server's key, else keyless Exa, a host tool one request to the run's own model provider with its hosted search (Anthropic, OpenAI direct); behind a gateway (AGENT_MODEL_BASE_URL) or on another provider, Parallel on the server's key, else keyless Exa
web_search, native Claude's own WebSearch, unavailable on OpenRouter or under a spend cap the same, unavailable behind a gateway, on OpenRouter or under a spend cap the same, unavailable where there is none: never keyless Exa
web_search, off unavailable (web_search is turned off in settings): nothing is searched the same the same
web_search, exa-keyless Exa's public MCP search, a host tool the same the same
web_fetch, kernel (the default) the guarded direct fetch the same the same
either, parallel Parallel's search and extract, host tools the same the same

GET /settings/tools answers the server's settings, {web_search: {provider}, web_fetch: {provider}, browser: {provider}}, the default where none is set; PUT /settings/tools takes the same map and replaces it (a capability it leaves out takes its default). An agent's definition.tool_settings has the same shape and is over the server's. A provider is one offered for its capability (auto, native, exa-keyless or parallel for web_search; kernel or parallel for web_fetch; auto, browserbase, kernel or chrome for browser, below); an unknown capability or provider is 422. Any tool's settings also take a permission, allow, ask or deny, which the tool gate holds every call to (an ask parks the run for the owner's approval), and an MCP tool's a pin, the arguments its calls must carry (MCP servers, 422 for one that is not one); a workflow's call node, which cannot wait for an approval, refuses its run when its tool is set to ask or deny (409, details.asks or details.denied). The settings are kept in the data directory (tool-settings.json, 0600, written whole and durably), not a secret, and read by every run launched from then on. GET /capabilities is the one tool catalog: each of the three tools there gives the providers offered (providers), the server's (provider, its default where it sets none), the built-in default (default_provider) and resolves, what it resolves to on each harness under the server's settings and default model (native, kernel, exa-keyless, parallel, or unavailable: <reason>), with why, keyed the same, one plain sentence for each (Codex's own search, behind a gateway Codex has no own search; your Parallel key (saved), where saved is a key saved on Tool keys and server one in the server's environment), both recomputed at each PUT and each key saved or deleted. GET /agents/{id} projects it onto the agent: tools, each tool its current revision grants in the same order, and for one with a choice the provider in force, provider_from (agent, its own tool_settings; server; or default), default_provider (the server's) and resolved, on its harness and model. Parallel picked where the server holds no PARALLEL_API_KEY resolves unavailable: parallel picked, no PARALLEL_API_KEY, never to another provider: a revision naming the tool saves with a warning, and its run is 409 with details.missing.

The server resolves a run's tools at its first launch, from its pinned revision and the settings then, keeps the outcome in the run's serve.json for the run's life (a later PUT changes the next runs, never a run already started; a Parallel key gone since makes Parallel unavailable, never another provider), and hands its process the outcome as a description with no secret in it (AGENTRUN_SERVE_TOOL_PLANE); the run reads its providers there, never from a request. A workflow's tools are those its revision lists and those its model and call nodes name.

Upgrading to the tool gate: a workflow whose call node's tool is set to ask, by a setting or a default, now refuses its run (409 with details.asks); set that tool to allow for the agent (definition.tool_settings). Built-in tools are allowed by default; catalog (monid.*) and MCP tools, which ask by default, are no call node's tools here.

Upgrading: a server holding PARALLEL_API_KEY used to read every web_fetch page through Parallel's extract on its own. Now web_fetch is the guarded direct fetch unless Parallel is picked; to keep the old behaviour, set PUT /v2/settings/tools {"web_fetch": {"provider": "parallel"}} once (or tool_settings in an agent). Parallel's key reaches an agentic run only when one of its capabilities resolves to Parallel (see Decision workflows), and a web tool reaches Parallel only where its own capability does.

pi's native web_search is one standalone request, as Claude's CLI runs its WebSearch: one user turn carrying the query and an instruction to answer with sources, on the run's own model and key, with the provider's hosted tool added and nothing else (Anthropic's {"type": "web_search_20250305", "name": "web_search", "max_uses": 5}, OpenAI's Responses {"type": "web_search"}). The results, their citations and the search count are read from the provider's own events; nothing of the request enters the run's conversation but the tool's answer, with text, the provider's own short answer with its sources. A pause_turn answers what it has, and nothing is retried. It is booked as one of the run's requests: its tokens at the model's price plus $10 per 1,000 searches (Anthropic) or calls (OpenAI).

With no native search and no Parallel key, web_search falls back to Exa's public keyless MCP endpoint, a third-party service with its own rate limits (unpublished) and its own terms (https://exa.ai/terms). To turn it off, set web_search to native (native only) for the server (PUT /v2/settings/tools) or for an agent (tool_settings). To search with a key instead, save a Parallel key on Tool keys (or set PARALLEL_API_KEY): auto then searches through Parallel and bills the key.

Keyless Exa is one exchange with Exa's public MCP endpoint (https://mcp.exa.ai/mcp, its web_search_exa tool, at most 8 results, no key), dialled over IPv4 alone: the server looks Exa's name up for IPv4 and connects to that address, since Exa takes keyless use only over IPv4, straight to its endpoint (through a proxy it answers 403, said by name). Past its free limit Exa answers a 429 with a Retry-After, or a tool result it marks rate-limited (ai.exa/rateLimited); the search is tried once more after a jittered 1 to 3 s, or the Retry-After Exa names when it is 5 s or less (a longer one, as for its daily limit, is not waited out). Still limited, the tool answers {error} in our words: Free web search is rate-limited right now. Try again in a minute, or add a Parallel key in Settings → Tool keys to search with a key. Any other failure Exa answers reads Free web search failed. Exa said: "<the first sentence of its text>", cut at 120 characters; Exa's own instructions (a key for its MCP URL) are never passed on.

Every provider answers one shape, {provider, query, results: [{url, title, snippet, published?}], text?}, provider naming the one that served it (native, exa-keyless, parallel), each snippet cut at 3,000 characters; the answer the model reads ends with one line asking it to cite the URL of each result it relies on.

web_fetch answers a Page with via, a part of the page at a time (offset, max_chars, as above). With Parallel picked the page is read through Parallel's extract (POST https://api.parallel.ai/v1/extract, its full content as the text, about $0.001 an extract, one a page a run) and via is "parallel", with status 200 and the absolute links of its markdown; when Parallel lists the URL in its errors, answers with no content, a 5xx or not within 60 s, it is the guarded direct fetch, via: "direct", with parallel saying why Parallel gave no page (refused the key (401), answered 503, timed out, listed the URL in errors, not tried: refused earlier in this run), and the model reads a line saying so; a 401 or 403 stops Parallel for the rest of the run. A fetch Parallel was not picked for says nothing of it. A URL passes the guard's own check before anything is sent to Parallel: a URL the guard refuses is refused and never leaves the machine. A call stopped before its page arrived answers ok: false with via: "stopped": no page came for it (the run's read of the URL goes on for its other calls).

web_search through Parallel uses its Search API (POST https://api.parallel.ai/v1/search, its fast mode, about $0.001 a search). On every provider the query is cut at 200 characters and n is 1 to 25 (10 by default). A result is a lead: the agent reads the page with web_fetch. A failure answers {error}, with nothing searched instead; a 401 or 403 to either web tool stops Parallel for both for the rest of the run.

Parallel's fees are the run's spend, booked as monid's are: each search and each page Parallel extracts is marked in the run's records before it is sent (agentrun.tool_started, held at its price) and settled once Parallel answers (agentrun.tool_cost: tool web_search or web_fetch, charged_by "parallel", usd), shown on its timeline (Tool cost: parallel charged $0.0010 for web_search), summed into its cost and under /usage's tools meter. Parallel answers what it used as counts, never an amount, so each is booked at Parallel's published price (docs.parallel.ai, read 2026-11-03), an estimate: a fast search $0.001 for up to 10 results and $0.001 for each one past them, an extract $0.001 a URL; on a 4xx other than a 408 nothing (Parallel refused it), and on a 408, a 5xx, a timeout or a lost connection its price (Parallel may have billed it). Under a spend cap each is a paid call: sent only when what the run has spent and holds, plus its price, fits the cap. Otherwise nothing reaches Parallel: a search answers the cap's refusal (spend_cap_reached: Not sent: this run has spent $0 of its $0.0009 cap, and the next step, Parallel's search, could cost up to $0.001 …) and a page is the guarded direct fetch, with parallel giving the same words. One sent holds its price until Parallel answers: a model request checked meanwhile, from any branch, tool round or harness, counts it beside what the run has spent, so the two never pass the cap together. A workflow's call node naming either tool is booked the same way, under the node's own call (its execution path).

Keyless Exa, where web_search resolves to it, searches through Exa's public MCP endpoint (https://mcp.exa.ai/mcp, its web_search_exa tool), with no key. What leaves the server is the search query (cut at 200 characters) and the number of results asked for (1 to 8, 5 by default), nothing else from the run, and no key. It goes to Exa over IPv4, straight from the run's process. Exa is a third party: its free, keyless use has a rate limit it does not publish, and its own terms. Past that limit the search is tried once more, and then is a tool error in our words (above): the model reads why and answers without it. A search ends at 60 s, its retry included, so a call never hangs. The answer is the query and its results, each a URL, a title, a snippet of at most 3,000 characters and its date when Exa knows it, then a line asking the model to cite the URLs it relies on. Keyless Exa is the default for a harness with no native search when the server holds no Parallel key; off turns web_search off, keyless Exa included.

The browser tool runs hosted sessions, at Browserbase or at Kernel, never a Chrome on this machine. Which one is picked like web_search's provider: the agent's own (definition.tool_settings.browser.provider) over the server's (PUT /settings/tools), one of auto (the default), browserbase, kernel and chrome. auto is Browserbase where the server holds its keys (BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID), else Kernel where it holds KERNEL_API_KEY, else unavailable, saying which keys would do. A vendor picked is that vendor alone: without its key it is unavailable, never the other one. chrome, a Chrome on the server's own host, is in the list but not set up on any server yet: it reads unavailable (a local Chrome is not set up on this server), and a run that would get it is 409. Capabilities' tools[] give the browser's providers, the server's pick and what it resolves to; a run keeps the vendor its first launch resolved for its life, and one whose vendor's key is gone since is refused each browser call, saying why. Kernel's browsers run headless, and keep no downloads, so a run on Kernel has no browser_downloads. The keys come from the server's environment or Tool keys and reach the run's process over its pipe, as the rest do. A session's custody (its lease, its tag, what it was doing) is kept in the run's own store, so a run opened again on its directory reconciles the sessions it held. Each page it reads is filed under the run's work/evidence/, each read in a file of its own (0001-browser_read.md, ...), reads at once included. There is no recording. The owner watches the browser live, takes it over and hands it back, and the agent can ask them to (below). When the run is let go (its work done, or a SIGTERM) the sessions it still holds are released at their vendor. An agent's browser stays signed in from one run to the next (saved sign-ins).

A session's time is the run's spend, as a paid tool's charge is: each browser call books what its session ran since the last charge, and the release books the rest, topped up to the provider's minimum. Each is part of its cost.usd and /usage's browser_time meter (dollars, and seconds once the run has ended). On the timeline a charge has no row of its own: it shows on the row of the browser call it was booked under, that call's tool_use row, as usage ({"usd": 0.0017, "requests": 0}, every charge of that call summed). The release's charge goes under the session's last call, so it lands on that call's row. Booked after the answer, it is served as a change of that row (the same id, its new usage), never a new row. Prices are by provider (pi-browser's provider name):

Provider List price Minimum Its own price
browserbase $0.12 an hour 60 s AGENTRUN_BROWSER_USD_PER_HOUR_BROWSERBASE
kernel (headless) about $0.06 an hour, by the second none AGENTRUN_BROWSER_USD_PER_HOUR_KERNEL
cdp (a local Chrome) $0 none AGENTRUN_BROWSER_USD_PER_HOUR_CDP

A variable gives the provider's price in US dollars an hour (a contract rate; the minimum stays). A provider with no price, or a variable that is not a non-negative decimal, has no price: its time is booked as an unknown cost, and under a cap every browser call is refused. Under a cap the browser's time is held before it runs (see Spend cap).

The browser's live view

The owner watches a run's browser live from the console, and takes it over: the same view for every vendor (pi-browser's own viewer, a screencast of the page the agent is on), never the vendors' own live-view links, which stay in the run's process. As a session opens, the run's process runs its viewer on a loopback port of its own and hands it to the server in one file in the run's directory, outside work/ (browser-live/<session>.json, one file per session, 0600), which goes when that session ends: a session's end never touches another's, so a relaunch's old session never hides its new one's view. A session a restarted process keeps (it was still running at the vendor) gets its viewer handed over again by the new process, with a new token, and nobody holding the browser. GET /v2/runs/{id}/browser answers {state: "live", viewer} while a session is open (viewer, a link on this server with its token in the fragment), {state: "none"} when the run has a browser but no session is open, and {state: "off", reason} when it has none: its agent has no browser tool, or its browser resolves unavailable here (the tool plane's words, a local Chrome is not set up on this server, or for Kernel with no key, that it needs a Kernel key). The same view is the computer family's too, one for every kind (browser, desktop, phone), read through the same code: GET /v2/runs/{id}/computer answers {state: "live", kind, viewer, hold} (the link under /v2/runs/{id}/computer/live/), {state: "none", kind, hold} or {state: "off", hold, why}; hold says who drives now (agent, pending or person). It takes the owner's password session in the console: a token, which a run's process, an MCP client or a script may hold, is 403.

The viewer lives under /v2/runs/{id}/browser/live/. Its page, script and style are this server's own (the files of the pi-browser it has installed), framed by this server's own origin alone (frame-ancestors 'self', the console's 'none' everywhere else); only its WebSocket goes to the run's port, taken only from this server's own origin (as the request reaches it, or the public address, AGENTRUN_PUBLIC_URL, for a TLS proxy that rewrites Host) with the owner's session, and on a loopback address only at one of its names, as every route is (a rebound name is refused, whatever its Origin). One viewer at a time: a second is refused (viewer_in_use). A browser with no page yet (Kernel's headless one starts with none) shows "No page yet" until one opens. The token dies with the session: the answer says none and the old link opens nothing. The token is in no record, timeline row, run view, run file, home, log or the model's context.

Take over, hand back. The viewer watches until the owner presses Take over in it; until then the run drops its input before the browser (held not_taken). Take over holds the browser for the owner until they press Hand back, the viewer has been gone 2 minutes, or 15 minutes pass with no input from them. While they hold it, their input is the only input; the agent's browser calls wait inside the tool (they do not fail), then go on with the page's old element ids dropped, and the model is told in plain words that the person had the browser and the page may have changed. A call that was under way at the take acts no more: each change of hands moves the session's epoch, and an act from before it is refused, each action of a run checked on its own; the act already in the browser (a run's code is one batch there) ends first, and only then does the owner's input reach the browser. The run stays working; its timeline says "You took over the browser" and how it came back ("You handed the browser back", or that it went back to the agent after the idle or away window), each row at the time it happened, and a browser call that waited for the hand-back reads "Waited for you 11 s, then opened …". The owner's time counts: Take over holds the next 15 minutes of session time at the provider's price under the run's cap first, and with no room it is refused in the cap's words, shown in the viewer. A take-over that input keeps alive is held again every 15 minutes, and with no room the browser goes back to the agent at the cap ("The browser went back to the agent at the run's spend cap", in the viewer and on the timeline, with the cap's words). A take-over and an ask on the same session share one hold; an ask that joins it holds the extra stretch its own expiry needs (refused in the cap's words without room), and a re-hold the cap refuses ends both: the take-over goes back to the agent, and the ask expires, the agent told the cap's words. When the browser comes back, what was held up front goes, and the time the person had stays held, at the provider's price, until the session's next charge books it: take-overs given back with no browser call between them still meet the cap.

The agent asks. The agent may ask the owner to do something in its browser (browser_request_handoff: an instruction and how it will know it is done, such as signing in). Its person's time is held the same way first; with no room no ask is made, and the tool's result says why. The run then reads input_required/waiting with pending: {kind: "browser", instruction, completion_condition, asked_at, expires_at}, its timeline shows "Waiting for you: ", and the webhook's run.needs_input says pending.kind: "browser"; the agent's turn waits inside the tool call. The owner takes over in the viewer, does it, and answers POST /v2/runs/{id}/browser/hand-back with {outcome: "done"} or {outcome: "cancelled"} (the owner's password session alone, as above). The first answer wins: the same answer again is 200, a different one 409 handoff_answered with the answer it has. Any answer hands the browser back to the agent. Unanswered for 15 minutes, the ask expires: the agent hears expired, and a later answer is 409 with outcome: "expired". The ask's record is one 0600 file in the run's directory (browser-ask/, never work/): the instruction, the done-when condition and the ask's times, never the viewer's link or token. A run's process that dies while the ask waits is started again, as a working run's is, and the new process takes the wait up: it reads the owner's answer, or writes the expiry. The record goes when the run ends, or when its process lets its browser sessions go.

Saved sign-ins

Sign in once in one of an agent's runs (for now, through the run's browser while it runs), and every later run of that agent starts from that signed-in browser. Sign-ins are kept per agent, never shared between agents, and the server picks which one a run gets, never the model.

  • Where the cookies are. At the browser's vendor: a Browserbase context, or a Kernel profile under a random name (agentrun- and 24 hex digits: Kernel's names are its account's, so a name derived from the agent would be any server's that shares the key). This server keeps only a pointer per agent and vendor, sealed under the seal key (AGENTRUN_SEAL_KEY) and bound to its agent, in <data>/homes/<key>/sign-ins/sign-ins.json. With no seal key nothing is kept and runs start signed out; capabilities say sign_ins: true only with a seal key and a browser vendor here. A local Chrome keeps none: its cookies would sit on this server's disk.
  • How a run gets it. Before a run with the browser tool is launched, the server makes the agent's context at Browserbase (POST /v1/contexts, which opens no session; Browserbase bills sessions, not contexts) or its profile at Kernel (POST /profiles, which starts no browser) if it has none, and hands the run's process the pointer over its pipe, with its secrets, never in its environment. The run's process never gets the seal key. Every session the run opens uses that profile, by the name default; the model never sees the pointer. A vendor that does not answer leaves that run's browser signed out, said in the server's log.
  • One run saves at a time. The first run of an agent to start with the browser holds the sign-in's save lease, and its sessions save what changed (Browserbase persist, Kernel save_changes). A run of the same agent started while another holds it starts signed in and saves nothing. The lease ends when its run ends (completed, failed or cancelled) or lets go cleanly to wait for its person. A run whose process went, or a server restart, keeps it with the run for its relaunch, since a session it opened before may still write back at the vendor, but for no longer than such a session can live (pi-browser's session timeout, 30 minutes) while its process stays gone: a run that waits on its person after its process died, or is never opened again, lets the others save after that. A run whose process went without letting its sessions go keeps the lease that way even once it ends. Each launch decides again whether its run saves: a follow-up of a run that saved, opened while another run holds the lease, saves nothing. One that names a run that ended while the server was down counts as let go. Browserbase writes a saved context back a few seconds after its session ends, so a Browserbase saver's lease stays named for it 10 seconds past its end (a bound this server chose; Browserbase publishes none): a run started in that time starts from the context as it was, and saves nothing over it, and the saver itself, resumed in that time, waits it out before it saves again. A gone holder's Browserbase lease gets the same 10 seconds past the 30 minutes. A V1 limit: the lease goes to the first run of the agent to start with the browser in its tools, whether or not it opens a browser, and stays with it until it ends, so a long run that never browses keeps the others of that agent from saving meanwhile.
  • What you see. GET /v2/agents/{id} lists sign_ins: {key: "default", provider, state, held_by, verified_at}, held with the run's id while a run holds the lease, else ready; verified_at is when a saving session last ended. unreadable (this server's own state, marked signins/1 in the served contract) is a pointer that does not open under the seal key the server has now (none, or another): runs start signed out, and none is made in its place until you forget it, since the vendor's object it points to would be left behind. A run still going when the server starts that was handed the sign-in, which now does not open (or whose sign-ins file can't be read or is damaged), is not taken up: it is stopped (or, its process already gone, never opened again) and ends failed ("This server can't read the run's browser sign-in (its seal key changed or is missing, or the sign-in is damaged): …"), and its rows are not read, since what it says could repeat the pointer, which the server could not cut. Once the sign-in opens again (the right seal key back), its rows are read and shown, cut of it. A run that had already ended keeps how it ended, but while the sign-in does not open none of what it said is served (rows, answer, output, error words, a review's reason, a workflow run's graph, and its files, GET /runs/{id}/files and each file, which answer 403 forbidden), it can't be continued, and what its end-of-run learning turn wrote is not checked, graded or merged (it waits as it was for a server that reads the sign-in). It is shown again, cut, once the sign-in opens. A run that was never handed the sign-in (one launched signed out since the key changed) is taken up as any other. That a run was handed it is kept in its serve.json; a launch that can't save that hands it none, and the run browses signed out. A run still going whose serve.json is there but can't be read or parsed (where its plane and that mark are kept) is hidden the same way when its agent's revision grants the browser, or can't be read: nothing else says which browser it ran on, so whether it was handed the sign-in is not known ("This server can't read the run's record (its serve.json is damaged or can't be read), …"). An ended run whose serve.json is damaged since is withheld the same way, its end kept. That mark is the SHA-256 of each pointer the run was handed (a list, one per pointer; never a pointer), and what a run was handed is read back by it alone: each hash matched to the agent's live pointer or a forgotten one (below). A run with no mark was never handed one. When any of its hashes matches no pointer that opens (one under another seal key or none, one forgotten and since dropped out of the newest 32 kept, the agent's sign-ins file gone), the run is hidden or withheld as above, and shown again, cut, once every one does: nothing keeps that verdict, each server reads it anew. Forgetting the agent's other sign-ins never withholds it. A mark of any other shape (one an older build wrote, true or a single hash, or a damaged one) is read as handed some sign-in no one can name: the run is hidden or withheld, never served uncut, and forget waits for it while it goes. A run list's row for an ended run the server has not read (from its row and serve.json alone) serves its error (its details too) and review cut the same way, or withholds them in the same words, and withholds them when its serve.json is damaged. Its held_by still names a run that holds its lease, as a held one does. Never the pointer. DELETE /v2/agents/{id}/sign-ins/{key} forgets one: the vendor's context or profile is deleted, then the pointer. A delete the vendor does not answer is said in the answer (at_vendor: "not_deleted"), and the pointer goes all the same. It is 409 sign_in_held while a run holds the lease (details.held_by), and also while a run it was handed is still going without the lease (details.handed_to): one that waits for its person, say, or one whose process went more than 30 minutes ago. What that run says could repeat the pointer, and once it is forgotten a restarted server could not read it back to cut it. The message names the run, never the pointer; forget it once that run ends. A forgotten pointer is still kept, sealed as it was, in the agent's sign-ins file (forgotten), the newest 32 per agent: never listed in sign_ins, never served, never handed to a run, never a sign-in a forget finds. It is kept only so a later server still cuts it from what a run that was handed it said: one that ended before the forget, or one opened again since on a new sign-in. Archiving an agent keeps its sign-ins; this server has no route that deletes an agent.

monid.discover, monid.inspect and monid.run give an agent monid's whole catalog of data endpoints on one key, MONID_API_KEY: it finds an endpoint in plain words, reads its input schemas and price, and runs it. Each run is paid per call from the monid workspace's wallet, and is sent once, never retried. An async provider's run is read until it ends, about two minutes at most, and asked to stop at monid when the run stops or it outlasts that. A refusal comes back as {error, status?} in words: the wallet too low (402), a workspace control such as its budget (BLOCKED), FAILED, TIMED_OUT, the provider's own error (not charged). The model reads each answer as JSON cut at 60,000 characters, the whole of it then filed under the run's work/evidence/monid/. An answer carries what monid charged (cost, in dollars), and that charge is the run's spend: booked once in the run's own records as a tool cost (agentrun.tool_cost: tool monid.run, charged_by "monid", usd), shown in its timeline on a line of its own (Tool cost: monid charged $0.0030 for monid.run) and summed into its cost, the total its spend cap reads, and /usage grouped by meter shows it as its own meter, tools, apart from tokens. Each run is marked in the run's records before it is sent, held at the price an earlier monid.inspect of the run quoted for that endpoint, else at an allowance of $1; its charge settles the mark. A mark a crash left unsettled leaves the run's cost unknown, as a model request a crash cut does, and its cap counts it at its held figure, never $0. A run that may have charged and says no cost (FAILED, TIMED_OUT, one stopped by the run or the wait, one whose sending failed) books an unknown charge, never $0: the run's cost reads null, and under a cap it counts at its held figure, the bound the cap let it go at, as a crash-cut mark does, so the run goes on while its cap allows, in its process and in one opened again from its store; one monid says it did not charge (refused as sent, BLOCKED, STOPPED, the provider's error) books nothing. Under a cap, each monid.run is held as a model request is: unless what the run has spent plus what the run is held at (the price an inspect quoted, else the $1 allowance) fits its max_cost_usd, or when the spend is unknown, the call is refused with a spend_cap_reached error the model reads, naming its price and the cap (and, when the allowance was used, that monid.inspect gives a quote), and monid is never called. Runs go one at a time under a cap, and on claude-agent and codex the check holds each model request still in flight at its worst case. A run sent holds its figure until its charge is in, on every harness: a model request checked meanwhile counts it beside what the run has spent, and a run that fails without an answer counts at it. discover and inspect cost nothing and are never refused. They run on every harness: pi binds them, and claude-agent and codex call them as host tools in the run's process, where a monid.run call a crash cut off is answered interrupted, never sent again (discover and inspect may run again). On each, the model, the call's journal and the run's timeline name them monid_discover, monid_inspect and monid_run: a model API takes no dot in a tool's name. The key is a secret like the model keys: a run's process gets it over its pipe, never in its environment.

Tool keys

A tool's key comes from one of two places. The server's own environment, through its launcher: PARALLEL_API_KEY, MONID_API_KEY, BROWSERBASE_API_KEY and KERNEL_API_KEY are among the secrets it hands the server over its pipe. Or the page, by name: PUT /v2/settings/tool-keys/{id} with {values: {NAME: "..."}} saves one, field by field (parallel takes PARALLEL_API_KEY, monid MONID_API_KEY, browserbase BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID, kernel KERNEL_API_KEY, typesafe TYPESAFE_API_KEY, which unlocks Jev's decisions, decisions.jev), and DELETE forgets it. A model provider's key is saved the same way (anthropic, openai and openrouter, each kind: "model", the others "tool"): every run gets it, as every run gets the launcher's model keys, and with no AGENT_MODEL it gives the default model (models); its harnesses are pi and the harnesses that talk to that provider's API (OpenAI's: codex and opencode; OpenRouter's, all four), of those this server runs or would run once the key is set, so a row names them before it is saved: one left out is one this server cannot run for another reason (not installed, not the pinned build, more than this host's memory). GET /v2/settings/tool-keys lists each key, what it unlocks and the harnesses that run that, and each field's set and source (server, stored or null): never a value, nor any part of one. unlocks holds only what capabilities show this server binds, its tools and workflows.decisions.providers (as decisions.<id>): a key whose tools it binds none of still lists, with nothing in unlocks. A field the server is started with is the server's, and wins: saving it is 409 key_set_by_server. A value is text of at most 1024 bytes with no control character (422 otherwise).

A key is checked against its provider before it is kept, with one call that costs nothing, given 5 s: monid's discover of one result, Parallel's list of monitors, Browserbase's list of projects, Kernel's list of browsers (at most one) and TypeSafe's list of models (at BROWSERBASE_BASE_URL, KERNEL_BASE_URL or TYPESAFE_BASE_URL when the server is started with one). A key the provider refuses (401) is 422 input_invalid, its field's reason refused_by_provider, in plain words with the provider's first sentence (Monid refused this key (401: Invalid API key format)) and never any of the key, and nothing is kept: a key saved before stays as it was. Otherwise it is kept, and GET gives its row a check: {state: "accepted", at}, or {state: "not_checked", reason, at} when the provider would not answer the check (403: a key scoped away from that call may still work for its tools), did not answer in 5 s, could not be reached, answered a redirect (never followed, so the key goes nowhere else) or something else, or has no call that costs nothing. A tool's key the server is started with is checked once as it starts, with the same call, in the background (the start never waits on it): its row's check is accepted, not_checked, or {state: "refused", reason, at} (Browserbase refused BROWSERBASE_API_KEY (401: Unauthorized) when this server started: set the right one where AgentRun starts, then restart it), and a tool that needs a refused key reads unavailable in capabilities with the same words. check is null when the key is not set, or is the server's and its check has not answered yet; a key saved before this server checked keys reads not_checked, and is checked when saved again. A model key the server is started with is not checked here: a run on a refused one fails at its first request, saying so. A save of Browserbase's project id alone sends no key, and leaves its check as it was. A save whose check is still waiting when the server begins to close keeps nothing (503 unavailable, while its connection lasts): the check stops waiting, and the server's lock, released at close, may be another server's by then. Under --faux a key is checked only against the fake its runs are given (fauxMonid, fauxParallel, fauxJev); with none it is not_checked, and nothing reaches the network. A key the server starts with is checked under --faux only against a check's own fake (fauxBrowserbase, fauxMonidCheck, fauxKernel), so the fakes the runs use see the runs' calls alone; with none, its check stays null.

A saved key is kept only as ciphertext: AES-256-GCM in DIR/tool-keys.json (0600), each value bound to its field's name, under the seal key AGENTRUN_SEAL_KEY, which only the server opens it with. A run's process never gets the seal key. With AGENTRUN_SEAL_KEY not set, the launcher keeps it in agentrun/seal.key under $XDG_CONFIG_HOME (or ~/.config), making it the first time: 32 random bytes in base64, 0600 in a 0700 directory, and one another user can read is refused, not changed. It is outside the data directory on purpose: a copy or a backup of the data directory holds the keys as ciphertext alone, and opens nowhere without the seal key. With no seal key (started with no launcher and no AGENTRUN_SEAL_KEY, or with no home directory) nothing is saved, never in the clear: 409 no_sealing_key, and capabilities' features.tool_keys.at_rest is unavailable (sealed otherwise).

A stored value that does not open is never a reason not to start. It reads set: false, stored_unreadable: true, and reenter names those fields with why: seal_key_missing, seal_key_changed (a lost seal.key is made anew, so it shows as this) or file_unreadable. The log says so once, naming the condition and the file, never a value; a PUT of the value seals it again under the current key. In a container, ~/.config is the container's own and goes with it: pass AGENTRUN_SEAL_KEY, or mount a directory at XDG_CONFIG_HOME, so a new container opens the keys the volume holds.

A saved key is read by name like one the launcher hands over, and the launcher's value wins for a name it holds. A run gets the saved keys at its launch, over the pipe its secrets come on (a workflow run's PARALLEL_API_KEY on its decision descriptor), never in any process's environment, and never the seal key. No restart: a PUT or DELETE answers once capabilities, a save's checks and a run's checks see the change, in milliseconds. No harness reads a tool key, so a key leaves what each harness offers as it is, and the harnesses are not checked again. A run already running keeps what it was handed; the next run started, or a run's process started again, takes what is saved then. BROWSERBASE_PROJECT_ID is on the launcher's list for this, as every field a person may save is. Capabilities name each tool's key (tools[].key, a GET /settings/tool-keys id; for browser its vendor's, kernel where Kernel is picked or resolved, else browserbase), so a page can link a tool's missing names to where its key is saved. agentrun-serve import opens the saved keys in its data directory too and cuts each out of what it writes, as it cuts a key-shaped variable of its environment; one that does not open there is not known, so not cut, and the import says so.

MCP servers

Add a remote MCP server once, with its credential, and see its tools before you keep it. An agent that grants one of its tools calls it on pi and on claude-agent (below).

curl -X POST "$BASE/v2/settings/mcp-servers/preview" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"url": "https://mcp.example.com/mcp", "auth": {"type": "bearer", "token": "..."}}'
curl -X PUT "$BASE/v2/settings/mcp-servers/example" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"url": "https://mcp.example.com/mcp", "auth": {"type": "bearer", "token": "..."}}'

POST /v2/settings/mcp-servers/preview takes {url, auth} (or {name} for a saved server) and connects over Streamable HTTP with the official MCP SDK client (@modelcontextprotocol/client): the 2026-07-28 revision first, a 2025-era server through its initialize. It answers the revision agreed (protocol), the server's server_info, any instructions and every tool tools/list gives, all pages, each with its name, title, description, input_schema, output_schema and annotations (the server's own claims, not checked). It stores nothing. auth is {type: "none"} (or absent), {type: "bearer", token} (sent as Authorization: Bearer <token>) or {type: "headers", headers: {Name: value}} (up to 8). An oauth server is signed in to first (below), then previewed by name. url is https, or http on loopback.

What can go wrong, told apart:

  • 422 auth_required: the server answered 401 or 403. details.auth is oauth when it names protected-resource metadata (in its WWW-Authenticate, or at /.well-known/oauth-protected-resource), else bearer.
  • 422 not_mcp: the URL answered, but nothing it answered was a JSON-RPC reply (a web page, a JSON API, a 404).
  • 422 input_invalid, field url, reason redirect: the server answered a redirect. None is followed, so a credential goes only to the address you gave; the message names where it points.
  • 424 upstream_failed: the server could not be reached, did not answer within 15 s (30 s for the whole listing), answered 5xx or more than 4 MiB at once, or failed to list its tools; details.status is its HTTP status, if any.

A server is kept with at most 500 tools and 1 MiB of tool names, descriptions and schemas: past either, the listing stops at that page and the answer is 422 input_invalid (field url, reason too_many_tools or tools_too_large), naming the limit and how many tools were read.

PUT /v2/settings/mcp-servers/{name} previews the same way, then keeps the server: its URL, the tools it listed (the snapshot an agent will be shown) and its credential. A name is lowercase letters, digits and dashes, starting with a letter, at most 32, with no underscores, so mcp__<server>__<tool> splits one way. Each credential header's value is sealed like a tool key: AES-256-GCM under AGENTRUN_SEAL_KEY, bound to the server's name and the header, in DIR/mcp-servers.json (0600, written whole, synced and renamed), which holds no value in the clear. With no seal key, a server that takes a credential is 409 no_sealing_key, before anything is contacted; one with auth.type none is kept. A PUT that sends no token (or no headers) keeps the sealed credential, as long as the auth type and the URL's origin stay the same: a saved credential never goes to another origin unless you send it again.

GET /v2/settings/mcp-servers and GET .../{name} show each server with credential: sealed, seal_key_missing (no seal key now) or unreadable (sealed under another key, or damaged: a PUT with the credential seals it again), null for auth.type none. No read, log line or error shows a credential; a server's error text is cut of the values sent before it is shown. POST .../{name}/refresh lists the tools again with the saved URL and credential, keeps them, and answers {added, removed, changed, server}: changed names a tool whose title, description, schemas or annotations differ. DELETE .../{name} forgets the server and its credential. A saved-servers file that does not read (damaged, or another format) is never written over: the list answers servers: [] with file_unreadable: true, and every other route is 409 store_unreadable until the file is restored or removed. Capabilities' features.mcp_client says transports: ["streamable-http"], the auth types and at_rest (sealed, or unavailable with no seal key).

Signing in with OAuth

A server whose preview answers details.auth: "oauth" is signed in to by this server, which keeps the tokens: the agent's machine never sees one. POST /v2/settings/mcp-servers/{name}/oauth/start with {url, scopes?, client_id?, client_secret?} answers {authorization_url}, the page the owner opens. It runs the official MCP SDK's OAuth: the server's protected-resource metadata (RFC 9728), then its authorization server's (RFC 8414); PKCE S256; the server's URL as the RFC 8707 resource. The client is, in this order, the client_id (and client_secret) you give; else, signing in again to the same origin, the client kept sealed from the last sign-in (so a pre-registered client's id and secret are given once); else this server's client metadata document, GET /v2/settings/mcp-servers/oauth/client.json, when this server's address (AGENTRUN_PUBLIC_URL) is public https and the authorization server takes one; else dynamic client registration.

The authorization server sends the browser back to GET /v2/settings/mcp-servers/oauth/callback, which takes no token: its state is random, bound to the server's name, used once and good for 10 minutes (else 400), and the PKCE verifier never left this server. It finishes only in the browser that started the sign-in: the start's answer sets a cookie for that sign-in alone (named by its state, so two tabs do not replace each other's; HttpOnly; SameSite=Lax, path the callback, 10 minutes, cleared when it finishes), and a callback without it is 403 with nothing exchanged. So a start must come to the address the callback is under: one that came to another (the console opened under another name for this machine than the server's own address) is 409 wrong_origin, with details.open the console's MCP page there. The redirect URI never changes with the address a request came to, so a registered client's stays valid. Behind a proxy on a public https address, set AGENTRUN_PUBLIC_URL to that address and have the proxy pass the Host header and X-Forwarded-Proto: https: without them a start reads as another address (or plain http) than the callback's, and every start answers 409 wrong_origin. For an MCP server whose URL is not loopback, every endpoint its authorization server names (and every request to one) must be https and resolve to no private, loopback or link-local address, or the sign-in or refresh is refused with words and nothing is sent. A loopback MCP server is a development-only exception: its authorization server may name endpoints on the MCP server's own loopback host, exactly as its URL writes it (never another loopback address, the IPv6 loopback, or a name for this machine in its place), on any port but this server's own; any other loopback endpoint is refused the same way. The host is pinned, not the port, as a local authorization server listens on its own port. What that leaves: such an authorization server can still have this server send its sign-in and refresh requests to other ports on that same loopback host (a port Docker publishes there, say), blind, learning only the endpoint and its status, never a body. That is why the allowance is for development only. The callback exchanges the code, seals the token set (access and refresh tokens, expiry, scopes, the client and its secret) under AGENTRUN_SEAL_KEY, bound to the server's name, lists the tools as a PUT does, and redirects to the console's MCP servers page, /#/settings/mcp?server=<name>&sign_in=ok (or sign_in=failed&error=<code>). The redirect URI is this server's own callback under its address: on loopback, the plain http address and port it listens on.

Reads show auth.type: "oauth", credential: "sealed" and oauth: {expires_at, scopes, client, client_id}, where client is pre-registered, metadata-document or dynamic; never a token or a client secret. A PUT with auth: {type: "oauth"} keeps the token set. Refresh tokens never leave this server: a run's launch refreshes an access token that expires within 5 minutes (one refresh at a time per server, so a rotating refresh token is spent once; one given with no expiry is taken to last an hour) and hands the run the access token alone. A refresh the authorization server refuses (revoked, expired) makes the server read credential: "expired", and a preview, refresh or run says to sign in again in Settings → MCP servers. A launch's refresh waits at most 4 s a try, two tries, so an authorization server that does not answer holds a start's answer about 8 s at most; the run's call is then refused unsent, saying to try again. A token that expires during a run is not refreshed in that run: its calls fail as unauthorized, and the run's next launch refreshes it. A server that stops aborts any refresh or code exchange under way and writes nothing after; if the authorization server had already rotated that refresh token, the next start reads credential: "expired" and asks for a sign-in again.

test/fixtures/fake-mcp-server.mjs is a loopback MCP server for trying this: startFakeMcp({era, token, oauth}) answers {url, log, tools, close}; it serves add, slow_write, delete_thing and paid, and logs each request's method, tool and whether an Authorization header came, never its value.

Agents call a server's tools

An agent grants a saved server's tool by its catalog name, mcp:<server>/<tool>, in definition.tools ("tools": ["mcp:github/list_issues"]). A save checks each one against the server's saved tools: a server that is not saved, or a tool it did not list, is 422 naming the server's tools, and so is a name whose wire name, mcp__<server>__<tool>, is over 52 characters, or a tool whose x-mcp-header declaration cannot be sent (on a property not reached through properties alone, not a string, integer or boolean, not a header name, or declared twice). pi, claude-agent and codex run them (features.mcp_client.harnesses); on another harness the save warns that they are not bound there yet, and a run is refused. A workflow's nodes bind no MCP tool yet: a workflow that grants one, in its tools or on a node, is 422 ("a workflow's nodes don't bind MCP tools here yet; grant it to an agentic agent").

A known meta tool, one that runs other actions by name, is never granted: Composio's COMPOSIO_MULTI_EXECUTE_TOOL, COMPOSIO_REMOTE_WORKBENCH, COMPOSIO_REMOTE_BASH_TOOL and COMPOSIO_PROXY_EXECUTE, Klavis Strata's execute_action (on klavis.ai), Zapier's execute_zapier_read_action, execute_zapier_write_action and write_code_action (on mcp.zapier.com), Arcade's Arcade_UseTool, Docker's mcp-exec and generated code-mode-* tools, and Cloudflare's portal_execute and portal_codemode_execute. A grant of one couldn't name the real action, so allow would cover every action it runs and deny could target none. Reads mark it grantable: false with its reason, a save that grants it is 422 in those words, a run of a revision saved granting one is 409 agent_not_runnable naming it under details.not_grantable (each with its tool and reason), and a run that pinned one before is refused at its launch. Use the server's direct tools instead: each of these providers has a direct mode. The list is closed: a meta tool it doesn't know is an MCP tool like any other, asked by default.

  • Pinned. A run's first launch keeps each granted tool as the server listed it when you saved it (its description and schema) and the server's URL then, in the run's directory (mcp-tools.json). A refresh or a new save never changes a running agent's tools. If the server's URL has changed since, that run's calls answer "the server's address changed since this run started" and nothing is sent, the new credential least of all.
  • The credential. A run whose revision grants a server's tool is handed that server's credential at each launch (so a new one counts at the next), over its secrets pipe, never its environment; a run that grants none of a server's tools gets none of it. It stays in the run's process: no harness's CLI, file or log holds it, and a run that writes it into a learned note is refused by the secret scan.
  • pi binds each granted tool in the run's process as mcp__<server>__<tool>, with the pinned description and schema, and calls the server from there.
  • claude-agent uses the Claude CLI's own MCP client: one HTTP server entry per granted server at the session's model proxy, /mcp/<server>, behind the session's key, sent over the CLI's stdin (never its arguments), with only those servers (strictMcpConfig) and only the granted tools allowed. The proxy answers the MCP exchange itself and makes the call with the credential. The CLI's tool search is off in such a session, so the granted tools are offered whole. The proxy answers the handshake and the tool list itself, so a remote server that can't be reached shows at the first call, as that call's error; only a proxy route the CLI can't connect to fails the turn.
  • codex uses Codex's own MCP client the same way: the harness writes each granted server in Codex's mcp_servers (an agent's own config still may not), at the session's model proxy with the session's key, with only the granted tools enabled. Each call runs without Codex asking (default_tools_approval_mode = "approve"): the proxy is the gate. Codex sends a call as soon as it starts it, so the proxy records the call in the session before it sends anything. Each server is required, but the proxy answers its handshake, so a remote server that can't be reached shows at the first call, as that call's error; only a proxy route Codex can't reach fails the turn. A tool whose schema Codex would drop or retype (a not, patternProperties, ...) is 422 at the save, naming the place and why.
  • A call goes to the pinned URL alone, the 2026-07-28 revision first and a 2025-era server through its initialize; on 2026-07-28 each x-mcp-header parameter of the pinned tool is sent as its Mcp-Param-* header. No redirect is followed, at most 4 MiB is read, and it may take 120 s. The model reads the text blocks joined; past 60,000 characters, or with blocks that are not text, the whole answer is filed under the run's evidence/mcp/<server>/ and the text says where. A tool's own error (isError) reaches the model as its error. A refused credential (401, 403) reads "the MCP server refused its credential (its sign-in expired or was revoked, or its key changed): sign in again in Settings → MCP servers, or update its credential there"; another failure names the server and the HTTP status, never a header. A stop, or a client that goes away, cancels the call at the server. A call a crash cut off is answered interrupted on the resumed run and never sent again.
  • The gate. Every call passes the run's one tool gate as mcp:<server>/<tool>, on every harness. Set mcp:<server>/<tool> or, for all of a server's tools, mcp:<server>/* to allow, ask or deny in PUT /v2/settings/tools or an agent's definition.tool_settings (the agent's exact key, then its server-wide key, then the server's two, then the default). An MCP tool with no setting asks. A denied call is refused and the model reads why; nothing is sent. An asked call parks, as any tool's does: its turn ends, nothing is sent, and the run reads input_required with pending.kind: "approval", the tool, its arguments and its price (or "not metered"), until the owner answers POST /v2/runs/{id}/approvals/{approval_id} from their password session. Allowed, the run sends the call once and resumes with its result; denied, it resumes with the refusal. A restart while a call is parked loses nothing.
  • Pins. An MCP tool's own key (mcp:<server>/<tool>, never the wildcard) may also carry pin, 1 to 8 of the tool's top-level arguments, each to the one JSON scalar its calls must carry (null: the argument left out), or a list of 1 to 16 of them, the argument one of the list, compared strictly: {"mcp:slack/slack_send_message": {"permission": "allow", "pin": {"channel_id": "C0123"}}}, {"mcp:github/pull_request_review_write": {"pin": {"event": [null, "COMMENT"], "method": ["create", "delete_pending", "submit_pending"]}}} (the code-review template's pin). The gate checks a pinned tool's call before it runs or asks anyone, whatever the permission (an allow, an ask, a grant): a call whose arguments are not as pinned is refused, never sent and never asked about, and the model reads "mcp:slack/slack_send_message may only be called with channel_id = C0123: its destination is fixed by the agent's settings". The run's process logs a line naming the tool, the call and the arguments that did not hold, and its Timeline row is a failed call, as any refusal's. A call approved under ask is checked again before it is sent. A pin adds to a setting and is never a permission: the agent's pin and the server's both hold (an argument both pin takes what holds both), and where nothing holds both every call is refused, saying so. A template pins its sends' destinations to its parameters (Templates). A pin in tool-settings.json that does not read refuses every call of its tool, and GET /settings/tools serves it as pin_broken: true. A PUT that keeps the tool's key and sets no valid pin (the mark sent back as served, or the pin left out) keeps it broken, so saving another tool's setting never lifts it; a valid pin repairs it, and leaving the key out removes the setting. An agent keeps a pin of its own in definition.tool_settings.<tool>.pin, and the gate holds its runs to it and to the server's.
  • Prices. PUT /v2/settings/mcp-servers/{name} takes prices, each tool's price in USD a call ({"prices": {"search": 0.01}}; a finite number, 0 or more, for a tool the server lists; else 422). A priced call is held at its price under the run's max_cost_usd, refused unsent when the cap cannot take it, and booked as the run's tool cost (a Tool cost row, the tools meter of GET /usage). A tool with no price runs "not metered": a row says so, the run's usage row counts it (not_metered), and GET /usage's tools line counts the calls (not_metered_calls); it is outside the run's cost and its cap. A run keeps the prices its first launch saw. A PUT that names no prices keeps the saved ones, for the tools the server still lists at the same URL (a new URL keeps none), so a save from any client never leaves a paid tool unmetered; "prices": {} clears them. A read lists the tools no price meters as not_metered.
  • The timeline shows each call as <server> · <tool>.

Webhooks

One URL hears of your runs without anyone watching the page. PUT /v2/settings/webhook with {url} sets it: an http or https address, with no user or password in it. Loopback and private-network addresses are allowed, so a receiver on your own machine or network works. Two kinds of message are POSTed to it about runs: run.needs_input when a run comes to need its person (input_required: a question, a call held for approval, a pause at its cap, an ask for its person in its browser, desktop or phone, or a stopped turn) and run.ended when it ends (completed, failed or cancelled, once carried back). A schedule whose firing fails sends a third, trigger.failed; a server with no triggers never sends one.

A run's end and its run.ended are one durable step. The server chooses the end's time once and gives it to the agent store, so the row's ended_at and the message's webhook-id name one time. run.ended is queued only once the agent store has the end, so a receiver that reads the run back on its message (GET /v2/runs/{id}) reads it ended; while the store refuses the end (a full disk, a busy store), nothing is sent, until a later try of the write or the next server stores it. The outbox (DIR/webhook-outbox.json) keeps, in the same write as each message, which end and which wait of each run it queued. A server that starts queues any end of the last day that the agent store holds and the outbox does not, under the same webhook-id (one lost to a kill, or to a crash of the machine), and a server that takes over a run queues a wait it stands at that the outbox does not name (one the run came to while no server was up), under its one webhook-id. An end before the webhook was set is never sent. A run is continued only once its end's run.ended is in the outbox on disk (503 meanwhile), so a continued run's next end is its own, under its own id.

{ "type": "run.needs_input", "timestamp": "2026-11-01T12:00:00.000Z",
  "data": { "run_id": "…", "agent_id": "triage", "status": "input_required", "title": "…",
            "url": "https://agents.example.com/#/runs/…", "pending": { "kind": "question" } } }

A message is thin: ids, the status, the title and a link to the run on the page; never a tool's arguments, an output or an input, which the API reads (GET /v2/runs/{id}). The title is the run's own, as the store holds it: a trigger's run is titled from its trigger, <Source> <name> and the event when its source names one ("Webhook reviews: pull_request opened"), never from its input; a workflow run's from its agent's name and its input; any other run's from its input. pending.kind is question (an ask_person), approval (a tool call that waits for a person's approval), stopped (a stopped turn), budget (a run paused at its cap) or browser (the agent asked for the owner in its browser). An agent's ask for the owner on its desktop or phone (the run's pending.kind computer) is told as question.

Messages are signed by the Standard Webhooks spec, so a receiver verifies them with any of its libraries: webhook-id (the same on every try of a message, so it is the receiver's idempotency key), webhook-timestamp (Unix seconds) and webhook-signature: v1,<base64 HMAC-SHA256 of "{id}.{timestamp}.{body}"> under the secret's bytes. A receiver checks the signature against the raw body and refuses a timestamp more than 5 minutes from its clock, so a replay does not pass. The first PUT makes the secret, whsec_ and 32 random bytes in base64, and answers 201 with it, once; GET /v2/settings/webhook never shows it. It is kept like a tool key: sealed under AGENTRUN_SEAL_KEY in DIR/webhook.json (0600), so with no seal key no webhook is set (409 no_sealing_key). A new secret is a DELETE and a PUT.

A try that is not answered 2xx within 15 s fails; a redirect is not followed. A failed message is tried again on the spec's schedule, with jitter: after about 5 s, 5 min, 30 min, 2 h, 5 h and 10 h, then logged as failed (event=webhook condition=delivery_failed). Each message goes on its own tries, so two queued close together can arrive in either order: a receiver orders them by webhook-timestamp, or reads the run (GET /v2/runs/{id}). A 410 Gone turns the webhook off (disabled in GET, and the log says why) until a PUT sets it again. Pending messages are kept in DIR/webhook-outbox.json, so the next server goes on with them after a restart; a message can then come twice, under the same webhook-id. An outbox file of another version is not read: the server starts with none, and the log says so (condition=outbox_not_read). GET /v2/settings/webhook/deliveries lists the last 50 attempts (webhook_id, type, status code, latency, error, when, and the next try's time), and POST /v2/settings/webhook/deliveries/{webhook_id}/redeliver sends one of those messages again, once, under the same webhook-id. POST /v2/settings/webhook/test sends a webhook.test message once and answers how it went. A webhook a 410 turned off sends nothing, a test or a redelivery included (409 webhook_off), until a PUT sets it again. DELETE /v2/settings/webhook removes the webhook and drops its pending messages.

Schedules

A schedule starts an agent's runs with no one there. POST /v2/triggers with a source and a target:

{ "name": "nightly triage",
  "source": { "kind": "schedule", "cron": "30 1 * * *", "timezone": "America/Los_Angeles" },
  "target": { "kind": "start_run", "agent_id": "triage", "input": { "content": "Triage last night's issues." },
              "max_cost_usd": 2 } }

A source is a cron line of five fields in an IANA zone (default UTC), {at} for one time (the trigger turns itself off after it fires), or {every_s, start_at?} for every N seconds, from 60 to a year and a day, on the grid from start_at. A time a clock change repeats fires once, at its first pass; one it skips fires at the first time after the gap. Each next time is reckoned from the last nominal one, so a timer that wakes late does not drift. Every run a trigger starts is capped: by the target's max_cost_usd, else by the agent's default (its definition's max_cost_usd) as it stands when the firing starts. A target with neither is 422, and a firing that finds neither (the default removed since) fails, so no trigger starts an uncapped run, a workflow agent's included (workflow runs). Each run's input ends with a line naming the schedule and the time it fired for, in its zone, and the run's trigger names the trigger and the firing, in the run's view and in the runs lists (GET /v2/runs, GET /v2/agents/{id}/runs) alike; a run a caller started has trigger: null.

A firing starts its run once. Its key comes from the trigger and its nominal time, it is recorded due in DIR/triggers.json (written whole and synced) before its run starts, and the run is started under that key, so a firing a restart drives again finds its run. A firing's key stays bound to that run: when the server stopped while setting the run up, the firing reads failed ("The server stopped while setting up the run.") with that run's id, it never fires again, the next firing goes ahead, and POST /v2/triggers/{id}/fire is how a person tries it again. While the trigger's last run is active (working, or waiting on a person), a firing is skipped, with why (a webhook trigger with accept or max_active_runs is the exception: each event it takes starts its own run); a full server (429) keeps it due and tries again. Times missed while the server was down are one missed firing with their count, and the schedule goes on from now; the latest of them still fires, once, when it is no older than the trigger's catchup_window_s (default an hour; 0, never), marked catch_up. A firing while the last run is active (working, or parked waiting on a person) is skipped with why, never queued, because a firing queued behind a parked run would wait as long as the person does. A firing whose run is refused is failed, and a webhook set on this server gets a trigger.failed message for it (data: {trigger_id, name, firing_key, nominal_at, error, auto_paused}, its webhook-id made from the firing's key, so a restart never sends it twice). Five failed firings in a row turn the trigger off (disabled: {reason: "auto_paused"}); a firing that starts its run resets the count, and so does turning it on again. Run now (below) works on an auto-paused trigger too, and counts toward the streak like any firing: one that starts its run resets it, one that fails adds to it. A trigger whose cron line or zone no longer reads here (after an upgrade of the cron library or of the time zone data), or whose every_s a hand edit of its row put out of range, is turned off alone, at a start or when it comes due (disabled: {reason: "source_unreadable", detail}), and every other schedule goes on firing; turning it on again takes a new source. A trigger's firings are driven one at a time, in order, so a firing a crash left due settles to its run before the next one's overlap is checked. PATCH /v2/triggers/{id} changes it, and {enabled: false} pauses it: the time it was off is skipped, not caught up. POST /v2/triggers/{id}/fire runs it now, once per Idempotency-Key (required): the same key within limits.idempotency_ttl_s answers its firing, after the firing has left the history too, and later it is a new firing that a run the key started still answers. GET /v2/triggers/{id}/firings lists its last 200 firings.

Webhook triggers

A trigger with source: {kind: "webhook"} fires on each request to POST /v2/hooks/{trigger_id}: this server's one route with no token. Its view carries that address as hook_url, at the server's public URL when it has one (AGENTRUN_PUBLIC_URL), else the path; a schedule has none. The request is signed by the trigger's own secret under the Standard Webhooks spec, the one this server's outbound webhooks sign with, so a sender uses any of its libraries: webhook-id (1 to 255 printable ASCII characters, no space), webhook-timestamp (Unix seconds, within 5 minutes of this server's clock either way) and webhook-signature: v1,<base64 HMAC-SHA256 of "{id}.{timestamp}.{body}"> under the secret's bytes (several, space-separated, and any one may match). The signature is checked on the raw body, as sent and whatever its content type, before anything else. A bad signature, a stale timestamp, an unknown trigger, one that is off and one that is not a webhook's all answer the same 401, so no trigger id can be found by asking; a body past 256 KiB is 413.

The secret, whsec_ and 32 random bytes in base64, is made with the trigger and shown in the 201 once; no GET shows it. It is kept sealed under AGENTRUN_SEAL_KEY in DIR/trigger-settings-webhook.json (each trigger source's sealed settings have a file of their own), so with no seal key no webhook trigger is made (409 no_sealing_key). POST /v2/triggers/{id}/secret makes a new one, shown once, and the old one signs nothing from then on. Like every secret this server keeps (its tokens, its outbound webhook's secret), it is cut from what a run shows, from what an import keeps and from every learning text. A secret replaced by a rotation, or whose trigger is deleted, is kept too, sealed in a retired row of its own (scope retired:<trigger>:<n>), never a setting and in no GET: a run that quoted it before is still cut on every later server. Retired rows are kept for good for now; dropping one once no run older than it is left is still to come. A secret that does not open under the seal key the server starts with is said in its log (condition=trigger_setting_unopened, naming the trigger), and its trigger is refused until a new secret is made. A trigger-settings-webhook.json that does not read is never written over: until it is fixed or moved away, no webhook trigger is made or rotated (409 store_unreadable) and none fires.

The request's webhook-id, which its signature covers, is its firing's key: the first answers 202 once the firing is recorded, and its run starts after; the same webhook-id again, for a day after, answers 200 with that firing and starts nothing ({"ok": "duplicate"} once the firing has left the trigger's history of 200 and the 1,000 it keeps past it). Each trigger keeps the keys of its last day's pushed firings in triggers.json, at most 5,000, the oldest let go first past that, which the log says once (condition=trigger_seen_full). The agent gets the trigger's input, then the body, cut at 16 KiB, inside <trigger-payload source="webhook" scheme="standard" untrusted>...</trigger-payload>: whatever the sender wrote is data, not an instruction. The tag names the signature scheme, the event as that scheme reads it (event="..."), the JSON body's top-level action (action="...") and an unsigned delivery id (delivery="...", GitHub's X-GitHub-Delivery) and, for a GitHub delivery, its pull request or issue's number="..." (pull_request.number, else issue.number) and a pull request's head_sha="..." (40 hex; a comment event has none, so no head_sha at all), read from the signed body so the payload's cut never loses them, each when there is one, so an agent's instructions can read what it was given; a sender's words there are cut to 200 characters, their quotes and brackets escaped, and a closing tag in the body, in any case, is defused.

A webhook trigger's signature names its scheme, one of a table of them: standard (the default, above) or github, for a GitHub repository or organization webhook. A request is checked by the one scheme whose signature header it carries (webhook-signature, X-Hub-Signature-256), and the scheme gives its firing key, from what its signature covers, and its event. With github, paste the trigger's whole whsec_... secret, as it is, into the GitHub webhook's Secret (content type application/json): each delivery is checked by X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the raw body>, keyed by the UTF-8 bytes of that string. GitHub signs the body alone, not X-GitHub-Delivery nor a time, so the firing's key is the SHA-256 of the raw body: a redelivery from GitHub's page sends the same body and answers its firing, and so does a captured delivery sent again within the key's day under any delivery id. After the day it would fire again (over TLS, a risk taken); there is no 5-minute window. Two real deliveries never share a body, since GitHub's carry their own ids and times. X-GitHub-Delivery (1 to 255 printable ASCII characters) only names the delivery on the payload's tag, for tracing. A request that carries no scheme's signature header, or more than one, or is signed under a scheme other than its trigger's, is the same 401 as a bad signature, and the headers are checked before the body is read.

accept makes a trigger take only the events it names, from the delivery alone:

{ "kind": "webhook", "signature": "github",
  "accept": [
    { "event": "pull_request", "actions": ["opened", "synchronize", "ready_for_review"],
      "where": [ { "pointer": "/pull_request/draft", "in": [false] } ] },
    { "event": "issue_comment", "actions": ["created"],
      "where": [ { "pointer": "/issue/pull_request", "exists": true },
                 { "pointer": "/comment/author_association", "in": ["OWNER", "MEMBER", "COLLABORATOR"] },
                 { "pointer": "/comment/body", "prefix": "@reviewer review" } ] } ] }

A rule's event is the event as the trigger's scheme reads it: GitHub's X-GitHub-Event, or a Standard Webhooks body's top-level type. GitHub signs the body, not X-GitHub-Event: a delivery a trigger ignored keeps its key for the day, so the same body sent again under another event answers {"ok": "duplicate"}, and where it matters a rule should also test a field only that event's body has (/pull_request, /comment, /issue/pull_request); actions, when given, lists the values the body's top-level action may take; each of where's conditions is a JSON pointer (RFC 6901) into the body with one test: in (the value there equals one of the JSON values listed, an object's fields in any order), exists (true, the pointer names a value, null included; false, it names none) or prefix (the value is a string whose first line, trimmed, starts with it, ignoring case). A delivery fires when any rule takes it, with all its conditions holding. A verified delivery no rule takes (GitHub's ping among them, a body that is not JSON too) answers 200 {"ok": "ignored"}, fires nothing, and is one log line (condition=hook_ignored, with the trigger and why). Without accept, every verified request fires, as before. A trigger holds at most 20 rules, each at most 20 conditions and 20 actions; an in lists at most 20 values of at most 1024 characters of JSON each, a pointer is at most 512 characters, and an event, an action or a prefix at most 200. A trigger with accept starts a run for each event it takes, its last run active or not, since the events it takes are each their own work (a review per pull request); the server's run limit still holds: a start it refuses (429) keeps the firing due and tries it again, and past 20 firings due a new delivery answers 429. This holds for every firing of such a trigger, a run now (POST /v2/triggers/{id}/fire) included. Runs start in the order their events came while the server has room; while one firing waits to be tried again after a 429, a later one can start first.

Such a trigger can take every run this server has room for, starving other triggers and the runs people start. Give it max_active_runs (1 to 100; null clears it): past that many of its runs active, a firing waits due and is tried again, as at a full server, and is never skipped. It counts the runs this trigger started while they work, kept apart from its history (so a long run counts however many firings follow it) and read again at each check; a run a person opens again after it ended is that person's follow-up, not counted. Any start_run trigger of a pushed source (a webhook) takes it, with or without accept: set, each event starts its own run, never skipped behind the last, up to the cap; past it a firing waits due, and past 20 due an event answers 429 rate_limited with Retry-After. Without max_active_runs, a trigger with no accept keeps the overlap skip: an event while its last run is active is skipped. A schedule keeps the overlap skip and takes no cap, and nor does a message_run target, which starts no run (422 for either). On a public repository, a rule for issue_comment or issues events needs a condition on /comment/author_association or /issue/author_association (in: ["OWNER", "MEMBER", "COLLABORATOR"]): without one, anyone who can comment starts runs.

A trigger's input, a started run's first message or a firing's message, is the trigger's, never the person's: it carries the origin {kind: "trigger", trigger_id, source, owner}, owner decided by the trigger's definition as it fires and never by its payload (true for a schedule, fired on time or by hand; false for a webhook, its body empty or not). The model reads the very text it did; the run's timeline shows it as a phase row that names its source and its trigger as it is now, "Schedule utc-check: " (the trigger's id once it is gone), with origin: "trigger" and trigger: {id, source, owner} (so a client labels it by its source without reading the summary), never as "You: ...", and what the run learns reads it as the trigger's. A trigger renamed renames the row; the name it had when it fired is not kept yet.

A trigger's target may also be {kind: "message_run", run_id, input: {content}}, with a webhook or a schedule source: each firing sends its input to that run as POST /v2/runs/{id}/messages would (a steer, or a follow-up that opens a completed run again), under its own cap. The run must have a cap (422 on target.run_id otherwise, and a firing to a run that has none fails), and a firing never answers a run that waits on its person (a question, an approval, its cap): it is skipped, "The run is waiting on a person.", and the run still waits. The firing's key is the message's id in the run's store, which takes one message per id; a firing driven again after a restart first looks for it there, so it sends the message once and never opens a completed run again for one it already has. A firing to a run that is working waits for it (tried every 5 s), and a trigger holds at most 20 firings due: past them a webhook event and a run now answer 429 rate_limited with Retry-After: 30, recording nothing, and a schedule's time is skipped.

GitHub triggers (Connect GitHub)

A server has at most one GitHub App, made with GitHub's app-manifest flow, and every github trigger (and every GitHub template) uses it. In the console, Connect GitHub calls POST /v2/integrations/github/connect ({org?}, to make the App under an organization) and posts the form it answers to GitHub: action, GitHub's new-App page, and manifest, which names a name GitHub lets the person edit, the server's public URL, its hook /v2/hooks/github, its callback and setup URLs, the permissions (pull requests write; contents, metadata and issues read) and the events (pull_request, issue_comment). It needs the address GitHub can reach, AGENTRUN_PUBLIC_URL (409 github_no_public_url, "GitHub can't reach this server: set its public URL first"), a seal key (409 github_no_seal_key, naming AGENTRUN_SEAL_KEY), and no App connected already (409 github_already_connected, "This server already has a GitHub App. Disconnect it first."), and a request at the public URL itself, where GitHub sends the browser back (409 github_wrong_origin, "Open this server at to connect GitHub.", the URL in details.public_url). Its state is random, used once, good for an hour, and bound to the browser that started it by a cookie on the callback's path; GitHub reads it from action's URL, and the form posts only manifest.

GitHub sends the browser back to GET /v2/integrations/github/callback with a code. The server converts it with GitHub and keeps the App's id, slug and client id, which are no secret, and its private key and webhook secret, which are, sealed in DIR/trigger-settings-github.json; GitHub's client secret is never kept, and no secret is logged or answered. The browser goes on to install the App, with a fresh install state GitHub passes on and its cookie on the installed path; GitHub's redirect after (/v2/integrations/github/installed) records the installation only with that state and cookie, once GitHub, asked with the App's own JWT, says it is the App's, and sends the browser to the console at the public URL, <public>/#/settings/github?github=connected. Without the state and cookie it records nothing and lands there too: an installation made from GitHub's own pages arrives as the App's signed installation event. Neither leg ever answers an error body: a state that is unknown, used, older than an hour or from another browser sends the browser to <public>/#/settings/github?github=error&reason=state_invalid, a code GitHub does not convert to ...&reason=conversion_failed, one whose App GitHub made while another Connect kept its App to ...&reason=already_connected (the first App stays), and an installation GitHub does not confirm, or one checked while GitHub was disconnected or connected again, to ...&reason=installation_unverified; nothing is kept. The App's installation events keep the list current, each taken only in its own shape (the installation, its account and this App's id) and as GitHub, asked with the App's JWT, bears it out: X-GitHub-Event is not signed, so another event's signed body sent as one changes nothing. GitHub out of reach when an event is checked answers it 503, so GitHub shows the delivery failed and it can be sent again; and the list is repaired from GitHub's own installations at the server's start and when GET /v2/integrations/github is read (at most once a minute), by the same rule: none left turns the github triggers off, one dropped only shrinks it. A list that an event or the install redirect wrote while GitHub was asked is not overwritten by GitHub's older answer: the repair asks once more, and leaves the list as it is when it moved again. A deletion or suspension that would leave none is confirmed with GitHub's own list first: only none there turns the github triggers off, and its list as it comes (the one removed included, when GitHub holds it active again) becomes the list. An installation event follows the same rule: GitHub's answer to its check is taken only when no write came meanwhile (else it is asked once more), so an older suspend never undoes a newer unsuspend. A server that closes stops its repair and its events' checks, which write nothing after (an event cut so answers 503, to be sent again). GET /v2/integrations/github always answers 200: {connected: false} with no App, else {connected: true, app_id, slug, installations}, never a secret. Not connected is an answer, not a 404 a browser logs as an error. DELETE with nothing connected is still 404.

A trigger with source: {kind: "github", accept: [...]} takes the App's events its accept names (the same rules as a webhook trigger's, above; required) and keeps no secret of its own. POST /v2/hooks/github takes no token: each delivery is checked by X-Hub-Signature-256 under the App's webhook secret, and one event fires every github trigger whose accept takes it, each its own firing, keyed by the SHA-256 of the signed body, and its own run (github triggers may take the same events; max_active_runs caps each). Every other github trigger keeps the body's key for its day, so the same body sent again under another X-GitHub-Event, which GitHub does not sign, starts nothing: where it matters, a rule also tests a field only that event's body has. When a trigger that takes an event holds too many firings due, the delivery is refused 429 with Retry-After though the others recorded theirs: the retry records it there once it has room, and is the others' duplicate. An event no trigger takes is 200 {"ok": "ignored"}; GitHub's ping is 200 {"ok": "pong"}. The payload's tag also names the installation and the repository (installation="..." repo="owner/name"). A github trigger made or turned on with no App connected is 409 github_not_connected ("Connect GitHub in Settings"). A {kind: "webhook", signature: "github"} trigger is unchanged: a repository webhook with its own secret.

An installation deleted or suspended on GitHub leaves the list, and one unsuspended comes back; the github triggers turn off only when no installation is left (disabled: {reason: "connection_lost", detail}, naming it), and coming back turns none on. DELETE /v2/integrations/github disconnects the App and turns every github trigger off the same way, answering {disconnected: true, triggers_off, note}, the note "Delete the App itself on GitHub: ": the App's credentials leave this server only, and it stays on GitHub until deleted there. Turned off, a trigger is kept: an agent keeps its triggers, and each goes on again once turned on with GitHub connected.

An MCP server saved with auth: {type: "github_app", headers?} (PUT /v2/settings/mcp-servers/{name}) uses the connected App's tokens and keeps none of its own: only its static headers (such as X-MCP-Tools), sealed as written, and never an Authorization. Its tools are listed with a token narrowed to one repository, of the first of the App's installations (in order) that covers one; an installation whose account has none selected is passed over. GitHub that does not answer while that token is minted (a 429, a 5xx, a rate limit's 403, no answer) is 502 github_failed, "GitHub didn't answer: try again", retryable, and the preview, save or refresh changes nothing. At each launch of a run that grants its tools (its start, every resume, a relaunch after a crash) the server mints an installation token for that run, narrowed to the one repository the run is about, with the App's permissions: a GitHub event's run uses its installation and repository (the server's own payload tag); any other names it in its input as https://github.com/<owner>/<repo>, whose installation the server asks GitHub for with the App's JWT. The token goes to the run's MCP calls with the static headers beside it, lives only in memory (handed to the run's process over its pipe, never its environment, journal, a log line or an answer), and is cut from whatever a run shows while it may be in use. A run whose repository the App does not cover, or that names none, has those calls answered in words ("The GitHub App isn't installed on owner/repo"). A token lasts an hour, and the runner mints a new one for the run 5 minutes before it expires, so a long run's calls never carry an expired token. When that mint fails, the token held keeps working until it expires; then the run's calls of that server are answered, unsent, that its GitHub App token expired and a new one could not be made, with GitHub's reason, and the next launch (a resume) mints a fresh one.

Templates

A template package makes a working agent in one call: its definition, its skills and rules, and its triggers, from a folder the server ships. GET /v2/templates lists them; POST /v2/templates/{name}/apply with {parameters, overrides?: {model}} makes one, or nothing when a check fails (a trigger refused archives the agent it made). A package's version is SemVer and never definition.version, the agent's revision number. The first package is code-review, a code review agent for one GitHub repository. The format and the rules of an apply are in templates/README.md; agentrun template validate DIR checks a folder as the server does.

agentrun template eval DIR (agentrun-serve eval-template DIR) runs a package's eval cases as real runs of its agent and grades each run with the case's graders: by default on a server of its own over a temp data directory, so it never touches yours, with each case's fake MCP servers on loopback and its learned notes seeded through the learning API. Real models cost money: each run is capped at its case's max_cost_usd. It exits 0 when every case passed, 1 when one failed, 2 when nothing ran. The runner, the graders and the report are in templates/README.md.

eval-template flag What
--case NAME A case to run; repeat it. Without one, every case.
--runs N Runs of each case, in place of each case's runs.
--server URL Run on that server, its token in AGENTRUN_API_KEY (the launcher hands it over its pipe; never printed). The agents the eval makes are archived at the end.
--grader-model PROVIDER/MODEL The llm graders' model; default the server's.
--report FILE The whole report as JSON (per case, run and grader: pass, reason, cost); a summary goes to stdout, progress to stderr.
--faux MODULE Answer every model request from the module, as the server's --faux does. Not with --server.

What it serves

Route Here
GET /health Without a credential: whether the store reads and the disk has room (capacity and health).
GET /openapi.json Without a credential: this API as an OpenAPI 3.1 document, the agentrun.v2/0 contract (vendored in src/openapi/, its source in info) with this server's own routes marked x-agentrun-serve the operations it answers 501 marked x-agentrun-serve-unsupported, and a contract operation that takes more than the contract gives marked x-agentrun-serve-changed with each change named; links.openapi in capabilities points at it.
GET /capabilities Without a credential. backend: local, every feature flag present and true only when it works; tools, the tools an agentic revision may name; workflows, what a workflow revision's model nodes get and, in workflows.decisions.providers, the decision providers its typed questions run on; models, where models come from and the model of each tier.
POST /agents, GET /agents, GET /agents/{id} POST takes {definition, skills?, note?} and makes the agent with its first revision, current. A definition has instructions (an agentic revision) or workflow (a workflow revision, checked by the DSL first), and name, description, model and thinking (agentic only), harness (harnesses), place (place), tools, tool_settings (search and fetch), skills (names; each <name>/SKILL.md is in skills) and version, which is the number the revision gets, and max_cost_usd, the agent's default cap (an agentic agent's or a workflow's), kept by an edit that leaves it out and cleared only by 0, and cap_mode, how such a cap prices a request, expected or strict (spend cap). A field the store does not keep (input_schema, output_schema) is 422 input_invalid by name, and so is a definition that can never run here; one this host cannot run yet saves with warnings (models). The ETag (home_etag) is the current revision's id. GET /agents/{id} adds tools, the tools its current revision grants with each one's provider (search and fetch).
PUT /agents/{id}/definition The same body, checked the same way: a new revision, its parent the current one, made current. Without skills the current revision's skill files carry over. A definition that is the current revision already writes nothing, so a PUT sent twice makes one revision. An agent's id, name and description stay those it was made with. If-Match that is not the current ETag is 412 precondition_failed; the store compares it again inside the commit, so of two edits that read the same revision exactly one lands. The contract's body is taken too: the definition itself, with If-Match required (412 without it) and definition.version greater than the current one (422). Any such version is taken, and always makes a new revision, the same definition included; the revision is numbered as this server numbers them, the next, and the answer says which; the route reads a body holding definition as this one, any other as the contract's.
GET /agents/{id}/revisions The agent's revisions, newest first: revision, version, parent, kind, current, note, created_at and the definition.
POST /agents/{id}/current {revision}: one of the agent's revisions becomes current again.
POST /agents/{id}/archive, POST /agents/{id}/unarchive Archiving: hides the agent and its runs from the lists, or shows them again. Answers the agent, with archived_at.
DELETE /agents/{id}/sign-ins/{key} This server's own route (x-agentrun-serve: "signins/1"): forgets one of the agent's saved sign-ins, at its vendor and here. 409 sign_in_held while a run holds its save lease, or while a run it was handed is still going.
POST /agents/{id}/runs, GET /agents/{id}/runs A run of the current revision, or of revision. An agentic run's input is {content: string}; a workflow run's is what its workflow's input schema takes. file_ids names uploaded files, copied into the run's work/inputs/ before it starts and named in its first message. The agent's runs, newest first.
GET /runs Every agent's runs, newest first, a page of at most 100; agent, revision and status narrow it, and archived=true lists only archived ones. A failed run's summary carries its error, and a waiting run's its pending.
GET /runs/{id} The run: its revision, kind, status, state, result, error, pending (this server's own, pending/1: what a waiting run waits on, by kind: {kind: "question", question, asked_at} for a question it asked a person; approval for a tool call held for a person's approval, with the tool, its arguments and its price; budget for a run paused at its cap (a run paused at its cap); {kind: "browser", instruction, completion_condition, asked_at, expires_at} for its agent's ask for the owner in its browser; or `{kind: "computer", computer: "desktop"
GET /runs/{id}/timeline JSON pages with the change-number cursor (after, next_after, reset, drop, has_more, final, wait_ms long poll, and this server's own limit, 1 to 500 rows a page, default 500), or server-sent events with Accept: text/event-stream (id is the cursor, Last-Event-ID resumes). Rows sort by seq; the usage row (Cost so far), the run's running total, is always the last row, and moves to the end when a row comes after it.
POST /runs/{id}/messages To an agentic run: steer while the agent works, answer when it asked the person, follow_up after a stop or after it completed: a completed run opens again, the same run on its own session and store, takes a place under --max-runs (429 when none is free), and completes again. A failed or cancelled run, an archived run and a run of an archived agent are 409 not_continuable; a workflow run takes none (501).
POST /runs/{id}/stop, POST /runs/{id}/cancel Stop ends an agentic run's turn and leaves the run input_required and idle. Cancel ends a run cancelled once its process has exited.
POST /runs/{id}/archive, POST /runs/{id}/unarchive Archiving one run. Answers the run, with archived_at.
GET /runs/{id}/files, GET /runs/{id}/files/{path} The files under the run's work/, raw.
POST /files, PUT /files/{id}, GET /files, GET and DELETE /files/{id} Uploads: a file sent in one call (POST /v2/files?path=..., its bytes the body), or reserved and then sent (PUT /v2/files/{id}); the server's uploads listed, read and deleted.
GET /agents/{id}/home/files, and GET, PUT, DELETE on a file's path below it The files in the agent's home, listed, read, written and deleted (agent home). This server's own routes (x-agentrun-serve: "home/1").
GET /stats This server's own route, outside the contract (x-agentrun-serve: "stats/1"): the agents, the runs (total, active, needs_input, failed, needs_review, status_unknown), each agent's run count, the cost and what is archived, read from the agent store's aggregates alone, no run's files opened. Archived agents and runs are left out of every number but archived. A completed or cancelled run counts only in runs.total, but for an escalated one (completed, waiting for a person's review), which needs_review (stats/3) counts; failed never does. Each running run is in exactly one of active (queued or working) and needs_input (input_required), both from the runs this server holds, and status_unknown: a running run it does not hold, or holds as ended before its end is written. cost.usd sums the runs that ended with a cost and is null when none did, never $0, with basis estimate, or null beside a null usd; unknown_runs counts the rest. analytics (stats/2) sums the runs' metrics over the runs not archived that from and to (RFC 3339 date-times to the millisecond, an impossible one or a finer fraction refused; runs started in [from, to)) and agent pick, each optional, 400 when malformed: filter echoes them; totals (runs, completed, an escalated run included, needs_review (stats/3), failed, never an escalated run, runs_with_metrics, tokens and each token kind, requests, unmetered, cost_usd and unknown_cost_runs), time (wall_ms over the ended runs, active_ms where metrics have it), by_harness_effort, by_day (UTC), by_source (live or the import source) and the top 20 tools. A run with no metrics counts in runs and in no token sum. learning (learning/1) counts the runs that tried to learn over the same filters, from the learning ledger and the runs this server holds (Learning). The query bounds analytics and learning alone.
GET /runs/{id}/graph A workflow run drawn, live: its workflow, the DSL's workflowGraph, each node's state (a map's items with their labels and the branch each route took for them, a route's branch), every decision with its receipt (answers, evidence, the state the judge saw, image links), every call with its input and output, and the totals (cost, null when any part is unknown). While the run goes it reads the run process's live.json, rewritten every 300 ms, never the run's store, which the process holds; once it ended, its store is read lock-free, so a poll never waits on it and never answers home_busy. An agentic run has none (404).
POST /runs/{id}/pause, POST /runs/{id}/resume A workflow run: what is in flight finishes, and no node, model call or decision starts until it is resumed. Its status reads paused, and stays so across a server restart. An agentic run is 501, an ended one 409.
POST /workflows/validate {workflow, tools?}: every issue a save would refuse ([{path, code, message}], the DSL's validation with code not run) and what this host lacks as warnings. Writes nothing. A refused save's 422 carries each issue as a field under definition.workflow, with its code.
GET /settings/tool-keys, PUT and DELETE /settings/tool-keys/{id} Tool keys, this server's own routes (x-agentrun-serve: "toolkeys/1"): each key's fields by name and where each is set, never a value; save one sealed, or forget it.
GET /settings/mcp-servers, POST /settings/mcp-servers/preview, GET, PUT and DELETE /settings/mcp-servers/{name}, POST .../{name}/refresh, POST .../{name}/oauth/start, GET /settings/mcp-servers/oauth/callback and GET .../oauth/client.json MCP servers, this server's own routes (x-agentrun-serve: "mcpservers/1"): preview a remote MCP server's tools, save it with its credential sealed, read it back without the credential, refresh its tools, forget it, or sign in to it with OAuth (the callback and the client document take no token).
GET /settings/tools, PUT /settings/tools This server's own: the providers of web_search, web_fetch and browser (search and fetch).
GET /runs/{id}/browser This server's own (x-agentrun-serve: "liveview/1"): the run's live browser view for the owner, {state: "off", reason}, {state: "none"} or {state: "live", viewer} (the live view).
POST /runs/{id}/browser/hand-back This server's own (liveview/2): the owner's answer to the agent's ask for them in its browser, {outcome: "done" | "cancelled"}; the first answer wins, a different one is 409 handoff_answered (the live view).
GET /runs/{id}/computer This server's own (computer/1): the run's live computer view for the owner, one family for every kind, {state: "off", hold, why?}, {state: "none", kind, hold} or {state: "live", kind, viewer, hold}, its viewer under /runs/{id}/computer/live/; the browser is kind browser here, beside its own route (the live view).
GET, PUT and DELETE /settings/webhook, POST /settings/webhook/test, GET /settings/webhook/deliveries and POST .../deliveries/{webhook_id}/redeliver Webhooks, this server's own routes (x-agentrun-serve: "webhook/1"): the URL run notices are POSTed to, signed by the Standard Webhooks spec; never its secret but once.
GET and POST /triggers, GET, PATCH and DELETE /triggers/{id}, POST /triggers/{id}/fire, GET /triggers/{id}/firings, POST /triggers/{id}/secret, POST /hooks/{id} (no token: signed) Schedules, this server's own routes (x-agentrun-serve: "trigger/1"): schedules that start an agent's capped runs, each firing once, and their history.
GET /templates, POST /templates/{name}/apply Templates, this server's own routes (x-agentrun-serve: "templates/1"): the template packages it ships, and an apply that makes one's agent and triggers, all or nothing.
POST /mcp This server's own route (x-agentrun-serve: "mcp/1"): the MCP endpoint, Streamable HTTP with one JSON-RPC message per POST (initialize, ping, tools/list, tools/call), answered as JSON, stateless. Its ten tools call these routes in process with the caller's token. The Bearer token only, never the page's cookie; a browser Origin other than this server's own is 403; any other method is 405.
GET /usage The runs' cost estimates, by meter, agent, key, day or region (key and region are one line, local). A run of unknown cost adds what it is known to have spent so far (its so_far_usd: each priced charge, browser time and paid tools included) and is counted in unpriced_runs (in the total and in each line it is unknown on), so no sum is ever null, it never passes as $0, and a total is never below a line of its own: with unpriced_runs above 0 the total is a lower bound.

Home export and import, quotes and POST /runs/{id}/kill answer 501 unsupported_here naming the feature, and capabilities declare each one false. No run waits out input_wait_s here (null). limits.max_cost_usd is the highest cap a run may name: null here, as any cap above $0 is taken; it does not mean caps are unsupported (spend cap).

Uploads

A run's input files. Send one in one call, its bytes the body and its path in the query:

curl -s "$AGENTRUN_BASE_URL/files?path=plan.md" -H "authorization: Bearer $AGENTRUN_API_KEY" --data-binary @plan.md

It answers 201 with the file, ready, under file: its file_id, size_bytes (the body's length) and sha256 (the body's own). Its content_type is the query's content_type, else the request's own type when it names one (not application/octet-stream, not curl's application/x-www-form-urlencoded), else its path's extension's, as a run's files are served (plan.md is text/markdown; charset=utf-8), else application/octet-stream. A sha256 in the query is checked against the bytes (422 input_invalid when they differ). A multipart form (curl -F) is 415: send the bytes themselves. Without path a body that is not JSON is 422 at path, saying how to call. Idempotency-Key replays the first file for the same bytes.

Or, as the contract has it, in two calls: POST /v2/files with the JSON {path, size_bytes, content_type?, sha256?} reserves the file, pending, and answers where to send its bytes, upload.url; PUT them there as the raw body, any content type. Their length must be size_bytes and their SHA-256 the sha256 given (422 input_invalid at that field), and then the file is ready. upload.url is a path from the server's root, /v2/files/{id}: send it to the server's address (curl -X PUT --data-binary @plan.md "${AGENTRUN_BASE_URL%/v2}$url"), not after the /v2 base, where a 404 says so. With no content_type the reservation's type is its path's extension's.

Either way the bytes are streamed to disk, never held whole, so max_body_bytes does not apply; a file is at most limits.max_upload_bytes, 50 MiB (413). The same bytes sent again change nothing; other bytes are a 422. GET /v2/files lists the server's uploads, pending and ready, newest first (limit, cursor), GET /v2/files/{id} reads one's record, and DELETE /v2/files/{id} removes it and its bytes (204): these three are this server's own (x-agentrun-serve: "files/1").

A run names up to limits.max_files_per_run, 20, ready files in file_ids: an unknown, pending or repeated one is 422 at file_ids before anything starts. Each is copied, never linked, into the run's work/inputs/ at its path, folders kept, before the run's process starts, whatever its harness; a name already taken gets a suffix (notes-2.txt, docs-2/), the same for the same files in the same order. The run reads them as any file of its workspace, and GET /v2/runs/{id}/files lists them. An agentic run is told of them in its first message, on every harness: after the input's content and a blank line, one line, Files for this run: "inputs/notes.txt" (32 B), "inputs/docs/plan.md" (1.5 KB)., each file by the path the run's tools take, quoted as a JSON string with a control, format or separator character as its \uXXXX escape (so no name forges an entry or reorders the line), and its size, at most 20 and then how many more, and never past what the run's door takes in one message (fewer named, then only how many). That is the message the harness gets, so it is the timeline's first user_message; the run's input stays as it was sent. A run given no files is sent its content as it is. A deleted upload stays in the runs that copied it; a run started after names it in vain. Each upload is kept in DIR/files/<file_id>/, 0600. A start clears the temp files a crashed upload left (.incoming.*.tmp in DIR/files/, bytes.*.tmp in a file's folder), each only when it is a regular file (a DIR/files/ that is a link is not swept), and logs event=uploads_swept removed=N. upload.expires_at, which the contract requires, is advisory and not enforced in this release: a PUT after it is still taken.

Agent home

Each agent has a home, a directory of its own at <data>/homes/<key>/, kept across its runs; runs do not see it yet. The key is the agent's id in lower case and 16 hex digits of the id's SHA-256, so two ids that differ only in case (keeper, Keeper) never share a home on a disk that folds case, as macOS's does by default. GET /v2/agents/{id}/home/files lists every file in it, recursively, in the shape of a run's file list (with agent_id in place of run_id), and a home never written to lists none. Below it, a file's own path: GET reads the file as a run's file is read, and PUT writes the raw body as the file, whole (a temporary file renamed over it, 0600), making the directories on the way and the home itself on the first write, and answers its entry; of two writes at once, the last wins. A body over capabilities.home.max_file_bytes (limits.max_body_bytes) is 413. DELETE removes a file (204; 404 when it is not there) and leaves an emptied directory in place. Paths are checked as a run's are, a write goes at most 16 directories deep (as deep as the list goes), and a link anywhere on the way is never followed: a read is 404, a write or a delete 403 system_file_forbidden. learning/ and sign-ins/ (the agent's saved sign-ins) are the server's alone (capabilities.home.host_owned): the list shows each as one entry, {"path": "learning", "host_owned": true}, with none of what is in it, and a GET, PUT or DELETE at or under one, however the path is spelled (./Learning/x), is 403 system_file_forbidden (details.reason host_owned). A read's refusal names where to read it instead: the learning API (/v2/agents/{id}/learning) on a server that has that route (on one that does not, the server keeps learning/ for itself; this server has no learning API yet), and /v2/agents/{id} for sign-ins/. A host-owned name is matched by its first segment folded (compatibility form, any case, no combining marks) and, where that segment and learning/ are both on disk, by identity (the same device and inode), so a disk that takes another spelling for one name is refused the same way. A path takes only plain segments: each its own NFC form, with no default-ignorable code point, or it is 400 invalid_file_path. An archived agent's home is read and written as its definition is. A write is held to its directory through /proc/self/fd; where there is none (macOS), capabilities.home.writes is false and PUT and DELETE are 501 unsupported_here, as Node has no openat to write safely by path, while the list and reads still work. GET and PUT /agents/{id}/home, the contract's packed home, stay 501.

For coding agents

A coding agent (Claude Code, Codex) sets the server up and drives it with skills/agentrun-serve/SKILL.md, shipped in this package. GET /llms.txt, at the server's root and without a credential, is the API in brief (src/llms.txt), with no data of the server's.

POST /v2/mcp is the MCP endpoint: Streamable HTTP in its simplest form, one JSON-RPC message per POST answered as JSON, with no stream and no session. Its ten tools:

Tool Routes
whoami GET /capabilities: the token checked, and what this server runs.
agents_list, agents_create GET /agents, POST /agents (the arguments are the definition; definition carries any other field, checked by the route).
agents_revise GET /agents/{id}, then PUT /agents/{id}/definition with the fields given changed, on that revision (If-Match).
runs_start POST /agents/{id}/runs, answered at once.
runs_get GET /runs/{id} (its pending is a waiting run's question), with console_url.
runs_events GET /runs/{id}/timeline with limit (100 by default).
runs_files GET /runs/{id}/files, or one file's text up to 64 KiB.
runs_respond, runs_cancel POST /runs/{id}/messages, POST /runs/{id}/cancel.

Each tool calls its routes in process, with the caller's token, so it checks and answers as they do: the route's JSON is the result's structured content (and its text), and a refusal is a result with isError holding the route's error. A run is started, then polled with runs_get; no call is held open for one, and a question a run asks waits as its input_required state, for runs_respond or a person in the console. The endpoint takes the Bearer token only (the page's session cookie is never taken there), refuses a browser Origin other than this server's own, and on loopback refuses a Host that is not a loopback name, as every route does.

claude mcp add --transport http agentrun "$AGENTRUN_BASE_URL/mcp" --header "Authorization: Bearer $AGENTRUN_API_KEY"
codex mcp add agentrun --url "$AGENTRUN_BASE_URL/mcp" --bearer-token-env-var AGENTRUN_API_KEY

Harnesses

A definition names the harness that runs it: pi (the default, and what a definition that names none gets), claude-agent, codex or opencode; any other is 422 input_invalid on definition.harness. Every answer names it. An edit (PUT /agents/{id}/definition) that names no harness keeps the agent's, as it keeps its skill files; name pi to move it back. GET /v2/capabilities lists each in harnesses, with available and, when not, the reason, and default_model: the model the harness runs when a definition names none (provider/model-id, as a save and a run resolve it), or null where it runs none (the harness does not run here, or no default model here runs on it: claude-agent never takes a gateway's model). A definition naming a harness this server does not run yet saves with a warning on definition.harness, and a run of it is 409 agent_not_runnable with details.harness; it never runs on pi instead. This server runs pi always, and claude-agent, codex and opencode when each adapter's package, pins and provider endpoint check out; the harness's reason says what is missing. claude-agent and codex install themselves on first use: AgentRun never ships the Claude Agent SDK or the Codex CLI, and the first run on each (or the first agent saved on it) installs the version AgentRun pins (@anthropic-ai/claude-agent-sdk@0.3.286, @openai/codex@0.164.0) from npm into <data>/harnesses/claude-agent or <data>/harnesses/codex, with the npm that runs the server, once. Each install is pinned by the lock the server ships, harnesses/claude-agent/ or harnesses/codex/ (a package.json that names the package at the pin, and npm's lock for it: every package of the tree, the platform packages that hold the Claude CLI or the Codex binary and the peers' dependencies included, by sha512), which npm ci --ignore-scripts --include=optional holds every tarball to, from whichever registry the person's npm config names; a config that omits optional packages still gets this platform's CLI. It lands whole or not at all: npm writes into a fresh directory beside the install's own, the result is checked there (the package at the pin, its entry, this platform's CLI or binary, and for Claude the MCP library), then it is renamed into place and made read-only. node scripts/harness-locks.mjs makes the locks again when a pin moves (--check checks them). Every run that needs one waits for that one install, its ready time counted from the install's end, and shows a phase row, Installing Claude Code (first use)… or Installing Codex (first use)…, while it does. Each run then loads the harness from its own packages inside its install and nowhere else, never from a node_modules above it; a tree that is not whole is installed again. Claude Code as an earlier build installed it, at the top of <data>/harnesses itself, is removed before the first install (the server logs harness_install_removed), and it installs again; a harnesses directory that holds anything else is left as it is. Until it is installed, capabilities list the harness with installs: "on_first_use". When npm fails, the run fails and its error names the command that installs it by hand, the same locked install: the shipped package.json and package-lock.json copied into a fresh <data>/harnesses/<harness>.new, npm ci --ignore-scripts --include=optional there, then that directory put in place of <data>/harnesses/<harness> (made writable first: the server leaves its install read-only). A server that stops mid-install stops its npm (SIGTERM). opencode needs opencode-ai@1.19.5 installed beside the server (an exact optional peer of it), and its model goes where codex's does: the gateway at AGENT_MODEL_BASE_URL, or OpenAI with OPENAI_API_KEY, on the Responses API. The packages and pins are checked once, at the start (event=harness_offers why=start): a harness installed after it, or put back on its pinned build, stays unavailable until AgentRun restarts, and its reason ends saying so, once it is installed, restart AgentRun to pick it up or once the pinned build is installed, restart AgentRun. Run from npx, a reason whose install line is the npx line that starts the server with what is missing needs no such clause: running that line is the restart. A reason a setting fixes (a model key, say) names no restart.

claude-agent sends its requests to ANTHROPIC_BASE_URL (else Anthropic) with ANTHROPIC_API_KEY and Anthropic's own model id, and prices the run by it: a revision's anthropic/<model-id>, its dots as dashes, so OpenRouter's dotted name (anthropic/claude-sonnet-5.5) and Anthropic's own (claude-sonnet-5-5) are one model. pi sends its Anthropic models' requests there too, compaction, a hosted search, a learned node and a grade included: to ANTHROPIC_BASE_URL with ANTHROPIC_API_KEY as x-api-key alone (never ANTHROPIC_AUTH_TOKEN or an OAuth token), and capabilities read its Anthropic default ready only where that route runs (models.needs: "key" without ANTHROPIC_API_KEY, or with an ANTHROPIC_BASE_URL a key may not go to, whose run is refused in its words). Such a base refuses Anthropic's route alone: a model of any other provider runs as it does without it. codex and opencode send their requests to serve's gateway (AGENT_MODEL_BASE_URL), else OpenAI with OPENAI_API_KEY. A harness with none of its own settings, where OPENROUTER_API_KEY is set, runs through OpenRouter (https://openrouter.ai/api, the key as a bearer token): claude-agent on its Messages endpoint, with Anthropic's own id on the wire (OpenRouter takes it: its Claude Code guide says its "Anthropic Skin … handles model mapping") and the run priced by OpenRouter's catalog entry; codex and opencode on its Responses API, with the model's whole id (openai/gpt-6-sol). On that key alone the defaults are openrouter/openai/gpt-6-sol for pi (pi-ai's OpenRouter provider, on the bearer route), anthropic/claude-sonnet-5.5 for claude-agent and openai/gpt-6-sol for codex and opencode; a direct provider's key gives the default for its harnesses where it is set. A model named openrouter/<vendor>/<model> (pi's name for it) goes through OpenRouter on every harness. An endpoint the host names never gets OpenRouter's key: ANTHROPIC_BASE_URL without ANTHROPIC_API_KEY is refused. A model with more than one segment after its provider (anthropic/anthropic/claude-sonnet-5.5) is refused, naming the form to use. A provider that refuses the run's key (401) fails the run naming the key and where to set it ("OpenRouter refused OPENROUTER_API_KEY (401: User not found): set it where AgentRun starts, or save it in Settings → Model keys."), on every harness, with the provider's first sentence only.

claude-agent loads the revision's skills itself: the model is shown each as agentrun:<name>, loads one with the Skill tool and reads its other files with read_skill_file, and the system prompt holds the instructions alone. A skill the adapter does not take (a name that is not lowercase letters, digits and dashes, a SKILL.md without front matter giving its name and a description, a file path that is not plain or is deeper than five segments) stays inlined in the system prompt; so do all of them when together they hold more than 1 MiB. On pi, codex and opencode, a run with the read or bash tool is told each skill's name, what it is for (its description) and its SKILL.md's path under .agents/skills/, and reads the file when the skill fits the step it is on; a run with neither gets every SKILL.md inlined in its system prompt.

definition.harness and capabilities.harnesses are this server's, outside the agentrun.v2/0 contract: GET /v2/openapi.json marks each x-agentrun-serve: "harness/1", beside the other properties it adds (model/1, serve/1), and lists them in info.x-agentrun-serve.fields.

Decision workflows

A workflow's typed questions (its judge, pick, sift, route and ask nodes) run on a decision provider when this server's environment sets the provider's key. They are fast decisions, not agent turns: one request per node, with typed answers and their probabilities.

  • Jev, TypeSafe's System One, through @parcha/agentrun-jev, answers a node that names no provider. Set TYPESAFE_API_KEY: it is the route whose key stays out of a run's reach (below). OPENROUTER_API_KEY also works, for Jev through OpenRouter, and so does a model gateway on OpenRouter: with AGENT_MODEL_BASE_URL on the host openrouter.ai and neither key set, Jev runs on AGENT_MODEL_API_KEY.
  • Parallel Decisions, through @parcha/agentrun-decisions, answers a node that says provider: "parallel", with web citations as its evidence. It runs on one of two routes. With PARALLEL_API_KEY set, on Parallel's own API: each request is booked at Parallel's list price as an estimate. Without it, on Parallel's model through OpenRouter, parallel/decisions-fast, on the OpenRouter key (below): only an OpenRouter key is needed. Its citations (OpenRouter's url_citation annotations) are the same evidence, and its cost is the one OpenRouter reports. A request whose outcome was lost after sending is never sent again on either route: the run escalates it as interrupted. Capabilities say which route it runs on (route: "parallel" or "openrouter").
  • OpenRouter's Decisions models answer a node that says provider: "openrouter" and names its model in model (liquid/d1, perplexity/pplx-decider-v1.2, cloudflare/clef, typesafe/jev-1.14). They run on the OpenRouter key Jev's OpenRouter route reads (OPENROUTER_API_KEY, else AGENT_MODEL_API_KEY with AGENT_MODEL_BASE_URL on openrouter.ai), handed to the run's process on the decision descriptor as Jev's is. That key is also the model gateway's, which a run's process holds. Images to them are experimental. A model that cites the web returns url_citation annotations, which become the node's evidence. A cost OpenRouter reports above 0 is booked; 0 (a model its catalog prices at 0) or none is unknown, never $0.
  • A decision node's images are files of the run's work/, such as the pages pdf_pages draws: the run's process reads them and sends them with the request. The receipt keeps each image's SHA-256, never its bytes.
  • GET /capabilities lists the providers in workflows.decisions.providers ([{id: "jev" | "parallel" | "openrouter", available, reason?, note?, route?}], route on parallel), and runJudge is among workflows.adapters once any of them runs here. On the OpenRouter route, jev's note names the variable Jev runs on (OPENROUTER_API_KEY or AGENT_MODEL_API_KEY) and says that it is the model gateway's key.
  • With no key for a provider a workflow's nodes name, the workflow saves with a not_set_up_here warning on definition.workflow naming the variable, and a run of it is 409 agent_not_runnable, with details.adapters: ["runJudge"] and details.decisions naming each missing provider, for example {parallel: {reason: "not_set_up_here", needs: ["PARALLEL_API_KEY"]}}. A provider this server does not have refuses the save, 422.
  • Each question's receipt is a row of the run's store (agentrun.decisions) before its answer acts, and a resumed run takes an answered one instead of asking again.
  • Jev's API reports token counts and no price, so each answered decision is booked at TypeSafe's published price ($0.042 per million input tokens; output tokens are free) as an estimate, and the run's cost.usd is its decisions' sum, with basis: "estimate". A decision whose cost is unknown (no token counts, the OpenRouter route, or a call that failed after it may have been sent) keeps its receipt's cost null, and the run's reads null, never 0.
  • The TypeSafe and Parallel keys never enter a run's environment, which its tools and its model's provider read. The server drops TYPESAFE_API_KEY and PARALLEL_API_KEY from every run's process. It hands a run's process those it needs (Safety), a workflow's decision providers' and Parallel's where its web_fetch or web_search resolves to Parallel, once, by name, on an inherited pipe (the first descriptor after its stdio and its secrets pipe, as macOS refuses a high one), at each launch, a resume after a restart included. The process reads them once into its decision providers and web tools, closes the descriptor before anything else runs, and keeps the keys nowhere else. It is never in an environment, an argument list or a file, and no program the run starts inherits it. With no key, no descriptor is opened. On the OpenRouter route Jev's key comes on the same descriptor, never from the run's environment; that key is also the model gateway's key, which a run's process holds for its model nodes.
  • TYPESAFE_API_KEY and PARALLEL_API_KEY are among the secrets the launcher hands the server over its pipe, so the server holds them out of its own environment too. Neither is ever among the secrets a run's process gets over its own pipe, which are its tools' environment: the decision descriptor is their only way into a run.

Archiving

An agent or a run is archived, never deleted: DELETE stays 405. These routes are this server's own, outside the /v2 contract, like GET /runs and an agent's revisions; capabilities list them in features.archive.

  • POST /v2/agents/{id}/archive hides the agent and its runs from GET /agents, GET /runs and GET /agents/{id}/runs; POST /v2/runs/{id}/archive hides one run. .../unarchive shows them again. Each is idempotent (archiving again keeps the first time), takes no body, and answers what it changed.
  • Archiving an agent turns off every trigger that runs as it (a start_run of it, a message_run to one of its runs) with the archive, in one write: disabled: {reason: "agent_archived", detail}, and the answer lists them in triggers_off: [{id, name}]. From then no firing starts a run of it: one that comes due anyway (waiting on its max_active_runs, or on a retry) is skipped, "The agent is archived.". While it is archived, a trigger made on, turned on or aimed at it is 409 agent_archived. Unarchiving turns none on: its answer lists those still off, and PATCH /v2/triggers/{id} {enabled: true} turns each on.
  • Each list takes archived=true to list only archived ones (false, the default, lists the others).
  • Every read by id still answers, with archived_at (null when it is not archived; a run's is its own, else its agent's): GET /agents/{id}, GET /runs/{id}, its timeline and its files. /usage still counts archived runs: the spend happened.
  • A new run of an archived agent is 409 agent_not_runnable, saying it is archived (details.archived_at); a run already going when its agent is archived keeps going.
  • The store keeps each change as one row of archive_changes in store.sqlite, written once (the store). A data directory an earlier version wrote opens as it is, with nothing archived. Going back to an earlier version keeps every row, but that version does not read them: archived agents and runs show as not archived there, and an archived agent's runs start again.

The console

Open the link the server prints, once, in a browser. / is the console (apps/console in the repository), on the server's own origin, under a CSP that allows that origin only:

  • Mission Control: the store's counts and the latest runs.
  • Agents: each agent with its kind and harness, archived ones behind Archived. New agent and an agent's page: a name, a description, a prompt (with its model, thinking level and tools) or a workflow, sent to POST /v2/agents; an edit is PUT /v2/agents/{id}/definition with If-Match set to the revision it started from (412 when another came first). Its revisions, with Make current (POST /v2/agents/{id}/current), and Archive or Unarchive.
  • Playground: a run of an agent, on the revision you pick, with its input, followed as it goes.
  • Runs: every run, newest first, filtered by agent and by state, archived runs behind Archived; the address keeps the filters. A run's page follows GET /v2/runs/{id}/timeline with a long poll, sends messages (an answer while it waits on ask_person), stops or cancels the run, shows its files and a workflow's graph, and has Archive or Unarchive.
  • Settings → MCP servers: add a remote MCP server (its name, address and credential: none, a bearer token, headers, or Sign in with OAuth), Preview its tools and protocol, then Save; each saved one with its tool count and its credential's state, Refresh, Change (a credential left empty keeps the saved one) and Delete. An agent's Tools step lists each saved server's tools; a box ticked grants mcp:<server>/<tool>.

Its files (index.html, and Vite's hashed files under /assets/) are public and hold no data: everything it shows comes through /v2. The store's read-only JSON stays under /api/* (/api/store, /api/agents/{id}, /api/runs and the rest of @parcha/pi-durable-agent's store page's data), for a script with the token or a browser with its cookie. With accounts on (the default) the console asks for the owner's password (your password) and the browser holds the owner's session cookie; a ?token= link sets no cookie: the token is for scripts and the CLI. With AGENTRUN_ACCOUNTS=0, the printed link (/?token=) sets a cookie (HttpOnly, SameSite=Strict, Path=/, and Secure when X-Forwarded-Proto says https) and sends the browser to the same address without the token. The cookie's value is derived from the token, never the token itself, and it lasts until the browser closes. Without it, the console says to open the link. Either cookie takes the place of Authorization: Bearer on /api/* and /v2: for a GET, and for any other method only when the request's Origin is the server's own origin and its body is application/json, or, on a PUT or POST to a route that takes bytes (PUT /v2/files/{id}, POST /v2/files), of any content type. A request that sends an Authorization header is checked on that header alone.

The repository's npm ci && npm run build builds the console (apps/console) first, and the server's build copies it into dist/console; the image always carries it. A server built without it answers / with a 404 that says so. /console/ (the console's address before) and /setup and /sign-in (the old page's, which the printed setup link still names) send the browser to /, which keeps a link's #code.

Your password

AgentRun Server is for one person, who signs in to the console with a password (AGENTRUN_ACCOUNTS=0 turns this off). With no AGENTRUN_PUBLIC_URL, listening on loopback (the default), the start prints Set your password: <url>/setup, and the first visit sets it, 12 characters or more. With a public address, or listening on any other address (0.0.0.0 in the image, a LAN address), the start prints Set up this server: <url>/setup#code=<code>, and setup takes that code (or the token, in the code field or as the Bearer): anyone who reaches the port could otherwise set the password first. The server keeps a scrypt hash of the password, never the password, in DIR/owner.json, and signs its session cookies with the key in DIR/.session-secret; both are 0600, and no run reaches either. A session lasts 30 days. Sign out (POST /v2/auth/sign-out) signs out this browser: its session is revoked on the server (a copy of its cookie stops working too) and its cookie cleared, and every other browser stays signed in. Sign out everywhere (POST /v2/auth/sign-out-everywhere) ends every session on every browser and phone, this one too.

Sign-in tries are limited, and never for good. Each address (an IPv6 one by its /64) has ten tries in 15 minutes; past them each next try waits a second after the last, twice that the time after, never more than a minute. The whole server takes 100 wrong passwords in 15 minutes; past them every try, from any address, waits the same way after the last wrong one. A refused try is a 429 that says how long (retry-after, retry_after_ms). The limit counts up to 10,000 addresses at once; a new one past that is held by the server's budget only, and no address counted is ever dropped to make room. Behind a proxy every visitor would be the proxy's address, so one guesser would hold the owner too: set AGENTRUN_TRUSTED_PROXIES to the proxy's addresses or ranges (comma-separated, or loopback, linklocal, uniquelocal), and a request from one of them counts under the client its X-Forwarded-For names (the nearest address that is not itself a trusted proxy). Unset, that header is never read. render.yaml sets it to uniquelocal, the private ranges Render's proxy reaches a service from.

Forgot it: run agentrun-serve reset-password --data DIR where the data directory is (docker exec agentrun node /opt/agentrun/packages/serve/dist/cli.js reset-password --data /data in the image). It clears the password and signs every browser out, and the next visit sets a new one. The token works for scripts and the CLI either way.

A browser's writes must come from the server's own origin. Serve the image on another address or port behind a proxy, and set AGENTRUN_PUBLIC_URL to the address people open. Behind a proxy every sign-in comes from the proxy's address, so the limit is one for everyone: ten wrong tries in 15 minutes, from anyone, keep the owner out until the window passes (the token still works for scripts meanwhile). Serve it over https where you can: on plain http a browser sends the session cookie to every server on the same host name, whatever its port, so another app on that host could read it.

Importing your sessions

agentrun-serve import --data DIR reads the Claude Code, Codex and pi sessions in your own home (~/.claude/projects, ~/.codex/sessions, ~/.pi/agent/sessions) into the data directory, once. Each project folder becomes an agent, with its CLAUDE.md or AGENTS.md and its skill folders, and each session becomes a run you can read but not continue. Nothing leaves this machine, and the values of key-shaped environment variables are cut out of what it writes. Run it again and it takes only what it has not taken already. Sessions written in the last 10 minutes wait for the next import.

  • --dry-run lists what it would import and writes nothing.
  • --from claude-code,codex,pi picks the sources.
  • --include-deleted also imports the sessions of project folders that no longer exist, such as a git worktree you deleted. Each such folder is still its own agent, with no instructions or skills to read.
  • --all keeps those too, as well as sessions of temporary folders and sessions with no message of yours.
  • --verbose lists each file it leaves out, and why.

The dry run and the import both end by counting what they left out, by why, with the flag that brings each kind in:

left out: 84 sessions from folders that no longer exist (--include-deleted imports them too)
left out: 2 sessions still being written: they come in next time

A run's life

  1. POST /agents/{id}/runs starts the run in the store (startRun, which pins the revision and seeds DIR/disk/agents/<agent>/runs/<id>/), then its process: pi-durable-disk run --dir DIR/disk/agents/<agent>/runs/<id> --app <this package's app> --serve 0 on loopback, behind a token of its own (DIR/.door-token), with its stderr in runs/<id>/process.log. The run's input is in runs/<id>/serve.json.
  2. The app reads the run's revision from the store.
    • An agentic revision runs as the root agent: its instructions and skills, its model, and the tools it names of the workspace's read, write and edit, and ask_person, which ends the turn; the timeline then shows a waiting row, and the run is input_required and waiting until a message answers it. bash is bound only where the run has a shell (computers): in a computer that mounts the run's folder, or on Archil's serverless exec in Mode 2; in Mode 1 a run with no such computer has the file tools alone. The run is queued until its first message shows; while the process runs, the timeline comes from its door's events through @parcha/pi-durable-agent/timeline. Once the run is idle and its newest row is the agent's answer, the server sends the process SIGTERM. A model error ends it failed (run_failed). A run whose max_cost_usd cannot buy its next request is paused, input_required and waiting with pending.kind budget; one its cap refuses for a reason no cap fixes ends failed (spend_cap_reached, below). A provider's content filter stopping the answer ends it failed with moderated on every harness, saying so in plain words ("The model provider stopped this answer (its content filter). Rephrase the request, or try another model."); the Timeline's error row keeps the harness's own words for the stop: pi-ai's on pi ("Response incomplete: content_filter", say), the kernel's on claude-agent and codex ("content_filter: the model provider stopped this answer"), never the provider's own. On claude-agent and codex the session's model proxy reads the provider's stop and ends the turn at once, so the CLI never asks for the refused answer again. A stop sent while the run still reads queued (a message sent to it not yet in its timeline) waits, up to a request's deadline, for that message to show, then stops the turn as it stops any working run. A stopped run can be continued: a follow-up starts a fresh turn on the whole history, and the turn the stop cut is not replayed. A cancelled run cannot: a message to it is 409 not_continuable, and its next_action is to start a new run of the agent.
    • A workflow revision runs on agentrun-pi's durable workflow task, started by startWorkflowRevision of @parcha/pi-durable-agent/workflow (keyed by the run's id): its model nodes (agent, decide, extract, report) on the run's model, with the file tools each node names, and its typed questions on Jev or Parallel (decision workflows). A workflow that needs code or a tool call this host does not run is not saved here (422). The run is queued until its process serves, then working; when the workflow ends, the app writes the run's final status with the outcome and releases it. Nothing is carried back from inside the process, which holds the run's store until it exits.
  3. When the process has exited, the run's rows are read from its released store (an agentic run's conversation, a workflow run's model nodes), and the server carries the run back, every run of both kinds: carryBack records its state, cost and a summary in the store (an agentic run's answer; a workflow run's workflowCarry, its completed nodes, then where it failed or escalated). Its last page says final. v2's cancelled is the store's stopped; a workflow that escalated ends completed, its work done and waiting for a person's review: error is null and review is {kind, node, reason, at}, in the run and in the lists. In this release kind and node are always null: the store keeps only the escalation's summary (reason) and the run's end (at). Every other run's review is null. GET /v2/stats counts it in needs_review, never in failed.
  4. When the server starts, it reads every run the store says is running. A run process an earlier server started and that still serves the run is supervised again, by the holder its row names (its pid, its start, its door); a run that already ended is carried back; a run whose process is gone before it ended is opened again on its directory and resumes from its store (an agentic run waiting for an answer waits without one, until a message). A carry back that finds the store held by another writer is tried again with backoff, and at the next start.

An Idempotency-Key answer is kept in DATA/.idempotency.json for limits.idempotency_ttl_s (a day), saved once the work is done and before the answer is sent, so a retry after a restart gets the first answer; a server that goes down between the work and the save loses it, and a save that fails still answers. A run create's key is also kept with its run in the agent store, written with the run itself, so a retry after any restart, or after that day, gets that run (as it is now, marked Idempotent-Replayed: true), and another body under the key is still 409 idempotency_mismatch.

A completed agentic run's result is {output: null, final_answer}; a completed workflow run's is {output: <the workflow's output>, final_answer: null}.

Learning

Learning is off unless an agent's definition says learning: true (an agentic one: a workflow refuses the field). A definition that sets it keeps it on its revision, false included, and an edit that leaves it out keeps the agent's; one that never sets it stores nothing, so its revision id is the one it had before the switch. The server's --learning off turns learning off for every run it starts, whatever the agent says.

An agent that learns has a learning folder, learning/ in its folder on the disk (DATA/disk/agents/<key>/learning/), in Claude Code's auto-memory format:

  • MEMORY.md, the index a run reads, one line per active note: - [name](file.md) — description, built by the server;
  • one file per note, <slug>.md: front matter with name, description and type (user, feedback, project or reference), and optionally status: superseded or retired with superseded_by (no status: active), then the note's text. The server parses it and builds the index with its own code, in src/learn/, whose SPEC.md is the folder's contract.

The folder is plain git with main checked out. The agent's first run that learns makes it, with one commit on main holding an empty MEMORY.md; an agent that does not learn never gets one. The server is its only writer. Every git call the server makes there runs with the folder's hooks and fsmonitor off, no replace refs, no lazy fetch and no system or global config, one at a time per agent.

Each run of an agent that learns starts from main as it is when the run is made, whatever its harness:

  • runs/<id>/learning.json pins that commit (seed). It is written once: a resume or a follow-up reads the same commit, however main moves on.
  • What the agent learned goes into the run's instructions after the agent's, under # Learned, so a run reads it whatever its tools: an agent with no read tool still follows its notes. Only active notes are served: a note with any other status is never there, whole or by its index line (one a hand edit left in MEMORY.md is dropped), and is not counted toward the budget. Each active note is there whole (its ## name (file.md), its description, then its text), the one changed last on main first (a merge or a person's edit; then by name), while they all fit the server's learning budget (--learning-budget, 8000 bytes by default). Past it, the run reads the first 200 lines of MEMORY.md (and no more than 25 KB, as Claude Code loads its own), then the notes changed last, whole, while they fit what is left; the rest are named only in the index, and an agent with the read tool is told they are in .learning/, to open one when it applies (one without is not). A line before them says to follow a note when it applies. # Learned opens, for every run that learns, notes or none, with what the run is told of learning: that only what it answers when it is asked at the end is kept, never a file it writes, so it tells the person it will keep what they ask it to remember, and that a secret is never kept. A run with nothing learned yet is told so. A run that does not learn (or whose seed failed) gets no # Learned. Every name, description, text and index line is cut of the server's secrets as they are when the run is made, as a person's reads are. That is pi's system prompt and claude-agent's and codex's instructions: the same channel CLAUDE.md and AGENTS.md travel in. No such file is written into the run's work (claude-agent keeps project settings off, so a .claude/ in the work never loads). The pin keeps the section the run reads (section), so a resume or a follow-up reads the same; a pin an earlier server wrote, the index alone (learned), is read as that server gave it.
  • MEMORY.md and the active notes are copied into the run's work/.learning/, made fresh and cut of the server's secrets the same way, for the run to read; a note that is not active is not, and neither is its line in the index.
  • The skills the agent wrote, each a folder skills/<name>/ on main with its SKILL.md, join the revision's own: each is copied, every file of it, into the run's work/.agents/skills/<name>/, and the run gets it as it gets the revision's (pi and codex read each SKILL.md in their prompt, claude-agent loads the ones its adapter takes through its Skill tool). A skill named like one of the revision's is the revision's, and only an active skill is served: one whose SKILL.md front matter says status: pending (held for its owner's OK), or any status but active, is not. The learned ones get 256 KiB, every file of each together, and never more than the revision's skills leave of the 1 MiB an adapter takes of a run's skills in all, so they never push the revision's back into the prompt: the one changed last on main first, each whole while it fits. One that does not fit, or holds one of the server's secrets, is left out. The pin names the files of the ones laid (skills) and the ones left out (skills_left_out), and a follow-up gets them laid again, fresh, whatever the run wrote over them; one the work no longer holds whole when the run's process starts is left out of what its harness gets, and the run goes on. A skill whose name or files are out of shape is not read, and a symbolic link or a submodule at skills/ or anywhere under it is a learning fault.
  • The workflows the agent learned, each a folder workflows/<name>/ on main with its workflow.json and its README.md, are the run's as one tool, learned_workflow {name, input}, on every harness (pi, claude-agent, codex), with no edit of the agent's definition. Only an active one is, by a skill's rule: its README.md sets no status, or active. Its description names each workflow with the line its README gives, 8 KiB of lines in all: by name, each while its line fits, and one that does not, or holds one of the server's secrets, is left out. A README whose front matter does not read one way (not closed, or a line that is no key at its left edge) is not read. The pin names the ones the run gets (workflows), each kept whole in the run's directory (learned-workflows.json), outside its work, and the ones left out (workflows_left_out). The list is fixed when the run starts: a workflow approved or retired while it runs is the next run's to get or not, as a note or a skill is. A call checks the workflow again against the agent's own tools as its run resolves them, as the merge did: one whose call node names a tool the agent was not granted (or one its run resolves unavailable), whose model node names any tool (a learned model node runs without tools, so the merge refuses one too), or that holds a code node or a shell or executor call, is refused before any of its nodes runs. It runs with the workflow DSL: each model node on the run's own model (the one the run was made with and handed the key of; a tier the run has no model of runs there too), in a session of its own with no tool of its own, each call node via: "tool" on a tool the agent has, its paid requests booked under the learned_workflow call, and web_search on the provider's own search where it resolves native, through the run's models. Its model requests are the run's, under its cap: on pi, side requests of the run's models labelled learned_workflow on its timeline; on claude-agent, codex and opencode, requests on the run's one route, the session's model proxy, under a key of the run's own: the same body rules, provider, key, price and cap as the harness's own requests, booked on the session's records labelled learned_workflow. So they go wherever the harness's go (the operator's gateway at ANTHROPIC_BASE_URL or AGENT_MODEL_BASE_URL, OpenRouter, the vendor's API), and every run is offered the workflows its pin names. A call a crash cut off is answered interrupted, never run again. The tool gate holds learned_workflow like any tool: its settings key is learned_workflow, allowed by default (the agent's own graded workflow, each node checked), and tool settings may set it to ask (the run parks until an owner answers) or deny.

The server reads the commit with git plumbing, never the working tree. What it holds that is not a regular UTF-8 file of 1 MiB or less, or a note out of shape, is not read, and the server's stderr names it. A learning fault never blocks a run: when the folder cannot be made or read, or its notes copied, the run starts with nothing learned. A host without git 2.32 or later (what learning's own git calls need) is one such fault, said once on stderr.

What a run learns

When a run that learns answers or fails, the server asks the same session one more turn before it lets the run go, whatever its harness and whatever tools its agent has: one message through the run's door under the fixed request id learn-reflect-<run id>, so a server that restarts mid-turn asks again and the run takes it once. The server alone sends it, with the origin reflect; no request field sets it. The message (reflectPrompt, in src/learn/reflect.ts) asks the agent to look back over the whole conversation, what the user asked it to remember included, and to answer with each note to keep as one ```note <file>.md block in the note format, and to use no tool: no write tool is needed, and nothing the run wrote in work/.learning/, during the task or the turn, counts. It also says that a memory is replaced by a new note with supersedes: [old.md] and never by reusing a name, how many index lines are left (200 less the active notes), that No notes. is a fine answer, and what not to keep. The turn runs under the run's own cap, with no reserve, and within 5 minutes; its rows are labelled learning, and the run's answer stays the job's.

As the run settles, before it reads ended and before a follow-up can open it again, the server keeps what the turn answered, the whole text of each of its messages, and what the user said in the run, in runs/<id>/learning-turn.json. Once the run has ended, in the agent's queue, the server reads the notes from that answer alone (parseReflection, in src/learn/reflect.ts): an answer with no note is empty, and one that gives notes out of format (a note outside a block, a block with no file name, or one name given twice with other words) is unparsed, nothing kept. A block opens with the fence asked for (```note or four backticks for a note, four for a skill or a draft) and runs to the next line that is that fence; a shorter fence is text, a longer one an example, and an opening never closed is left out alone. checkProposal (src/learn/check.ts) then refuses, before anything is written anywhere, a note that reuses an existing note's name, a path that is not a .md file at the top, a bad name, front matter out of shape or setting status, a supersedes naming a note that is not active, more than 8 notes superseded, more than 8 new notes or a note over 4068 bytes (4096, less the 28 the server's own origin and status lines may take once it merges the note), an index past 200 lines, hidden characters, and text shaped like a secret or holding one of the server's own secrets (the launcher's, the keys and tokens in its environment, its API and door tokens, each webhook trigger's signing secret as it is now), in a note or its name. A MEMORY.md the answer gives is ignored. What passes is kept as the run's proposal, runs/<id>/learning-proposal.json, with what the user said, each message cut of the server's secrets.

Then one grader decides, one grade at a time per agent: gradePrompt (src/learn/grade.ts), which holds GRADE_RUBRIC, the rubric's one statement, bounded (3 minutes), against main as it is then, and read by parseVerdict, which fails closed. It runs on the model the server grades on: --grade-model (or AGENTRUN_SERVE_GRADE_MODEL), else the strong tier (AGENT_MODEL_STRONG), named as the strong tier takes one; with neither, on the agent's own model. GET /capabilities names it as learning.grade_model (null for each run's own). A cheap model is a poor grader, so point the strong tier (or --grade-model) at a strong one. The grader reads each active note's index line, the whole text only of the notes the proposal supersedes, what the user said in the run (cut of the server's secrets, as the proposal keeps it), and the new notes, so its prompt stays under 200,000 characters however many notes the folder holds. No injection screen runs before it: a page classifier cannot see where a note came from, and read a preference the user stated as a planted instruction; the grader's Planted question decides. What the grade spends is in no run's totals: it is kept on the run's learning, and counted once on GET /usage under the meter learning, by agent and by day; a call cut off at its bound spent what nobody knows, so its cost is unknown, never $0.

A kept verdict merges as one commit on main, made with plumbing: the new notes added, each with its origin (you for what the user asked in their own words, run for what the run worked out), each note they supersede marked status: superseded with superseded_by, MEMORY.md rebuilt from the active notes, and the checked-out files brought in line. A risky rule (to trust a site or a seller, skip or loosen an approval or a check, or send money or data to an account), whether the user asked for it or the run worked it out, is merged as status: pending: in no MEMORY.md and no run's copy, to wait for its owner's OK, and named on the run's learning (pending: [{file, name}], detail "Waiting for your OK: .", after what it learned when it kept one too). GET /capabilities says so (learning.pending: true). Its message is learning: <run id>: merged, the grader's reason and a Run-Id trailer; main moves only from the commit the merge was made on (once more on a main that moved meanwhile), and a run a trailer already names is merged, never twice. The merge's own questions are asked again of main first: a name another run added since is refused as exists. A server that goes between steps picks the run up at its next start, once.

The run's learning (on GET /runs/{run_id}, and {state, reason, detail} in run lists, the detail cut to 140 characters and no time) says how it went: seeded, then reflecting during the turn and until what it answered is checked, proposed while it is graded, and merged, or dropped with a reason from a closed list (seed_failed, no_budget, unknown_cost, cancelled, crashed, reflect_timeout, reflect_failed, empty, unparsed, propose_failed, checker, grader, grade_failed; a run record or ledger line with any other reason is not counted). It is null for a run that does not learn (its agent's learning is not true, the server's switch was off when it started, or it is a workflow's or an imported run). Its detail says the same in a person's words, never the machine's: "Started from 2 notes.", "Looking back on this run…", "Checking 1 new note…", "Learned: Check units (replaces 1 older note)", "Learned: Check units. Waiting for your OK: Buy from PaperDepot24 first.", "Nothing new to remember from this run.", "Not kept: <the grader's reason, its first sentence>", "Not kept: it held something that must not be stored (one of this server's secrets).", "Not kept: its notes weren't in the expected format.", "Not kept: learning stopped (looking back took too long)." and so on (learning-words.ts); a client that acts on it reads state and reason. A note's name or the grader's reason in it is said again at each read from what the run keeps (its learned and pending notes, the grader's verdict), cut of the server's secrets as they are then before the words are cut short (300 characters, 140 in a list), so a value that became a secret after the run is never shown in part. Once it is proposed, merged or dropped (seed_failed included), the run's timeline has one row of kind learning whose summary is that detail.

Four more fields say what the run was given, read, learned and holds for the owner's OK (learning/2), on its read and in run lists alike, each absent when it holds no note: given, [{file, name}], the notes it started with (each its # Learned section names, whole or as an index line naming its file, as it was seeded; absent when it started with nothing learned); read, [{file, name}], the notes its job read from .learning/ (absent when it opened no note file), found once as the run settles from its timeline (each read call on any harness whose result was a success, of a note the run started from: never MEMORY.md, nor a read its end-of-run turn made, nor a note read through a command such as bash's cat) and kept in serve.json, so a list never reads a timeline; learned, [{file, name, supersedes}], the notes its merge added, once merged; and pending, [{file, name}], the notes its merge holds for the owner's OK, once merged, never among learned. A name and a file are cut of the server's secrets as they are now, a file holding one [redacted]. Whether a run applied a note is not measured: given and read say what it had and what it opened. Each note on the agent's learning view and notes page counts its runs of each, runs: {given, read}, over the runs the server knows (held ones from memory, ended ones from the learning ledger).

Each run's learning end is written once to the learning ledger, DATA/learning-usage.jsonl, one line {run, agent, state, reason, at, cost, tokens, seed_notes, given, read, answered_at, job_cost_usd, look_back_usd} (cost and tokens the grade's, null when unknown, 0 when nothing was graded; seed_notes how many notes the run started from; given and read the files of the notes it was given and read, each absent when there are none; once it looked back, answered_at and job_cost_usd, when its job's answer was final and what it had cost then; look_back_usd what looking back cost, 0 when it did not), after its serve.json holds it; a server that went between the two writes it at its next start. A run's notes are the one part that moves after its end (a follow-up reads another note): a line {run, agent, notes: true, given, read} then says them anew, the latest one winning, and counts neither the run nor its costs again. GET /stats counts the runs that tried to learn under learning, over its from, to and agent, from that ledger and from the runs the server holds (in_progress), never opening a run's files; the learning meter on GET /usage reads the same lines.

An eval set of 20 runs and what each proposed, labelled keep or drop (test/fixtures/learn-eval.json: lessons the agent worked out, user preferences and a "remember" during the task, one-shop facts, secrets, a tool's failure and four injections), runs in CI through the parser, the checks and the grader's prompt on a faux grader that answers by label (test/learn-eval.test.mjs). scripts/learn-eval.mjs runs the same set on real models, the grade and the end-of-run turn both, and prints keep-precision, keep-recall and the injection-drop rate per model; it spends.

What a person sees and controls

A person reads and changes an agent's learning through this server's own routes (learning/1 in /v2/openapi.json), with the token, an archived agent's too:

  • GET /agents/{agent_id}/learning: main's head, MEMORY.md as index, the first 50 notes (active first, then newest first) and the first 20 changes on main (newest first, each by: {run} from its Run-Id trailer or {user: true}, with its commit, time, reason and files), and how the agent's runs that tried to learn fared (merged, dropped by reason, in_progress), counted as GET /stats counts them: from the learning ledger and the runs the server holds, never a run's files; and impact (learning/2), what learning changed: the agent's completed runs that started from at least one active note against those that started before its first note (the earlier of its first merge and the start of its first run that started from a note, so a person's first note counts), each side's runs; median_duration_s, the time to the answer (the run's start to its job's end, before it looks back); median_cost_usd, the job's own cost without its look-back, over the cost_runs whose cost is known; and median_learning_usd, what learning itself cost a run (its look-back and its grade). From the same rows and ledger; null unless both sides count 2 runs. An agent with no folder answers main: null, empty pages and impact: null. GET .../learning/notes?cursor=&limit=&status= and GET .../learning/log?cursor=&limit= page on, up to 200 at a time; a cursor reads the same commit its first page did.
  • GET .../learning/files/{file}: one note (or MEMORY.md) on main as text/markdown, with its ETag, the note's git blob id.
  • PUT .../learning/files/{file} with the note's whole text (as text/markdown or text/plain, or as application/json {text}, the form the console's page sends under its session) edits it under If-Match: <etag>, or creates it under If-None-Match: * (201); POST .../learning/files/{file}/retire under If-Match sets status: retired, on an active or a pending note; POST .../learning/files/{file}/approve under If-Match makes a pending note active (see Approving a note that waits, below). An edit keeps it waiting: a PUT that drops or changes its status is 400 with the rule status, so a token holder cannot make a held rule active by writing it back. A note a person creates with status: pending waits the same way. A stale ETag, or a name already taken, is 412; neither header (or If-Match: *) is 428 precondition_required. The note passes the same structural checks as a run's (checkNote, in src/learn/check.ts), and no grader: a refusal is 400 with problems, and nothing is written. A retire is how a person revokes a note, so it checks the note's format alone: its front matter as retired, and the note cap with the status line on top. A secret shape or one of the server's values in its name or text never blocks it (its answer and the log are cut of them as any read is), and a leaking note is replaced by retiring it and creating a clean one. Each write is one commit on main in the agent's queue, learning: <edited, created or retired> by a person: <file> with a Changed-By: user trailer, with MEMORY.md rebuilt, main moved only from the commit it was made on, and the folder's files brought in line; it answers the note as it now is, with its new ETag. MEMORY.md itself is never written (400).
  • POST .../learning/files/{file}/restore under If-Match makes a retired note active again, and gives a retired skill (its SKILL.md) or workflow draft (its README.md) to every later run again: one commit, learning: restored by a person: <file>, with MEMORY.md rebuilt and the notes it supersedes marked superseded, as an approval marks them. Unlike a retire, a restore checks the text again as a new note or skill is checked (secret shapes, the server's values, hidden characters, its format, and every file of a skill's or a draft's folder): a refusal is 400 with problems, and the item stays retired. Only a retired item is restored (else 400). It takes the owner's password session alone, as an approval does; one that waited for its owner's OK before its retire is recorded as their approval too (Approved-Via: console). The learning view gives each item last_change ({kind, by, at}: approved, edited, created, retired or restored by a person, or learned by a run) and each retired one retired ({at, by}, the time of its retire, which an edit since moves not).
  • GET /runs/{run_id}/learning: the run's learning whole (its given, read and learned as on its read, its seed, its merge, the grader's verdict with its item for each new note, {file, keep, question, why}, question the rubric's question a dropped note failed, the checker's problems, what the grade cost), null for a run that does not learn; verdict is null until a grade ran. GET /runs/{run_id}/learning/diff: a merged run's merge commit against its parent, else its proposal against its seed, each file's patch as git writes it from the first @@, or withheld (too_large, secret).

Every read is the folder's snapshot (.agentrun/snapshot.json, written last by the same exec as each commit to main), the files it names, each checked against its blob id (a file that is not, read twice, is a write in flight: 503), and a merge's .agentrun/diffs/<run>.json, through the folder's file API: no git and no exec. Every text it answers is cut of the server's secrets as they are then; a file name holding one (a note named after a value made secret since, say) reads [redacted] whole, in the notes (file, superseded_by), the log's files and a diff's path, and a write's Location leaves it out, while its route still answers the real name to a caller who knows it. A page reads only its own notes' files, each once at its blob; a log page reads the snapshot alone (it keeps the newest 200 changes). A cursor reads at the main it names while this server still holds that snapshot, else as the first page of main now. Every write to main (a run's merge, a person's edit, create, retire, approve or restore) is one exec of the learning script, a compare and swap on main. A symbolic link at the agent's home, its learning folder or the folder's .git is never followed: a read or a write there answers 500, and nothing is written.

Approving a note that waits

Who can approve: the owner, signed in with their password. No token approves, the server's own included, so a run that reaches this server only through its API (its MCP tools, a sandbox run) can't approve its own notes. Runs that use bash on the host run as you: they can read this server's files, its session key among them, so no route keeps them from approving. For untrusted tasks, use the sandbox.

POST .../learning/files/{file}/approve (and .../restore, which puts a retired item back into every run) takes the console's password session, sent from this server's own origin with a JSON body as every page write is. Any token is 403: the server's own (what scripts and the MCP endpoint's tools carry), a run's door token, and a token sent beside the session. So is every request on a server with accounts off (AGENTRUN_ACCOUNTS=0), whose page cookie any token holder can mint. With no credential it is 401, as every route. capabilities.learning.approve is true only for the owner's session; every other caller reads approve: false with approve_needs: "password". A note that is not pending is 400; a stale ETag is 412, none 428. The approval is one host commit, learning: approved by a person: <file>, with the trailers Changed-By: user and Approved-Via: console; the log shows by: {user: true, via: "console"} at the commit's time, never the note's text.

Spend cap

A run may name max_cost_usd, an agentic run or a workflow run (below). A run that names none takes its agent's default, the max_cost_usd of the revision it runs, kept with the revision; a run's own cap wins, since its caller chose it. A definition's max_cost_usd above 0 sets that default, and 0 clears it (no default); anything else is 422. An edit that leaves the field out keeps the agent's default, as it keeps its harness and place: leaving a field out never removes a spend limit. A workflow definition keeps one the same way. The cap is checked in the run's own process before each model request, the agent's and a compaction's alike, on every harness: what the run has spent so far, plus what each request still in flight is held at, plus what this request is expected to cost must stay within it, or the request is not sent and the run pauses (below). A request costs its input, plus the output it is sent with at the output price, plus any fee its tokens do not show (below). How its input is priced is the run's cap_mode: the run's own (POST /agents/{id}/runs with cap_mode), else its agent's (a definition's cap_mode, kept by an edit that leaves it out), else expected; only a run with a cap takes one (422 not_allowed otherwise).

  • expected: priced from the previous request of its conversation, as that request's usage reported it. The input it read from the cache or wrote to it (cache_read_input_tokens and cache_creation_input_tokens on Anthropic's API, input_tokens_details.cached_tokens on the Responses API) at the cache-read price, if that request ended less than 4 minutes before (Anthropic keeps a cache 5); the rest (its input after the cache, plus what the conversation grew by since, one token per byte) at the higher of the input and cache-write prices. With no previous request in hand, or one older than 4 minutes, all of the input at that price. On claude-agent, codex and opencode the previous request is the session's last on the same model in the run's current process, so a run's first request after a restart is priced with nothing cached; on pi it is the conversation's last answer. Output never passes the cap (below); a request that misses the cache can, by at most its bound: its input at full price less its input expected. The run's cost.overshoot_bound_usd serves the bound of the requests in flight. A request's real cost is always booked, never clipped: a run that passed its cap so is not failed for it, its timeline says Went $X over its cap on a cache miss (at most $Y)., and its next request is refused, so it pauses at its cap as usual. A request whose cost never comes in (cut, stopped, lost) counts at what it was held at, its expected cost, in this process and after a restart alike.
  • strict: every input token at the higher of the input and cache-write prices (the provider may bill any of the input as a cache write), the worst case: no request passes the cap, and a run whose requests read the cache pauses well before it has spent its cap.

Near its cap, a run's model is told once to finish: when what is left (the cap less what was spent and what requests in flight are held at) is under three times the run's typical request, the next request of the run's own model carries "About $X left of this run's budget: finish now with your answer." at its end, and the Timeline shows it as the server's row ("AgentRun: About $X left …"). The typical request is the median of the run's own model's priced requests after the first (the first writes the cache), else that one, else, while nothing is priced, the next request at its input and a 4,096-token answer (never its whole output allowance: a $1 run on Sonnet 4.5 is not told at its start); side calls on another model and paid tools count in what is left, never in the typical request. Told while nothing is priced (a small cap, due at the run's first request), the words say the budget instead, on every harness: "This run's budget is about $X, enough for little work: answer as soon as you can." On pi the words go into that one request's context; on claude-agent, codex and opencode the model proxy adds them to the body (after everything the CLI sent, so its cached prefix is unchanged). Neither CLI nor pi keeps them in its transcript, and no message is queued, so nothing runs after the model's answer. Once per cap, across restarts; a raise re-arms it.

Under a cap each request is limited to the output tokens what is left of the cap can buy, thinking included, never more than it asks for or than the model's ceiling (its max output tokens, else its context window, clamped to what the window leaves): on pi through the request's own options (its max tokens, and on Anthropic's API its thinking budget), on claude-agent, codex and opencode written into its body by the model proxy (max_tokens and thinking.budget_tokens, or max_output_tokens). The limit is what it is held at, so the cap stays exact. A request is refused only when what is left cannot buy its least: 1,024 tokens for its answer (what it asks for, when that is fewer), and when it thinks, the provider's least thinking budget where thinking takes one (Anthropic's, 1,024, the budget squeezed to it at the least) or else its thinking level's budget, since its reasoning then shares the limit (minimal 1,024, low 2,048, medium 8,192, high 16,384). The input bound is one token per UTF-8 byte of what is sent (a byte-level tokenizer never makes a token of less than one byte, and an image's base64 is far more bytes than its tokens), plus what the provider adds server-side (a tool-use prompt, each image), from the same table the model proxy of claude-agent, codex and opencode uses; there the proxy measures each request's body. A request in flight holds what it was admitted at until it answers, so requests sent side by side cannot pass the cap together. Spent so far is read from the run's store each time its process starts, so it survives a restart, plus each response since, and with them each charge a paid tool booked (monid.run, which is itself refused once the spend reaches the cap; see monid).

The browser tool's session time is held the same way, before it runs, so no browser minute passes the cap. Its next stretch is the most a session can run before the run can check again: the provider's minimum (what its release may top it up to), pi-browser's idle window before it releases a session nobody calls (180 s), and 30 s for that release to be confirmed at the provider; at Browserbase's price, 270 s or $0.009. Before each browser call, the call is held at what its session ran since its last charge plus two stretches (one for the call's own time, one for a session it opens): spent, plus every hold, plus that must stay within the cap. Otherwise the call is not made: the model gets the cap's words (spend_cap_reached: Not sent: this run has spent $X of its $C cap, and the next step, this browser call, could cost up to $0.018 ...), and the run's session is released. A session's first charge moves one stretch of its call's hold to the session, held until its release is booked, which covers its idle time, its top-up and its release; each call's charge comes out of the call's hold, which goes when the call returns. So what the run spent plus what it holds stays within the cap through a session's create, its calls, the idle time between them and its release. A session a create's lost answer left to a release after the create deadline is held for that deadline and a stretch until its release is billed. Across a restart of the run's process, every session custody still tracks (kept, unreached, still releasing) is held from the reopen, at a stretch beside what it ran since its last charge, and one kept is released an idle window later unless a call uses it. Three things can still pass the cap, each by what it ran over, booked and shown: a single browser call that runs longer than a stretch, a release that takes longer than 30 s to be confirmed, and the time a run's process was down while it held a session (spent before any process could hold it). A session whose release is never confirmed is billed in full (what it ran and its minimum) when the run gives up on it. When the run is let go, its sessions are released and their last charges booked before its process exits.

Under a cap, whatever cannot be priced is refused, never passed as $0, and the run ends failed with spend_cap_reached, since no cap would send it:

  • A model serve has no price for (a gateway id pi-ai's catalog does not know, with no AGENT_MODEL_PRICE): its first request is refused before anything is sent, and the run ends spend_cap_reached at $0. With no cap its run's cost reads null. A model the catalog lists at $0 is a known $0, and passes any cap.
  • A model whose output is priced and that names no output ceiling (no max output tokens and no context window): nothing bounds a request. One whose output costs nothing at every price tier keeps a known worst case, and passes.
  • An input that could not be measured.
  • A spend no one knows: a cost that is not a number, a cost still pending, and on pi a request a stop or a crash cut with no held figure (below). Every later request of the run is refused. claude-agent and codex runs also continue after a cut, at the worst case the cap held, as pi does.

A request you stop counts what it streamed plus up to 1,024 output tokens the provider may have produced after the last word we read; never more than it was held. That is its input bound and each byte of content it streamed (its text, thinking and tool calls on pi; on claude-agent, codex and opencode, the text, reasoning and tool-argument deltas the response sent, never the provider's framing around them, which runs to about 12 bytes a character on the Responses API and 24 on the Messages API) as a token, plus 1,024 tokens of output (STOP_ALLOWANCE_TOKENS), at the model's price, and at most the worst case the cap held for it. The cut stream's own usage is never read: it is not what the provider billed (the Responses API reports none until the end, and Anthropic's output count comes at the end). The 1,024 tokens are a stated bound, as the native search allowance is: a provider that went on producing for longer after the stop could bill past what is counted. A request that ended on an error after its response began (an overload part way, a reset), or a summary that failed, still counts at its worst case, and so does a request cut by a crash (its process gone mid-request). A request stopped before the provider's first header counts its input and the 1,024 tokens, on every harness.

Reasoning the provider never streams counts too. A model whose reasoning streams only as a summary (the Responses API's reasoning; Claude 4-class thinking, and redacted thinking) or not at all is billed for far more than the summary's bytes, so a stopped request that reasons also counts the part of its reasoning budget that did not stream: input + streamed + 1,024 + max(0, reasoning budget - reasoning streamed), never more than the hold. Reasoning streamed is its thinking's bytes on pi, and on claude-agent, codex and opencode the bytes of the reasoning events the response sent (a thinking delta, a reasoning summary delta); answer text never stands in for it. The budget is its effort's or thinking level's (minimal 1,024, low 2,048, medium 8,192, high 16,384; on Anthropic's API, the budget_tokens it was sent with). A Claude model whose thinking is adaptive (pi-ai sends every current one thinking: {type: "adaptive"}, no budget) thinks within its output limit as it sees fit, so on pi it counts that limit less 1,024 tokens, as claude-agent counts max_tokens, and is sent the limit it is held at. With no effort or level given, on an API whose model then reasons at its own default (OpenAI's), it counts its whole output limit, the conservative side; with thinking off on Anthropic's API, none, unless the model thinks adaptively at any level (pi-ai's models with mid-conversation effort). On claude-agent, every request that thinks counts its budget, since Claude 4-class models stream only a summary of their thinking; thinking that streamed is subtracted, so a stop after a budget's worth of visible thinking counts nothing on top. The figure is an estimate: an effort's budget is not a limit the provider keeps, so a request can reason past it. It is bounded by the request's hold, and its real usage replaces it when that comes.

So on a model whose thinking is adaptive (every current Claude model, on pi and on claude-agent), a stopped request counts up to its hold: the provider is given no thinking budget, and what streamed says nothing of the thinking billed. That whole budget is the safe figure, kept on purpose: the stream reports the thinking billed only on its final message_delta (usage.output_tokens_details.thinking_tokens), which a stopped stream never receives, and the live thinking_delta.estimated_tokens is a display hint that can under-count. Under a cap with little left beside that hold, the request after the stop can be refused, and the run then pauses for a raise.

On pi the figure is written on the request's start mark (agentrun.request_started, its worst case) and on the end mark of one stopped (agentrun.request_ended, what it counts at), as heldUsd; on claude-agent and codex on the request's runner.request record (its worst case) and, for one stopped, on its runner.turn. A later process counts it the same: a capped run stopped and sent another message, or opened again after its process died, goes on while its cap allows. A cut request from a store written before serve kept the figure has none, and is a spend no one knows.

A refusal says so in plain words, with the numbers, on its timeline row and on the run's pending (or, for a refusal no cap fixes, its error), as a person who has never seen a model request reads them: "Not sent: the next step needs at least $0.03, and this run's cap is $0.01. Raise the cap to at least $0.03 to continue.", and once a run has spent: "Not sent: this run has spent $0.45 of its $0.50 cap, and the next step needs at least $0.03. Raise the cap to at least $0.48 to continue." What was spent, and what steps still running hold (named when it is a cent or more), read to the nearest cent ($0.0237416 reads $0.02); the exact amounts are pending.spent_usd. The cap to raise to is what was spent, what steps still running hold, and the next step's least, rounded up to the cent so that it is enough. The words name no model. The refusal's timeline row is an error row with paused: true: the request was not sent and the run waits on a raise, so it reads as a pause, never as a failure. The error's code is spend_cap_reached (out of budget): before those words: pi-ai's retry reads only an error's text, and takes "out of budget" as final whatever digits the words hold. A request the cap refused was never sent: the run's usage counts no request for it, and a run whose only request was refused says "no model request sent". A reply the limit cut short says so on the run's timeline: "The reply stopped at 1,500 tokens: all that the $0.15 left of the run's $0.50 cap could buy."

A run paused at its cap

A refusal for room (what is left of the cap cannot buy the next request's least) pauses the run instead of ending it: the refused request was not sent, so the cap holds, and a higher cap would send it. The run is input_required and waiting, error null, with pending: {kind: "budget", cap_usd, spent_usd, needs_usd, least_cap_usd, suggested_cap_usd, said, at}: its cap, what the cap counted as taken (spent, and held for requests still answering) and the least the refused request needs (a cap above their sum sends it), the least cap that sends it, the raise it is offered, the refusal's words above, and when it came. least_cap_usd is the very figure the words name ("Raise the cap to at least $X"), in whole cents and above the cap, carried from the refusal that wrote them, so a client shows it without parsing the sentence. The offered raise, suggested_cap_usd, is spent_usd + 10 × needs_usd rounded up to a whole dollar, and always above the cap, so a PATCH with it (below) resumes the run; it is absent with the other figures. The figures come from the refusal's own start mark, so a server started again says them too, shown as cost.usd is (to a millionth of a dollar), except spent_usd once the paused process is gone: the refusal counted requests still answering at what it held for them, so then it is the store's spend, when that is lower, and suggested_cap_usd follows it. The refusal's words (said) keep its own figures. Its timeline shows the refusal's row, the server's phase row "Paused at the run's cap" (origin server, its id ending :note, so it is no message) marked paused (never an error row; it stays once the cap is raised, as the run's history), then a waiting row: "Waiting for you to raise the cap", and no more: the words are pending.said's, which a client shows once. The webhook's run.needs_input says pending.kind: "budget". Its process is let go while it waits (SIGTERM: it drains and releases the run), so a paused run holds no place; a server started again reads it paused from its store. A paused process that does not let go within the drain timeout (--drain-timeout, 30 s by default) is killed, and the run ends failed, as any run whose process is killed does, unless its cap was raised meanwhile: it held nothing, so the raise's follow-up goes (below). A message to a paused run is 409 budget_reached; a stop does nothing more, and a cancel ends it cancelled. Only a refusal for room pauses a run: one no cap fixes (above) ends it failed, as does a cost found past the cap after a response.

A paid tool call (monid's run, a search or a fetch through Parallel, browser time) that the cap refuses for room pauses the run the same way. The call is not sent, and its result says so in the cap's words. The model's next request is refused in those words, so the turn ends there and the model gets no turn to work around it. The run's pending holds what was taken (spent_usd), the call's hold as what it needs (needs_usd), and the raise it is offered. A raise sends the run on, and the model makes the call again if it still needs it: each call the model makes is one call, and nothing is retried for it. The refusal answers that turn's next request alone: a stop in between, then a message, starts a turn that is judged as any other. A paid call refused because what the run has spent is unknown still ends the run failed.

PATCH /runs/{run_id} with {max_cost_usd} raises the cap, and only raises it: the new cap is above the run's current cap, its last raise included, by more than a float's tail (1e-9; 422 otherwise, and for a run with no cap; 409 not_continuable for an ended run). A workflow run's raise is below. The same raise sent again (a retry), within that tail, changes nothing and answers as the first did. The run's serve.json keeps the raise before any answer, with each raise in cap_raised, so its next process opens under it, as does a server started again; a raise it could not keep is not taken (an error answers it), and the same raise again is taken anew. A run paused at its cap goes on: the answer is {run_id, max_cost_usd, resumed: true, message_id, status, state} at once, and the pause gets one follow-up turn, "The cap was raised to $2; continue the task.", under that message_id, which the server sends once the process being let go is gone, and which runs under the new cap. From the moment the raise is taken, the run reads queued (state working), in the answer's status and in every read after it, until the follow-up's turn runs: nothing waits on a person, so it is never input_required with no pending meanwhile. A second raise before it goes raises the cap it names and answers the same message_id. It is sent once: a server stopped before it went, or between starting its process and sending it, sends it when it starts again. One that cannot be sent is tried again (after 1 s, then 3 s); after 3 tries the run ends failed, saying the cap was raised but the run could not be sent on. The follow-up shows in the run's timeline as the server's, "AgentRun: The cap was raised…", never as the person's message (its submit names the server as its origin, cap_raised). A working run's live process keeps the cap it was opened with until its next one, and the answer's note says so. The raise is keyed to the refusal's own start mark, so a row shown after the refusal never reads the run paused again.

A request carrying a native web search tool reads pages into its input that its body does not hold (the provider bills them as input): under a cap its input bound is its body's plus an allowance. Claude's own search, an explicit native pick, does not run under a cap: each search has a per-use fee the cap cannot reserve yet, so a capped claude-agent run picking it is refused, and on auto it searches through Parallel on the server's key (a paid call the cap holds) or else keyless Exa, as a capped codex run does; pi's own search request is held at its context plus 125,000 (5 searches) and its most fee, 5 searches at $0.01. What the request cost, booked once it answers, stops the next request through the cap, and a run whose booked spend passes its cap shows one timeline row saying by how much (Spent $X, $Y over the run's cap of $Z, in cents).

The server also checks the run's cost after each response, as a backstop, and stops a run whose cost passed its cap. Either way the run keeps the answer it paid for: a run the cap stopped has result: {output: null, final_answer}, the newest answer it received, beside its error; one stopped before any answer has result: null.

A run's cost.usd is $0 before its first model request (a run cancelled before one included), and null when a response reported no price or a request was cut before its end (a stop, a cancel, a crash): no request costs nothing, but a request of unknown price is unknown, never $0. A stopped request shows null whether or not its cap counted it at its held worst case.

A workflow run under its cap

A workflow run is capped by the same checks, in its own process, each before the paid step is sent:

Step Held at Where
A model node's request its expected cost, as an agentic run's request the run's models (cap.ts capModels)
A call node's llm call the same the same, as a side request
A call node's paid tool call (Parallel's search or extract) its fee paidCall, as an agentic run's
A decision on Parallel its list price for one request, $0.001 before the driver admits it (decision-cap.ts)
A decision on Jev TypeSafe's published price, $0.042 a million input tokens, one token a byte of its state and questions the same
A decision on an OpenRouter Decisions model its catalog price: per request, per image, per input token, and per output token for 256 a question (expected) or the catalog's most (strict) the same

What a decision charged is booked in the run's store as the cap counted it, so a run opened again counts it once. A step the cap has no room for is not sent and waits where it stands: the run is input_required, state waiting, with pending.kind budget and pending.node the node's label. A model node's request waits unsent; a call node's call ends its effect held, sending nothing, and goes again as a new effect once raised, never as the tool's own failure, which a map that collects failed items would take for an item's; a decision waits before the driver admits it, so it is never a request asked and unanswered. What a call node waited is not counted against its deadline. A pause holds the run's process for 10 minutes, then lets it go (nothing in flight but the waits): the run stays paused, and no process is started for it. PATCH /runs/{run_id} raises the cap as for an agentic run, with no follow-up turn: a waiting process judges its step again under the new cap, and a run whose process was let go is opened again under it, resuming from its journal, so no completed node is sent again (resumed: true, no message_id). A capped start is refused before anything is sent, as an agentic run's is, when a model one of its nodes or llm calls runs on has no price here, or a decision pins an OpenRouter Decisions model the catalog lists no price for: 409 agent_not_runnable, each in error.fields with the node's label and its fix. A step no cap fixes all the same (a spend no one knows) ends the run failed, spend_cap_reached, in the cap's words, after the node's label.

The faux model

--faux MODULE loads MODULE in each run's process. Its default export is called for every model request with the transcript (context.messages, pi's messages) and helpers, and returns pi's assistant message:

export default function reply(context, faux) {
  const asked = context.messages.filter((m) => m.role === "user").length;
  if (asked === 1) return faux.message(faux.toolCall("ask_person", { question: "Which region?" }), { stopReason: "toolUse" });
  return faux.message([faux.thinking("They answered."), faux.text("Using that region.")]);
}

startServe({ faux, fauxBrowser, fauxWeb }) also names modules whose default exports give the browser tool's { provider, driver } (fauxBrowser, which the CLI takes as --faux-browser MODULE; the others are the tests' alone) in place of Browserbase's or Kernel's (pi-browser's fake backend, told the vendor the run's plane names and the saved sign-in the run was handed: test/fixtures/faux-browser.mjs), web_fetch's direct fetch's { lookup, fetchImpl } in place of the system's resolver and undici (a table of names and pages, test/fixtures/faux-web.mjs), and, as fauxMonid, the fetch the monid tools send through in place of monid's API (test/fixtures/faux-monid.mjs). Each is honored only with faux.

faux.message, faux.text, faux.thinking and faux.toolCall are pi-ai's faux helpers; faux.signal is aborted when the turn is stopped. A workflow's model node offers the submit tool (in its system message's toolsAdded), and delivers its record by calling it. A module may also export cost ({input, output, cacheRead, cacheWrite} in USD per million tokens) to price the faux usage; unpriced, every run's estimate is $0, and with cost = "unknown" its usage carries no cost at all, as from a provider that does not price the model. The tests' module is test/fixtures/faux-model.mjs.

Safety

  • An import (agentrun-serve import, and setup's import) cuts secrets out of everything it writes: the values of the environment's key-shaped variables and the keys saved on this server, the secrets this server made and keeps (its API token, its door token, its sign-in secret, and its webhook's signing secret when the seal key opens it; when it does not, the CLI's import says so once, on stderr, and goes on), and the keys secretlint's recommended rules find in what it reads (Anthropic, OpenAI, GitHub, Slack and Stripe keys, AWS secret keys beside their name, private keys, database URLs with a password, and more). Each becomes [redacted], and the import says how many distinct values it removed. Known key formats are removed. Other secrets in your session history, like passwords in config files, are imported as they are. So are a key-shaped value this server did not make (a whsec_ secret from another service) and a run's own door token.
  • The decision keys (TYPESAFE_API_KEY, PARALLEL_API_KEY, OPENROUTER_API_KEY as Jev's or OpenRouter's) are never in a run's environment or files: a run's process gets on a pipe of their own only those it needs, for its decision providers (decision workflows) and its web tools: Jev's (by its route) for a node that names Jev or none, Parallel's for a node that names Parallel or for a web_fetch or web_search that resolves to Parallel (search and fetch), OpenRouter's for a node that names OpenRouter. A run that needs none gets no pipe.
  • web_fetch's direct fetch dials public addresses only, each hop checked and pinned (see Tools): a run cannot reach this machine, its network or a cloud's metadata address through it.
  • A browser session runs at Browserbase or Kernel, not on this machine: a page the model opens is reached from that vendor's network, so what the model may reach there is the operator's choice of vendor, keys and project. Its downloads and uploads stay in the run's work/. An agent's saved sign-in stays at the vendor; this server keeps a sealed pointer to it, which a run's process gets over its pipe and its tools and model never see. Anyone holding the vendor's key can open that context or profile there.
  • A run's live browser view is a full-control credential for its session: once taken over, it drives the browser. Its token lives in the run's process, in one 0600 file in the run's directory (never work/) while the session is open, and in the owner's answer to GET /runs/{id}/browser, which takes the owner's password session alone, as the hand-back does. The viewer's WebSocket is taken only under the run's path, from this server's own origin, with the owner's session; its page is framed by this server's own origin alone; outside a take-over its input is dropped by the run before the browser; the vendors' own live-view links never leave the run's process.
  • The viewer's code always comes from this server: its page, script and style are the files of the pi-browser this server has installed, served under script-src 'self', never anything a run's port answers. A run's port only ever carries the viewer's WebSocket, which is data, so whatever answers on a run's port never runs script on the console's origin.
  • The owner's time in a run's browser counts toward its cap and is held before it starts: a take-over, and an agent's ask, hold 15 minutes of session time at the provider's price (once per session, shared by a take-over and an ask on it), refused in the cap's words without room. A take-over that steady input keeps alive past 15 minutes is held again every 15 minutes; with no room the browser goes back to the agent, in the cap's words.
  • It binds 127.0.0.1 by default, and there refuses a request whose Host names something other than this machine (DNS rebinding): localhost, 127.0.0.1, [::1], the AGENTRUN_PUBLIC_URL name and each --allow-host name are served, on any port, so a tunnel (ssh -L 8080:localhost:7340) or a proxy in front works; any other name is 403, a page in a browser and a JSON error for a script, each saying which names work. On another address (--listen, the container's 0.0.0.0) it serves every Host name, and the token is its only lock. It sends no CORS headers, so a page on another origin cannot read an answer.
  • Every route but GET /capabilities needs Authorization: Bearer <the token>, compared in constant time, or the browser's cookie under the rules of the console. The console's own files (its HTML, scripts, styles and fonts) hold no data; everything it shows comes through those routes. It loads nothing from the network, and its CSP allows this origin only.
  • Each run's door listens on loopback and takes only that run's own door token (DIR/disk/agents/<agent>/runs/<id>/door-token, 0600, made at its first launch); a run's process an older server started keeps the server-wide one (DIR/.door-token).
  • A run's process gets the server's environment (the AGENT_MODEL variables among it) without the server's token or any key: no AGENTRUN_TOKEN, no AGENTRUN_API_KEY, no variable whose value is the token, and no variable named like a key (one of its _-separated segments, in any case, is KEY, TOKEN, SECRET, PASSWORD, PASSWD, PASS, PAT, CREDENTIAL, CREDENTIALS or DSN, or its last is PWD after another: SERVICE_KEY, GITHUB_TOKEN_RO, DB_PASS, SENTRY_DSN, DB_PWD; never PWD or OLDPWD), whether the launcher's list names it or not. The rule is pi-durable-disk's (@parcha/pi-durable-disk/secret-name), which an import's cut and a harness's log take too. The keys it needs come over its pipe. It gets none of the boxes' keys (Boxes). A tool key reaches it only when its revision names a tool the key unlocks (MONID_API_KEY a monid tool, BROWSERBASE_API_KEY and KERNEL_API_KEY browser; a workflow's tools are those its model and call nodes name), and a model key only when its model needs it (Models), read from the revision at each launch, a relaunch or a resume after a restart included.
  • A variable named like a key that is not on the launcher's list (capabilities.secrets.names) is left out of every run's environment, but the launcher does not take it out of the server's: it stays in the server's own environment (/proc/<pid>/environ), which any process of the same user can read. Set only the keys the server needs where it starts.
  • A credential a provider's SDK finds through a variable named like a key is not supported for runs: no run's environment holds the variable, and the SDK reads it from there alone. For Amazon Bedrock that is a web identity token file (AWS_WEB_IDENTITY_TOKEN_FILE) and a container role (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, AWS_CONTAINER_CREDENTIALS_FULL_URI, AWS_CONTAINER_AUTHORIZATION_TOKEN or its _FILE); for Vertex, GOOGLE_APPLICATION_CREDENTIALS; for Anthropic's workload federation, ANTHROPIC_IDENTITY_TOKEN_FILE. Bedrock's access keys or bearer token (held, and handed to a run on Bedrock), an AWS_PROFILE, GOOGLE_CLOUD_API_KEY or the default application credentials file work. A credential file a provider reads by path (AWS_PROFILE's ~/.aws files, the default application credentials) is the host's: a run's process reads it for its model, and its file tools cannot.
  • TYPESAFE_API_KEY is a decision key, handed to a workflow run's decision providers on a pipe of their own and never with a run's other secrets, so a pi run on a typesafe/ model does not get it: its process finds no key for that model, and the run fails.
  • A run's file tools (read, write, edit) reach its own work/, and two places of its own beside it: read also reaches the run's tmp/ (next to work/, where a command's spilled output would go), and a write may also land in a temporary directory the run's environment made for a tool (createTempDir). Every other path, a link inside work/ included, is resolved and refused (outside the workspace), so a run reads neither the server's token, the door token, store.sqlite, another run's files (its work/ or its tmp/) nor /proc, and writes nowhere else. This is pi-durable-disk's confinement inside the run's process, not an operating-system boundary: the run and the server share a user (one uid in the container), so the confinement holds only because the run has no shell on this machine.
  • No path offers a shell on this machine. On the runner bash is not bound: a definition that names it is not saved here (422), one saved before that check is not runnable (409), and a model that calls it anyway, as an agent or as a workflow's model node, is answered that the tool is not available and runs nothing. In a sandbox it runs in the run's box, which binds the run's work/ alone, has no network, and holds no key or token.
  • Files are served from a run's work/ and an agent's home only. A path with an empty, . or .. segment, a backslash or a NUL is 400 invalid_file_path; a segment that starts with . (the run's skills under .agents/) is 403 system_file_forbidden; a symlink on the way is 404 (a write or a delete in a home through one, 403). Every answer is nosniff, and HTML carries a CSP with sandbox and default-src 'none'.
  • The launcher hands the server every secret on its list that is set over one pipe, which holds 4,096 bytes for sure before the server reads it: each set secret costs its name, its value and two bytes. Past that it refuses to start, saying the secrets come to N bytes, more than the 4096 a pipe is sure to hold, and starts nothing. A long value adds up fast (an AWS session token is often over 1,000 bytes): set only the keys the server needs where it starts.

Known gaps

  • The provider keys are checked against pi-ai when this package is tested, not derived when the server starts: the launcher's list and PROVIDER_KEYS match the pi-ai this package is tested with (its peer range is *, as Pi's package guidance asks). The drift test fails, naming the provider and the key, on a pi-ai that reads a key neither names. A pi-ai installed past that test that reads a key neither names would leave it in the server's environment and out of every run's, and a run on that provider would fail at its start for want of the key.
  • The cut is by name alone. A variable named like no key whose value is one reaches a run's environment and is not cut from what an import or a harness's log keeps: a URL with a user and password in it (DATABASE_URL, HTTPS_PROXY), headers that carry a token (OTEL_EXPORTER_OTLP_HEADERS), or a held key's value under another name.
  • The 4,096-byte pipe limit stays until the launcher writes the pipe from a background subshell that blocks until the server has read it.
  • The browser's evidence is numbered from the highest number the run's evidence/ holds when its browser first files there, through the run's Workspace, which has no exclusive create. One browser per run files there, so two never pick one number; two filing into one run's evidence/ at the same moment could.

Test

npm run build -w @parcha/agentrun-dsl -w @parcha/agentrun-pi -w @parcha/pi-durable-disk -w @parcha/pi-durable-agent -w @parcha/agentrun-serve
npm test -w @parcha/agentrun-serve

The tests start real run processes on the faux model, both kinds: no key, no network.