foldrun 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/foldrun.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  // foldrun approvals what is waiting for a person, anywhere in the account
18
18
  // foldrun approve <run-id> release a waiting gate (asks first) — reject refuses it
19
19
  // foldrun stop <run-id> kill a run in flight
20
- // foldrun account the account's own defaults — timezone, notify, budget, concurrency
20
+ // foldrun account the account's own defaults — timezone, notify, budget, concurrency, deploy_block, network_enforce
21
21
  // foldrun schedule every flow in the account that fires on a clock, and when it fires next
22
22
  // foldrun triggers why nothing ran: fired vs started per flow, and each reason
23
23
  // foldrun webhooks <verb> deliveries / redeliver <id> — the notify: webhooks a workspace sent, and each attempt
@@ -101,11 +101,12 @@ const HELP = `foldrun — agents are just folders
101
101
  foldrun flow new <name> one more flow — its first step names an agent you already have
102
102
  foldrun flow new <name> --platform make it in the deployed workspace from a shape (--pattern pipeline|review-loop|fan-out|debate|router, --to)
103
103
  foldrun tool new <name> one more tool: a folder with its program beside it (--transport, --language; --platform in the deployed workspace, --template <gallery entry>)
104
- foldrun agent ls the agents deployed in a workspace: model, description (--to, --json) — flow ls the flows and how each fires
104
+ foldrun agent ls the agents deployed in a workspace: model, last run, owner, description (--to, --json, --stale [days]) — flow ls the flows and how each fires
105
105
  foldrun check [dir] validate every workspace here, and the shared library
106
106
  foldrun check --to <ws> validate the DEPLOYED copy instead — fetched from the platform, checked here
107
107
  foldrun check --security only the security rules: hard-coded secrets, hidden text, agents that can leak, unpinned code — fails on a warning
108
108
  foldrun check --rules every rule check knows: id, level, what it guards against (--json)
109
+ foldrun check --to <ws> --online also the network checks, run by the platform: known vulnerabilities for exact runtime pins (OSV) and remote MCP servers' tool text — and what could not be checked
109
110
  foldrun agent run <name> run one agent once on the platform (--task "…", --to, --wait, --test)
110
111
  foldrun agent link <agent> add to its team, in its frontmatter: --subagent <worker>, --consult <agent> or --can-ask (--description for a worker with none)
111
112
  foldrun agent unlink <agent> take one away: --subagent <name>, --consult <name> or --can-ask
@@ -134,24 +135,34 @@ const HELP = `foldrun — agents are just folders
134
135
  foldrun hook rm <name> delete a hook folder — says which agents still turn it on, asks (--yes, --library)
135
136
  foldrun extract [dir] move single-file script tools into folders (tool.md + run.*)
136
137
  foldrun run <target> run an agent or flow HERE (target: name, or flow:name) — on the platform: flow run / invoke a flow, agent run one agent
137
- foldrun eval [name] run one eval, or all of them (--to <workspace>: the deployed evals, run on the platform)
138
+ foldrun eval [name] run one eval, or all of them (--to <workspace>: the deployed evals, run on the platform; --last reads their latest results, --run <id> one run's verdict, running nothing)
138
139
  foldrun eval new <name> a new eval in the deployed workspace, its agent the first one there — edit its cases, then eval <name> --to (--to)
139
- foldrun promote <run-id> keep a finished run as a regression case in evals/ (--eval <name>, --expect "contains: …" repeatable, --case <name>)
140
+ foldrun promote <run-id> keep a finished run as a regression case in evals/ (--eval <name>, --expect "contains: …" repeatable, --case <name>); a run that went wrong takes --should "what it should have done"
140
141
  foldrun probe <model> live check: can this model hold a tool loop here?
141
142
  foldrun logs [run-id] recent runs in one workspace, or one run's full event trail (--local: this machine's)
142
- foldrun runs what has run lately, across the account — --status, --verdict, --since, --to, --limit (--local prints exactly what logs --local prints)
143
+ foldrun runs what has run lately, across the account — --status, --verdict, --outcome (good, done, quiet, blocked, bad, failed, needs-you, …), --since, --to, --limit; search with --tool, --tool-error, --agent, --model, --min-tokens 50k, --min-secs, or --view <name>
144
+ foldrun runs views your saved run searches (--local prints exactly what logs --local prints)
145
+ foldrun runs doctor <run-id> the run doctor: the assistant reads a failed run and the files it names, says why (platform, check, work or outside) and proposes the fix as diffs, saved on yes (--to, --yes, --dry-run, --json)
143
146
  foldrun runs rm <run-id> delete a run's record and archived outputs; a live one is stopped first — asks first, --yes means it
144
147
  foldrun report <run-id> one run, whole: header, every step, what it wrote and what is still waiting
145
148
  foldrun report <run-id> get <agent>/<path> download a file the run archived from that agent's outputs/ (--file <path>|-, --force)
149
+ foldrun report <run-id> --compare [<run-id>] beside the flow's last good run (or the one named): changed files and what differs step by step (--json)
150
+ foldrun report <run-id> --tree the run as one tree: step → model call → tool call, with what went wrong marked (--json)
151
+ foldrun report <run-id> --calls also every model call, and the tool calls whose input and output were recorded
152
+ foldrun report <run-id> call <call-id> one tool call's recorded input and output, secrets masked (--json)
146
153
  foldrun report <run-id> live the live browser view: which agents have a frame; --agent <name> saves the newest as a JPEG (--file <path>|-)
147
154
  foldrun approvals every gate waiting on a person, with its question (--to <workspace> for one)
155
+ foldrun insights recurring problems found in the runs, with what they cost and what to do (--days, --to, --json)
156
+ foldrun issues what went wrong, grouped, open until resolved (--to <workspace>, --all, --status, --json)
157
+ foldrun issues show|ack|snooze|resolve|reopen <id> one issue — snooze --for 1d or --times 5, --note "…"
158
+ foldrun issues doctor <id> the run doctor on the issue's newest run, in its workspace: why, and the fix as diffs, saved on yes (--yes, --dry-run, --json)
148
159
  foldrun approve <run-id> release a waiting gate — asks first, --yes means it, --note "…" steers the step; --payload releases a wait: event step
149
160
  foldrun reject <run-id> refuse one, with --note as the reason
150
161
  foldrun stop <run-id> kill a run in flight — asks first, --yes means it
151
162
  foldrun stop --status running [--since 2h] [--until 1h] [--flow f] every run a filter matches (runs/bulk): lists them, asks, stops only those (--dry-run, --to)
152
163
  foldrun answer <run-id> "…" answer the question an agent is asking mid-step (--option <n> picks a choice)
153
164
  foldrun message <run-id> "…" say something to a running agent — it hears it after its next tool call
154
- foldrun rerun <run-id> run it again from a step (--from <n>) or from an agent's step (--agent <name>); --wait
165
+ foldrun rerun <run-id> run it again from a step (--from <n>) or from an agent's step (--agent <name>); --instruction "…" tells that step something else this once; --wait
155
166
  foldrun rerun --status failed [--since 24h] [--until 2h] [--flow f] rerun every run a filter matches, from --from <n> (default 1) — lists, asks (--dry-run, --to)
156
167
  foldrun observe where the account fails, retries and spends, per workspace (--to <workspace> in full, --since <days>, --json)
157
168
  foldrun usage what the account consumed, and what it was charged week by week (--days <n>, --json)
@@ -172,10 +183,11 @@ const HELP = `foldrun — agents are just folders
172
183
  foldrun billing card Stripe's page for saving a card for auto top-up (owner only)
173
184
  foldrun billing credit <usd> put credit on the balance by hand (--note "…") — only on an install without Stripe (self-hosted, dev); refused once billing is live
174
185
  foldrun gallery the platform's built-in tools (web) and whether you keep your own copy — also pull (a copy for offline runs), upgrade <tool>
175
- foldrun account the account's defaults — also set <key> <value>, clear <key>, providers (--check) (singular; accounts lists logins)
186
+ foldrun jobs the four jobs — governance, guardrail, observability, evaluation — and what each has set (--to <workspace>, --json)
187
+ foldrun account the account's defaults — also set <key> <value>, clear <key> (timezone, notify, budget, concurrency, deploy_block none|secrets|security, network_enforce on|off, owner, alerts <threshold> <value>, judge model|effort <value>; or <job>.<field>, e.g. governance.owner), pause [--reason] [--stop] / resume (the kill switch), telemetry [set <url> --header name=\${SECRET} | clear | test] (runs as OpenTelemetry traces), providers (--check) (singular; accounts lists logins)
176
188
  foldrun account export everything the platform holds about the account as one JSON file (owner only; --file <path>, --force)
177
189
  foldrun account model the account's own model key — set <provider> --key <api key> (--key - reads stdin; custom --base-url --format), remove
178
- foldrun workspace one deployed workspace's settings (--to) — also set <name|description|timezone|budget|notify> <value>, clear <key>
190
+ foldrun workspace one deployed workspace's settings (--to) — also set <name|description|timezone|budget|notify|network_enforce|owner|alerts|judge> <value> (or <job>.<field>), clear <key>, pause [--reason] [--stop] / resume (the kill switch)
179
191
  foldrun workspace vocabulary the names the dashboard's editor completes in a workspace: agents, flows, skills, tools, secrets, scripts, types, documents (--to, --json)
180
192
  foldrun notify test send one test notification from a workspace and say what happened (--to <workspace>)
181
193
  foldrun webhooks deliveries the notify: webhooks a workspace sent, newest first — status, attempts, last answer (--failed, --status <s>, --event <e>, --limit, --offset, --to)
@@ -293,7 +305,11 @@ Options
293
305
  --step <n> approve, reject: decide only that step; default is every step that is waiting
294
306
  --payload <json|text|@f> approve: release a step waiting on wait: event with this body, recorded as you
295
307
  --note "<text>" approve, reject: guidance the agent reads — or the reason for a refusal; billing credit: the ledger line's note
296
- --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, assistant, history restore, restore <ws>, import, billing plan, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
308
+ --run <id> eval --to: that eval run's verdict, by the id the run printed; runs nothing
309
+ --stale [days] agent ls: only agents not run in that many days (never run included); bare, those past alerts: stale_days
310
+ --reason "<text>" account pause, workspace pause: why — shown to everyone who opens it
311
+ --stop account pause, workspace pause: also stop every run going now (asks; --yes to mean it)
312
+ --yes approve, stop, rerun (bulk), pause --stop, flow rotate-hook, flow draft, assistant, history restore, restore <ws>, import, billing plan, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
297
313
  --json report: the raw run record instead of the report; observe, usage, api version, changelog, find, preferences, workspace vocabulary, flags, workspaces, workspaces show, agent ls, flow ls, agent import --list, backups requests, team, hook ls: the raw document; version: {cli, core, platform}
298
314
  --events <a,b> account set notify: failed, awaiting-approval, completed
299
315
  --failed webhooks deliveries: only the ones that gave up
@@ -365,7 +381,7 @@ if (!command || command === "--help" || command === "-h") {
365
381
  // `--value` as the account's argument and stored an empty secret; `--force
366
382
  // ./dir` swallowed the directory. A flag followed by another flag is also
367
383
  // boolean, so an unlisted switch at least does not eat its neighbour.
368
- const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules"]);
384
+ const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules", "calls", "tree", "tool-error", "stop", "last"]);
369
385
  // Flags said more than once collect into a list: `--tag a --tag b`.
370
386
  const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove", "rule"]);
371
387
  // `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
@@ -477,7 +493,11 @@ function pinned() {
477
493
  return null;
478
494
  }
479
495
 
480
- const workspace = pinned() ?? here;
496
+ // `init` makes a new account in the directory it is given. It never resolves
497
+ // to a workspace above it: a home directory holding some unrelated `agents/`
498
+ // (a git repo of that name) passes the workspace test, and `init` anywhere
499
+ // under it used to scaffold into the home directory itself (2026-10-21).
500
+ const workspace = (command === "init" ? null : pinned()) ?? here;
481
501
  if (!ACCOUNT_WIDE.has(command)) process.env.FOLDRUN_WORKSPACE = workspace;
482
502
  // The account scope, decided once here and honoured by the core's
483
503
  // singleAccountRoot — so `library/` beside `workspaces/` is what an agent's
package/docs/agents.md CHANGED
@@ -29,10 +29,42 @@ and new posts. Write the digest to outputs/digest.md.
29
29
  |---|---|
30
30
  | `name` | kebab-case, unique in the account. The agent's identity — the folder must match |
31
31
  | `description` | what it does *and when to use it*. Other agents read this when consulting; people read it in the dashboard |
32
+ | `owner` | the person who answers for it — an email or a name. Unset, it is the workspace `AGENTS.md`'s `owner:`, then the account's. Shown on the agent list, the agent's page and `foldrun agent ls` |
33
+
34
+ ### Who owns it, and whether anyone still uses it
35
+
36
+ `owner:` names the person who answers for the agent. The agent list shows it
37
+ beside **last run** — when its latest step started — so an agent nobody has
38
+ used in months is visible, and `alerts: stale_days` in `AGENTS.md` tells
39
+ `notify:` about one ([Notifications](notifications#thresholds-alerts)).
40
+ An idle agent still holds every tool and secret it was granted: retire it
41
+ by deleting its folder and redeploying.
32
42
 
33
43
  Only `name` and the body are required. Every other field widens or narrows
34
44
  what the agent can reach, and the default is narrow.
35
45
 
46
+ ### Grouped by job
47
+
48
+ The fields that govern an agent or guard it may be grouped under the job
49
+ they do. `governance:` holds `owner` and `budget`; `guardrail:` holds
50
+ `limits`, `network`, `hooks`, `disallowedTools`, `permissionMode` and
51
+ `secrets`:
52
+
53
+ ```yaml
54
+ ---
55
+ name: researcher
56
+ tools: [web, read]
57
+ governance:
58
+ owner: ana@acme.io
59
+ guardrail:
60
+ network: [api.example.com]
61
+ limits: {web.search: 40, repeat: 5}
62
+ ---
63
+ ```
64
+
65
+ It reads exactly as the same fields at the top. A field written both ways is
66
+ refused. See [The four jobs](four-jobs).
67
+
36
68
  ## Which model, and how hard it thinks
37
69
 
38
70
  | field | values | |
@@ -63,8 +95,8 @@ the point of the week; a step that weighs two arguments and picks one is not.
63
95
  | `hooks` | your own code at every tool call, step start and step end — refuse, rewrite, add context — the same on every model ([Hooks](hooks)). Also in a workspace's or the account's `AGENTS.md` for every agent under it |
64
96
  | `apis` | an HTTP API declared inline, as one tool |
65
97
  | `mcpServers` | an MCP server declared inline |
66
- | `secrets` | vault entries its tools may use — names only, never values |
67
- | `limits` | the most calls one step may make — per tool, per web action, or in all: `limits: {web.search: 40, crm: 20, calls: 300}`. A call past a limit is refused, never run ([Tools](tools#limits--how-many-calls-a-step-may-make)) |
98
+ | `secrets` | vault entries its tools may use — names only, never values. The names the step's own model call uses (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS`, the `ANTHROPIC_*_MODEL` tiers, `CLAUDE_CODE_OAUTH_TOKEN`, `FOLDRUN_MODEL_KEY`) never replace the model's own settings, and `foldrun check` warns: script and API tools and `verify:` get the declared value under that name, but the agent's own shell sees the model's — give it another name (`MY_ANTHROPIC_KEY`), or run the model on it with `provider:` |
99
+ | `limits` | the most calls one step may make — per tool, per web action, or in all: `limits: {web.search: 40, crm: 20, calls: 300, repeat: 5}` (`repeat`: the same call with the same input). A call past a limit is refused, never run ([Tools](tools#limits--how-many-calls-a-step-may-make)) |
68
100
  | `network` | which sites its sandbox may reach: `open`, `trusted` or `none`, or a list of sites added to the trusted list — `network: [api.hubapi.com]`. Unset, an agent that may fetch or browse the web is `open` and every other `trusted`. Also in a workspace's or the account's `AGENTS.md` ([Network](network)) |
69
101
  | `permissionMode` | `plan` makes the run read-only, whatever else was granted |
70
102
  | `web` | the `web` tool's settings: `actions:` — which of its eight it may use (all when absent) — and who does each, `web: {search: brave, fetch: jina, browse: browserbase, crawl: firecrawl}`, with the key named in the vault. Unset is foldrun's own. `search:` also takes a block of the own engine's settings — engines, categories, safesearch, plugins ([Tools](tools#tuning-the-accounts-own-engine)); `browse:` a block of the browser's ([below](#webbrowse--which-browser-and-how-it-presents-itself)), and `browse:` alone cascades from `AGENTS.md`. The providers and their keys: [Tools](tools#reaching-the-web). A per-action key outside the block is not read — `check` errors on it |
@@ -82,6 +114,7 @@ only Web takes a provider:
82
114
  | | `history` | `recall_runs()` and `read_run(id)` — the workspace's last thirty finished runs | — |
83
115
  | | `desks` | `recall_desk_runs()` and `read_desk_run(id)` — the same, across the account's *other* workspaces: ten recent runs each, named per line, up to 100 runs in all. Run records, not files: the way a digest agent reads what every desk concluded this week | — |
84
116
  | **People** | `ask` | `ask_person(question, options?)` — asks the person running the desk mid-step and waits for the answer ([below](#asking-a-person-mid-step)) | — |
117
+ | | `peers` | `peers_list()` and `peers_message(to, text)` — the other steps running beside it in its parallel group, and a message to one of them (or all), which reaches that agent after its next tool call ([below](#asking-a-colleague-beside-you)) | — |
85
118
  | **Installing** | `install` — every agent has it | `install(manager, packages)` — installs a missing program or library mid-step: Debian packages (`apt`), Python (`pip`), Node (`npm`) ([below](#installing-what-a-step-needs)) | `disallowedTools: [install]` removes it |
86
119
  | **Colleagues** | `agents:` · `subagents:` | not in `tools:` — their own fields: consult a colleague for an answer, or delegate a job to one that works with its own tools ([consult](#consulting-colleagues), [delegate](#delegating-to-a-sub-agent)) | — |
87
120
 
@@ -116,6 +149,45 @@ Every question, answer and delivered message is on the step's trace and its
116
149
  record. A local `foldrun run` has no inbox; there `ask_person` asks on the
117
150
  terminal when there is one.
118
151
 
152
+ ### Asking a colleague beside you
153
+
154
+ `tools: [peers]` gives the agent `peers_list` and `peers_message`. Steps that
155
+ share a number in a flow run in parallel, and until this they ran blind to
156
+ each other: a writer that needed the image-maker's file name guessed it, and
157
+ an image-maker that found the plan's subject unusable had no one to tell.
158
+ Now a step can ask the colleague beside it for what it has, in the moment,
159
+ instead of guessing, failing, or stopping for a person.
160
+
161
+ ```yaml
162
+ tools: [read, write, peers]
163
+ ```
164
+
165
+ `peers_list()` names the other steps of the group — each agent, its step
166
+ number and the first line of its instruction — as they were when the group
167
+ launched. `peers_message(to, text)` leaves a message in that colleague's own
168
+ inbox, the same inbox a person's message uses, signed with the sender's
169
+ agent name; `to: all` reaches every sibling. It reaches the colleague after
170
+ its next tool call, as context, and the colleague answers the same way, so
171
+ nothing waits: the sender keeps working and reads what arrives after its own
172
+ next call. A message is a request or a fact, never a hand-over — the
173
+ receiving step's tools and instruction do not change. A model writing
174
+ without calling tools hears a message at the next call it makes; a sibling
175
+ that has already finished reads nothing, and the sender is told nobody
176
+ received it.
177
+
178
+ The prompt tells the agent who runs beside it this run, so a step that
179
+ happens to run alone knows not to wait for anyone. Each colleague has an
180
+ address, `agent#step` (the step as the flow numbers it), and a message is
181
+ signed with the sender's; a fan-out's instances share one agent name, so the
182
+ bare name reaches every instance and the address reaches one. A sibling
183
+ without `peers` of its own still receives: the inbox is every step's, only
184
+ the sending is the tool's.
185
+
186
+ Every message, sent or received, is on the step's trace and on its record
187
+ (`messages`, with `from` or `to`), and the run page shows the conversation
188
+ under the step beside the person's own. A local `foldrun run` has the same
189
+ line between siblings, in process; only a person cannot write in there.
190
+
119
191
  **Retired**, still granted so a deployed agent keeps running, and each an
120
192
  error in `foldrun check` naming what to write instead: `files` and `bash`
121
193
  (now `write` and `code`). The web is `web` alone; Anthropic's `WebSearch` and
@@ -530,6 +602,8 @@ from here), `notify:`, `budget:` (a cap in USD over
530
602
  a month — `60` — or a day or week — `60/day`, `60/week`; unset is no limit),
531
603
  `concurrency:` (in the account's file: how many of its runs go at once — the
532
604
  next one waits its turn; see [Budgets and billing](budgets-and-billing)),
605
+ `deploy_block:` (`none`, the default, `secrets` or `security` — what the
606
+ scanner's findings do to a deploy; see [Checks](checks#blocking-a-deploy)),
533
607
  `limits:` (per-step call limits, as defaults — the one key that merges: an
534
608
  agent's `limits: {web.search: 40}` overrides that key and keeps the rest),
535
609
  `web.browse:` (see above),
package/docs/api.md CHANGED
@@ -66,15 +66,16 @@ api workspaces/$WS/flows/publish/run -X POST # start a flow
66
66
  | Route | Methods | |
67
67
  |---|---|---|
68
68
  | `/api/workspaces` | GET, POST | list; create (`name`, plus one of: `template: true` — blank, just an AGENTS.md, what the dashboard sends; `starter: true` — the example researcher, writer and publish flow; `demo: true`; `files`). A POST to a name that exists deploys over it; add `ifAbsent: true` to create only — a name that exists is then `409` and nothing changes, decided atomically (the assistant's Create workspace and `foldrun assistant` send it) |
69
- | `/api/workspaces/<ws>` | GET, PATCH, DELETE | read; rename or re-describe (`name`, `description`), or set its defaults (`timezone`, `budget`, `notify` — null clears one, edited in AGENTS.md in place); delete |
69
+ | `/api/workspaces/<ws>` | GET, PATCH, DELETE | read; rename or re-describe (`name`, `description`), or set its defaults (`timezone`, `budget`, `notify`, `network_enforce` — null clears one, edited in AGENTS.md in place); delete |
70
+ | `/api/workspaces/<ws>/pause` | GET, POST, DELETE | the workspace's kill switch. GET `{ paused, account, workspace }` — what holds runs here (`paused` is the one that applies, `scope` saying whose), each `{ at, by, reason? }` or null. POST `{ reason?, stop?: true }` pauses: every start here — schedule, webhook, chained flow, a person's Run, a rerun — is refused `423` with who paused it, and a refused trigger is logged as `halted`; A run queued before the pause and not yet begun waits until the resume. `stop: true` also stops every running, queued and parked run (needs `run:start`) and answers `stopped` and `failed`. DELETE resumes. Needs `workspace:manage`. The account's pause holds here too and only the account's DELETE lifts it; an operator's suspension is separate and neither lifts it |
70
71
  | `/api/workspaces/<ws>/vocabulary` | GET | which document kinds this workspace admits — its `docs` list is what `[[ ]]` resolves: knowledge, memory, `state/` and `storage/` files, plus the folders themselves |
71
- | `/api/workspaces/<ws>/check` | GET | what `foldrun check` says about the deployed copy, as findings with rule ids — what a deploy refuses or warns about, and the scanner's secret, hidden-text, agent-risk and supply-chain rules ([Checks](checks)). `?security=1` keeps the security rules, `?rule=<id or category>` (repeatable) one; `{ counts, findings: [{ rule, level, file, line, message, fingerprint }], rules }`. Read-only, no model call |
72
+ | `/api/workspaces/<ws>/check` | GET | what `foldrun check` says about the deployed copy, as findings with rule ids — what a deploy refuses or warns about, and the scanner's secret, hidden-text, agent-risk and supply-chain rules ([Checks](checks)). `?security=1` keeps the security rules, `?rule=<id or category>` (repeatable) one, `?online=1` adds the network checks (known vulnerabilities, remote MCP tool text); `{ counts, findings: [{ rule, level, file, line, message, fingerprint }], rules, online?: { problems, sources }, rescan: { checkedAt, findings, pending, problems } | null }`. Read-only, no model call |
72
73
 
73
74
  ## Source — the files themselves
74
75
 
75
76
  | Route | Methods | |
76
77
  |---|---|---|
77
- | `/api/workspaces/<ws>/source?path=` | GET, PUT, PATCH, DELETE | GET: one file (`{ path, content, sha256 }`), or the tree with no `path`. PUT `{path, content, message?, ifMatch?}` writes one file (a revision, `message` its note); with `ifMatch` — the `sha256` a GET returned — it is refused with `409` and nothing written if the file has changed since; `ifMatch: null` says the caller saw no file there, and is refused the same way once one exists (History's restore and the AI draft's save send it, the draft's new files as `null`). PATCH `{from, to}` moves one. DELETE `{path}` removes a file or folder. The body carries the path on writes — a `?path=` there is a `400` |
78
+ | `/api/workspaces/<ws>/source?path=` | GET, PUT, PATCH, DELETE | GET: one file (`{ path, content, sha256 }`), or the tree with no `path`. PUT `{path, content, message?, ifMatch?, check?}` writes one file (a revision, `message` its note) and answers `{ ok, path, sha256, issues }`, the deploy's checks for that file as saved. With `check: true` (what the dashboard editor sends) a file with an error a deploy would refuse is refused with `422` and the `issues`, nothing written; warnings never refuse; with `ifMatch` — the `sha256` a GET returned — it is refused with `409` and nothing written if the file has changed since; `ifMatch: null` says the caller saw no file there, and is refused the same way once one exists (History's restore and the AI draft's save send it, the draft's new files as `null`). PATCH `{from, to}` moves one. DELETE `{path}` removes a file or folder. The body carries the path on writes — a `?path=` there is a `400` |
78
79
  | | PUT | write (`path`, `content`, optional `message` — recorded on the revision, as `foldrun source put --message` sends it) |
79
80
  | | PATCH | move (`from`, `to`) |
80
81
  | | DELETE | remove (`path`) |
@@ -171,14 +172,14 @@ history with each message.
171
172
 
172
173
  | Route | Methods | |
173
174
  |---|---|---|
174
- | `/api/workspaces/<ws>/agents` | GET, POST | list; create (`name`) |
175
+ | `/api/workspaces/<ws>/agents` | GET, POST | list — each agent's frontmatter as read, `owner` and `ownerFrom` (its own `owner:`, else the workspace's, else the account's), `lastRunAt` (when its latest step that ran started, from the runs kept; null for none) and `stale` (past `alerts: stale_days`); create (`name`) |
175
176
  | `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description", value } }`, or `{ network }` — the sites its sandbox may reach: `"open"`, `"trusted"`, `"none"`, a list of sites, or `{ level, allow, defaults }`; `null` removes the line so it inherits ([Network](network)). A `network` nobody could read is a 400 that says why. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
176
177
  | `/api/workspaces/<ws>/agents/<agent>/run` | POST | run one agent alone (`task`; `test: true` for a test run) |
177
178
  | `/api/workspaces/<ws>/agents/import` | GET, POST | copy an agent in from another workspace of the account. GET lists what could be imported (every other workspace you can read, with its agents). POST `{ from, agent, as? }` copies `agents/<agent>/` — its agent.md and own skills/, scripts/, knowledge/; never memory/, outputs/, state/ — and answers `{ name, files, warnings }`; `warnings` names tools, skills, scripts and secrets the copy uses that this workspace lacks. 409 on a name clash (pass `as`), 404 for an unknown workspace or agent, 403 when `from` is outside your key's scope. Idempotent |
178
179
  | `/api/workspaces/<ws>/export` | GET | this workspace, one flow or one agent as a `.zip` — `?kind=flow&name=<flow>` (the flow, every flow it runs as a step, every agent those steps and their colleagues name, and the workspace tools, skills and scripts they grant), `?kind=agent&name=<agent>` (its folder and what it grants), or neither for the whole authored tree. Never memory/, state/, outputs/, storage, runs or a secret's value; `foldrun-package.json` inside says what it is. Any role that can read the workspace |
179
180
  | `/api/workspaces/<ws>/import` | POST | take a package in; the body is the `.zip` (`content-type: application/zip`, at most 20 MB). `?dryRun=1` answers `{ kind, name, from, creates, added, overwritten, unchanged, needs: { secrets, tools, skills, scripts, agents, flows } }` plus `skipped` (entries it will not write: memory, state, anything outside the authored folders) and writes nothing. Without it, applies as one revision; 409 when it would replace a file with different text unless `?overwrite=1`, or when a file cannot be written there (all or nothing). Never deletes. A `Content-Length` is required (411 without). A workspace package into a name that does not exist makes that workspace (`workspace:manage`, the plan's workspace count). 400 for anything not a package, or one holding memory/, state/ or outputs/. Idempotent |
180
181
  | `/api/workspaces/<ws>/evals` | GET, POST | list; create (`name`) |
181
- | `/api/workspaces/<ws>/evals/<eval>/run` | POST | run one eval |
182
+ | `/api/workspaces/<ws>/evals/<eval>/run` | GET, POST | POST runs one eval and answers with its result; with `{ async: true }` it answers `202 { started, eval, runId, startedAt }` at once and the eval runs on — an eval of slow cases outlives a proxy's read timeout (Cloudflare's is 125 s by default). One run of an eval at a time: a second is `409` naming the run that holds it. `GET ?run=<runId>` is what to poll — that run alone, whatever else runs or writes the eval's latest result meanwhile: `{ runId, state, running }` where `state` is `running`, `done` (with its `result`), `failed` (with `error`), `lost` (it stopped reporting before saving a result, e.g. a restart) or `unavailable` (an id not on record — the last 20 runs are kept). Plain `GET` answers who holds the eval now (`running`, `runId`, `startedAt`) and the latest `result`. The dashboard and `foldrun eval --to` start async and poll by run id |
182
183
  | `/api/workspaces/<ws>/tools/<tool>/test` | POST | exercise one tool (`args`, `path`). An http tool with an `operations:` allowlist is probed only with a GET to one of its operations (no `path`: the first GET without parameters), and fails if the allowlist resolves nothing, as a run does |
183
184
  | `/api/workspaces/<ws>/hooks` | GET | the [hooks](hooks) this workspace can turn on — its own and the library's (shadowing marked), invalid ones with the reason, and who turns each on — plus `recent`: every deny, rewrite and failure hooks wrote in the last `?runs=` runs (1–500, default 50), newest first; `?all=1` includes notes and context |
184
185
  | `/api/workspaces/<ws>/hooks/<name>/test` | POST | run one [hook](hooks) on a sample event: body `{ event }`, answer the hook's own (`ok`, `decision`, `reason`, `input`, `context`, `reply`, `note`, `failure`, `ms`). Shares the `hooks/` path with flow webhooks below — `/test` is only ever a hook's. Answers `404` for no such hook, `422` for an invalid `hook.md`, `400` for an event that is not an object. On an isolated install hook code never runs in the platform's own process, so this answers with where to test it instead (`foldrun hook test` locally, or a test run) |
@@ -190,23 +191,26 @@ paying for the steps around it.
190
191
 
191
192
  | Route | Methods | |
192
193
  |---|---|---|
193
- | `/api/workspaces/<ws>/runs?limit=` | GET | `{ runs: [{ id, flow, status, startedAt, finishedAt, ... }] }` — off the run index (see [Runs](runs#the-runs-list)); a finished run's steps carry `agent`, `status`, `costUsd` and `tokens` (`{ input, output, cacheRead, cacheWrite }`, see [Runs](runs#the-record)), and the run its summed `tokens` — not their reply or trace |
194
+ | `/api/workspaces/<ws>/runs?limit=` | GET | `{ runs: [{ id, flow, status, verdict, outcome, startedAt, finishedAt, ... }] }`; search (see [Runs](runs#searching-runs)) with `tool` (a step called a tool whose name contains it), `tool_error=1`, `agent`, `model` (contains), `min_tokens` (`50k`), `min_secs` — `limit` counts the matches — `verdict` is the word the summary leads with (`GOOD`, `QUIET`, `BLOCKED`, `BAD` or null) and `outcome` the one word every page shows (`running`, `queued`, `waiting`, `good`, `done`, `quiet`, `blocked`, `bad`, `failed`, `stopped`); `failure` is `{ agent, reason }` for a run that failed (null otherwise), `chargedUsd` is what the account was charged for the run (one credit is one cent), null until the ledger settles it, while `costUsd` is the provider's token cost — off the run index (see [Runs](runs#the-runs-list)); a finished run's steps carry `agent`, `status`, `costUsd` and `tokens` (`{ input, output, cacheRead, cacheWrite }`, see [Runs](runs#the-record)), and the run its summed `tokens` — not their reply or trace |
195
+ | `/api/workspaces/<ws>/runs/<id>/calls/<call>` | GET | one tool call's recorded content (see [Runs](runs#what-a-run-records)): `{ call: { id, tool, input, output, inputChars, outputChars, truncated, inputCut, outputCut } }` — what it was given and what it gave back, secrets masked, each at most 65,536 characters; `inputChars`/`outputChars` are the whole length, `inputCut`/`outputCut` say which part was cut (`truncated`: either). `<call>` is the tool event's `call`; an event with `captured: true` has content here. 404 when nothing was recorded: the workspace says `capture: metadata`, or the run is from before 19 Oct 2026. A run's steps also carry `calls`, each model call (`n`, `at`, `ms`, `model`, `stop`, `tokens`, `tools`, and `subagent` / `attempt` when a sub-agent or a later attempt made it), and the run its `revision` (the workspace's latest revision when a worker picked it up — a deploy's commit, or the id an edit through `/source` was saved as) and `drivenAt` (when that was) |
196
+ | `/api/workspaces/<ws>/runs/<id>/trace` | GET | the run as one tree (see [Runs](runs#the-trace-tree)): `{ tree, failure }`. Each span is `{ id, kind, label, start, end, status, children, … }`, where `kind` is `run`, `step`, `model` (one model call: `model`, `stop`, `tokens`), `tool` (`call`, `captured` when its input and output were recorded) or `error` (`text`); `start`/`end` are epoch milliseconds and `status` is `ok`, `error`, `running`, `waiting`, `skipped` or `pending`. A tool call sits under the model call that asked for it. `failure` is the ids from the root to the first thing that went wrong, which the run page opens on; `[]` when nothing did |
197
+ | `/api/workspaces/<ws>/runs/<id>/compare?with=` | GET | this run beside another of the same flow (see [Runs](runs#compare-with-the-last-good-run)): `{ comparison: { base, run, revisionChanged, changedFiles, steps }, chosen }`. Each step is `{ step, agent, base, run, changes }`: both sides' status, instruction, model, seconds, tokens, model calls, tool counts, tool errors, first errors and first line said, and `changes` in words (`status: completed → failed`, `new error: …`, `instruction changed`, `tools: +web ×3`, `took 12s → 48s`, …). `changedFiles` lists the files changed between the revisions the two ran (null when one is unknown). `with` names the other run; unasked it is the newest earlier run of the flow that finished well, else the run this one re-ran (`chosen` says which) |
194
198
  | `/api/workspaces/<ws>/runs/<id>` | GET, DELETE | the whole run: every step, its output, cost, timing, the runner `image` it ran on and — for a step that browsed through the account's pod from slim — its `browserPod` reconnects and fallback ([runs](runs#the-record)). `?wait=true&timeout=<s>` instead blocks until the run stops (or `timeout`, default 300, max 900) and answers the verdict the run routes give — `200` completed, `500` failed with the record, `202` still going (`timedOut`) or parked on a person (`awaitingApproval`). Ask in short pieces: whatever sits in front of the platform cuts a request that stays silent too long (Cloudflare at 100 s, an AWS ALB at 60 s), and a flow can run for many minutes — the CLI's `--wait` asks 25 s at a time and comes back here on every `202` |
195
199
  | `/api/workspaces/<ws>/runs/<id>/stream` | GET | server-sent events, live |
196
200
  | `/api/workspaces/<ws>/runs/<id>/stop` | POST | destroys the sandbox, not just the record |
197
- | `/api/workspaces/<ws>/runs/<id>/rerun` | POST | again — `step` to start partway, or `agent` to start at the first step that agent runs (one of the two is required). Takes `?wait=true&timeout=` like a flow start |
201
+ | `/api/workspaces/<ws>/runs/<id>/rerun` | POST | again — `step` to start partway, or `agent` to start at the first step that agent runs (one of the two is required). `instruction` tells that step something else this once, in place of the flow's (the flow file is not changed); the new run's `rerunOf` says `{ run, step, instruction }`. Takes `?wait=true&timeout=` like a flow start |
198
202
  | `/api/workspaces/<ws>/runs/bulk` | POST | one action on many runs at once. `action` is `stop` or `rerun`; the runs are named by any of `runIds`, `status`, `flow`, `since`, `until`, AND-ed together, and a call naming none of them is refused rather than treated as "everything". `from` picks the step a rerun starts at, `dryRun: true` answers with what would be touched and touches nothing. At most 100 runs per call — past that it answers `422` with the count, because a filter matching more than that is almost certainly the wrong filter. Each run is its own outcome: the reply carries `matched`, `succeeded`, `failed` and a `runs` array, so one run that finished a second ago does not abort the other thirty |
199
203
  | `/api/workspaces/<ws>/runs/<id>/approve` | POST | release a run parked on a human: `decision` (`approve` or `reject`), `note`, `reason`, `step` (one of several waiting). `payload` (a string, or JSON sent as its text, up to 20000 characters) releases a step parked on `wait: event` with that payload, as its event URL would, recorded as released by the caller — 400 with `reject` or when no such step is waiting. `run:start`. Idempotent |
200
204
  | `/api/workspaces/<ws>/runs/<id>/answer` | POST | answer a question an agent asked mid-step (`tools: [ask]`): `{question: <id>, answer}`. The agent is waiting in a live sandbox and gets it within a second or two. First answer wins — a second is `409` with the answer that stands; also `409` when the agent stopped waiting (its ask timed out) or its step is no longer running. `404` when the question is not this run's or has expired |
201
205
  | `/api/workspaces/<ws>/runs/<id>/message` | POST | say something to a running agent: `{text, step?}` (at most 4 000 characters). It reaches the model after its next tool call, as context — it never changes the step's tools. `step` is the step's index in the run; left out, the only running step. With several running (a parallel group) and no `step`, `400` listing them in `running`, so a sibling never takes a message meant for another. `409` unless the run, and that step, are running. Replies `{ok, step, message}` |
202
206
  | `/api/workspaces/<ws>/runs/<id>/archive` | GET | one file the run archived from an agent's `outputs/` — `agent` and `path` name it; the run page's Out column links here, and plays a recording or shows a picture inline. Served as bytes, never as a page; a `Range` header gets `206` with that slice, which is how a video seeks |
203
207
  | `/api/workspaces/<ws>/runs/<id>/live` | GET | the live view: with no query, `{ agents: { <agent>: <last frame at> } }`; with `agent=`, the newest frame that agent's browser streamed as `image/jpeg` (or `204` when there is none), and `x-live-url`, `x-live-at`, `x-live-ended`, `x-live-engine` headers. Frames arrive from `web action=browse` through the step's egress lease and expire five minutes after the last |
204
- | `/api/workspaces/<ws>/runs/<id>/promote` | POST | save a completed run as a regression case in `evals/<target>-regressions.md` — `evalName` to pick the file, `caseName` to name the case, `expect` (assertion lines, e.g. `["contains: $34"]`) to replace the default judge. Returns `{ ok, file, created, caseName }` |
205
- | `/api/workspaces/<ws>/triggers?days=` | GET | why nothing happened: one row per flow saying how often its trigger fired, how often that became a run, and what the difference was — a duplicate delivery, a throttled burst, a debounced burst still being waited out, a quarantined flow, an `overlap: skip`, a fire missed while the platform was down. Plus every decision newest-first, and the hook/inbox delivery log, which answers the separate question of whether a request arrived and was authentic. `days` 1–365, default 7 (`since=`, its older name, still works — deprecated) |
208
+ | `/api/workspaces/<ws>/runs/<id>/promote` | POST | save a finished run as a regression case in `evals/<target>-regressions.md` — `evalName` to pick the file, `caseName` to name the case, `expect` (assertion lines, e.g. `["contains: $34"]`) to replace the default judge. A run that went wrong (failed, BLOCKED or BAD) needs `should` — what it should have done, the judge's claim for the case — or `expect`: its own answer is not the one to keep. Returns `{ ok, file, created, caseName }` |
209
+ | `/api/workspaces/<ws>/triggers?days=` | GET | why nothing happened: one row per flow saying how often its trigger fired, how often that became a run, and what the difference was — a duplicate delivery, a throttled burst, a debounced burst still being waited out, a quarantined flow, a workspace or account paused by a person (`halted`), an `overlap: skip`, a fire missed while the platform was down. Plus every decision newest-first, and the hook/inbox delivery log, which answers the separate question of whether a request arrived and was authentic. `days` 1–365, default 7 (`since=`, its older name, still works — deprecated) |
206
210
  | `/api/workspaces/<ws>/notify/deliveries` | GET | the outbound webhook log: every `notify:` url delivery from this workspace, newest first — `{ deliveries, total, limit, offset, endpoint }`. A delivery is `{ id, event, status, destination, attempts, attemptLog, lastStatusCode, lastError, nextAttemptAt, createdAt, updatedAt, body }`: `id` is the `X-Foldrun-Delivery` header; `status` is `pending` (a retry is due at `nextAttemptAt`), `delivered`, `failed` (gave up after six attempts) or `skipped` (the endpoint was switched off); each attempt is `{ n, at, statusCode, durationMs, response (first 500 characters), error, manual? }`. `body` is what was sent, except that a gate's `approveUrl`/`rejectUrl` (top level and in `links`) read `[redacted]` — they decide without a login, and this log is readable by every role; the receiver still gets them, on a redelivery too. `?status=`, `?event=`, `?limit=` (50, at most 200), `?offset=`. `endpoint` is `{ failingSince, lastSuccessAt, lastFailureAt, disabledAt, disabledReason }` or null. Kept 30 days |
207
211
  | `/api/workspaces/<ws>/notify/deliveries/<id>/redeliver` | POST | send one delivery again now — the same id and body, signed afresh, to the URL `notify:` names today. `{ ok, attempt, delivery }`, always `200` when the delivery exists (whether the receiver took it is the answer); `404` when it does not. An accepted redelivery switches a disabled endpoint back on. Needs the permission that runs |
208
212
  | `/api/workspaces/<ws>/notify/test` | POST | sends one real notification to whatever this workspace's `notify:` resolves to, and answers `{ ok, destination, detail }` — including the provider's own words when it failed, which are what name the problem. Always `200`: the request was handled, and the result is the answer. Needs the permission that runs, because it sends real mail |
209
- | `/api/workspaces/<ws>/observe?days=` | GET | the observability report the Observe page renders — per-agent failures, retries, tokens, `cacheHitRate`, `turns` and `systemPromptChars` (each `{ n, p50, p95, max }`), per-tool calls/errors/latency and `resultSize` (characters per result, same shape), `resultChars` (their sum) and `heavyCalls` (results over 10,000 characters), per-flow duration, day series, recent failures, and `startup` — one row per start-up phase in order, `{ phase, took, at }`, each `{ n, p50, p95, max }` in seconds (`took` is the phase's own share, `at` when it ended counted from the step's start; percentiles need five samples). `days` 1–365, default 30 (`since=`, its older name, still works — deprecated) |
213
+ | `/api/workspaces/<ws>/observe?days=` | GET | the observability report the Observe page renders — `tally` (the shared outcome counts) and `chargedUsd` beside `costUsd` on the report, each flow, day and agent (what was charged, the ledger's line or list price until settled, versus the provider's token cost); per-agent failures, retries, tokens, `cacheHitRate`, `turns` and `systemPromptChars` (each `{ n, p50, p95, max }`), per-tool calls/errors/latency and `resultSize` (characters per result, same shape), `resultChars` (their sum) and `heavyCalls` (results over 10,000 characters), per-flow duration, day series, recent failures, and `startup` — one row per start-up phase in order, `{ phase, took, at }`, each `{ n, p50, p95, max }` in seconds (`took` is the phase's own share, `at` when it ended counted from the step's start; percentiles need five samples). `days` 1–365, default 30 (`since=`, its older name, still works — deprecated) |
210
214
  | `/api/workspaces/<ws>/history?path=&id=&limit=` | GET | every change to the workspace, newest first — `path` narrows to one file, `id` returns one revision with its before/after and a line diff, `limit` 1–500, default 100. A deploy's revision id is its commit |
211
215
  | `/api/workspaces/<ws>/restore` | POST | put the workspace's source back as it was at a point in its history. `to`: a revision id (`r-…`), the commit a deploy named (or its prefix), an ISO date/time (the last revision at or before it) or an age (`90m`, `12h`, `3d`, `2w`) — every revision since 17 Oct 2026 holds a snapshot of the whole source. `{ to, dryRun: true }` answers `{ to: { id, sha, at, by, message }, from, added, updated, removed, files: [{ path, change, diff }], blockedBy, noop, issues, warnings }` and writes nothing; `issues` are the deploy checks that would refuse that tree, `warnings` the ones it would deploy with. The restore needs `confirm` set to the workspace's name (400 without) and takes the dry run's `expectRemoved`; it deploys that revision's snapshot as a new revision (undo: restore to `from`; `to.sha` is the same id as `to.id`, kept from when revisions were git commits), keeps runs, `state/`, storage, secrets and agent-written memory, and is refused 409 while runs are in flight or when it would remove something not in `expectRemoved`. `workspace:write`; audited as `workspace.restored` |
212
216
 
@@ -324,7 +328,7 @@ Reads return which secrets exist, never their values.
324
328
 
325
329
  | Route | Methods | |
326
330
  |---|---|---|
327
- | `/api/keys` | GET, POST, DELETE | API keys — an admin's (`keys:manage`) to list, mint and revoke. POST `label`, `role` (viewer \| editor \| admin, never above your own, default editor), `workspaces` (names, or `null` for all — never wider than your own scope); `expires` (`30d`, `90d`, `365d`, any number of days up to 3650, or `never` — the default; anything else is a 400, never a key that lives forever); returns `{ ok, key, id, prefix, role, workspaces, expiresAt }`, and the key is shown once. GET lists each key with `createdAt`, `createdBy`, `expiresAt`, `lastUsedAt`, `lastIp` and `replacedBy` (the id of the key a rotation replaced it with; `null` until then) — the last-use pair written at most once a minute per key, the address cut to its /24 (IPv4) or /48 (IPv6). A key past its `expiresAt` is refused with 401 `API key expired on <date>`; the owner is emailed seven days before. Mint, revoke and rotate are written to the audit log. POST with `workspace` or `access` is refused (400): those minted deploy keys for the platform's own git, which went on 17 Oct 2026 — a deploy key minted before is refused everywhere (403). A key for one workspace is `workspaces: ["<ws>"]`. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap) |
331
+ | `/api/keys` | GET, POST, DELETE | API keys — an admin's (`keys:manage`) to list, mint and revoke; any key may revoke itself (DELETE with its own `id`, audited as self — what `foldrun logout` does). POST `label`, `role` (viewer \| editor \| admin, never above your own, default editor), `workspaces` (names, or `null` for all — never wider than your own scope); `expires` (`30d`, `90d`, `365d`, any number of days up to 3650, or `never` — the default; anything else is a 400, never a key that lives forever); returns `{ ok, key, id, prefix, role, workspaces, expiresAt }`, and the key is shown once. GET lists each key with `createdAt`, `createdBy`, `expiresAt`, `lastUsedAt`, `lastIp` and `replacedBy` (the id of the key a rotation replaced it with; `null` until then) — the last-use pair written at most once a minute per key, the address cut to its /24 (IPv4) or /48 (IPv6). A key past its `expiresAt` is refused with 401 `API key expired on <date>`; the owner is emailed seven days before. Mint, revoke and rotate are written to the audit log. POST with `workspace` or `access` is refused (400): those minted deploy keys for the platform's own git, which went on 17 Oct 2026 — a deploy key minted before is refused everywhere (403). A key for one workspace is `workspaces: ["<ws>"]`. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap) |
328
332
  | `/api/keys/<id>/rotate` | POST | a new key with the old one's label, role, workspaces and deploy scope — and, when it had an end, the same lifetime from today — returned once like a mint (`{ ok, key, id, prefix, role, workspaces, expiresAt, replaced: { id, endsAt } }`). `grace`: `0` (default — the old key is revoked at once), or a duration up to `7d` such as `1h` or `24h`, during which the old key still works and after which it is refused as expired. A key is rotated once: rotating a key already replaced is 409 `this key was already rotated to <prefix>…; rotate that one` — and someone who lost the new key's secret rotates the NEW key, which mints another and ends the lost one (rotating the old one again would leave the lost key working). Two rotations of one key at once mint one key. `keys:manage`; a key outside the caller's scope is 404. Audited as `apikey.rotated` |
329
333
  | `/api/team` | GET, POST | members with their roles and workspace scope; mint an invite — `role` (viewer \| editor \| admin, default editor), `workspaces` (names, or omit for all), `email` (bound into the invite: only that address can accept it; sent through the account's `RESEND_API_KEY` unless `send: false`). Returns `path` (the invite's relative URL), `expires`, `sent` and any `sendError` either way; the full link is in the mail |
330
334
  | `/api/team/members/<id>` | PATCH, DELETE | change a member's role (`role`) and/or which workspaces they may open (`workspaces`: names, or null for all); remove them |
@@ -334,14 +338,20 @@ Reads return which secrets exist, never their values.
334
338
  | `/api/account/providers` | GET, POST | GET: the model providers this account's files actually use — each one's preset name, where it is declared, the model a check would send — plus the last daily health check. POST forces one now, which spends one minimal request at each distinct endpoint, and needs the permission that runs |
335
339
  | `/api/usage` | GET | what has been spent, by meter; each bucket's tokens are `inputTokens` (all input), `outputTokens`, `cacheReadTokens` and `cacheWriteTokens` (the part of the input the prompt cache served and stored), and `cacheHitRate` — reads over `measuredInputTokens`, the input of steps that report the split, `null` when none do. A key or member scoped to some workspaces sees those workspaces, and totals over them; the ledger figures are the account's, as the wallet's are |
336
340
  | `/api/approvals` | GET | every gate waiting on a person, across workspaces — one entry per waiting **step** (`workspace`, `runId`, `flow`, `step`, `agent`, `question`, `asked`, `waitingSince`), oldest first. By step status, not run status: a decided gate leaves the list at once, before the worker resumes the run. `wait: event` steps are not listed; no button answers them. Beside it, `questions`: every question an agent is asking mid-step in a running run (`kind: "question"`, `id`, `workspace`, `runId`, `flow`, `agent`, `question`, `options?`, `askedAt`) — answered with `…/runs/<id>/answer` |
341
+ | `/api/insights?days=&workspace=` | GET | recurring problems found in the runs of the window (see [Runs](runs#insights)): `{ days, insights: [{ id, kind, workspace, agent, tool?, title, why, fix, credits, steps, evidence: [{ workspace, runId, step }] }] }`. `kind` is `cache` (an agent's big prompt mostly uncached), `big-result` (a tool result over 20k characters sent again on every later model call), `retry-loop` (a tool erroring three or more times in a step) or `timeout` (steps using 80% or more of their `timeout:`); `credits` is about what it cost in the window, null when it is not money. `days` 1–30, 7 unasked. A key scoped to some workspaces sees theirs |
342
+ | `/api/issues?status=&workspace=` | GET | the account's issues: what went wrong, grouped and kept open until a person resolves it (see [Runs](runs#issues)). One issue is one desk's flow failing at one agent, or ending BLOCKED or BAD, or one platform cause across desks. Each carries `id`, `kind` (`platform`, `failed`, `blocked`, `bad`, `missed` — a scheduled fire that started no run), `status` (`open`, `acknowledged`, `snoozed`, `resolved`), `regressed` and `sinceRevision` (it was resolved and came back, since the revision its new run ran), `snoozedFor`, `workspace`/`workspaces`, `what`, `agent`, `reason`, `action` (what to do), `firstSeen`, `lastSeen`, `count`, the newest `runs`, `recoveredAt` (the flow's newest good run since — it closes nothing), `acknowledged`, `snoozedUntil`, `resolved` and the `history` of what people did. `status` is `active` unasked (open + acknowledged), or `open`, `acknowledged`, `snoozed`, `resolved`, `all`; `workspace` keeps the ones that hit it. A key scoped to some workspaces sees the issues of those |
343
+ | `/api/issues/<id>` | GET POST | one issue; POST acts on it: `{ action: "acknowledge" | "snooze" | "resolve" | "reopen", for?: "30m" | "4h" | "1d" | "1w", until?: ISO, times?: n, note? }` (editor or above), kept in its history with who did it, and answers `{ issue }` as it now stands. A snooze needs `for`, a future `until`, or `times` (until it happens that many more times; with a time too, whichever comes first). A platform issue is the account's: closing it needs access to every workspace. A run that goes wrong the same way after a resolve opens the issue again |
337
344
  | `/api/account/export` | GET | the owner's copy of what the platform holds about the account, as one JSON file (`foldrun-<account>-export.json`): `workspaces` (with their flows), `members` (email, role, scope, since), `runs` (an index — id, workspace, flow, status, times, headline) and the whole `ledger` (each line's charge; the platform's provider cost is left out). `billing:manage`, so the owner; audited as `account.exported`. The markdown itself comes from `foldrun pull` or the source API |
345
+ | `/api/account/telemetry/test` | POST | send one made-up run as an OpenTelemetry trace to the collector `telemetry:` names — the account's, or `{ workspace }`'s — with its header secrets filled from the vault; `{ ok, status?, error?, spans?, endpoint }`. `409` when no `telemetry:` is set. Needs `workspace:manage` |
346
+ | `/api/account/jobs?workspace=` | GET | the four jobs (governance, guardrail, observability, evaluation) and each `AGENTS.md` field of each: `{ jobs: [{ name, title, what, fields: [{ key, account, workspace?, combine, writable }] }] }` — the account's value and the workspace's when `workspace` is given (null: unset), as written with blocks folded; `combine` is how the two meet at run time (`workspace replaces`, `either turns it on`, `both apply`, `per key`, `per field`, `level nearest, sites from both`, `account only`); `writable` is whether that scope's settings PATCH (`/api/account`, or the workspace's) writes it — the rest are written in `AGENTS.md`. See [The four jobs](four-jobs) |
347
+ | `/api/account/pause` | GET, POST, DELETE | the account's kill switch: the same as a workspace's `/pause`, for every workspace at once. GET `{ paused, account, workspaces }` (`workspaces`: those paused on their own). POST `{ reason?, stop?: true }`, DELETE resumes the account — a workspace paused on its own stays paused. Needs `workspace:manage` and access to every workspace |
338
348
  | `/api/account/close` | POST | the owner asks to close the account — in person, never a key, with `confirm` set to the account's name and an optional `reason`. Not a delete: the account is suspended at once (runs in flight finish, sign-in and the export still work), `closeRequested` is set on its admin state, and the super admin is mailed to settle the last invoice and any refund. Returns `{ ok, suspended, notified }`; 409 when already asked. Hosted installs only |
339
349
  | `/api/account/backups` | GET | what is backed up for this account: `policy` (`schedule`, `keepLocal`, `keepOffsiteDays`, `encrypted`, `covers`, `excludes`), `last`, `backups` (each nightly archive that holds this account, newest first: `id`, `at`, `bytes` (the encrypted object), `sha256` (the archive inside the encryption — what a decrypt must reproduce), `kinds` — files, database, keys — `verified`, `offsite`, `keepDays`, `encrypted` (sealed with age before it left the server; false for archives taken before October 2026), `recipient` (the key's fingerprint, `sha256:<16 hex>`, or null)), `requests` (earlier restore requests) and `workspaces` (`name`, `head`, `history`, `lastChange`) — what a restore can reach. See [Backups and restore](backups-and-restore.md) |
340
350
  | `/api/account/backups/requests` | GET, POST | ask the platform team to restore what an account cannot restore itself. POST `{ what, target, workspace?, backupId?, note? }` (`what` is runs, state, storage or everything) records the request, mails the operator and audits it as `backup.restore_requested`; answers 201 `{ ok, request }` with `notified` saying whether the mail went. Restores nothing. `workspace:manage`, and a member scoped to some workspaces must name one. GET lists this account's requests |
341
351
  | `/api/account/api` | GET, PATCH | how this account meets the API — GET `{ version: { current, pinned, effective, supported, supportMonths }, limits: { plan, windowSeconds, perCredential: { read, write }, perAccount: { read, write } }, usage }`. `usage` is the last 30 days — `{ days, versions: [{ version, requests, lastSeen }], deprecated: [{ route, requests, lastSeen, deprecatedAt, sunsetAt, link, callers: [{ credential, requests, lastSeen }] }] }`, `credential` naming a key by its label and prefix — or `null` when it could not be read; PATCH `{ version: "YYYY-MM-DD" \| null }` pins the version API-key requests without a `Foldrun-Version` header get (null: always current); the dashboard always gets current. `keys:manage`. See [api-usage](api-usage.md) |
342
352
  | `/api/account/flags` | GET | the feature flags as they stand for this account — `{ flags: [{ name, description, owner, default, on, source, rollout }] }`, `source` being what decided it: `account` (an override for this account), `global` (the install-wide setting), `rollout` (a percentage of accounts, by a stable hash of account and flag; `rollout` is the percentage) or `default`. Read-only, every role; only the super admin changes a flag. See [Feature flags](feature-flags.md) |
343
353
  | `/api/openapi.json` | GET | this API as an OpenAPI 3.1 document — every route and method, bearer auth, the rate-limit, version and Idempotency-Key headers, and request/response schemas for runs, flow runs, deploy, source, secrets, keys and billing. No key needed. Generated from the route files by `scripts/gen-openapi.mjs`; the dashboard renders it at Docs → API reference, `foldrun api spec` saves it |
344
- | `/api/account` | GET, PATCH | the account's defaults from its AGENTS.md frontmatter — `timezone`, `budget` (a USD cap — a number for a month, `"60/day"`, `"60/week"`, or `{ usd, period }`; null is no limit), `notify` (`{ url?, email?, events }`); PATCH sets the keys sent, null clears one, and edits the file in place |
354
+ | `/api/account` | GET, PATCH | the account's defaults from its AGENTS.md frontmatter — `timezone`, `budget` (a USD cap — a number for a month, `"60/day"`, `"60/week"`, or `{ usd, period }`; null is no limit), `notify` (`{ url?, email?, events }`), `concurrency`, `deploy_block` (`none`, `secrets` or `security` — answered as `deployBlock`), `network_enforce` (answered as `networkEnforce`), `owner`, `alerts` (as written), `judge` (`{ model?, effort? }`), `telemetry` (`{ otlp, headers }` — header values are `${SECRET}` names; answered as `{ endpoint, headers }`); PATCH sets the keys sent, null clears one, and edits the file in place |
345
355
  | `/api/audit?since&until&action&actor&workspace&limit&cursor&format` | GET | the account's audit log, newest first — the caller's account only, whatever a header, query or body names. The owner and unscoped admins (people, or `admin` keys); `audit:read`, and refused to a member or key scoped to some workspaces, because the log is the whole account's. `since`/`until`: `7d`, `24h`, `30m` or a date; `action`: exact (`apikey.created`) or a family (`apikey`); `actor`: exact (an email, `key:<label>`, `support:<email>`); `workspace`: entries about one; `limit` 1–1000 (100; 1000 for CSV); `cursor`: the `next` of the page before. JSON `{ entries: [{ id, at, actor, action, subject, detail }], next, retention }`; `format=csv` downloads the same rows (`x-next-cursor` header when there are more). Records sign-ins and refusals, sign-outs, session revokes, password and email changes, MFA on/off, key mint/rotate/revoke, invites, joins, role/scope changes, removals, ownership transfers, secrets set/deleted (by name — never a value), deploys, workspace deletes, plan and billing changes, export and close requests, and `support.viewed`/`support.ended`. The super admin's own levers (`operator.*`) are the platform's record and not listed. Kept for the life of the account |
346
356
  | `/api/avatars/<kind>/<id>` | GET, PUT, DELETE | a picture for a `user` (their own id), a `workspace` (its name) or the `account` (the tenant). PUT the bytes of a PNG, JPEG or WebP under 1 MB; a person sets their own, an admin a workspace's or the account's. GET is cached for a year — the URL's `?v=` changes with the picture |
347
357
  | `/api/search?q=` | GET | find anything in the account by name — workspaces, agents, flows, tools, skills, knowledge, memory, runs (by id, flow or headline); ranked, at most six per kind. The dashboard's Find palette reads it |
@@ -445,7 +455,7 @@ signed-in person, never by a key — mints an ordinary API key at their role.
445
455
  | `/api/me` | GET | who is calling: `account`, `owner`, `role`, `workspaces` (null: all), and the `actor` — a person's email, or a key's id, label, prefix and who minted it |
446
456
  | `/api/me/onboarding` | GET, PATCH | the account's getting-started steps, each derived from what is really there — `{ steps: [{ id, title, why, href, cli?, done }], done, total, complete, next, dismissed, dismissedAt }`. Steps: `model` (first; only for an account that must bring its own key — done once it has one, or a `provider:` block in the account's AGENTS.md), `workspace`, `agent` (an agent written or deployed after the workspace was made), `run`, `success` (a completed run), `secret`, `trigger` (a schedule or webhook), `teammate` (a second member), `billing` (hosted, not fully waived: a saved card or a plan), `cli` (any API key or CLI login). PATCH `{ dismissed: true or false }` hides or brings back the dashboard card for the signed-in person (kept in their prefs); a key reads `dismissed: false` and cannot PATCH |
447
457
  | `/api/me/notifications` | GET, PATCH | what mail you get. GET `{ email, categories: [{ category, label, description, required, why?, perWorkspace, enabled, workspaces: { <ws>: bool } }] }` for `security`, `invites`, `billing`, `account` (required — always sent) and `run-alerts`, `approvals` (per workspace), `low-balance`, `product-updates`. PATCH `{ category, enabled, workspace? }` or `{ preferences: [ … ] }`; answers the same as GET. Turning a required category off is a `409` with the reason; `workspace` on an account-wide category is a `400`; `enabled: true` account-wide clears that category's per-workspace offs. A key acts for the person who minted it |
448
- | `/api/me/preferences` | GET, PATCH | your own display preferences: `{ theme }` — `system` (the default: follow the computer's light or dark setting), `light` or `dark`. PATCH `{ theme }` answers the same; anything else is a `400`. Kept in your prefs, so it follows you to every browser; the dashboard applies it on the server, before the first paint. A key acts for the person who minted it; a key minted by nobody is a `400` |
458
+ | `/api/me/preferences` | GET, PATCH | your own display preferences: `{ theme, runViews }` — `theme` is `system` (the default: follow the computer's light or dark setting), `light` or `dark`; `runViews` your saved run searches, `[{ name, query }]` (see [Runs](runs#searching-runs)). PATCH `{ theme }` and/or `{ runViews }` (the whole list, at most 20, each query of the runs filters) answers the same; anything else is a `400`. Kept in your prefs, so it follows you to every browser; the dashboard applies it on the server, before the first paint. A key acts for the person who minted it; a key minted by nobody is a `400` |
449
459
  | `/api/unsubscribe?token=` | POST | one-click unsubscribe (RFC 8058) — no login, the signed token is the credential. It is the `List-Unsubscribe` URL on every optional mail, beside `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; a mail client POSTs `List-Unsubscribe=One-Click` to it. Turns off the one category (for the one workspace) the token was minted for, for its address. The token may also come as a form field (`token`, and `from=page` to be sent back 303 to the `/unsubscribe` confirmation page) or JSON. `200 { ok, category, workspace }`; `400` no token, `401` forged, `410` expired (180 days; `FOLDRUN_UNSUBSCRIBE_TTL_DAYS`). Never on a GET — `/unsubscribe?token=` is the page that asks first |
450
460
 
451
461
  ## OAuth — connecting an agent to a third party
package/docs/assistant.md CHANGED
@@ -14,7 +14,7 @@ your browser remembers the choice. Each workspace keeps its own conversation.
14
14
 
15
15
  ## What it knows
16
16
 
17
- Only two things:
17
+ Only three things:
18
18
 
19
19
  - **foldrun's docs**, these pages. It searches them and reads the sections
20
20
  it needs before it answers, and it cites what it used as
@@ -22,14 +22,44 @@ Only two things:
22
22
  **Read N doc sections** lists them.
23
23
  - **This workspace's files**: its `AGENTS.md`, agents, flows, skills,
24
24
  tools, hooks, knowledge, evals and scripts, plus the account library and
25
- the account's `AGENTS.md`. It never reads `state/`, `storage/`, runs or
25
+ the account's `AGENTS.md`. It never reads `state/`, `storage/` or
26
26
  secrets.
27
+ - **This workspace's runs**, when you ask about one: each step's status,
28
+ verify, errors and result, and one step in full. Run records are
29
+ redacted as they are written, so no secret value is in them.
27
30
 
28
31
  It has no web access, no shell and no other tools. If the docs do not cover
29
32
  something, it says so; it does not guess. Anything that is not about
30
33
  building on foldrun gets a one-line refusal. Text inside your files is
31
34
  treated as data, never as instructions to it.
32
35
 
36
+ ## The run doctor
37
+
38
+ A run that failed, or ended BAD or BLOCKED, has a **Doctor** button on its
39
+ page, and so does each [issue](runs#issues) under Needs attention and on the
40
+ Issues page, for the issue's newest run. It opens the assistant with "Doctor:
41
+ why did run <id> fail, and how do I fix it?" ready to send, in the workspace
42
+ that run is in — from the account home too. The assistant reads the run, then the
43
+ failing step in full and the files it names, and answers in this order:
44
+
45
+ 1. **Cause**, one of four:
46
+ - `platform`: foldrun itself, such as a pod, the worker or a proxy.
47
+ - `check`: the work was done, but a `verify:` graded the wording, not
48
+ the result.
49
+ - `work`: the agent did not do what was asked.
50
+ - `outside`: a provider, a key, a balance, or a site that refused.
51
+ 2. **What happened**, quoting the run's lines that show it.
52
+ 3. **The fix**. For `check` and `work` it proposes changed files as diffs:
53
+ a verify that checks the artefact, a clearer instruction, an `on-fail:`
54
+ rescuer. For `platform` and `outside` it says who must act.
55
+ 4. **Where to rerun from**: the step, or a fresh run.
56
+
57
+ It never proposes a verify that accepts BLOCKED, or removes a check to make
58
+ a run pass. Nothing is saved or rerun by itself: you save the fix, then
59
+ rerun from the run page. From a terminal:
60
+ `foldrun runs doctor <run-id> --to <workspace>`, or `foldrun issues doctor
61
+ <issue-id>` for an issue's newest run.
62
+
33
63
  ## What it writes
34
64
 
35
65
  Only these paths:
@@ -25,7 +25,7 @@ mistake the editor names rather than a cap it quietly keeps.
25
25
  When an agent's cap and the flow's both apply to one step, the tighter one
26
26
  wins, and the step's error says which line set it: `budget: in the flow
27
27
  file` or `budget: on the writer agent`.
28
- | **runs at once** | `concurrency: 3` in the account's `AGENTS.md`, or Settings → Defaults | the fourth run waits its turn in the queue — never refused, only later. Empty means the install's share (`FOLDRUN_ACCOUNT_CONCURRENCY`) or plan number (`FOLDRUN_PLAN_CONCURRENCY`, which applies only where billing is on), or no cap. Your own number can lower that, never raise it; a higher number comes with a plan |
28
+ | **runs at once** | `concurrency: 3` in the account's `AGENTS.md`, or Settings → Governance | the fourth run waits its turn in the queue — never refused, only later. Empty means the install's share (`FOLDRUN_ACCOUNT_CONCURRENCY`) or plan number (`FOLDRUN_PLAN_CONCURRENCY`, which applies only where billing is on), or no cap. Your own number can lower that, never raise it; a higher number comes with a plan |
29
29
  | **the queue lane** | the plan's lane (`high` \| `normal` \| `low`); `priority: low` in the account's `AGENTS.md` lowers it, and `high` there counts only with a plan that gives it | when slots are short, accounts take turns, weighted by lane — high 3, normal 2, low 1: every account's first run goes before anyone's second, a fast lane takes three turns to a slow lane's one and never all of them, an account with runs going waits behind one with none, and a run that has waited ten minutes moves up a turn. A flow's own `priority:` orders runs within that. The cap above still holds |
30
30
  | **room to start** | the install's `FOLDRUN_RUN_MEMORY_GIB` and `FOLDRUN_ACCOUNT_PODS` | a step whose sandbox would not fit, or whose account already has its most sandboxes running, waits to start — `room: waiting to start — …` on its trail — instead of failing. The wait is not billed; the run page's start-up chip shows it as `room`. A step still waiting after 30 minutes (`FOLDRUN_ROOM_WAIT_SEC`) fails, saying why |
31
31
 
@@ -145,11 +145,12 @@ cost from what the last one consumed.
145
145
 
146
146
  ## Prompt caching
147
147
 
148
- Every step's model calls are cached by prefix, on their own: the agent's
148
+ Every step's model calls are cached by prefix, on the provider's side: a
149
+ request whose opening bytes match one the model has seen reads the model's
150
+ work on them instead of paying for it again. The opening is the agent's
149
151
  tools and system prompt, then the conversation so far. Each turn of a step
150
- re-reads what the turn before sent, and steps of the same agent in an
151
- `each:` fan-out or a retry share the same opening. Nothing has to be
152
- switched on.
152
+ re-reads what the turn before sent, and steps of the same agent share the
153
+ same opening. Nothing has to be switched on.
153
154
 
154
155
  A read from the cache is priced at a tenth of fresh input, and a write at a
155
156
  quarter more; a step's cost uses those rates, both against its `budget:` and
@@ -159,6 +160,42 @@ Usage and Observe pages, `foldrun report`, `foldrun usage` and
159
160
  `foldrun observe` show the **cache hit rate**: cache reads over all input,
160
161
  counted over the steps that report the split (from 18 Oct 2026).
161
162
 
163
+ ### How the prompt is laid out for it
164
+
165
+ A byte that differs invalidates everything after it, so the system prompt
166
+ is assembled in **layers**, the ones that never change first (from 20 Oct
167
+ 2026): the agent's prose, its locale and place, `AGENTS.md`, skills,
168
+ knowledge, scripts, APIs, tools, secrets and the platform's rules; then
169
+ what moves between runs — the memory index, the state files and the clock.
170
+ The prompt is built once per step and cached as one block, so every turn
171
+ of a step re-reads it, and so do the agent's other steps in the same run
172
+ while it is unchanged. When an agent's memory or state changes, its next
173
+ step writes the prompt again; across runs a day apart that costs nothing
174
+ extra, since the cache has expired by then anyway. Each layer's name,
175
+ length and hash ride on the step record as `promptLayers`.
176
+
177
+ A **fan-out** of one agent (`each:`) starts one instance first and holds
178
+ the rest until its first response has begun, then starts them together:
179
+ the cache entry a request writes is readable only once its response has
180
+ begun, and twenty copies started at once would each write the same prefix
181
+ and none read it. It waits for the response, not for a fixed time — each
182
+ copy spends seconds starting its sandbox before it calls the model, and a
183
+ timer would release the rest before there was anything to read. The
184
+ waiting copies say so on their trace (`prompt cache: waiting for the first
185
+ … step's reply`); `parallel:` still caps how many run at once after that.
186
+ On dev, 20 Oct 2026, the waiting copies of a three-way fan-out read 88% of
187
+ their input from the cache, against 19% for the one that went first.
188
+
189
+ ### When a step wrote instead of read
190
+
191
+ A step that wrote a fifth or more of its input to the cache gets a
192
+ **prompt cache** line on the run page and in `foldrun report`, saying why:
193
+ `memory, clock changed since step 2` compares its layers with the same
194
+ agent's previous step in the run; `the agent's first step in this run` is
195
+ the write every run pays once; `the same prompt as step 2; the entry had
196
+ expired or was still being written` means nothing in the prompt moved —
197
+ the gap was longer than the cache lives, or two copies raced.
198
+
162
199
  The cache keeps a prefix for an hour on a Claude subscription and five
163
200
  minutes on an API key. A low rate usually means one of:
164
201
 
@@ -170,7 +207,11 @@ minutes on an API key. A low rate usually means one of:
170
207
 
171
208
  Providers on the OpenAI format (`provider:` with `format: openai` or
172
209
  `responses`) cache on their own side; the share they report comes back as
173
- `cacheRead` too.
210
+ `cacheRead` too. Where the endpoint routes its cache by a key (OpenAI,
211
+ Mistral), every request of an agent carries the same `prompt_cache_key`,
212
+ one per agent and account, so its requests land where its prefix is. Other
213
+ endpoints get the layered prompt and nothing else; a self-hosted model
214
+ (vLLM, Ollama) caches prefixes in its own memory and benefits the same way.
174
215
 
175
216
  ## What each desk costs
176
217