agents are markdown
Browse docs

Docs/Reference

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.mdevalName 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 → deployrerun.

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.

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.

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.