API reference
Every route under /api. Generated by hand from the handlers and kept honest by
tests/consistency.test.ts, which fails the build if a route exists and this
file does not mention it.
Authentication
One funnel for every caller. A route either takes an authenticated tenant or refuses — there is no separate “browser API”.
Authorization: Bearer <api-key> # machines. Settings → API keys
Cookie: foldrun_session=... # the dashboard, same-origin
It fails closed. It used to be the other way round — no key meant “trust
the x-foldrun-tenant header” unless a flag was set — which let anyone who
reached the deployment read every workspace and write any secret. A missing
flag should refuse, not admit.
FOLDRUN_DEV_NO_AUTH=1 opts out for local development only, and only next dev
sets it.
Errors are { "error": "..." } with a real status: 401 unauthenticated,
402 out of credit, 404 missing, 409 conflict, 422 a refusal you have to
read (a deploy with issues), 429 rate limited.
Quick start
export FOLDRUN_TOKEN=... FOLDRUN_URL=https://your-server WS=my-workspace
api() { p=$1; shift; curl -sS -H "authorization: Bearer $FOLDRUN_TOKEN" \
"$FOLDRUN_URL/api/$p" "$@"; }
api workspaces/$WS/runs # what has run
api workspaces/$WS/runs/<id> # every step of one
api workspaces/$WS/flows/publish/run -X POST # start a flow
Workspaces
| Route | Methods | |
|---|---|---|
/api/workspaces |
GET, POST | list; create (name, template, demo, files) |
/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 |
/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 |
Source — the files themselves
| Route | Methods | |
|---|---|---|
/api/workspaces/<ws>/source?path= |
GET | one file, or the tree with no path |
| PUT | write (path, content) |
|
| PATCH | move (from, to) |
|
| DELETE | remove (path) |
Editing one file at a time. For a whole tree, deploy.
Deploy
POST /api/workspaces/<ws>/deploy
{ "files": [{ "path": "agents/x/agent.md", "content": "..." }],
"commit": "abc123", "force": false, "dryRun": false }
Returns { ok, applied, added, changed, removed, issues }. 422 with
ok:false is a refusal, not a transport failure — read issues. A workspace
is declarative, so the whole of it is checked before any of it goes live: a
broken flow is caught at push time rather than at 3am when its schedule fires.
dryRun: true checks and changes nothing. force: true deploys over a live
run, which otherwise refuses — swapping files under a running flow means step 3
reads agents step 1 never saw.
Never touched by a deploy: runs/, state/, secrets.json, and any memory an
agent wrote that the push does not mention.
Flows
| Route | Methods | |
|---|---|---|
/api/workspaces/<ws>/flows |
GET, POST | list; create (name, pattern) |
/api/workspaces/<ws>/flows/<flow> |
POST, PATCH, DELETE | edit a step (step, instruction, options, target, subflow), rearrange (groups), set trigger; delete |
/api/workspaces/<ws>/flows/<flow>/run |
POST | start it |
POST .../run takes task (the input), from (start at step N — a rerun of
the interesting half), and tags. Returns { ok, runId, steps, from }.
Agents and evals
| Route | Methods | |
|---|---|---|
/api/workspaces/<ws>/agents |
GET, POST | list; create (name) |
/api/workspaces/<ws>/agents/<agent>/run |
POST | run one agent alone (task) |
/api/workspaces/<ws>/evals |
GET, POST | list; create (name) |
/api/workspaces/<ws>/evals/<eval>/run |
POST | run one eval |
/api/workspaces/<ws>/tools/<tool>/test |
POST | exercise one tool (args, path) |
Running an agent alone is the fastest way to test a prompt change without paying for the steps around it.
Runs — the debug loop
| Route | Methods | |
|---|---|---|
/api/workspaces/<ws>/runs?limit= |
GET | { runs: [{ id, flow, status, startedAt, finishedAt, ... }] } |
/api/workspaces/<ws>/runs/<id> |
GET, DELETE | the whole run: every step, its output, cost, timing |
/api/workspaces/<ws>/runs/<id>/stream |
GET | server-sent events, live |
/api/workspaces/<ws>/runs/<id>/stop |
POST | destroys the sandbox, not just the record |
/api/workspaces/<ws>/runs/<id>/rerun |
POST | again — step to start partway, agent to swap who runs it |
/api/workspaces/<ws>/runs/<id>/approve |
POST | release a run parked on a human |
/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. Served as bytes, never as a page |
/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 } |
/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 |
/api/workspaces/<ws>/history?path=&id= |
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. A deploy’s revision id is its commit |
/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 |
/git/<tenant>/_library.git |
git | the account library as a git remote — a push replaces the shelf |
/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 |
status is one of queued, running, awaiting-approval, completed,
failed.
summary is what the run concluded, in one line: the first line of the last
step that produced a result, captured when the run finishes. Nothing declares
it and nothing has to — agents are asked to lead with a headline, and this
reads the line they already wrote. It is what the runs list shows under a row
and what a notification puts in its subject, so a run answers “what did this
do” without being opened. Null on a run that produced no output at all.
approve takes decision (approve or reject), step, and a note or
reason — the note is kept on the run, so why a human said yes survives.
A run that parks on a human also emails or POSTs its notification with
approveUrl and rejectUrl (see /api/approve/… under Triggers) when
FOLDRUN_PUBLIC_URL names the install’s public origin; without it the
notification goes out without links and the worker logs why, once. The step’s
trace says which door the decision came through: “approved by a human” from
the dashboard, “approved via emailed link” from the link, “approved by an
external event” when a wait: event step was released.
A step that declared output: json carries its parsed value as data beside
result; a wait: event step carries what released it as eventPayload;
and a run of a flow with budget: carries the cap it was held to as
budgetUsd, stamped when it started. memoryWrites lists the memory files
the run created or changed, workspace-relative, so what the agents decided
to remember can be reviewed without diffing the folder.
A run that worked is worth keeping: promote writes its task and its
conclusion into evals/ as a case, appending when the file exists. A flow’s
file is born trigger: manual — each case costs a whole run — so it runs
when a person presses Run, not on every push.
The usual loop: runs to find it → runs/<id> to see which step failed and
what it cost → fix the markdown → deploy → rerun.
Storage — workspace files agents produce
| Route | Methods | |
|---|---|---|
/api/workspaces/<ws>/storage?path= |
GET, POST, PUT, DELETE | list, record (path, sha, size), write, remove |
/api/workspaces/<ws>/storage/download?path= |
GET | a redirect to a presigned URL |
/api/workspaces/<ws>/storage/upload-url |
POST | a presigned PUT (path, sha) |
/api/workspaces/<ws>/assets |
POST | create a document (kind, name, agent, template) |
Blobs go to object storage; the index of what exists stays local. Presigned URLs mean nothing in a run pod ever holds the bucket credential.
Shares — a public link to one file
| Route | Methods | |
|---|---|---|
/api/workspaces/<ws>/shares |
POST, GET, DELETE | mint (path, ttlDays), list, revoke (?token=) |
/s/<token> |
GET, HEAD | the file itself, no credential |
POST returns a url under /s/<token>. That URL is the only route on the
server that answers without a credential — the token is the authorisation,
so it carries 24 random bytes and is never derived from the path.
Only storage/, files/ and outputs/ can be shared. An agent talked into
sharing something is confined to what the workspace produced and can never
hand out memory/, an agent’s prompt, or a credential.
Links expire in 7 days unless ttlDays says otherwise (null never expires).
Revoking is permanent; a re-share mints a new token rather than reviving an
old one. Every failed fetch is the same 404, so a guesser learns nothing about
whether a token was ever real.
This needs FOLDRUN_PUBLIC_URL — the origin the outside world reaches this
install on. Without it POST returns 409 rather than handing back a link that
only works from inside.
It exists because some work is not done until something outside can fetch the bytes: a Google Business Profile post will not accept uploaded image data for its header and requires a URL it can GET for itself.
Secrets
GET, PUT, DELETE /api/secrets
PUT takes name plus exactly one shape: value (a string), file (a PEM,
a cert), oauth2, service_account, ssh, or api. They go to the vault;
agents name them in secrets: and never see them in a file.
Reads return which secrets exist, never their values.
Account
| Route | Methods | |
|---|---|---|
/api/keys?tenant= |
GET, POST, DELETE | API keys (label, tenant, id). POST with workspace and access: read|write mints a deploy key — git clone/push for that workspace only, refused everywhere else |
/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 the link either way |
/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 |
/api/team/transfer |
POST | hand the account to a member (to) — owner only; you become an admin |
/api/usage |
GET | what has been spent, by meter |
/api/approvals |
GET | everything waiting on a human, across workspaces |
/api/account |
GET, PATCH | the account’s defaults from its AGENTS.md frontmatter — timezone, budget (monthly USD cap), notify ({ url?, email?, events }); PATCH sets the keys sent, null clears one, and edits the file in place |
/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 |
/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 |
/api/schedule |
GET, POST | scheduled flows |
/api/library/<kind>?path= |
GET, POST, PUT, DELETE | account-level shared documents (name, content, path, template) |
Roles
Every member and every API key has a role, and every write route asks for the least role that may take that action. Reads are open to every role.
| role | may |
|---|---|
viewer |
read everything — workspaces, runs and their output, files, history, usage |
editor |
+ edit files, upload, deploy, push; run, stop, re-run, approve and promote runs; test tools |
admin |
+ create and delete workspaces; write secrets and connect credentials; mint keys; invite, re-role and remove editors and viewers |
owner |
+ billing; make and unmake admins; transfer the account |
There is exactly one owner: the account’s creator until they transfer it. The owner is never re-roled or removed — transfer first. Nobody changes their own role. An admin cannot promote to admin or remove one; that is the owner’s, because an admin who can promote peers is an owner with extra steps.
A refused call answers 403 with the role that was needed. A key is minted
with a role (editor unless you say otherwise) and keys from before roles
existed act as admin — the power they always had. Acts against a named
member — inviting, re-roling, removing, transferring — need a signed-in
person: a key names an account, never a who.
Billing
| Route | Methods | |
|---|---|---|
/api/billing |
GET, POST | balance; add credit (usd, note) |
/api/billing/checkout |
POST | a Stripe session (usd) |
/api/billing/confirm?session_id= |
GET | settle one |
/api/billing/card |
POST | save a card |
/api/billing/wallet |
GET, PUT | auto top-up and low-balance email |
/api/billing/stripe |
POST | Stripe’s webhook — signature-verified, not for you |
Authentication routes
| Route | Methods | |
|---|---|---|
/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 |
/api/auth/login |
POST | email, password |
/api/auth/logout |
POST | ends the session |
/api/auth/session |
GET | who am I |
/api/auth/sessions |
GET, DELETE | list; revoke one (id) or the rest (others) |
/api/auth/password |
POST | current, next |
/api/auth/timezone |
POST | timezone — what a cron expression means |
Login is rate limited per IP, which only works when FOLDRUN_TRUST_PROXY=1
tells the platform a real proxy is reporting the caller.
Signing the CLI in
foldrun login is the device-authorization shape: the terminal holds a
secret request id, the person sees only a short code, and approval — by a
signed-in person, never by a key — mints an ordinary API key at their role.
| Route | Methods | |
|---|---|---|
/api/cli/login |
POST | hostname → { id, code, verifyUrl, expiresAt, interval }. Open: there is no credential yet. Ten-minute lifetime |
/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 |
/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. Every role may sign its own CLI in; an owner’s key is an admin’s |
/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 |
OAuth — connecting an agent to a third party
| Route | Methods | |
|---|---|---|
/api/oauth/clients |
GET, POST, DELETE | registered providers (name, client, config) |
/api/oauth/start |
POST | begin a connection (client, secret, workspace) |
/api/oauth/callback |
GET | where the provider returns |
Triggers
| Route | Methods | |
|---|---|---|
/api/hooks/<tenant>/<ws>/<flow>?token= |
POST | run a flow from outside. 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 |
/api/inbox/<tenant>/<ws>/<flow>?token= |
POST | 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 |
/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 |
/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 |
/api/workspaces/<ws>/hooks/<flow>/rotate |
POST | new token, old URLs stop working |
/api/git/<tenant>/<ws>?branch=&dir= |
POST | GitHub push webhook — HMAC-signed, fetches the tarball at that commit and deploys it |
Operations
| Route | Auth | |
|---|---|---|
/api/healthz |
open | { ok, version, role, workerAlive, database, redis } — reports, never gates |
/api/metrics |
FOLDRUN_METRICS_TOKEN or a session |
Prometheus text: queue depth, oldest pending, runs running, runs awaiting approval |
healthz reports a failing database rather than refusing, because taking the
web tier out of the load balancer for a dependency it can still serve most
pages without is the wrong trade.