Framed Platform API
Complete reference for the Platform API, Phase 1. Version 2026-09-21.
Framed is a background workhorse: it runs a workspace's processes end to end (playbooks, connectors, a human approval only where the process asks for one). The Platform API is how a client of that workspace drives the work and reads it back without opening the Framed app: a partner's dashboard, an internal tool, a back office, a host assistant. They build their own UI; Framed stays the runtime and the source of truth for process and result.
Base URL: https://www.framed.dev/api/platform. Every route on this page
hangs off it. Never the Supabase project URL, never the function name.
Contents
- Quick start
- Authentication and identity
- Concepts
- Conventions
- Reference: processes
- Reference: runs
- Reference: results
- Reference: approvals
- Reference: files
- Resource schemas
- Results in depth
- Human approvals in depth
- Polling
- Errors and reason codes
- The same nouns over MCP
- Security model
- Where it lives in the code
- Operations
- Client example
- On our roadmap
- Changelog
1. Quick start
Five calls. Everything else on this page is detail on these.
KEY="frk_live_…" # Settings → Blueprint → Data & connections → Framed API keys
WS="WORKSPACE_ID" # from GET https://www.framed.dev/api/workspaces
API="https://www.framed.dev/api/platform"
# 1. What can this workspace run?
curl -H "X-Framed-Api-Key: $KEY" "$API/processes?workspace_id=$WS"
# 2. Start one. Answers 202 with the run (or with an approval, see below).
curl -X POST -H "X-Framed-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"input": {"region": "north"}, "reason": "Weekly check from the dashboard"}' \
"$API/processes/PROCESS_ID/runs"
# 3. Poll the run until "settled": true. "blockers" says what it waits on.
curl -H "X-Framed-Api-Key: $KEY" "$API/runs/RUN_ID"
# 4. When a blocker is an approval, decide it.
curl -X POST -H "X-Framed-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"decision": "approve", "reason": "Both renewals are already in flight."}' \
"$API/approvals/APPROVAL_ID/decision"
# 5. Read what the run produced.
curl -H "X-Framed-Api-Key: $KEY" "$API/runs/RUN_ID/results"
# 6. Or the last completed run's JSON, without starting Studio.
curl -H "X-Framed-Api-Key: $KEY" "$API/processes/PROCESS_ID/latest-record"
A working walkthrough of exactly this sequence, over a fixture workspace and
without a key, is at /dev/platform-api-preview in the app. It runs the same
handler the API runs.
2. Authentication and identity
The key
Send the key in one of two headers. Both are accepted on every route.
X-Framed-Api-Key: frk_live_…
Authorization: Bearer frk_live_…
An account issues its own keys in Settings → Blueprint → Data & connections →
Framed API keys (account admins). A key may be scoped to a chosen set of
workspaces; otherwise it reaches every non-archived workspace on the account.
Plaintext is shown once; only a SHA-256 hash is stored. The workspaces a key can
reach are listed by GET https://www.framed.dev/api/workspaces (the Developer
API, same key).
A Framed Connect OAuth access token works too, for a host that already
connected through https://www.framed.dev/mcp.
Who a call acts as
Every platform call runs as a person. Which person depends on the credential:
| Credential | Acts as |
|---|---|
| Connect OAuth token | the person who consented to the assistant |
Framed API key (purpose: connect) | the account admin who issued the key |
| Reseller key or reseller session | nobody |
That person's workspace reach and grants decide every read, and a start, a
transition or a decision is recorded as theirs: created_by on the run,
resolved_by on the approval, with the reason as the card's note and
evidence.decided_via = { channel: 'platform_api', client }. This is what keeps
a key from being more powerful than a colleague: a key issued by someone who
can open two workspaces reaches those two, and no more.
A caller with nobody behind it can read, and it can queue a start that the
process wants a person to approve first. Anything that would run or decide on
its own answers 403 { reason: 'no_person' } with a sentence saying what to do
instead.
Grants
The same per-person grant matrix the app enforces (Settings → Access)
applies here. A refusal names the grant and where to change it.
| Route | Grant needed |
|---|---|
| list / get processes, runs, results, approvals | read_memory (what the workspace knows) |
a file result for a document | read_studio as well; without it the file result is withheld and counted, the read still answers |
| start a run, transition a run, decide an approval | propose_run (may set work in motion) |
Rows in a space the person cannot open are never listed and answer 404 when
addressed directly. A list says how many it left out (withheld).
3. Concepts
Four nouns. They are the same four Framed Connect (MCP) speaks, with the same ids, so a client that uses both sees one workspace.
| Noun | What it is | Framed row |
|---|---|---|
| process | A written-down method the workspace runs: its steps, its owner, its cadence. A process type. | v2_processes |
| run | One execution of a process: status, the stage it is on, what blocks it, what it left behind. The thing a client starts, follows and steers. | v2_stepwise_runs |
| result | One thing a run produced. Either a structured record or list as JSON, or a file reference into Framed's own store. | v2_artifacts, v2_insights, v2_approvals, or the run's own step state |
| approval | A human-approval step: a card waiting on a person. Decidable here. | v2_approvals |
The lifecycle
POST /processes/:id/runs
│
├── process starts immediately ──► run: queued ──► running ──┬──► done
│ │
│ awaiting_input ◄─────┤
│ (approval | question)│
│ │ ├──► error
│ decide approval / continue ────┘
│ cancel ──────────────────────────► canceled
│
└── process asks a person first ──► approval: open
│
approve ──► the run above (run_id on the card)
reject ──► nothing runs, a rule is written
Run statuses
Framed's own words, unchanged, so MCP and HTTP read the same thing.
status | Meaning | settled |
|---|---|---|
queued | Created, not yet picked up by the clock | no |
running | A step is executing | no |
awaiting_input | Parked: a human approval or a question. blockers says which | no |
done | Every step finished | yes |
error | A step failed and the run stopped. blockers carries the message | yes |
canceled | Cancelled by a person or a client | yes |
Step statuses
pending, running, done, flagged (an acceptance check raised problems; the run may be parked on it), failed.
4. Conventions
Envelope. Every success is { "ok": true, "api_version": "2026-09-21", … }.
Every refusal is { "ok": false, "error": "<one sentence>", "reason": "<code>", … }.
The sentence is written for a person; the reason is what a program switches on.
Methods. GET reads, POST acts. There is no PATCH or DELETE; a run is
steered through its transitions, an approval through its decision.
Parameters. Query string for GET, JSON body for POST. A POST may also
carry query parameters; the body wins where both name the same key.
workspace_id is required on every list; a single resource is addressed by id
and answers 404 when the person cannot reach its workspace or space.
Limits. Lists default to 25 items and cap at 100 (limit). A start's
input is at most 8 000 characters serialised; a reason at most 500.
Text payloads are clipped at 60 000 characters; a list payload at 500 rows
(truncated_to says so).
Timestamps. ISO 8601, UTC. updated_after filters take the same.
Links. Every resource carries links with absolute URLs. Follow them
rather than building paths.
Ids. Opaque strings. A result id is <run id>.<slot>.<ref> and is stable
across reads (see Results in depth).
Language. This surface says human approval, approval step, waiting for approval. Framed's own screen for these is called To review.
Rate and size. Request bodies over 1 MB answer 413 at the edge.
5. Reference: processes
GET /platform/processes
The processes a workspace can run, most recently updated first, each with its latest run.
| Query | ||
|---|---|---|
workspace_id | required | |
query | optional | part of a process name |
limit | optional | 1–100, default 25 |
{
"ok": true,
"api_version": "2026-09-21",
"workspace_id": "ws-nordhaven",
"processes": [
{
"id": "proc-permit-check",
"workspace_id": "ws-nordhaven",
"slug": "permit-check",
"name": "Permit expiry check",
"goal": "Catch grid permits that expire within 30 days before the planner does.",
"owner": "Ines Vermeer",
"cadence": "weekly",
"status": "verified",
"runnable": true,
"starts": "immediately",
"playbook": null,
"steps": 3,
"schema_refs": ["nordhaven:permit_rows/2"],
"latest_run": null,
"latest_record": null,
"links": {
"self": "https://www.framed.dev/api/platform/processes/proc-permit-check",
"runs": "https://www.framed.dev/api/platform/processes/proc-permit-check/runs",
"latest_record": "https://www.framed.dev/api/platform/processes/proc-permit-check/latest-record"
},
"updated_at": "2026-09-16T09:00:00.000Z"
}
],
"withheld": 1
}
withheld counts processes in spaces this person cannot open. Errors: 400 invalid_input (no workspace_id), 401, 403 workspace_out_of_reach | access_policy.
GET /platform/processes/:id
One process, with its latest run.
{ "ok": true, "api_version": "2026-09-21", "process": { "…": "as above" } }
Errors: 404 no_such_process (also for a process in a locked space), 403.
GET /platform/processes/:id/latest-record
The typed JSON the last completed run left behind, so a host can write the
document without starting Studio. v0 wraps the run's own step extracts (source: "run_extract"). A dedicated v2_process_records row is the follow-up.
404 when the process has never completed a run, or none fall in the period.
| Query | ||
|---|---|---|
period_start | optional | YYYY-MM-DD or ISO; inclusive |
period_end | optional | YYYY-MM-DD (that day included) or ISO; exclusive once expanded |
{
"ok": true,
"api_version": "2026-09-21",
"record": {
"id": "run-1",
"project_id": "ws-nordhaven",
"process_id": "proc-permit-check",
"run_id": "run-1",
"schema_ref": "nordhaven:permit_rows/2",
"schema_version": 2,
"produced_at": "2026-09-16T09:00:00.000Z",
"period": null,
"payload": {
"items": [
{ "permit": "NH-2211", "site": "Substation West", "expires": "2026-10-02" }
],
"summary": "14 permits read; 2 expire within 30 days."
},
"source_refs": ["document:doc-1"],
"open_fields": [],
"materialize": { "studio_artifact_id": "doc-1", "status": "draft" },
"source": "run_extract"
},
"links": {
"self": "https://www.framed.dev/api/platform/processes/proc-permit-check/latest-record",
"process": "https://www.framed.dev/api/platform/processes/proc-permit-check",
"run": "https://www.framed.dev/api/platform/runs/run-1",
"results": "https://www.framed.dev/api/platform/runs/run-1/results"
}
}
Errors: 400 invalid_input (malformed period), 404 no_such_process | no_such_record, 403.
POST /platform/processes/:id/runs
Start a run. This is the workspace's own Start button: the call goes through
v2-process-run with every gate that button runs (the plan gate, the campaign
measure gate, the one-run-at-a-time guard, the first-run verification policy).
| Body | ||
|---|---|---|
input | optional object | free JSON for the run, ≤ 8 000 characters serialised. Kept on the run verbatim, returned as run.input, readable by a walkthrough step that fills from: 'input'. Never handed to a model as an instruction. |
reason | optional string ≤ 500 | why this client started it. Shown on the approval card when one is queued. |
What happens depends on the process's own setting under Who starts it
(starts on the resource):
starts: "immediately": the run exists and is going.
{
"ok": true,
"api_version": "2026-09-21",
"status": "started",
"run": { "id": "run-001", "status": "queued", "settled": false, "…": "…" },
"note": "The run is going in the background. Poll its link until `settled` is true; a step that needs a person shows up under blockers and as an approval."
}
starts: "after_approval": one approval card is queued, nothing runs.
{
"ok": true,
"api_version": "2026-09-21",
"status": "waiting_for_approval",
"approval": { "id": "apr-005", "kind": "connect_run", "status": "open", "run_id": null, "…": "…" },
"job_id": "job-006",
"process": { "…": "…" },
"note": "Nothing is running yet. This process asks a person before it starts. Poll the approval; once it is approved, its run_id points at the run."
}
Poll the approval (or decide it here). Once approved, approval.run_id names
the run. job_id is the same pollable job the Developer API's GET /jobs/:id
reads.
Both answer 202. Errors:
| Status | reason | When |
|---|---|---|
| 400 | invalid_input | input is not an object, or too large |
| 403 | no_person | the key has nobody behind it and the process starts immediately |
| 403 | access_policy | the person lacks propose_run here |
| 404 | no_such_process | |
| 409 | not_runnable | the process has no plan Framed can run yet |
| 409 | already_running | a run of this process is still going; run_id and run say which |
| 502 | start_failed | v2-process-run could not start it; error carries its sentence |
Steps inside the run that change something outside Framed still stop for a person, exactly as when the run is started from the screen.
6. Reference: runs
GET /platform/runs
Runs in a workspace, most recently updated first. Process runs only; agent runs are not on this list yet (see On our roadmap).
| Query | ||
|---|---|---|
workspace_id | required | |
process_id | optional | only this process's runs |
status | optional | comma-separated run statuses, e.g. running,awaiting_input |
updated_after | optional | ISO timestamp |
limit | optional | 1–100, default 25 |
{
"ok": true,
"api_version": "2026-09-21",
"workspace_id": "ws-nordhaven",
"runs": [ { "…": "run" } ],
"filter": { "process_id": null, "status": ["done"], "updated_after": null, "limit": 25 }
}
filter echoes what was applied, so a client can see what a default or a cap
did to its request.
GET /platform/runs/:id
One run. This is the resource to poll.
{
"ok": true,
"api_version": "2026-09-21",
"run": {
"id": "run-001",
"workspace_id": "ws-nordhaven",
"process_id": "proc-permit-check",
"process": { "id": "proc-permit-check", "name": "Permit expiry check" },
"playbook": null,
"title": "Permit expiry check",
"status": "awaiting_input",
"settled": false,
"stage": { "index": 1, "count": 3, "key": "sign_off", "title": "A person checks the exceptions", "status": "running" },
"steps": [
{ "key": "read_register", "title": "Read the permit register", "status": "done" },
{ "key": "sign_off", "title": "A person checks the exceptions", "status": "running" },
{ "key": "memo", "title": "Write the permit memo", "status": "pending" }
],
"blockers": [
{ "kind": "approval", "approval_id": "apr-002", "title": "A person checks the exceptions" }
],
"allowed_transitions": ["cancel"],
"input": { "region": "north" },
"links": {
"self": "https://www.framed.dev/api/platform/runs/run-001",
"results": "https://www.framed.dev/api/platform/runs/run-001/results",
"approvals": "https://www.framed.dev/api/platform/approvals?run_id=run-001",
"transitions": "https://www.framed.dev/api/platform/runs/run-001/transitions",
"process": "https://www.framed.dev/api/platform/processes/proc-permit-check"
},
"created_by": "user-ines",
"created_at": "2026-09-16T09:00:00.000Z",
"updated_at": "2026-09-16T09:00:00.000Z",
"completed_at": null
}
}
Errors: 404 no_such_run, 403.
POST /platform/runs/:id/transitions
Steer a run. Only the transitions its state allows; there are no free-form stage writes.
| Body | ||
|---|---|---|
transition | required | continue or cancel |
reason | optional ≤ 500 |
| Run is | Takes |
|---|---|
queued, running | cancel |
awaiting_input on a question (a flagged step) | continue, cancel |
awaiting_input on a human approval | cancel only. continue is refused: the approval is decided, or the run is cancelled. A bare continue is not a yes. |
done, error, canceled | nothing |
continue accepts the flagged step and re-queues the run. cancel ends it
and dismisses any approval card it was parked on.
Success answers the run after the transition:
{ "ok": true, "api_version": "2026-09-21", "transition": "cancel", "run": { "status": "canceled", "…": "…" } }
Refusals carry allowed_transitions, and for a run parked on an approval, the
approval to decide:
{
"ok": false,
"error": "This run is waiting for a human approval. Decide that approval; a bare continue is not a yes.",
"reason": "transition_not_allowed",
"status": "awaiting_input",
"allowed_transitions": ["cancel"],
"approval_id": "apr-002",
"approval": "https://www.framed.dev/api/platform/approvals/apr-002"
}
| Status | reason | When |
|---|---|---|
| 400 | unknown_transition | not continue or cancel |
| 403 | no_person, access_policy | |
| 404 | no_such_run | |
| 409 | transition_not_allowed | the state does not take it now |
| 409 | transition_failed | v2-process-run refused; error says why |
7. Reference: results
GET /platform/runs/:id/results
Everything a run produced. Readable while the run is going (partial) and after it settles. Order: files and lists first (the deliverables), then records.
| Query | ||
|---|---|---|
kind | optional | record, list or file |
schema_ref | optional | exact match |
{
"ok": true,
"api_version": "2026-09-21",
"run_id": "run-001",
"process_id": "proc-permit-check",
"run_status": "done",
"settled": true,
"results": [
{
"id": "run-001.document.doc-003",
"process_id": "proc-permit-check",
"run_id": "run-001",
"step": { "key": "memo", "title": "Write the permit memo" },
"produced_at": "2026-09-16T09:00:00.000Z",
"links": { "self": "https://www.framed.dev/api/platform/results/run-001.document.doc-003", "run": "https://www.framed.dev/api/platform/runs/run-001" },
"kind": "file",
"schema_ref": "framed:document/memo",
"schema_version": "1",
"title": "Permit status, week 38",
"file_ref": {
"id": "document:doc-003",
"kind": "document",
"name": "permit-status-week-38.md",
"media_type": "text/markdown",
"url": "https://www.framed.dev/api/platform/files/document/doc-003?token=1758014100.…",
"url_expires_at": "2026-09-16T09:15:00.000Z"
}
},
{
"id": "run-001.insight.ins-004",
"kind": "record",
"schema_ref": "framed:insight/briefing",
"schema_version": "1",
"title": "Permit check: two renewals due",
"payload": {
"title": "Permit check: two renewals due",
"type": "briefing",
"snippet": "NH-2211 and NH-2218 expire in early October.",
"content_md": "## Two renewals due\n\n- NH-2211, Substation West, 2 October\n- NH-2218, Cable route 7, 9 October"
},
"…": "…"
},
{
"id": "run-001.approval.apr-002",
"kind": "record",
"schema_ref": "framed:approval/run_gate",
"schema_version": "1",
"title": "A person checks the exceptions",
"payload": { "exceptions": [ { "permit": "NH-2211", "site": "Substation West", "expires": "2026-10-02" } ] },
"approval": { "id": "apr-002", "status": "decided", "decision": "approved", "links": { "self": "…" } },
"…": "…"
},
{
"id": "run-001.step.read_register",
"kind": "record",
"schema_ref": "nordhaven:permit_rows/2",
"schema_version": "1",
"title": "Read the permit register",
"payload": {
"summary": "14 permits read; 2 expire within 30 days.",
"extract": "[{\"permit\":\"NH-2211\",\"site\":\"Substation West\",\"expires\":\"2026-10-02\"}]",
"confidence": 0.92
},
"…": "…"
}
],
"file_url_ttl_seconds": 900
}
withheld, when present, counts results this person may not open (a locked
space, or a document without the read_studio grant). Errors: 404 no_such_run, 403.
GET /platform/results/:id
One result by its id, with a fresh file link when it is a file. The id is the one a list printed; it is parsed, not looked up, so it stays valid for as long as the run does.
{ "ok": true, "api_version": "2026-09-21", "result": { "…": "…" } }
Errors: 404 no_such_result (malformed id, unknown run, or a result the run no longer carries), 403.
8. Reference: approvals
GET /platform/approvals
Human-approval steps in a workspace, newest first.
| Query | ||
|---|---|---|
workspace_id | required | |
status | optional | open (default): waiting on a person; decided: already answered; all |
run_id | optional | only the approvals belonging to this run |
updated_after | optional | ISO timestamp |
limit | optional | 1–100, default 25 |
open means status = pending on a card whose queue is needs_you or
ready. A notice asks nothing and is never open.
{
"ok": true,
"api_version": "2026-09-21",
"workspace_id": "ws-nordhaven",
"approvals": [
{
"id": "apr-002",
"workspace_id": "ws-nordhaven",
"kind": "run_gate",
"title": "A person checks the exceptions",
"summary": "Two permits expire within 30 days.",
"queue": "needs_you",
"status": "open",
"decision": null,
"reason": null,
"decided_by": null,
"decided_at": null,
"execution": { "status": "none" },
"run_id": "run-001",
"links": {
"self": "https://www.framed.dev/api/platform/approvals/apr-002",
"decision": "https://www.framed.dev/api/platform/approvals/apr-002/decision",
"run": "https://www.framed.dev/api/platform/runs/run-001"
},
"created_at": "2026-09-16T09:00:00.000Z",
"updated_at": "2026-09-16T09:00:00.000Z"
}
],
"filter": { "status": "open", "run_id": "run-001", "updated_after": null, "limit": 25 }
}
GET /platform/approvals/:id
One approval, with payload: what is actually being decided, as the card
stores it. Credential-shaped keys are stripped at every depth; a payload over
60 000 characters keeps its small fields and names the ones it dropped
(omitted_because_too_large).
{
"ok": true,
"api_version": "2026-09-21",
"approval": {
"…": "as in the list",
"payload": {
"exceptions": [
{ "permit": "NH-2211", "site": "Substation West", "expires": "2026-10-02" },
{ "permit": "NH-2218", "site": "Cable route 7", "expires": "2026-10-09" }
]
}
}
}
Errors: 404 no_such_approval (also in a locked space), 403.
POST /platform/approvals/:id/decision
Approve or reject, as the person behind the key.
| Body | ||
|---|---|---|
decision | required | approve or reject |
reason | required for reject, optional for approve, ≤ 500 | one sentence. Stored as the card's note; shown in Framed |
What happens, in order:
- The card is updated exactly as the To review panel updates it:
statusapprovedordismissed,note= reason,resolved_by= the person,resolved_at= now,evidence.decided_via = { channel: 'platform_api', client, at }. Only a card stillpendingtakes the update; two clients deciding at once leave exactly one recorded. - When there is something to carry out, the card is handed to Framed's
executor (
v2-approval-execute) as that person. That is every approve on a card with an action, and a reject on: a run gate (cancels the run), a Yes-queue kind (writes the standing rule so the same thing is not proposed again), a measured job, a browser-tab write, a custom-tool call. - The executor claims the card (
execution.statusnone → queued → running → succeeded | failed) and works in the background. A call the executor refused is written back asfailed, so the card never says "applied" about work nobody did.
{
"ok": true,
"api_version": "2026-09-21",
"decision": "approve",
"approval": { "id": "apr-002", "status": "decided", "decision": "approved", "reason": "Both renewals are already in flight.", "decided_by": "user-ines", "execution": { "status": "queued" }, "…": "…" },
"execution": "started",
"note": "The decision is recorded and Framed is carrying it out. Poll the approval: execution.status ends at succeeded or failed."
}
execution is one of started (the executor took it), not_needed (nothing
to run; the status change is the whole decision), failed (the executor could
not be reached or refused; execution_error says why, the card shows it, and
a retry is a new decision in Framed).
| Status | reason | When |
|---|---|---|
| 400 | invalid_decision | not approve/reject, or a reject without a reason |
| 403 | no_person, access_policy | |
| 404 | no_such_approval | |
| 409 | already_decided | the card is no longer pending; approval shows it as it stands |
| 409 | not_a_decision | a notice: nothing on it can be approved |
| 502 | decision_failed | the update itself failed; error says why |
9. Reference: files
GET /platform/files/document/:id?token=…
The bytes behind a file_ref of kind document, on the signed link the result
handed out. No API key is needed: the link carries its own credential
(token, an HMAC over the file id and the expiry, keyed on a secret only the
function holds) and is checked before any key is looked for, so a person
opening a memo from a dashboard needs nothing else. It stops working 15 minutes
after it was minted; fetch the result again for a fresh one.
Served as:
| The document is | Content-Type | Extension |
|---|---|---|
| a memo, or a report with sections | text/markdown | .md |
| a presentation or a designed report (Framed rendered HTML) | text/html | .html |
| a research table or a dashboard spec | application/json | .json |
Content-Disposition: inline; filename="<slug>.<ext>" names the file.
Cache-Control: private, no-store.
A file_ref of kind upload (a file someone uploaded that the run referenced)
points at a storage link instead, under the same 15-minute rule; nothing on
this route serves it.
Errors: 403 file_token_invalid (wrong file, wrong signature, malformed),
403 file_token_expired, 404 no_such_file.
10. Resource schemas
Process
| Field | Type | Meaning |
|---|---|---|
id | string | |
workspace_id | string | |
slug | string | null | the workspace's own handle for it |
name | string | |
goal | string | null | |
owner | string | null | the colleague responsible |
cadence | string | null | how often the workspace runs it |
status | string | null | where it is on Framed's ladder: documented, proposed, verified, … |
runnable | boolean | Framed has a plan (or a linked playbook) it can run |
starts | immediately | after_approval | the process's own Who starts it setting |
playbook | string | null | the playbook it runs, when linked |
steps | integer | documented steps |
schema_refs | string[] | result schemas its plan declares on its steps |
latest_run | { id, status, updated_at } | null | |
latest_record | { id, run_id, schema_ref, produced_at } | null | pointer at the last completed run's JSON; the body is GET …/latest-record |
links | { self, runs, latest_record } | runs is where a POST starts one |
updated_at | timestamp | null |
Run
| Field | Type | Meaning |
|---|---|---|
id | string | |
workspace_id | string | |
process_id | string | null | null for a run started from chat without a process |
process | { id, name } | null | |
playbook | string | null | |
title | string | |
status | run status | see Concepts |
settled | boolean | no longer queued, running or waiting |
stage | RunStage | null | the step the run is on: the running one, else the flagged one, else the failed one, else by position; the last step when done |
steps | { key, title, status, problems? }[] | up to 60; problems are the acceptance problems on a flagged or failed step, in words |
blockers | RunBlocker[] | empty unless waiting or failed |
allowed_transitions | (continue | cancel)[] | what a transition may ask for now |
input | object | null | what the client handed the start |
rehearsal | true (only when set) | a test run from the Processes page |
review | right | wrong (only when set) | a person's verdict on the ended run |
links | { self, results, approvals, transitions, process? } | |
created_by | string | null | the person the run was started as |
created_at, updated_at, completed_at | timestamp | null |
RunStage: { index, count, key, title, status }; index is zero-based.
RunBlocker, one of:
kind | Fields | Meaning |
|---|---|---|
approval | approval_id?, title | parked on a human approval; decide it |
question | question | parked on a question; a person answers in Framed, or continue accepts the flagged step |
error | message | the run failed |
Step internals are never on the run resource: no acceptance rubrics, judge
specs or walkthrough handles. A step's extract is on the result
(framed:step_conclusion) and on GET …/latest-record. Connect's
framed_fetch run:<id> also returns it, so a host can write from it.
Result
| Field | Type | Meaning |
|---|---|---|
id | string | <run id>.<slot>.<ref>, slot ∈ document, insight, approval, step |
process_id | string | null | |
run_id | string | |
step | { key, title } | null | the step that produced it, when known |
kind | record | list | file | |
schema_ref | string | what the payload is shaped like (see Results in depth) |
schema_version | string | "1" for every Framed built-in |
title | string | |
payload | object | record and list only |
file_ref | FileRef | file only |
approval | { id, status, decision, links } | only on a record that is an approval card |
produced_at | timestamp | null | |
links | { self, run } |
FileRef: { id: 'document:<id>' | 'upload:<id>', kind, name, media_type, size_bytes?, url, url_expires_at }. url is null only when no link could be minted (an upload whose storage object is gone).
Approval
| Field | Type | Meaning |
|---|---|---|
id | string | |
workspace_id | string | |
kind | string | the card kind: run_gate, connect_run, invoice_check, ads_change, … |
title, summary | string, string | null | |
queue | needs_you | ready | notice | needs_you: a person has to judge it. ready: Framed finished its own work and waits behind one yes. notice: nothing is asked |
status | open | decided | |
decision | approved | rejected | closed | null | null while open. closed: decided without a yes and without anything running |
reason | string | null | the decider's note |
decided_by | string | null | |
decided_at | timestamp | null | |
execution | { status, result? } | status ∈ none, queued, running, succeeded, failed; result is the executor's outcome, sanitised |
run_id | string | null | the run this card belongs to, when it has one |
links | { self, decision, run? } | |
payload | object | on the single read only: what is being decided |
created_at, updated_at | timestamp | null |
How Framed's own statuses map: pending → open; approved → approved;
dismissed, deviation → rejected; done → approved when the action
succeeded, else closed.
11. Results in depth
A result is one thing a run produced, found on the run row itself: the
artifact each step wrote (outputs), the refs in each step's state slice, and
the cards written against the finding the run produced. Nothing is copied; a
result points at the row that already exists. There is no second document
store.
| kind | payload / file_ref | default schema_ref | Source |
|---|---|---|---|
file | file_ref with a signed document link | framed:document/<type> (memo, report, presentation, …) | a Studio document the run wrote (v2_artifacts) |
file | file_ref with a storage link | framed:upload | an uploaded file the run referenced (v2_insights, type upload) |
list | { query, entity_type, status, found, target, columns, rows, truncated_to? } | framed:research_table | a research table (v2_artifacts with content.kind = 'research') |
record | { title, type, snippet, content_md } | framed:insight/<type> | a finding the run wrote (v2_insights) |
record | the card's payload, plus approval: { id, status, decision } | framed:approval/<kind> | a card the run raised (a gate), or the per-item cards written against its finding (an invoice check writes one per invoice) |
record | { summary, extract?, confidence?, flags? } | framed:step_conclusion | what a step concluded: the run row's state slice. Only for steps that ended done or flagged with something to say |
A research table as a list. Each row is one flat object: id, name,
url, entity_type, criteria_satisfied, criteria_total, then one key per
column with its value, <key>_sources (URLs) where the cell cites any, and
<key>_status when the cell is not ok (not_found, review). columns
carries { key, label, kind }.
Playbooks declare schemas; the platform does not. A plan step may carry
schema_ref (any string ≤ 200, e.g. nordhaven:permit_rows/2). When it does,
that step's results carry it instead of the Framed default, and the process
lists it under schema_refs before anything runs. Framed never reads it. It
rides through normalizeSteps, freshSteps and stepTemplates, so it
survives saving the plan, starting a run and a test run.
What a client should not assume. The order inside each kind is the order
the rows came back in; sort on produced_at when it matters. A partial read
(while the run is going) is honest about that: settled: false, and the list
grows on the next poll. A result whose row was deleted in Framed disappears
from the list and answers 404 by id.
12. Human approvals in depth
A human-approval step is where Framed stops for a person. Three places one can come from, and the Platform API treats them alike:
- A gate inside a run (
kind: run_gate,action.type: resume_run). The plan said "a person checks this here". The run parks withblockers[0].kind = 'approval'. Approve: the gate is marked done and the run continues. Reject: the run is cancelled. - A start the process wants approved first (
kind: connect_run). The card names the process, the client and the reason; the client'sinputrides on it. Approve: the run starts, the card carriesrun_id. Reject: nothing runs and Framed writes a rule ("do not start X because a client asked") so the same request is not proposed again tomorrow. - Work an agent finished that waits behind one yes (
queue: ready, e.g. an invoice check with a staged booking). Approve: the connector action runs. Reject: the standing rule is written, nothing is sent.
Whatever the kind, the decision is the To review panel's decision, made by a person Framed can name. An assistant over MCP cannot make it (see section 15).
Idempotency. A card takes one decision. The second answers
409 already_decided with the card as it stands, so a client that retried a
timed-out request learns what happened rather than failing.
What to poll after deciding. GET /platform/approvals/:id until
execution.status is succeeded or failed. For a gate, the run itself is the
thing to poll (blockers empties, status moves on). For an approved start,
approval.run_id appears once the executor started it.
13. Polling
Phase 1 is poll-based; there are no run or approval webhooks yet (the key's
webhook_url still fires the Developer API's job.status events for a
queued start, and only those).
- Poll
GET /platform/runs/:idevery 15–30 seconds whilesettledis false. A run that does deep research or lead work takes minutes to an hour. - When
blockersnames an approval, stop polling the run and put the decision in front of a person, or decide it. The run does not move on its own. - To watch a workspace rather than one run, use the lists with
updated_afterset to the lastupdated_atyou saw:GET /platform/runs?status=awaiting_input,runningandGET /platform/approvals?status=open. - A signed file link is good for 15 minutes. Do not store it; store the result id and re-fetch when a person opens it.
14. Errors and reason codes
Every refusal is { ok: false, error, reason, … }. The sentence is for a
person; switch on reason.
| HTTP | reason | Meaning |
|---|---|---|
| 400 | invalid_input | a required parameter is missing or malformed |
| 400 | invalid_decision | not approve/reject, or a reject without a reason |
| 400 | unknown_transition | not continue/cancel; allowed_transitions says what is |
| 401 | (none) | Missing API key / Invalid API key / Invalid session |
| 403 | workspace_out_of_reach | this key or person cannot open that workspace |
| 403 | access_policy | the person lacks the grant; grant names it, where says Settings, Access |
| 403 | not_on_this_connection | the route is not on this key's allowlist |
| 403 | no_person | nobody stands behind this key; it cannot run or decide |
| 403 | file_token_invalid, file_token_expired | a signed file link |
| 404 | unknown_route | no such platform route |
| 404 | no_such_process, no_such_run, no_such_result, no_such_approval, no_such_file, no_such_record | not there, or in a space this person cannot open |
| 409 | not_runnable | the process has no plan Framed can run |
| 409 | already_running | one run at a time per process; run_id says which |
| 409 | transition_not_allowed | the run's state does not take it; allowed_transitions, and approval_id when parked on one |
| 409 | transition_failed | the runner refused |
| 409 | already_decided, not_a_decision | |
| 413 | (none) | request body over 1 MB |
| 502 | start_failed, decision_failed | Framed could not carry it out; error says what it said |
15. The same nouns over MCP
Framed Connect (https://www.framed.dev/mcp) speaks the same four nouns. A
client that uses both sees one workspace with one set of ids.
| Noun | Platform API (HTTP) | Framed Connect (MCP) |
|---|---|---|
| process | GET /platform/processes, /processes/:id | framed_list of:"processes", framed_fetch process:<id> (latest_record on both) |
| record | GET /platform/processes/:id/latest-record | framed_fetch process:<id> → latest_record (full JSON). No separate record: ref yet. |
| playbook | playbook on a process (the method a process may link via playbook_slug) | framed_list of:"playbooks", framed_fetch playbook:<slug>, framed_propose_playbook (create or update; always waits for human approval; nothing is written on the call) |
| start | POST /platform/processes/:id/runs | framed_run |
| run | GET /platform/runs, /runs/:id | framed_list of:"runs", framed_fetch run:<id> |
| steer | POST /platform/runs/:id/transitions | not exposed: a parked run's question is put to the user, a person answers in Framed |
| result | GET /platform/runs/:id/results, /results/:id | framed_fetch run:<id> → produced refs, then framed_fetch document: / insight: / approval: |
| file | file_ref.url (signed link) | framed_fetch document:<id> (text), format:"html" |
| approval | GET /platform/approvals, /approvals/:id | framed_list of:"approvals", framed_fetch approval:<id> |
| decide | POST /platform/approvals/:id/decision | not exposed, by design |
A run.id here is the run:<id> ref a host opens over MCP. A result.id is
<run id>.<slot>.<ref>, where <ref> is the document: / insight: /
approval: id MCP prints. Run statuses are Framed's own words in both; the
HTTP surface adds settled and blockers so a client does not have to know
which status means "wait" and which means "a person has to act".
Why deciding differs. Over MCP the caller is an assistant, and the person is
in the room only through their host; letting the host approve what the host
proposed removes the gate. Over HTTP the caller is a system a person operates,
and the key names who stands behind it. So the HTTP surface decides, as that
person, and MCP reads. The process's Who starts it switch (assistant_start)
is read by both, so a process is either startable by a client or it is not,
in one place.
Playbook vs process. A playbook is the prescribed method (steps, framework,
output structure) stored on the workspace blueprint. A process is the runnable
row (v2_processes) that may link that method with playbook_slug. Connect
lists and fetches both; framed_run starts a process (or schedules a playbook
that has no process row yet). framed_propose_playbook is how a host offers a
create or an update of the method itself. Production workspaces always queue
that for human approval under Tasks; approval applies the approved snapshot
(not a merge). An update syncs name, goal and steps on an existing process
with that slug; a create does not invent a process row.
16. Security model
- One key format, one workspace gate. The Platform API is mounted inside the Developer API's function; the key lookup, the workspace pin/scope check and the account reach are the same code every other route uses.
- A person behind every write. Reach and grants are the consenting user's or the issuer's, resolved on every call; a start and a decision are recorded as theirs. Nobody behind the key means read-only.
- No second runtime. A start and a resume call
v2-process-runthrough a door that only the service role opens and that re-proves the named person's reach andpropose_rungrant itself. A decision hands the card tov2-approval-executethrough a matching door that re-checks reach against the card's own workspace. Neither door trusts the caller about who it acts for. - Transitions are a rule, not a menu.
continuepast a human approval is refused in the pure module, before any runner is called. - Spaces. A restricted space is enforced on every list (dropped and
counted) and every single read (
404, never "not allowed", so a locked space's existence is not disclosed). - Payload hygiene. Credential-shaped keys are stripped from approval payloads and execution results at every depth; text is clipped; a list is capped.
- File links are HMAC-signed with a secret only the function holds, bound
to one file, expire in 15 minutes, and are served with
no-store. - Supplier names never appear on this surface.
17. Where it lives in the code
| Piece | Where |
|---|---|
| Rules: routing, shapes, transition table, result mapping, decision rules, file tokens | supabase/functions/_shared/platform-api.ts (pure: no env, no network, no database) |
| Tests, including a golden path over the fixture | supabase/functions/_shared/platform-api.test.ts |
| Store on Supabase (every write through the app's own paths) | supabase/functions/_shared/platform-store.ts |
| Fixture store (tests + preview) | supabase/functions/_shared/platform-api-fixture.ts |
| Dispatch, auth, gates, file serving | supabase/functions/v2-partner-api/index.ts, handlePlatform |
| Ops on the key allowlist / grant map | _shared/partner-api.ts CONNECT_KEY_OPS, _shared/connect-access.ts OP_REQUIRED_GRANT (pinned by test) |
| Public proxy root | src/lib/api-partner-rewrite.ts (/platform), src/lib/api-public-proxy.ts (Content-Disposition) |
| Runner door | supabase/functions/v2-process-run/index.ts (start, resume, input) |
| Executor door | supabase/functions/v2-approval-execute/index.ts (actorUserId, getActor()) |
schema_ref on a plan step | _shared/agent-runs.ts (AgentStep.schemaRef), _shared/playbook-runs.ts (freshSteps), _shared/process-plan.ts (stepTemplates) |
| OpenAPI | public/platform-openapi.yaml → https://www.framed.dev/platform-openapi.yaml |
| Public docs | src/routes/marketing/docs.tsx → /docs#platform |
| Preview | src/routes/dev/platform-api-preview.tsx → /dev/platform-api-preview |
The store split is deliberate: handlePlatformRoute decides every status
code, shape and rule over a PlatformStore; the Supabase store and the fixture
store implement the same interface, so the tests and the preview prove the
contract without a database, and the edge function adds only the reads and
writes.
18. Operations
Deploy order. v2-process-run and v2-approval-execute first (the
Platform API calls their internal doors; an old executor answers
Approval not found or not accessible to the service bearer), then
v2-partner-api, then the frontend (the /platform proxy root lives in
src/lib/api-partner-rewrite.ts; until it is deployed the routes answer 404 on
the public host and work on the function's own URL).
No new table in this version. GET …/latest-record wraps
v2_stepwise_runs.state.extract. A v2_process_records write at the end of
the run is the follow-up.
Environment. Nothing new. The file-link secret is derived from the
function's service role key (platform-files:${SERVICE_KEY}); rotating that
key invalidates outstanding links, which last 15 minutes anyway.
Audit. A start leaves created_by on the run; a decision leaves
resolved_by, note and evidence.decided_via on the card. Platform calls do
not yet write a v2_tool_calls row (the Connect audit keeps only the
framed_* MCP tools; see On our roadmap).
Rate. No per-key rate limit beyond the edge's 1 MB body cap. A list is capped at 100.
Health. GET https://www.framed.dev/api/health is the Developer API's
health check and covers this surface too (same function).
19. Client example
Plain fetch, no SDK. Start a process, wait for it, decide its gate, read the
memo.
const API = 'https://www.framed.dev/api/platform'
const headers = { 'X-Framed-Api-Key': process.env.FRAMED_API_KEY, 'Content-Type': 'application/json' }
async function call(method, path, body) {
const res = await fetch(`${API}${path}`, { method, headers, body: body && JSON.stringify(body) })
const json = await res.json()
if (!json.ok) throw Object.assign(new Error(json.error), { status: res.status, reason: json.reason, body: json })
return json
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// 1. Start.
const started = await call('POST', `/processes/${PROCESS_ID}/runs`, {
input: { region: 'north' },
reason: 'Weekly check from the dashboard',
})
let runId
if (started.status === 'started') {
runId = started.run.id
} else {
// The process asks a person first. Decide it here, or wait for a colleague.
const yes = await call('POST', `/approvals/${started.approval.id}/decision`, {
decision: 'approve',
reason: 'Scheduled by the dashboard.',
})
// The run id appears once the executor started it.
let card = yes.approval
while (!card.run_id && card.execution.status !== 'failed') {
await sleep(3000)
card = (await call('GET', `/approvals/${card.id}`)).approval
}
if (!card.run_id) throw new Error(card.execution.result?.error ?? 'The start failed')
runId = card.run_id
}
// 2. Follow, deciding gates as they appear.
let run
for (;;) {
run = (await call('GET', `/runs/${runId}`)).run
if (run.settled) break
const gate = run.blockers.find((b) => b.kind === 'approval' && b.approval_id)
if (gate) {
const card = (await call('GET', `/approvals/${gate.approval_id}`)).approval
// Put card.payload in front of a person; here we approve.
await call('POST', `/approvals/${card.id}/decision`, { decision: 'approve', reason: 'Checked.' })
continue
}
const question = run.blockers.find((b) => b.kind === 'question')
if (question) {
// A person answers in Framed; or cancel:
// await call('POST', `/runs/${runId}/transitions`, { transition: 'cancel', reason: '…' })
}
await sleep(20_000)
}
// 3. Results.
const { results } = await call('GET', `/runs/${runId}/results`)
const memo = results.find((r) => r.kind === 'file' && r.schema_ref === 'framed:document/memo')
if (memo?.file_ref.url) {
const text = await (await fetch(memo.file_ref.url)).text() // no key needed; link lasts 15 minutes
console.log(text)
}
const rows = results.find((r) => r.kind === 'list')?.payload.rows ?? []
20. On our roadmap
What comes next on this surface. Each has an obvious seat in the same model; none of it changes what is here.
| Later | Shape it will take |
|---|---|
| Chat: create / get / post / list | /platform/chats, over the v2_chat_threads + v2-chat path the Developer API already exposes at /chats |
| Studio: create / get / list / update, attach artifacts | /platform/documents, over v2_artifacts + v2-studio-dispatch; a document result already points at the row |
Event webhooks (run.status, approval.decided) | the key's webhook_url already receives signed job.status POSTs; the same channel. Poll until then |
| Overview / funnel projection | derived from process + run + result + approval over the same ids; never stored beside them |
Agent runs (v2_agent_runs) in GET /platform/runs | MCP already merges them into of:"runs"; the HTTP list is process runs only until the agent runtime has stages worth exposing |
A per-key "may start" override of a process's assistant_start | today the process decides, for MCP and HTTP alike |
| A dedicated "decide" grant | today deciding sits behind propose_run |
Audit rows on v2_tool_calls for platform calls | buildConnectAuditRow keeps only calls whose tool is a framed_* MCP tool, so a platform call leaves no row there yet. A platform.<op> slug on the audit row is the next step, so Connect activity lists these too |
| Cursor pagination | updated_after + limit is enough for a workspace's volume today |
21. Changelog
| Version | |
|---|---|
2026-09-21 | GET /platform/processes/:id/latest-record. Process resources carry latest_record. Connect framed_fetch run:<id> returns step extract. |
2026-09-16 | Phase 1: processes, runs, results, approvals, signed file links. |