foldrun 0.6.0 → 0.8.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/README.md +98 -16
- package/bin/foldrun.mjs +333 -46
- package/docs/agents.md +483 -0
- package/docs/api-usage.md +156 -0
- package/docs/api.md +435 -0
- package/docs/backups-and-restore.md +171 -0
- package/docs/budgets-and-billing.md +362 -0
- package/docs/cli.md +449 -0
- package/docs/coding-agents.md +224 -0
- package/docs/deploying.md +77 -0
- package/docs/evals.md +90 -0
- package/docs/faq.md +53 -0
- package/docs/flows.md +670 -0
- package/docs/getting-started.md +142 -0
- package/docs/glossary.md +78 -0
- package/docs/harness.md +128 -0
- package/docs/how-it-works.md +255 -0
- package/docs/introduction.md +47 -0
- package/docs/knowledge-and-memory.md +85 -0
- package/docs/notifications.md +320 -0
- package/docs/providers.md +241 -0
- package/docs/runs.md +238 -0
- package/docs/scheduling.md +103 -0
- package/docs/secrets.md +163 -0
- package/docs/security.md +167 -0
- package/docs/self-hosting.md +65 -0
- package/docs/status.md +73 -0
- package/docs/storage-and-sharing.md +118 -0
- package/docs/team-and-access.md +146 -0
- package/docs/tools.md +1810 -0
- package/docs/troubleshooting.md +155 -0
- package/docs/versions.md +79 -0
- package/docs/workspaces.md +160 -0
- package/package.json +9 -5
- package/src/commands.mjs +6487 -260
- package/src/guide.mjs +172 -0
package/docs/api.md
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Every route under `/api`. Generated by hand from the handlers and kept honest by
|
|
4
|
+
`tests/consistency.test.ts`, which fails the build if a route exists and this
|
|
5
|
+
file does not mention it.
|
|
6
|
+
|
|
7
|
+
The same routes as an OpenAPI 3.1 document: `GET /api/openapi.json` (no key
|
|
8
|
+
needed; `foldrun api spec --out openapi.json` saves it). What every route has
|
|
9
|
+
in common — versions (`Foldrun-Version`), rate limits (`X-RateLimit-*`, `429`),
|
|
10
|
+
`Idempotency-Key`, errors and pagination — is in [Using the API](api-usage.md).
|
|
11
|
+
|
|
12
|
+
## Authentication
|
|
13
|
+
|
|
14
|
+
One funnel for every caller. A route either takes an authenticated tenant or
|
|
15
|
+
refuses — there is no separate "browser API". The exceptions carry their own
|
|
16
|
+
proof instead of a key: a token or signature (`/s/<token>`, `/api/hooks`,
|
|
17
|
+
`/api/inbox`, `/api/events`, `/api/approve`, `/api/unsubscribe`, `/api/git`, `/api/billing/stripe`,
|
|
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`.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Authorization: Bearer <api-key> # machines. Settings → API keys
|
|
24
|
+
Cookie: foldrun_session=... # the dashboard, same-origin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It **fails closed**. It used to be the other way round — no key meant "trust
|
|
28
|
+
the `x-foldrun-tenant` header" unless a flag was set — which let anyone who
|
|
29
|
+
reached the deployment read every workspace and write any secret. A missing
|
|
30
|
+
flag should refuse, not admit.
|
|
31
|
+
|
|
32
|
+
`FOLDRUN_DEV_NO_AUTH=1` opts out for local development only, and only `next dev`
|
|
33
|
+
sets it.
|
|
34
|
+
|
|
35
|
+
Errors are `{ "error": "..." }` with a real status: `401` unauthenticated,
|
|
36
|
+
`402` out of credit, `404` missing, `409` conflict, `422` a refusal you have to
|
|
37
|
+
read (a deploy with issues, an `Idempotency-Key` reused with a different body),
|
|
38
|
+
`429` rate limited (`Retry-After`, `{ error, retry_after }`).
|
|
39
|
+
|
|
40
|
+
**A person's alone.** A key carries a role and a workspace scope no wider
|
|
41
|
+
than the person who minted it, and opens every route below with that — except
|
|
42
|
+
the ones that are about people: `/api/team` (invite, roles, scope, remove,
|
|
43
|
+
transfer), `/api/auth/*` (profile, password, MFA, sessions), a user's
|
|
44
|
+
`/api/avatars/user/<id>`, and the super admin console. Those need a signed-in
|
|
45
|
+
session and answer a key with `401` (a user's avatar answers a key with
|
|
46
|
+
`403`: the key is read, then refused because it is not that person). Everything a workspace or an account
|
|
47
|
+
*does* — files, runs, secrets, shares, schedules, hooks, storage, billing —
|
|
48
|
+
takes the key.
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
export FOLDRUN_TOKEN=... FOLDRUN_URL=https://your-server WS=my-workspace
|
|
54
|
+
api() { p=$1; shift; curl -sS -H "authorization: Bearer $FOLDRUN_TOKEN" \
|
|
55
|
+
"$FOLDRUN_URL/api/$p" "$@"; }
|
|
56
|
+
|
|
57
|
+
api workspaces/$WS/runs # what has run
|
|
58
|
+
api workspaces/$WS/runs/<id> # every step of one
|
|
59
|
+
api workspaces/$WS/flows/publish/run -X POST # start a flow
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Workspaces
|
|
65
|
+
|
|
66
|
+
| Route | Methods | |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `/api/workspaces` | GET, POST | list; create (`name`, `template`, `demo`, `files`) |
|
|
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
|
+
| `/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
|
+
|
|
72
|
+
## Source — the files themselves
|
|
73
|
+
|
|
74
|
+
| Route | Methods | |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `/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` |
|
|
77
|
+
| | PUT | write (`path`, `content`, optional `message` — recorded on the revision, as `foldrun source put --message` sends it) |
|
|
78
|
+
| | PATCH | move (`from`, `to`) |
|
|
79
|
+
| | DELETE | remove (`path`) |
|
|
80
|
+
|
|
81
|
+
Editing one file at a time. For a whole tree, deploy. The files this door
|
|
82
|
+
writes are text: markdown, and a script's or a register's own formats —
|
|
83
|
+
`.py`, `.sh`, `.js`, `.ts`, `.rb`, `.sql`, `.txt`, `.json`, `.yaml`, `.toml`,
|
|
84
|
+
`.env`, `.csv`, `.tsv`.
|
|
85
|
+
|
|
86
|
+
## Deploy
|
|
87
|
+
|
|
88
|
+
`POST /api/workspaces/<ws>/deploy`
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "files": [{ "path": "agents/x/agent.md", "content": "..." }],
|
|
92
|
+
"commit": "abc123", "force": false, "dryRun": false,
|
|
93
|
+
"expectRemoved": ["storage/old-report.md"] }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Returns `{ ok, applied, commit, added, updated, removed, preserved }`; with `dryRun: true`, `{ ok, applied: false, added, updated, removed, issues, blockedBy }` and nothing is written. Over 10 MB is `413`. **`422` with
|
|
97
|
+
`ok:false` is a refusal, not a transport failure** — read `issues`. A workspace
|
|
98
|
+
is declarative, so the whole of it is checked before any of it goes live: a
|
|
99
|
+
broken flow is caught at push time rather than at 3am when its schedule fires.
|
|
100
|
+
|
|
101
|
+
`dryRun: true` checks and changes nothing. `force: true` deploys over a live
|
|
102
|
+
run, which otherwise refuses — swapping files under a running flow means step 3
|
|
103
|
+
reads agents step 1 never saw.
|
|
104
|
+
|
|
105
|
+
`expectRemoved` (optional) is the `removed` list a person confirmed from a dry
|
|
106
|
+
run. If the deploy would now remove anything outside it — a run wrote a new
|
|
107
|
+
`storage/` output in between, say — it is refused with **`409`**
|
|
108
|
+
`{ ok: false, reason: "removals changed", removed, unexpectedRemovals }` and
|
|
109
|
+
nothing changes; confirm the new `removed` and send it again. The check and the
|
|
110
|
+
swap happen in one step, so nothing can slip in between. Absent, no check.
|
|
111
|
+
`foldrun deploy` always sends it: `[]` when nothing was to be removed.
|
|
112
|
+
|
|
113
|
+
Never touched by a deploy: `runs/`, `state/`, `secrets.json`, and any memory an
|
|
114
|
+
agent wrote that the push does not mention.
|
|
115
|
+
|
|
116
|
+
`GET /api/workspaces/<ws>/runtimes` answers what a deploy started on the
|
|
117
|
+
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
|
|
120
|
+
platform ran for it — `pending`, `building`, `ready`, or `failed` with the
|
|
121
|
+
installer's own `error`. `foldrun deploy` polls it after a deploy.
|
|
122
|
+
|
|
123
|
+
## Flows
|
|
124
|
+
|
|
125
|
+
| Route | Methods | |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `/api/workspaces/<ws>/flows` | GET, POST | list; create (`name`, `pattern`) |
|
|
128
|
+
| `/api/workspaces/<ws>/flows/<flow>` | POST, PATCH, DELETE | edit a step (`step`, `instruction`, `options`, `target`, `subflow`), rearrange (`groups`), set `trigger`, a canvas edit (`edit`, `edits`), undo (`restore` + `expect`); delete. Every PATCH and DELETE takes `expect` — the text the step numbers were read from, or its sha256 — and is refused with `409` and the current `text`, nothing written, when the file no longer reads it (the canvas, the list view's board and step options, and the CLI's `flow add`/`rm-step`/`dup-step --to` send it; the dashboard shows the 409 as "this flow changed since you opened it — reload") |
|
|
129
|
+
| `/api/workspaces/<ws>/flows/draft` | POST, PATCH, DELETE | POST draft a flow with a model: `{ description, flow? }` (`flow` redrafts that one). Returns `{ ok, files: [{ path, content, before, sha256 }], issues: [{ where, message, level }], repaired, notes }` — checked with the deploy's checks and the flow lint, repaired once. **Writes nothing**; save with the source PUT. Needs `workspace:write`; a description over 4,000 characters is `413`; a Chat-Completions `provider:` is `422`. On the platform's models the balance is checked first (`402`) and the call is charged as one ledger line, flow `ai-draft`. PATCH and DELETE, and a POST without `description`, are the edits of a flow that happens to be named `draft`, exactly as at `flows/<flow>` |
|
|
130
|
+
| `/api/workspaces/<ws>/flows/<flow>/run` | POST | start it. An `Idempotency-Key` header, or `idempotencyKey` in the body, makes a retried call safe: a dropped response, a proxy timeout or a CI step that ran twice is answered `started: false` with the `runId` it already started, rather than starting a second run and billing for it. Keys are remembered for 24 hours, per flow |
|
|
131
|
+
|
|
132
|
+
`POST .../run` takes `task` (the input), `from` (start at step N — a rerun of
|
|
133
|
+
the interesting half), `tags`, and `test` (`true` for a [test run](runs#test-runs):
|
|
134
|
+
nothing outward, state untouched, receipts on the run). Returns
|
|
135
|
+
`{ ok, runId, steps, test }` (`from` is added when a start step was given); `409 { error, runId }` when the flow has `overlap: skip` and a run is live; with `?wait=true&timeout=<s>` it holds
|
|
136
|
+
on and returns the verdict instead, exactly as `GET .../runs/<id>?wait=true`
|
|
137
|
+
does below, which is where a caller goes on to wait when a piece runs out.
|
|
138
|
+
Left out, `test` is decided by the platform:
|
|
139
|
+
a preview workspace's runs are tests unless the flow says `live: true`;
|
|
140
|
+
everywhere else a run is live.
|
|
141
|
+
|
|
142
|
+
## Agents and evals
|
|
143
|
+
|
|
144
|
+
| Route | Methods | |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `/api/workspaces/<ws>/agents` | GET, POST | list; create (`name`) |
|
|
147
|
+
| `/api/workspaces/<ws>/agents/<agent>/run` | POST | run one agent alone (`task`; `test: true` for a test run) |
|
|
148
|
+
| `/api/workspaces/<ws>/evals` | GET, POST | list; create (`name`) |
|
|
149
|
+
| `/api/workspaces/<ws>/evals/<eval>/run` | POST | run one eval |
|
|
150
|
+
| `/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 |
|
|
151
|
+
|
|
152
|
+
Running an agent alone is the fastest way to test a prompt change without
|
|
153
|
+
paying for the steps around it.
|
|
154
|
+
|
|
155
|
+
## Runs — the debug loop
|
|
156
|
+
|
|
157
|
+
| Route | Methods | |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `/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` and `costUsd` — not their reply or trace |
|
|
160
|
+
| `/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` |
|
|
161
|
+
| `/api/workspaces/<ws>/runs/<id>/stream` | GET | server-sent events, live |
|
|
162
|
+
| `/api/workspaces/<ws>/runs/<id>/stop` | POST | destroys the sandbox, not just the record |
|
|
163
|
+
| `/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
|
+
| `/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 |
|
|
166
|
+
| `/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
|
+
| `/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
|
+
| `/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
|
+
| `/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
|
+
| `/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 |
|
|
172
|
+
| `/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
|
+
| `/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
|
+
| `/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 |
|
|
176
|
+
| `/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
|
+
| `/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
|
+
| `/git/<tenant>/_library.git` | git | the account library as a git remote — a push replaces the shelf |
|
|
179
|
+
| `/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` |
|
|
181
|
+
|
|
182
|
+
`status` is one of `queued`, `running`, `awaiting-approval`, `completed`,
|
|
183
|
+
`failed`.
|
|
184
|
+
|
|
185
|
+
`summary` is what the run concluded, in one line: the first line of the last
|
|
186
|
+
step that produced a result, captured when the run finishes. Nothing declares
|
|
187
|
+
it and nothing has to — agents are asked to lead with a headline, and this
|
|
188
|
+
reads the line they already wrote. It is what the runs list shows under a row
|
|
189
|
+
and what a notification puts in its subject, so a run answers "what did this
|
|
190
|
+
do" without being opened. Null on a run that produced no output at all.
|
|
191
|
+
|
|
192
|
+
`approve` takes `decision` (`approve` or `reject`), `step`, and a `note` or
|
|
193
|
+
`reason` — the note is kept on the run, so why a human said yes survives.
|
|
194
|
+
|
|
195
|
+
A run that parks on a human also emails or POSTs its notification with
|
|
196
|
+
`approveUrl` and `rejectUrl` (see `/api/approve/…` under Triggers) when
|
|
197
|
+
`FOLDRUN_PUBLIC_URL` names the install's public origin; without it the
|
|
198
|
+
notification goes out without links and the worker logs why, once. The step's
|
|
199
|
+
trace says which door the decision came through: "approved by a human" from
|
|
200
|
+
the dashboard, "approved via emailed link" from the link, "approved by an
|
|
201
|
+
external event" when a `wait: event` step was released.
|
|
202
|
+
|
|
203
|
+
A run started as a test carries `test: true`, its `summary` opens with
|
|
204
|
+
`[test]`, and the events that report what the platform did instead of the
|
|
205
|
+
real thing carry an `effect` — `{ kind, host, method, path, summary }`, where
|
|
206
|
+
`kind` is `refused` (a send answered 403 at the egress proxy), `redirected`
|
|
207
|
+
(an email pointed at Resend's sink), `diverted` (a `state/` or `storage/`
|
|
208
|
+
write kept under `runs/<id>/test-writes/` instead) or `withheld` (a
|
|
209
|
+
send-capable secret the sandbox was not given). A re-run of a test run is a
|
|
210
|
+
test run. See [Test runs](runs#test-runs).
|
|
211
|
+
|
|
212
|
+
A step that declared `output: json` carries its parsed value as `data` beside
|
|
213
|
+
`result`; a `wait: event` step carries what released it as `eventPayload`;
|
|
214
|
+
and a run of a flow with `budget:` carries the cap it was held to as
|
|
215
|
+
`budgetUsd`, stamped when it started. `memoryWrites` lists the memory files
|
|
216
|
+
the run created or changed, workspace-relative, so what the agents decided
|
|
217
|
+
to remember can be reviewed without diffing the folder.
|
|
218
|
+
|
|
219
|
+
A run that worked is worth keeping: `promote` writes its task and its
|
|
220
|
+
conclusion into `evals/` as a case, appending when the file exists. A flow's
|
|
221
|
+
file is born `trigger: manual` — each case costs a whole run — so it runs
|
|
222
|
+
when a person presses Run, not on every push.
|
|
223
|
+
|
|
224
|
+
The usual loop: `runs` to find it → `runs/<id>` to see which step failed and
|
|
225
|
+
what it cost → fix the markdown → `deploy` → `rerun`.
|
|
226
|
+
|
|
227
|
+
## Storage — workspace files agents produce
|
|
228
|
+
|
|
229
|
+
**A request body is at most 10 MB.** Anything larger is refused with `413`
|
|
230
|
+
before it reaches a route — it is never passed on cut short. Larger files go
|
|
231
|
+
through `POST /api/workspaces/<ws>/storage/upload-url`, which returns a signed
|
|
232
|
+
URL to PUT the bytes to directly.
|
|
233
|
+
|
|
234
|
+
A `PUT` to `storage?path=` needs a `Content-Length`. Without one it is refused
|
|
235
|
+
with `411`, because a body cut off at the limit cannot be told from a whole
|
|
236
|
+
one; a body that does not match the length it declared is a `400`.
|
|
237
|
+
|
|
238
|
+
| Route | Methods | |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `/api/workspaces/<ws>/storage` | GET, POST, PUT, DELETE | GET lists every stored file with the quota; POST records one (`path`, `sha`, `size`); PUT `?path=` writes one; DELETE `?path=` removes one |
|
|
241
|
+
| `/api/workspaces/<ws>/storage/download?path=` | GET | a redirect to a presigned URL |
|
|
242
|
+
| `/api/workspaces/<ws>/storage/upload-url` | POST | a presigned PUT (`path`, `sha`) |
|
|
243
|
+
| `/api/workspaces/<ws>/storage/preview?path=` | GET | the file parsed for reading, as JSON — text, a table, a document, slides, an archive listing, a mesh, a hex dump. Stored HTML and SVG come back as strings for a sandboxed frame, never served as pages. `413` over 25 MB |
|
|
244
|
+
| `/api/workspaces/<ws>/assets` | POST | create a document (`kind`, `name`, `agent`, `template`) |
|
|
245
|
+
|
|
246
|
+
Blobs go to object storage; the index of what exists stays local. Presigned
|
|
247
|
+
URLs mean nothing in a run pod ever holds the bucket credential.
|
|
248
|
+
|
|
249
|
+
## Shares — a public link to one file
|
|
250
|
+
|
|
251
|
+
| Route | Methods | |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `/api/workspaces/<ws>/shares` | POST, GET, DELETE | mint (`path`, `ttlDays`: days, `null` never; returns `url`, `token`, `path`, `contentType`, `expiresAt`), list, revoke (`?token=`, only a link of this workspace) |
|
|
254
|
+
| `/s/<token>` | GET, HEAD | the file itself, no credential |
|
|
255
|
+
|
|
256
|
+
From the terminal: `foldrun storage share <path>`, `shares`, `unshare <token>`.
|
|
257
|
+
|
|
258
|
+
`POST` returns a `url` under `/s/<token>`. That URL answers without a
|
|
259
|
+
credential — the token *is* the authorisation, so it carries 24 random bytes
|
|
260
|
+
and is never derived from the path. `createdBy` is the caller (a person's
|
|
261
|
+
email, or the key), never a name the request sends.
|
|
262
|
+
|
|
263
|
+
Only `storage/`, `files/` and `outputs/` can be shared. An agent talked into
|
|
264
|
+
sharing something is confined to what the workspace produced and can never
|
|
265
|
+
hand out `memory/`, an agent's prompt, or a credential.
|
|
266
|
+
|
|
267
|
+
Links expire in 7 days unless `ttlDays` says otherwise (`null` never expires).
|
|
268
|
+
Revoking is permanent; a re-share mints a new token rather than reviving an
|
|
269
|
+
old one. Every failed fetch is the same 404, so a guesser learns nothing about
|
|
270
|
+
whether a token was ever real.
|
|
271
|
+
|
|
272
|
+
This needs `FOLDRUN_PUBLIC_URL` — the origin the outside world reaches this
|
|
273
|
+
install on. Without it `POST` returns 409 rather than handing back a link that
|
|
274
|
+
only works from inside.
|
|
275
|
+
|
|
276
|
+
It exists because some work is not done until something outside can fetch the
|
|
277
|
+
bytes: a Google Business Profile post will not accept uploaded image data for
|
|
278
|
+
its header and requires a URL it can GET for itself.
|
|
279
|
+
|
|
280
|
+
## Secrets
|
|
281
|
+
|
|
282
|
+
`GET`, `PUT`, `DELETE /api/secrets`
|
|
283
|
+
|
|
284
|
+
`PUT` takes `name` plus exactly one shape: `value` (a string), `file` (a PEM,
|
|
285
|
+
a cert), `oauth2`, `service_account`, `ssh`, or `api`. They go to the vault;
|
|
286
|
+
agents name them in `secrets:` and never see them in a file.
|
|
287
|
+
|
|
288
|
+
Reads return which secrets exist, never their values.
|
|
289
|
+
|
|
290
|
+
## Account
|
|
291
|
+
|
|
292
|
+
| Route | Methods | |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `/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` and `access: read\|write` mints a deploy key — git clone/push for that workspace only, refused everywhere else. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap) |
|
|
295
|
+
| `/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` |
|
|
296
|
+
| `/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
|
+
| `/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
|
+
| `/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 |
|
|
300
|
+
| `/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 |
|
|
302
|
+
| `/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
|
+
| `/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
|
+
| `/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
|
+
| `/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
|
+
| `/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) |
|
|
308
|
+
| `/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
|
+
| `/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
|
+
| `/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 |
|
|
311
|
+
| `/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 |
|
|
312
|
+
| `/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 |
|
|
313
|
+
| `/api/schedule` | GET, POST | GET: every scheduled flow with `valid` (the cron parses) and `upcoming` (the next three fires). POST: tick the scheduler now — install-wide, so `workspace:manage`; returns `{ ok, fired }` |
|
|
314
|
+
| `/api/library/<kind>?path=` | GET, POST, PUT, DELETE | account-level shared documents (`name`, `content`, `path`, `template`) |
|
|
315
|
+
| `/api/library/gallery` | GET, POST | GET: every tool the platform ships to every account, whole (`tools: [{ name, kind, title, description, files: [{ kind, file, content }], installed }]`, `installed` being `current`, `differs` or null for this account's own copy) — what `foldrun gallery` reads and lays down on a laptop. POST: update an installed gallery tool to the version the platform ships (`tool`) — written through the revision log, so the previous copy is one revision back |
|
|
316
|
+
|
|
317
|
+
## Roles
|
|
318
|
+
|
|
319
|
+
Every member and every API key has a role, and every write route asks for the
|
|
320
|
+
least role that may take that action. Reads are open to every role.
|
|
321
|
+
|
|
322
|
+
| role | may |
|
|
323
|
+
|---|---|
|
|
324
|
+
| `viewer` | read everything — workspaces, runs and their output, files, history, usage |
|
|
325
|
+
| `editor` | + edit files, upload, deploy, push; run, stop, re-run, approve and promote runs; test tools |
|
|
326
|
+
| `admin` | + create and delete workspaces; write secrets and connect credentials; mint keys; invite, re-role and remove editors and viewers |
|
|
327
|
+
| `owner` | + billing; make and unmake admins; transfer the account |
|
|
328
|
+
|
|
329
|
+
There is exactly one owner: the account's creator until they transfer it.
|
|
330
|
+
The owner is never re-roled or removed — transfer first. Nobody changes their
|
|
331
|
+
own role. An admin cannot promote to admin or remove one; that is the
|
|
332
|
+
owner's, because an admin who can promote peers is an owner with extra steps.
|
|
333
|
+
|
|
334
|
+
A refused call answers `403` with the role that was needed. A key is minted
|
|
335
|
+
with a role (`editor` unless you say otherwise) and keys from before roles
|
|
336
|
+
existed act as `admin` — the power they always had. Acts against a named
|
|
337
|
+
member — inviting, re-roling, removing, transferring — need a signed-in
|
|
338
|
+
person: a key names an account, never a who.
|
|
339
|
+
|
|
340
|
+
## Billing
|
|
341
|
+
|
|
342
|
+
| Route | Methods | |
|
|
343
|
+
|---|---|---|
|
|
344
|
+
| `/api/billing` | GET, POST | balance and recent ledger entries; POST adds credit (`usd`, `note`) on a self-hosted install only — `403` wherever billing is live |
|
|
345
|
+
| `/api/billing/checkout` | POST | a Stripe session (`usd`) |
|
|
346
|
+
| `/api/billing/confirm?session_id=` | GET | settle one |
|
|
347
|
+
| `/api/billing/card` | POST | save a card |
|
|
348
|
+
| `/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
|
+
| `/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 |
|
|
351
|
+
| `/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
|
+
| `/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
|
+
| `/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 |
|
|
354
|
+
| `/api/billing/stripe` | POST | Stripe's webhook — signature-verified, not for you. Every delivery is logged (see `/api/admin/stripe-events`). Beside top-ups it turns plan cycles: one grant per (subscription, plan, period), whichever of the checkout, `invoice.paid` (either invoice shape) or `customer.subscription.updated` arrives first; `invoice.payment_failed` marks the plan past due and starts dunning (an email, a grace period, then new runs paused — see budgets-and-billing.md), `invoice.paid` and `invoice.voided` end it, `customer.subscription.deleted` ends the plan that subscription paid for — what is left of the bundle expires, the levers come off, money the customer added stays. `charge.refunded` debits a refunded top-up (never a plan fee), `charge.dispute.created` debits a chargeback and `charge.dispute.closed` returns it when won. Answers 500 — so Stripe redelivers — only when a lookup the event needs failed. The full event list is in budgets-and-billing.md |
|
|
355
|
+
|
|
356
|
+
## Versions
|
|
357
|
+
|
|
358
|
+
What a platform runs, and what changed. See [Versions and releases](versions).
|
|
359
|
+
|
|
360
|
+
| Route | Methods | |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| `/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 |
|
|
364
|
+
|
|
365
|
+
Every `/api` response carries the version in its headers, refusals included,
|
|
366
|
+
so a client learns what it is talking to without a second request:
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
X-Foldrun-Version: v2026.10.01.3 # the platform's version
|
|
370
|
+
X-Foldrun-Api: 2026-10-01 # the API contract — moves only on a breaking change
|
|
371
|
+
X-Foldrun-Cli: 0.6.0 # the CLI released with this version (absent when unknown)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Authentication routes
|
|
375
|
+
|
|
376
|
+
| Route | Methods | |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| `/api/auth/signup` | POST | `email`, `password`, `account`, `invite`. Rate limited per address and per caller. With signups closed every address gets the same refusal, so the form cannot be used to discover which addresses are registered |
|
|
379
|
+
| `/api/auth/login` | POST | `email`, `password` |
|
|
380
|
+
| `/api/auth/logout` | POST | ends the session |
|
|
381
|
+
| `/api/auth/session` | GET | who am I |
|
|
382
|
+
| `/api/auth/sessions` | GET, DELETE | list; revoke one (`id`) or the rest (`others`) |
|
|
383
|
+
| `/api/auth/password` | POST | `current`, `next` — at least 10 characters, not your email, not in a known breach |
|
|
384
|
+
| `/api/auth/email` | POST | `email`, `password` — move your login to a new address; it is unverified until it clicks the confirmation mail, and the old address is told |
|
|
385
|
+
| `/api/auth/name` | POST | `firstName`, `lastName` — optional halves, null clears one; shown by email when empty |
|
|
386
|
+
| `/api/auth/forgot` | POST | `email`. Always 200 with the same sentence, whatever the address; a reset link goes to the inbox if there is an account. 3 per address per hour |
|
|
387
|
+
| `/api/auth/reset` | POST | `token` (from the link), `password`. Works once and for 30 minutes; every other session of yours ends; signs you in |
|
|
388
|
+
| `/api/auth/verify?token=` | GET | the link in the confirmation email — marks the address verified and lands on the dashboard |
|
|
389
|
+
| `/api/auth/verify/resend` | POST | send the confirmation again, to the signed-in person, 3 an hour |
|
|
390
|
+
| `/api/auth/mfa/setup` | POST | start enrolling a second factor: `secret`, `uri`, a `qr` data URL, and an `enrol` token — nothing is stored yet |
|
|
391
|
+
| `/api/auth/mfa/enable` | POST | `enrol`, `code` from the app. Stores the secret (sealed) and returns eight `backup` codes exactly once |
|
|
392
|
+
| `/api/auth/mfa/verify` | POST | the second step of a sign-in: `challenge` (from `/api/auth/login` when it answered `mfa: true`) and `code` — six digits or a backup code. 6 tries per five minutes |
|
|
393
|
+
| `/api/auth/mfa/disable` | POST | `password` and `code` — a session alone is not enough to remove the second factor |
|
|
394
|
+
| `/api/auth/timezone` | POST | `timezone` — what a cron expression means |
|
|
395
|
+
|
|
396
|
+
Login is rate limited per IP, which only works when `FOLDRUN_TRUST_PROXY=1`
|
|
397
|
+
tells the platform a real proxy is reporting the caller.
|
|
398
|
+
|
|
399
|
+
### Signing the CLI in
|
|
400
|
+
|
|
401
|
+
`foldrun login` is the device-authorization shape: the terminal holds a
|
|
402
|
+
secret request id, the person sees only a short code, and approval — by a
|
|
403
|
+
signed-in person, never by a key — mints an ordinary API key at their role.
|
|
404
|
+
|
|
405
|
+
| Route | Methods | |
|
|
406
|
+
|---|---|---|
|
|
407
|
+
| `/api/cli/login` | POST | `hostname` → `{ id, code, verifyUrl, expiresAt, interval }`. Open: there is no credential yet. Ten-minute lifetime |
|
|
408
|
+
| `/api/cli/login/<id>` | GET | `{ status }` — `pending`, `denied`, `expired`, or `approved` with `token`, `account`, `email`, `role`. The key is returned once; the request is gone after |
|
|
409
|
+
| `/api/cli/authorize` | GET, POST | `?code=` describes the request (machine, expiry). POST `{ code, decision }` from a session approves (mints the key, labelled `cli · <hostname>`) or denies — `decision` is `approve` or `deny`, and absent approves; any other word is a `400`. Every role may sign its own CLI in; an owner's key is an admin's |
|
|
410
|
+
| `/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 |
|
|
411
|
+
| `/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: `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 |
|
|
412
|
+
| `/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 |
|
|
413
|
+
| `/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` |
|
|
414
|
+
| `/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 |
|
|
415
|
+
|
|
416
|
+
## OAuth — connecting an agent to a third party
|
|
417
|
+
|
|
418
|
+
| Route | Methods | |
|
|
419
|
+
|---|---|---|
|
|
420
|
+
| `/api/oauth/clients` | GET, POST, DELETE | registered providers (`name`, `client`, `config`) |
|
|
421
|
+
| `/api/oauth/start` | POST | begin a connection (`client`, `secret`, `workspace`) |
|
|
422
|
+
| `/api/oauth/callback` | GET | where the provider returns |
|
|
423
|
+
| `/api/oauth/reconnect` | POST | reconnect a secret with the client it was connected with — no client id (`secret`, `workspace`, `provider`) → `{ url }` |
|
|
424
|
+
| `/api/oauth/connections` | GET | each oauth2 secret's grant: saved client, `status` (ok, expiring, expired, failed, no-client), days left, last error |
|
|
425
|
+
|
|
426
|
+
## Triggers
|
|
427
|
+
|
|
428
|
+
| Route | Methods | |
|
|
429
|
+
|---|---|---|
|
|
430
|
+
| `/api/hooks/<tenant>/<ws>/<flow>` | POST | run a flow from outside. The token may be sent as `Authorization: Bearer <token>`, as `X-Foldrun-Token`, or as `?token=` in the URL — in that order of preference, because a URL leaks into proxy logs, access logs and browser history while a header does not; the query string stays supported because most webhook senders have nowhere to put a header. The token is derived, not stored. A flow with `signature:` (github, stripe, slack, hmac) and `signing_secret:` also has the provider's HMAC checked — a Slack `url_verification` body is answered with its challenge and starts nothing; refused deliveries are logged as `invalid-signature` |
|
|
431
|
+
| `/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
|
+
| `/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
|
+
| `/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 |
|
|
434
|
+
| `/api/workspaces/<ws>/hooks/<flow>/rotate` | POST | new token, old URLs stop working |
|
|
435
|
+
| `/api/git/<tenant>/<ws>?branch=&dir=` | POST | GitHub push webhook — HMAC-signed, fetches the tarball at that commit and deploys it |
|