foldrun 0.8.0 → 1.0.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/docs/api.md CHANGED
@@ -16,8 +16,8 @@ refuses — there is no separate "browser API". The exceptions carry their own
16
16
  proof instead of a key: a token or signature (`/s/<token>`, `/api/hooks`,
17
17
  `/api/inbox`, `/api/events`, `/api/approve`, `/api/unsubscribe`, `/api/git`, `/api/billing/stripe`,
18
18
  `/api/billing/confirm`), the sign-in and recovery steps (`/api/auth/signup`,
19
- `login`, `forgot`, `reset`, `verify`, `mfa/verify`, `/api/cli/login`,
20
- `/api/cli/authorize` GET, `/api/oauth/callback`), `/api/healthz`, `/api/status`, `/api/status/history`, `/api/version` and `/api/openapi.json`.
19
+ `login`, `forgot`, `reset`, `verify`, `mfa/verify`, `session`, `/api/cli/login`,
20
+ `/api/cli/login/<id>`, `/api/cli/authorize` GET, `/api/oauth/callback`), `/api/healthz`, `/api/status`, `/api/status/history`, `/api/version` and `/api/openapi.json`.
21
21
 
22
22
  ```
23
23
  Authorization: Bearer <api-key> # machines. Settings → API keys
@@ -65,7 +65,7 @@ api workspaces/$WS/flows/publish/run -X POST # start a flow
65
65
 
66
66
  | Route | Methods | |
67
67
  |---|---|---|
68
- | `/api/workspaces` | GET, POST | list; create (`name`, `template`, `demo`, `files`) |
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`) |
69
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 |
70
70
  | `/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
71
 
@@ -113,12 +113,18 @@ swap happen in one step, so nothing can slip in between. Absent, no check.
113
113
  Never touched by a deploy: `runs/`, `state/`, `secrets.json`, and any memory an
114
114
  agent wrote that the push does not mention.
115
115
 
116
+ | Route | Methods | |
117
+ |---|---|---|
118
+ | `/api/workspaces/<ws>/deploy` | POST | deploy the whole workspace from a list of files — checked as a whole first, refused with `422` and `issues` when it does not check out. `dryRun`, `force`, `expectRemoved` below. Idempotent |
119
+ | `/api/workspaces/<ws>/runtimes` | GET | the `runtime:` builds a deploy started, each with its `state` — below |
120
+
116
121
  `GET /api/workspaces/<ws>/runtimes` answers what a deploy started on the
117
122
  platform: every distinct `runtime:` the workspace's agents need
118
- (`fingerprint`, `agents`, `python`, `packages`, `npm`, and `rejected` for
119
- entries that are not requirements), each with the `state` of the build the
123
+ (`fingerprint`, `agents`, `python`, `packages`, `npm`, `system` — the Debian
124
+ packages it apt-installs — and `rejected` for entries that are not
125
+ requirements), each with the `state` of the build the
120
126
  platform ran for it — `pending`, `building`, `ready`, or `failed` with the
121
- installer's own `error`. `foldrun deploy` polls it after a deploy.
127
+ installer's `error`, its cause on the first line. `foldrun deploy` polls it after a deploy.
122
128
 
123
129
  ## Flows
124
130
 
@@ -144,10 +150,16 @@ everywhere else a run is live.
144
150
  | Route | Methods | |
145
151
  |---|---|---|
146
152
  | `/api/workspaces/<ws>/agents` | GET, POST | list; create (`name`) |
153
+ | `/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 } }`. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
147
154
  | `/api/workspaces/<ws>/agents/<agent>/run` | POST | run one agent alone (`task`; `test: true` for a test run) |
155
+ | `/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 |
156
+ | `/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 |
157
+ | `/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 |
148
158
  | `/api/workspaces/<ws>/evals` | GET, POST | list; create (`name`) |
149
159
  | `/api/workspaces/<ws>/evals/<eval>/run` | POST | run one eval |
150
160
  | `/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 |
161
+ | `/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 |
162
+ | `/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) |
151
163
 
152
164
  Running an agent alone is the fastest way to test a prompt change without
153
165
  paying for the steps around it.
@@ -162,22 +174,22 @@ paying for the steps around it.
162
174
  | `/api/workspaces/<ws>/runs/<id>/stop` | POST | destroys the sandbox, not just the record |
163
175
  | `/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 |
164
176
  | `/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 |
165
- | `/api/workspaces/<ws>/runs/<id>/approve` | POST | release a run parked on a human |
177
+ | `/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 |
166
178
  | `/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 |
167
179
  | `/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}` |
168
180
  | `/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 |
169
181
  | `/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 |
170
182
  | `/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 }` |
171
- | `/api/workspaces/<ws>/triggers?since=` | 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. `since` is days, default 7 |
183
+ | `/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) |
172
184
  | `/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 |
173
185
  | `/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 |
174
186
  | `/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 |
175
- | `/api/workspaces/<ws>/observe?since=` | GET | the observability report the Observe page renders — per-agent failures and retries, per-tool calls/errors/latency, per-flow duration, day series, recent failures. `since` in days, default 30 |
187
+ | `/api/workspaces/<ws>/observe?days=` | GET | the observability report the Observe page renders — per-agent failures and retries, per-tool calls/errors/latency, per-flow duration, day series, recent failures. `days` 1–365, default 30 (`since=`, its older name, still works — deprecated) |
176
188
  | `/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 |
177
189
  | `/git/<tenant>/<ws>.git` | git | the workspace as a git remote — `git clone`, `git push` (a push to main deploys and runs the evals; a push to any other branch deploys a **preview** workspace named `<ws>-preview-<branch>` from that branch's tree and runs its evals there — the scheduler never fires a preview's flows, a preview reads its source's secrets, and deleting the branch deletes the preview). A push while runs are in flight is accepted but **not** applied yet: git warns at push time, and the scheduler applies it when those runs finish. HTTP Basic: any username, an API key as the password |
178
190
  | `/git/<tenant>/_library.git` | git | the account library as a git remote — a push replaces the shelf |
179
191
  | `/api/workspaces/<ws>/repo?branch=` | GET, POST | GET: branches, tags, mirror settings; `?branch=` a branch's net change against main with diffs. POST `{action}`: `deploy` a `ref` (a rollback is a new commit on main), `merge` a `branch` into main and deploy, `delete-branch`, `mirror` (set `url`, https, credentials via the MIRROR_TOKEN secret), `mirror-now` |
180
- | `/api/workspaces/<ws>/restore` | POST | put the workspace's source back as it was at a point in its history. `to`: a commit or its prefix, a branch or tag, an ISO date/time (the last commit on main at or before it) or an age (`90m`, `12h`, `3d`, `2w`). `{ to, dryRun: true }` answers `{ to: { sha, at, by, message }, from, added, updated, removed, files: [{ path, change, diff }], blockedBy, noop }` and writes nothing. The restore needs `confirm` set to the workspace's name (400 without) and takes the dry run's `expectRemoved`; it deploys that commit's tree as a new commit on main (undo: restore to `from`), 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` |
192
+ | `/api/workspaces/<ws>/restore` | POST | put the workspace's source back as it was at a point in its history. `to`: a commit or its prefix, a branch or tag, an ISO date/time (the last commit on main at or before it) or an age (`90m`, `12h`, `3d`, `2w`). `{ to, dryRun: true }` answers `{ to: { 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 commit's tree as a new commit on main (undo: restore to `from`), 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` |
181
193
 
182
194
  `status` is one of `queued`, `running`, `awaiting-approval`, `completed`,
183
195
  `failed`.
@@ -279,7 +291,9 @@ its header and requires a URL it can GET for itself.
279
291
 
280
292
  ## Secrets
281
293
 
282
- `GET`, `PUT`, `DELETE /api/secrets`
294
+ | Route | Methods | |
295
+ |---|---|---|
296
+ | `/api/secrets?workspace=` | GET, PUT, DELETE | the vault: list which secrets exist (never a value); set one (`name` plus one shape, below); delete one (`name`). `workspace` names a workspace's own store; unsaid is the account's, which a key scoped to some workspaces may not touch. PUT is idempotent |
283
297
 
284
298
  `PUT` takes `name` plus exactly one shape: `value` (a string), `file` (a PEM,
285
299
  a cert), `oauth2`, `service_account`, `ssh`, or `api`. They go to the vault;
@@ -296,15 +310,17 @@ Reads return which secrets exist, never their values.
296
310
  | `/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 |
297
311
  | `/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 |
298
312
  | `/api/team/transfer` | POST | hand the account to a member (`to`) — owner only; you become an admin |
299
- | `/api/usage/history?days=` | GET | charged spend by flow, by agent and by workspace over time, read from the ledger rather than the run records — so it survives runs being pruned and reports what was billed rather than what was consumed. Buckets by week over three weeks, by day under. Each series carries its periods, its total, and the change between the last two COMPLETE periods, so a partial week never reads as a collapse. `unattributedUsd` is charges from before a line recorded its flow |
313
+ | `/api/usage/history?days=` | GET | charged spend by flow, by agent and by workspace over time, read from the ledger rather than the run records — so it survives runs being pruned and reports what was billed rather than what was consumed. Buckets by week over three weeks, by day under. Each series carries its periods, its total, and the change between the last two COMPLETE periods, so a partial week never reads as a collapse. `unattributedUsd` is charges from before a line recorded its flow. A caller scoped to some workspaces sees only the charges in those; `days` 1–365, default 56 |
314
+ | `/api/account/model` | GET, PUT, DELETE | the account's own model key — what every step runs on unless an agent, workspace or the account's AGENTS.md names a `provider:`. GET: `{ set, provider, baseUrl, format, keyHint, runsOn, platformKeyAllowed }` — never the key, only its last four characters; `runsOn` is `account` (its own key), `platform` (the platform's credential, for the operator's accounts) or `none` (runs are refused until a key is set). PUT `{ provider, apiKey, baseUrl?, format? }` (needs `secrets:write`): `provider` is a preset name (anthropic, openrouter, openai, groq, … — see providers) or `custom` with an https `baseUrl` and a `format` (anthropic or openai); a Claude login token (sk-ant-oat…) is refused for any account not allowed the platform's credential. Stored as the account secrets `FOLDRUN_MODEL_PROVIDER`, `FOLDRUN_MODEL_API_KEY`, `FOLDRUN_MODEL_BASE_URL`, `FOLDRUN_MODEL_FORMAT`. DELETE removes them. Honours Idempotency-Key on PUT; audited as model.key_set / model.key_removed |
300
315
  | `/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 |
301
- | `/api/usage` | GET | what has been spent, by meter |
316
+ | `/api/usage` | GET | what has been spent, by meter. 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 |
302
317
  | `/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` |
303
318
  | `/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 |
304
319
  | `/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 |
305
320
  | `/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) |
306
321
  | `/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 |
307
- | `/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 } } }`; PATCH `{ version: "YYYY-MM-DD" \| null }` pins the version requests without a `Foldrun-Version` header get (null: always current). `keys:manage`. See [api-usage](api-usage.md) |
322
+ | `/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 requests without a `Foldrun-Version` header get (null: always current). `keys:manage`. See [api-usage](api-usage.md) |
323
+ | `/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) |
308
324
  | `/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 |
309
325
  | `/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 |
310
326
  | `/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 |
@@ -347,7 +363,7 @@ person: a key names an account, never a who.
347
363
  | `/api/billing/card` | POST | save a card |
348
364
  | `/api/billing/details` | GET, PUT | who the invoices are made out to — GET `{ details, stripe, customer, tax }` for anyone who can see billing; PUT (the owner) `legalName` (required), `email`, `abn` (checked against the ABN rule), `address` `{ line1, line2, city, state, postcode, country }` (two-letter country) or `null`. Every problem comes back at once as `errors` with a `400`. Kept first, then sent to the Stripe customer (name, address, the ABN as its `au_abn` tax id): answers `synced`, and `syncError` with Stripe's words when Stripe refused — the details stand either way |
349
365
  | `/api/billing/portal` | POST | a Stripe Billing Portal session for the account's customer (the owner): answers `url`. Optional `returnTo`, a `/dashboard/…` path to come back to. `501` without Stripe |
350
- | `/api/billing/statement?month=&format=` | GET | one month of the ledger as a statement. JSON by default; `format=csv` downloads it with the month, the account and the spent and added totals in its header. `month` is `YYYY-MM`, default the current month. What the customer sees is the charge; the platform's provider cost is not on it |
366
+ | `/api/billing/statement?month=&format=` | GET | one month of the ledger as a statement. JSON by default; `format=csv` downloads it with the month, the account and the spent and added totals in its header. `month` is `YYYY-MM`, default the current month. What the customer sees is the charge; the platform's provider cost is not on it. The whole account's money, so refused (403) to a member or key scoped to some workspaces, as the audit log is |
351
367
  | `/api/billing/wallet` | GET, PUT | the balance summary (burn, runway, daily spend, month to date, the suggested top-up), the refill rule and the low-balance email — GET `{ enabled, stripe, summary, cardSaved, autoTopUp, email, dunning }`, PUT `autoTopUp`, `email`. `dunning.status` is `none`, `grace` (a plan invoice failed; runs go on until `graceUntil`) or `suspended` (new runs paused until it is paid), with `payUrl`, `amountDue` (minor units) and `currency` |
352
368
  | `/api/billing/plans` | GET | the catalogue (four plans: fee, credits, per-credit price, runs at once, workspaces, lane), where this account stands (plan, status, this cycle's credits and what is left, when it resets), the balance in dollars and credits, and any overdraft the platform has allowed |
353
369
  | `/api/billing/subscribe` | POST, DELETE | POST `plan` — starter, creator, pro or scale. With no subscription yet, answers `url`: a Stripe Checkout in subscription mode; the return trip starts the first cycle. With a live one, changes it at Stripe at once, prorated (an upgrade is invoiced today and the bigger bundle lands with it; a downgrade comes back as credit on the next invoice), and answers `changed: true`. POST `resume: true` takes a pending cancellation off — nothing is charged and no cycle moves. DELETE ends the plan with the current cycle: the credits stand until then, and credits bought on top are kept |
@@ -360,7 +376,7 @@ What a platform runs, and what changed. See [Versions and releases](versions).
360
376
  | Route | Methods | |
361
377
  |---|---|---|
362
378
  | `/api/version` | GET | open, no key. `{ version, released_at, build, api, components: { core, platform, web, docs, infra, cli }, packages: { core, cli }, browsers, browser_versions }` — `version` is `vYYYY.MM.DD.N` (`dev` on an install the deploy did not build); `components` are short commit shas (`cli` is the CLI commit released alongside); `packages.cli` is that CLI's npm version; `api` is the API contract's date version; `browsers` is the engines the runner image was built with (its `/opt/browser/engines.json`, read by the deploy — see [Engines](tools#engines)), `null` when the deploy did not say; `browser_versions` is their versions where the manifest has a dotted one (`{ chromium: "153.0.8010.12", chrome: "154.0.8037.58", lightpanda: "0.4.1" }`), which `foldrun check` compares with a pinned `user_agent`, `null` when none. `Cache-Control: no-store` |
363
- | `/api/changelog?limit=` | GET | `{ version, releases: [{ version, released_at, previous, components, notes }] }`, newest first — 10 by default, at most 50. `notes` is `{ features, fixes, docs, other }`, each a list of `{ repo, sha, subject }` grouped from commit subjects, or `null` when the deploy could not read what changed. Needs a key or a session |
379
+ | `/api/changelog?limit=` | GET | `{ version, api, releases: [{ version, released_at, previous, components, notes }] }`. `api` is the API's own changelog: `{ current, supportMonths, versions: [{ version, changes }], deprecations: [{ route, deprecatedAt, sunsetAt, link }] }` — every version a caller may pin, newest first, with the breaking changes it introduced, and the routes on their way out. `releases` newest first — 10 by default, at most 50. `notes` is `{ features, fixes, docs, other }`, each a list of `{ repo, sha, subject }` grouped from commit subjects, or `null` when the deploy could not read what changed. Needs a key or a session |
364
380
 
365
381
  Every `/api` response carries the version in its headers, refusals included,
366
382
  so a client learns what it is talking to without a second request:
@@ -431,5 +447,6 @@ signed-in person, never by a key — mints an ordinary API key at their role.
431
447
  | `/api/inbox/<tenant>/<ws>/<flow>` | POST | the token as for a hook (`Authorization: Bearer`, `X-Foldrun-Token`, or `?token=`). An inbound email for a `trigger: email` flow — JSON (`from`, `to`, `subject`, `text` or `html`; Resend's `data` wrapper understood) or an inbound-parse form (Mailgun, SendGrid). Same token as the hook |
432
448
  | `/api/events/<tenant>/<ws>/<id>?token=` | POST | release a run parked on `wait: event`; the body becomes the step's event payload. The token is derived from the run id, and the URL is printed in the waiting step's trace. `409` when the run is not waiting on an event |
433
449
  | `/api/approve/<tenant>/<ws>/<id>?token=` | GET, POST | decide a run parked on a human, from the link in its notification. GET shows what is waiting and two buttons — it never decides, because inbox link-checkers follow GETs. POST `decision` (`approve` or `reject`) and an optional `note`, form-encoded or JSON; answers JSON when `Accept` prefers it. The token is derived from the run id under the install key, not stored, so a key rotation kills every link. Needs `FOLDRUN_PUBLIC_URL` to be minted at all |
450
+ | `/api/workspaces/<ws>/hooks/<flow>` | GET | read the flow's webhook URL without changing it: `{ ok, flow, path, url }` — `path` carries the token, `url` is it made absolute with `FOLDRUN_PUBLIC_URL` (null when that is unset). `workspace:manage`, as rotate and the settings page that shows it, since the URL is a credential. `404` for no such flow, `409` for a flow that is not `trigger: webhook` |
434
451
  | `/api/workspaces/<ws>/hooks/<flow>/rotate` | POST | new token, old URLs stop working |
435
452
  | `/api/git/<tenant>/<ws>?branch=&dir=` | POST | GitHub push webhook — HMAC-signed, fetches the tarball at that commit and deploys it |
@@ -6,75 +6,47 @@ different things:
6
6
  | | What it brings back | Who does it | How far back |
7
7
  |---|---|---|---|
8
8
  | **A workspace's history** | its agents, flows, knowledge, skills, tools, scripts — the markdown | you, from Settings → Backups, `foldrun restore` or the API | every change it ever had |
9
- | **The nightly snapshot** | everything else: runs, `state/`, storage's index, secrets, people, keys, the ledger, the audit log | the platform team, when you ask | the snapshots kept (below) |
9
+ | **A database dump before a migration** | the database: people, API keys, the ledger and every balance, the queue, the audit log | the platform team, when you ask | the last 10 deploys that changed the schema |
10
10
 
11
11
  You do not need to switch either on.
12
12
 
13
- ## What the nightly snapshot holds
14
-
15
- On the hosted platform today (a single server), a job runs
16
- **nightly at 03:30, server time (Sydney)** and archives every account
17
- together:
18
-
19
- - the whole data volume: every workspace's files — source, `memory/`,
20
- `state/`, run records — each workspace's git history, the account's
21
- encrypted secrets (the vault) and the storage index;
22
- - the database (`pg_dump`): people, API keys, sessions, the ledger and every
23
- balance, the queue, the audit log;
24
- - the server's key file.
25
-
26
- **Encrypted before it leaves the server.** Every file the job writes — the
27
- archive (with the database dump inside it), the list of what it holds, and
28
- the key file — is encrypted with [age](https://age-encryption.org) to a
29
- public key whose private half is **not** on the server: the server can make
30
- a backup and cannot open one, and neither can anyone holding the object
31
- storage it is copied to. The archive is encrypted as it is written, so it is
32
- never stored unencrypted, even on the server. The job refuses to run rather
33
- than copy anything unencrypted. Snapshots taken before this change
34
- (October 2026) were copied off the server unencrypted, all but the key file:
35
- the first encrypted run deletes those copies from object storage, and the
36
- server's own copies of those nights age out of the 14 kept.
37
-
38
- Before an archive is kept it is checked: the database dump must be readable
39
- by `pg_restore --list` and list table data, row counts are taken from the
40
- live database at the same moment, and the archive is listed in full and
41
- its checksum recorded — of the very bytes being encrypted, in the same pass.
42
- An archive that fails the check is deleted and the older ones are kept.
43
- That an encrypted archive decrypts, with the private key, to exactly that
44
- checksum is proved by an automated test of the whole job on every change to
45
- it; the server cannot prove it nightly, because it does not have the key.
46
-
47
- **Kept:** the last 14 on the server, and 15 days in object storage off it
48
- (Cloudflare R2).
49
-
50
- A full restore from one of these archives was performed on 2026-09-05, from
51
- the R2 copy: the archive matched the server's byte for byte and restored the
52
- workspaces, run records, the database and the sealed key file. That was
53
- before the archive itself was encrypted; *not yet performed* on an encrypted
54
- one — the next restore drill is.
13
+ ## There is no nightly snapshot
14
+
15
+ The hosted platform today is a development server, and since 11 October 2026
16
+ it takes **no nightly backup**. Run records, `state/`, storage's index and
17
+ the vault are on its data volume and are not copied anywhere on a schedule.
18
+ The last full archive is from 7 October 2026 (`foldrun-data-20261007-0330`,
19
+ in Cloudflare R2), encrypted with [age](https://age-encryption.org) to a key
20
+ the server does not hold.
21
+
22
+ What remains is the **database dump taken before every deploy that runs a
23
+ new migration**. It is checked with `pg_restore --list` before it is kept,
24
+ encrypted to the same off-server key (the server can write it and cannot
25
+ read it), and the deploy refuses to go ahead without it, because a migration
26
+ has no way back. The newest 10 are kept on the server and a copy goes to R2.
27
+ It holds the database only, not the data volume.
55
28
 
56
29
  ### What it does not hold
57
30
 
58
- - **Storage file bytes.** On the hosted platform the files in a workspace's
59
- Storage live in object storage (R2), not on the data volume; the snapshot
60
- has the index of what exists, not the bytes. *Unverified:* whether a
61
- storage file deleted by mistake can still be recovered from object
62
- storage. Ask, and say when it was deleted.
31
+ - Workspace files, run records, `state/`, `memory/`, the vault and each
32
+ workspace's git history: the data volume is not backed up. A workspace's
33
+ source can still be restored from its own history (below).
34
+ - **Storage file bytes.** These live in object storage (R2), not on the data
35
+ volume. *Unverified:* whether a storage file deleted by mistake can still
36
+ be recovered from object storage. Ask, and say when it was deleted.
63
37
  - Anything that only existed inside a sandbox while a step ran.
64
38
  - What model providers and connected services hold on their side.
65
39
 
66
40
  A self-hosted install backs up however its operator set it up; this page
67
- describes the hosted platform. `foldrun-infra/dev/backup.sh` is the job, if
68
- you want the same for yours: it needs `age` and `FOLDRUN_BACKUP_RECIPIENT`
69
- (your age public key) and will not run without them.
41
+ describes the hosted platform. `foldrun-infra/dev/ci/premigrate-backup.sh`
42
+ is the pre-migration dump, if you want the same for yours: it needs `age` and
43
+ `FOLDRUN_BACKUP_RECIPIENT` (your age public key) and will not run without them.
70
44
 
71
45
  ## Seeing your snapshots
72
46
 
73
- Settings → **Backups** shows how often, how many are kept, when the last one
74
- was taken and whether it was verified, copied off the server and
75
- **encrypted**, and each snapshot that holds your account, newest first —
76
- each one marked encrypted or not (the ones from before October 2026 are
77
- not).
47
+ Settings → **Backups** and `foldrun backups` list the archives recorded for
48
+ your account. With the nightly job gone, nothing new is added to that list:
49
+ it ends at the last nightly run, on 7 October 2026.
78
50
 
79
51
  ```sh
80
52
  foldrun backups # the same, in the terminal
@@ -84,14 +56,10 @@ foldrun backups # the same, in the terminal
84
56
  GET /api/account/backups
85
57
  ```
86
58
 
87
- Each archive the job verifies is recorded with the accounts it holds; an
88
- account sees only the archives it is in, and its own name as the only entry
89
- in `accounts`. Each record says `encrypted` and
90
- names the key it was encrypted to by fingerprint (`recipient`,
91
- `sha256:` and 16 hex digits of the public key) — a change of fingerprint is
92
- a change of key. *Not yet verified on the server:*
93
- the recording landed on 2026-10-01, so the list starts with the first
94
- nightly run after that deploy — older archives exist but are not listed.
59
+ An account sees only the archives it is in, and its own name as the only
60
+ entry in `accounts`. Each record says `encrypted` and names the key it was
61
+ encrypted to by fingerprint (`recipient`, `sha256:` and 16 hex digits of the
62
+ public key).
95
63
 
96
64
  ## Restoring a workspace yourself
97
65
 
@@ -138,8 +106,9 @@ deploy any one of them — the same operation, by commit instead of by time.
138
106
  ## Asking for a restore from a snapshot
139
107
 
140
108
  Run records, `state/`, storage and the account's records (people, keys,
141
- balances) come back only from a nightly snapshot. That restore replaces a
142
- volume and a database every account shares, and the key that opens the
109
+ balances) come back only from an archive: the database from a pre-migration
110
+ dump, the rest only from the last full archive (7 October 2026). That restore
111
+ replaces a volume and a database every account shares, and the key that opens the
143
112
  sealed half of the archive is deliberately not on the server, so it is done
144
113
  by the platform team rather than by a button.
145
114
 
@@ -98,6 +98,10 @@ per-seat fee would tax the approver the safety story depends on.
98
98
  **Bring your own model key** and tokens cost no credits at all — the model
99
99
  is billed to your own provider account — so a plan then buys sandbox time
100
100
  and the platform, which is most of what a BYOK customer's credits go on.
101
+ A step counts as BYOK when it ran on a key of the account's own (Settings →
102
+ Model, or a `provider:` block); the run page says which credential each step
103
+ used. On a hosted platform every customer account brings one
104
+ ([providers](providers.md#your-accounts-own-model-key)).
101
105
 
102
106
  **Going into minus.** Ordinarily an empty balance starts no run. The
103
107
  platform can allow an account to run below zero — to a floor, or with none —
@@ -166,7 +170,7 @@ avatar, with eight tabs:
166
170
 
167
171
  | tab | what it is |
168
172
  |---|---|
169
- | **Balance** | the number, large, with how long it lasts in words, thirty days of spend as a sparkline, what is held against runs in flight, and Add credit with the amount that covers a month already chosen |
173
+ | **Balance** | the number, large, with how long it lasts in words, thirty days of spend as a sparkline, what is held against runs in flight, and Add credit with the amount that covers a month already chosen. While billing is off on an install (self-hosted, or a test platform), Add credit puts credit on by hand instead — no payment, and an empty balance stops nothing then — the same as `foldrun billing credit <usd>` |
170
174
  | **Plan** | the plan you are on, this cycle's credits and when they reset, and the plans you could move to |
171
175
  | **Usage** | what was consumed, from the run records, and what each flow and agent cost by week from the ledger |
172
176
  | **Statement** | the ledger as a statement: grouped by day, human labels, month-to-date, filters, and a CSV for the month |