Documentation

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

  1. Quick start
  2. Authentication and identity
  3. Concepts
  4. Conventions
  5. Reference: processes
  6. Reference: runs
  7. Reference: results
  8. Reference: approvals
  9. Reference: files
  10. Resource schemas
  11. Results in depth
  12. Human approvals in depth
  13. Polling
  14. Errors and reason codes
  15. The same nouns over MCP
  16. Security model
  17. Where it lives in the code
  18. Operations
  19. Client example
  20. On our roadmap
  21. 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:

CredentialActs as
Connect OAuth tokenthe person who consented to the assistant
Framed API key (purpose: connect)the account admin who issued the key
Reseller key or reseller sessionnobody

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.

RouteGrant needed
list / get processes, runs, results, approvalsread_memory (what the workspace knows)
a file result for a documentread_studio as well; without it the file result is withheld and counted, the read still answers
start a run, transition a run, decide an approvalpropose_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.

NounWhat it isFramed row
processA written-down method the workspace runs: its steps, its owner, its cadence. A process type.v2_processes
runOne 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
resultOne 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
approvalA 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.

statusMeaningsettled
queuedCreated, not yet picked up by the clockno
runningA step is executingno
awaiting_inputParked: a human approval or a question. blockers says whichno
doneEvery step finishedyes
errorA step failed and the run stopped. blockers carries the messageyes
canceledCancelled by a person or a clientyes

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_idrequired
queryoptionalpart of a process name
limitoptional1–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_startoptionalYYYY-MM-DD or ISO; inclusive
period_endoptionalYYYY-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
inputoptional objectfree 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.
reasonoptional string ≤ 500why 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:

StatusreasonWhen
400invalid_inputinput is not an object, or too large
403no_personthe key has nobody behind it and the process starts immediately
403access_policythe person lacks propose_run here
404no_such_process
409not_runnablethe process has no plan Framed can run yet
409already_runninga run of this process is still going; run_id and run say which
502start_failedv2-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_idrequired
process_idoptionalonly this process's runs
statusoptionalcomma-separated run statuses, e.g. running,awaiting_input
updated_afteroptionalISO timestamp
limitoptional1–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
transitionrequiredcontinue or cancel
reasonoptional ≤ 500
Run isTakes
queued, runningcancel
awaiting_input on a question (a flagged step)continue, cancel
awaiting_input on a human approvalcancel only. continue is refused: the approval is decided, or the run is cancelled. A bare continue is not a yes.
done, error, cancelednothing

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"
}
StatusreasonWhen
400unknown_transitionnot continue or cancel
403no_person, access_policy
404no_such_run
409transition_not_allowedthe state does not take it now
409transition_failedv2-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
kindoptionalrecord, list or file
schema_refoptionalexact 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_idrequired
statusoptionalopen (default): waiting on a person; decided: already answered; all
run_idoptionalonly the approvals belonging to this run
updated_afteroptionalISO timestamp
limitoptional1–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
decisionrequiredapprove or reject
reasonrequired for reject, optional for approve, ≤ 500one sentence. Stored as the card's note; shown in Framed

What happens, in order:

  1. The card is updated exactly as the To review panel updates it: status approved or dismissed, note = reason, resolved_by = the person, resolved_at = now, evidence.decided_via = { channel: 'platform_api', client, at }. Only a card still pending takes the update; two clients deciding at once leave exactly one recorded.
  2. 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.
  3. The executor claims the card (execution.status none → queued → running → succeeded | failed) and works in the background. A call the executor refused is written back as failed, 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).

StatusreasonWhen
400invalid_decisionnot approve/reject, or a reject without a reason
403no_person, access_policy
404no_such_approval
409already_decidedthe card is no longer pending; approval shows it as it stands
409not_a_decisiona notice: nothing on it can be approved
502decision_failedthe 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 isContent-TypeExtension
a memo, or a report with sectionstext/markdown.md
a presentation or a designed report (Framed rendered HTML)text/html.html
a research table or a dashboard specapplication/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

FieldTypeMeaning
idstring
workspace_idstring
slugstring | nullthe workspace's own handle for it
namestring
goalstring | null
ownerstring | nullthe colleague responsible
cadencestring | nullhow often the workspace runs it
statusstring | nullwhere it is on Framed's ladder: documented, proposed, verified, …
runnablebooleanFramed has a plan (or a linked playbook) it can run
startsimmediately | after_approvalthe process's own Who starts it setting
playbookstring | nullthe playbook it runs, when linked
stepsintegerdocumented steps
schema_refsstring[]result schemas its plan declares on its steps
latest_run{ id, status, updated_at } | null
latest_record{ id, run_id, schema_ref, produced_at } | nullpointer 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_attimestamp | null

Run

FieldTypeMeaning
idstring
workspace_idstring
process_idstring | nullnull for a run started from chat without a process
process{ id, name } | null
playbookstring | null
titlestring
statusrun statussee Concepts
settledbooleanno longer queued, running or waiting
stageRunStage | nullthe 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
blockersRunBlocker[]empty unless waiting or failed
allowed_transitions(continue | cancel)[]what a transition may ask for now
inputobject | nullwhat the client handed the start
rehearsaltrue (only when set)a test run from the Processes page
reviewright | wrong (only when set)a person's verdict on the ended run
links{ self, results, approvals, transitions, process? }
created_bystring | nullthe person the run was started as
created_at, updated_at, completed_attimestamp | null

RunStage: { index, count, key, title, status }; index is zero-based.

RunBlocker, one of:

kindFieldsMeaning
approvalapproval_id?, titleparked on a human approval; decide it
questionquestionparked on a question; a person answers in Framed, or continue accepts the flagged step
errormessagethe 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

FieldTypeMeaning
idstring<run id>.<slot>.<ref>, slot ∈ document, insight, approval, step
process_idstring | null
run_idstring
step{ key, title } | nullthe step that produced it, when known
kindrecord | list | file
schema_refstringwhat the payload is shaped like (see Results in depth)
schema_versionstring"1" for every Framed built-in
titlestring
payloadobjectrecord and list only
file_refFileReffile only
approval{ id, status, decision, links }only on a record that is an approval card
produced_attimestamp | 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

FieldTypeMeaning
idstring
workspace_idstring
kindstringthe card kind: run_gate, connect_run, invoice_check, ads_change, …
title, summarystring, string | null
queueneeds_you | ready | noticeneeds_you: a person has to judge it. ready: Framed finished its own work and waits behind one yes. notice: nothing is asked
statusopen | decided
decisionapproved | rejected | closed | nullnull while open. closed: decided without a yes and without anything running
reasonstring | nullthe decider's note
decided_bystring | null
decided_attimestamp | null
execution{ status, result? }statusnone, queued, running, succeeded, failed; result is the executor's outcome, sanitised
run_idstring | nullthe run this card belongs to, when it has one
links{ self, decision, run? }
payloadobjecton the single read only: what is being decided
created_at, updated_attimestamp | 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.

kindpayload / file_refdefault schema_refSource
filefile_ref with a signed document linkframed:document/<type> (memo, report, presentation, …)a Studio document the run wrote (v2_artifacts)
filefile_ref with a storage linkframed:uploadan uploaded file the run referenced (v2_insights, type upload)
list{ query, entity_type, status, found, target, columns, rows, truncated_to? }framed:research_tablea research table (v2_artifacts with content.kind = 'research')
record{ title, type, snippet, content_md }framed:insight/<type>a finding the run wrote (v2_insights)
recordthe 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_conclusionwhat 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:

  1. A gate inside a run (kind: run_gate, action.type: resume_run). The plan said "a person checks this here". The run parks with blockers[0].kind = 'approval'. Approve: the gate is marked done and the run continues. Reject: the run is cancelled.
  2. A start the process wants approved first (kind: connect_run). The card names the process, the client and the reason; the client's input rides on it. Approve: the run starts, the card carries run_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.
  3. 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/:id every 15–30 seconds while settled is false. A run that does deep research or lead work takes minutes to an hour.
  • When blockers names 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_after set to the last updated_at you saw: GET /platform/runs?status=awaiting_input,running and GET /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.

HTTPreasonMeaning
400invalid_inputa required parameter is missing or malformed
400invalid_decisionnot approve/reject, or a reject without a reason
400unknown_transitionnot continue/cancel; allowed_transitions says what is
401(none)Missing API key / Invalid API key / Invalid session
403workspace_out_of_reachthis key or person cannot open that workspace
403access_policythe person lacks the grant; grant names it, where says Settings, Access
403not_on_this_connectionthe route is not on this key's allowlist
403no_personnobody stands behind this key; it cannot run or decide
403file_token_invalid, file_token_expireda signed file link
404unknown_routeno such platform route
404no_such_process, no_such_run, no_such_result, no_such_approval, no_such_file, no_such_recordnot there, or in a space this person cannot open
409not_runnablethe process has no plan Framed can run
409already_runningone run at a time per process; run_id says which
409transition_not_allowedthe run's state does not take it; allowed_transitions, and approval_id when parked on one
409transition_failedthe runner refused
409already_decided, not_a_decision
413(none)request body over 1 MB
502start_failed, decision_failedFramed 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.

NounPlatform API (HTTP)Framed Connect (MCP)
processGET /platform/processes, /processes/:idframed_list of:"processes", framed_fetch process:<id> (latest_record on both)
recordGET /platform/processes/:id/latest-recordframed_fetch process:<id>latest_record (full JSON). No separate record: ref yet.
playbookplaybook 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)
startPOST /platform/processes/:id/runsframed_run
runGET /platform/runs, /runs/:idframed_list of:"runs", framed_fetch run:<id>
steerPOST /platform/runs/:id/transitionsnot exposed: a parked run's question is put to the user, a person answers in Framed
resultGET /platform/runs/:id/results, /results/:idframed_fetch run:<id>produced refs, then framed_fetch document: / insight: / approval:
filefile_ref.url (signed link)framed_fetch document:<id> (text), format:"html"
approvalGET /platform/approvals, /approvals/:idframed_list of:"approvals", framed_fetch approval:<id>
decidePOST /platform/approvals/:id/decisionnot 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-run through a door that only the service role opens and that re-proves the named person's reach and propose_run grant itself. A decision hands the card to v2-approval-execute through 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. continue past 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

PieceWhere
Rules: routing, shapes, transition table, result mapping, decision rules, file tokenssupabase/functions/_shared/platform-api.ts (pure: no env, no network, no database)
Tests, including a golden path over the fixturesupabase/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 servingsupabase/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 rootsrc/lib/api-partner-rewrite.ts (/platform), src/lib/api-public-proxy.ts (Content-Disposition)
Runner doorsupabase/functions/v2-process-run/index.ts (start, resume, input)
Executor doorsupabase/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)
OpenAPIpublic/platform-openapi.yamlhttps://www.framed.dev/platform-openapi.yaml
Public docssrc/routes/marketing/docs.tsx/docs#platform
Previewsrc/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.

LaterShape 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 projectionderived from process + run + result + approval over the same ids; never stored beside them
Agent runs (v2_agent_runs) in GET /platform/runsMCP 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_starttoday the process decides, for MCP and HTTP alike
A dedicated "decide" granttoday deciding sits behind propose_run
Audit rows on v2_tool_calls for platform callsbuildConnectAuditRow 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 paginationupdated_after + limit is enough for a workspace's volume today

21. Changelog

Version
2026-09-21GET /platform/processes/:id/latest-record. Process resources carry latest_record. Connect framed_fetch run:<id> returns step extract.
2026-09-16Phase 1: processes, runs, results, approvals, signed file links.