openapi: 3.1.0
info:
  title: Framed Platform API
  version: "2026-09-21"
  summary: Start and follow a workspace's processes, read what they produce, decide the human-approval steps.
  description: |
    Framed runs a workspace's processes end to end and stops for a person only
    where the process says so. This surface lets a client of the workspace
    (a dashboard, a partner's system, an internal tool) drive that work and
    read it back without opening the Framed app.

    Four nouns, the same four Framed Connect (MCP) speaks:

    * **process** - the written-down method a workspace runs (a process type).
    * **run** - one execution of a process: status, stage, blockers, links.
    * **result** - one thing a run produced: a structured record or list as JSON,
      or a file reference into Framed's own store. Never a second document store.
    * **approval** - a human-approval step waiting on a person. Decidable here,
      as the person behind the key.

    Every route is workspace-scoped. Reads answer under the reach and grants of
    the person behind the key (the consenting user of a Connect token, else the
    account admin who issued the API key); a start, a transition and a decision
    are recorded as that person. A key with nobody behind it can read and
    nothing else (`reason: no_person`).

    Long work is asynchronous: a start answers 202 with the run, and the client
    polls the run until `settled` is true. File links are signed and expire
    after 15 minutes; fetch the result again for a fresh one.
servers:
  - url: https://www.framed.dev/api
security:
  - ApiKey: []
  - Bearer: []
tags:
  - name: Processes
  - name: Runs
  - name: Results
  - name: Approvals
  - name: Files
paths:
  /platform/processes:
    get:
      tags: [Processes]
      summary: List the processes a workspace can run
      parameters:
        - $ref: "#/components/parameters/workspace_id"
        - name: query
          in: query
          schema: { type: string, maxLength: 200 }
          description: Part of a process name.
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: The processes, newest first, with each one's latest run.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  workspace_id: { type: string }
                  processes:
                    type: array
                    items: { $ref: "#/components/schemas/Process" }
                  withheld:
                    type: integer
                    description: Processes in spaces this person cannot open. Counted, never listed.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /platform/processes/{process_id}:
    get:
      tags: [Processes]
      summary: One process, with its latest run
      parameters:
        - $ref: "#/components/parameters/process_id"
      responses:
        "200":
          description: The process.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  process: { $ref: "#/components/schemas/Process" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /platform/processes/{process_id}/latest-record:
    get:
      tags: [Processes]
      summary: The JSON the last completed run left behind
      description: |
        v0 wraps the run's own step extracts (`source: run_extract`) so a host
        can write the document without starting Studio. 404 when the process
        has never completed a run, or none fall in the optional period.
      parameters:
        - $ref: "#/components/parameters/process_id"
        - name: period_start
          in: query
          schema: { type: string }
          description: Inclusive start (`YYYY-MM-DD` or ISO).
        - name: period_end
          in: query
          schema: { type: string }
          description: Inclusive calendar day, or an exclusive ISO timestamp.
      responses:
        "200":
          description: The process record.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  record: { $ref: "#/components/schemas/ProcessRecord" }
                  links: { $ref: "#/components/schemas/Links" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /platform/processes/{process_id}/runs:
    post:
      tags: [Processes, Runs]
      summary: Start a run of this process
      description: |
        Starts the process the way the workspace's own Start button does, with
        every gate that button runs. What happens depends on the process's own
        setting for who may start it (`starts` on the process resource):

        * `immediately` - the run is created and answers 202 with `status: started`.
        * `after_approval` - one approval card is queued and answers 202 with
          `status: waiting_for_approval`; nothing runs until a person says yes.
          The approved card then carries `run_id`.

        `input` is kept on the run verbatim (readable back as `run.input`, and
        by a walkthrough step that fills from `input`). It is never an
        instruction to a model.
      parameters:
        - $ref: "#/components/parameters/process_id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                input:
                  type: object
                  additionalProperties: true
                  description: Free JSON for the run, at most 8000 characters serialised.
                reason:
                  type: string
                  maxLength: 500
                  description: Why this client started it; shown on the approval card when one is queued.
      responses:
        "202":
          description: Started, or queued for a person's approval.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [ok, status, run]
                    properties:
                      ok: { type: boolean, const: true }
                      api_version: { type: string }
                      status: { type: string, const: started }
                      run: { $ref: "#/components/schemas/Run" }
                      note: { type: string }
                  - type: object
                    required: [ok, status, approval, job_id]
                    properties:
                      ok: { type: boolean, const: true }
                      api_version: { type: string }
                      status: { type: string, const: waiting_for_approval }
                      approval: { $ref: "#/components/schemas/Approval" }
                      job_id: { type: string }
                      process: { $ref: "#/components/schemas/Process" }
                      note: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: No access, no grant, or nobody behind the key (`reason: no_person`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `already_running` - a run of this process is still going (`run_id` says which);
            `not_runnable` - the process has no plan Framed can run.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /platform/runs:
    get:
      tags: [Runs]
      summary: List runs
      parameters:
        - $ref: "#/components/parameters/workspace_id"
        - name: process_id
          in: query
          schema: { type: string }
        - name: status
          in: query
          description: Comma-separated Framed run statuses.
          schema:
            type: string
            example: running,awaiting_input
        - name: updated_after
          in: query
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: Runs, most recently updated first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  workspace_id: { type: string }
                  runs:
                    type: array
                    items: { $ref: "#/components/schemas/Run" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /platform/runs/{run_id}:
    get:
      tags: [Runs]
      summary: One run - status, stage, blockers, links
      description: Poll this until `settled` is true.
      parameters:
        - $ref: "#/components/parameters/run_id"
      responses:
        "200":
          description: The run.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  run: { $ref: "#/components/schemas/Run" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /platform/runs/{run_id}/transitions:
    post:
      tags: [Runs]
      summary: Steer a run - only the transitions its state allows
      description: |
        A run's `allowed_transitions` says what it takes right now:

        * `cancel` - while queued, running, or waiting.
        * `continue` - only while waiting on a question (a flagged step). A run
          waiting on a human approval is continued by deciding that approval;
          a bare continue is refused with `reason: transition_not_allowed` and
          the `approval_id` to decide.

        There are no free-form stage writes.
      parameters:
        - $ref: "#/components/parameters/run_id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transition]
              properties:
                transition:
                  type: string
                  enum: [continue, cancel]
                reason: { type: string, maxLength: 500 }
      responses:
        "200":
          description: The run after the transition.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  transition: { type: string }
                  run: { $ref: "#/components/schemas/Run" }
        "400":
          description: Not a transition (`reason: unknown_transition`); `allowed_transitions` says what is.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The run does not take this transition now (`reason: transition_not_allowed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /platform/runs/{run_id}/results:
    get:
      tags: [Results]
      summary: Everything a run produced
      description: |
        Readable while the run is still going (partial) and after it settles.
        Files and lists come first (the deliverables), then records: findings,
        the approval cards the run raised, and what each step concluded.
      parameters:
        - $ref: "#/components/parameters/run_id"
        - name: kind
          in: query
          schema: { type: string, enum: [record, list, file] }
        - name: schema_ref
          in: query
          schema: { type: string }
      responses:
        "200":
          description: The results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  run_id: { type: string }
                  process_id: { type: [string, "null"] }
                  run_status: { $ref: "#/components/schemas/RunStatus" }
                  settled: { type: boolean }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/Result" }
                  withheld:
                    type: integer
                    description: Results this person may not open (a locked space, or no documents grant). Counted, never listed.
                  file_url_ttl_seconds: { type: integer, const: 900 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /platform/results/{result_id}:
    get:
      tags: [Results]
      summary: One result by id
      parameters:
        - name: result_id
          in: path
          required: true
          schema: { type: string }
          description: "`<run id>.<document|insight|approval|step>.<ref>`, as listed."
      responses:
        "200":
          description: The result, with a fresh file link when it is a file.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  result: { $ref: "#/components/schemas/Result" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /platform/approvals:
    get:
      tags: [Approvals]
      summary: List human-approval steps
      parameters:
        - $ref: "#/components/parameters/workspace_id"
        - name: status
          in: query
          schema:
            type: string
            enum: [open, decided, all]
            default: open
        - name: run_id
          in: query
          schema: { type: string }
          description: Only the approvals belonging to this run.
        - name: updated_after
          in: query
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: Approvals, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  workspace_id: { type: string }
                  approvals:
                    type: array
                    items: { $ref: "#/components/schemas/Approval" }
                  withheld: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /platform/approvals/{approval_id}:
    get:
      tags: [Approvals]
      summary: One approval, with what is being decided
      parameters:
        - $ref: "#/components/parameters/approval_id"
      responses:
        "200":
          description: The approval, with its `payload`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  approval: { $ref: "#/components/schemas/Approval" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /platform/approvals/{approval_id}/decision:
    post:
      tags: [Approvals]
      summary: Approve or reject, with a reason
      description: |
        Records the decision as the person behind the key, exactly as the
        To review panel records one, then hands the card to Framed's executor
        when there is something to carry out. `execution` says whether that
        started; poll the approval until `execution.status` is `succeeded` or
        `failed`. A rejection needs a reason.
      parameters:
        - $ref: "#/components/parameters/approval_id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [approve, reject]
                reason:
                  type: string
                  maxLength: 500
                  description: Required for reject.
      responses:
        "200":
          description: The decision is recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  api_version: { type: string }
                  decision: { type: string, enum: [approve, reject] }
                  approval: { $ref: "#/components/schemas/Approval" }
                  execution:
                    type: string
                    enum: [started, not_needed, failed]
                  execution_error: { type: string }
                  note: { type: string }
        "400":
          description: Not a decision, or a rejection without a reason (`reason: invalid_decision`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`already_decided`, or `not_a_decision` (a notice, which nothing can approve)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /platform/files/document/{file_id}:
    get:
      tags: [Files]
      summary: A document a result points at, on its signed link
      description: |
        Served on the `file_ref.url` a result hands out. The link carries its
        own credential (`token`) and expires 15 minutes after it was minted; no
        API key is needed to open it. Uploaded files use a storage link
        instead, under the same rule.
      security: []
      parameters:
        - name: file_id
          in: path
          required: true
          schema: { type: string }
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The document, as `text/markdown`, `text/html` (a rendered deck or designed report) or `application/json` (a table).
        "403":
          description: "`file_token_invalid` or `file_token_expired`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Framed-Api-Key
      description: A Framed API key (`frk_live_…`), issued in Settings by an account admin.
    Bearer:
      type: http
      scheme: bearer
      description: The same key as a bearer token, or a Connect OAuth access token.
  parameters:
    workspace_id:
      name: workspace_id
      in: query
      required: true
      schema: { type: string }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    process_id:
      name: process_id
      in: path
      required: true
      schema: { type: string }
    run_id:
      name: run_id
      in: path
      required: true
      schema: { type: string }
    approval_id:
      name: approval_id
      in: path
      required: true
      schema: { type: string }
  responses:
    BadRequest:
      description: Invalid input (`reason: invalid_input`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Workspace out of reach (`workspace_out_of_reach`), a grant the person does not have (`access_policy`), or nobody behind the key (`no_person`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such row in a workspace this person can open. A locked space answers not found, never not allowed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, const: false }
        error:
          type: string
          description: One sentence a person can act on.
        reason:
          type: string
          description: The machine-readable class of the refusal.
      additionalProperties: true
    Links:
      type: object
      additionalProperties: { type: string, format: uri }
    RunStatus:
      type: string
      description: Framed's own run statuses; MCP reads the same words.
      enum: [queued, running, awaiting_input, done, error, canceled]
    Process:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        slug: { type: [string, "null"] }
        name: { type: string }
        goal: { type: [string, "null"] }
        owner: { type: [string, "null"] }
        cadence: { type: [string, "null"] }
        status: { type: [string, "null"], description: "Where the process is on its ladder (documented, proposed, verified, ...)." }
        runnable: { type: boolean, description: Framed has a plan it can run. }
        starts:
          type: string
          enum: [immediately, after_approval]
          description: The process's own setting for who may start it.
        playbook: { type: [string, "null"] }
        steps: { type: integer }
        schema_refs:
          type: array
          items: { type: string }
          description: Result schemas the process's plan declares on its steps.
        latest_run:
          type: [object, "null"]
          properties:
            id: { type: string }
            status: { $ref: "#/components/schemas/RunStatus" }
            updated_at: { type: [string, "null"], format: date-time }
        latest_record:
          type: [object, "null"]
          properties:
            id: { type: string }
            run_id: { type: string }
            schema_ref: { type: string }
            produced_at: { type: [string, "null"], format: date-time }
        links: { $ref: "#/components/schemas/Links" }
        updated_at: { type: [string, "null"], format: date-time }
    ProcessRecord:
      type: object
      properties:
        id: { type: string }
        project_id: { type: string }
        process_id: { type: string }
        run_id: { type: string }
        schema_ref: { type: string }
        schema_version: { type: integer }
        produced_at: { type: [string, "null"], format: date-time }
        period:
          type: [object, "null"]
          properties:
            start: { type: string }
            end: { type: string }
        payload: { type: object, additionalProperties: true }
        source_refs:
          type: array
          items: { type: string }
        open_fields:
          type: array
          items: { type: string }
        materialize:
          type: object
          properties:
            studio_artifact_id: { type: [string, "null"] }
            status: { type: string, enum: [none, draft, published] }
        source: { type: string, enum: [run_extract] }
    RunStage:
      type: [object, "null"]
      properties:
        index: { type: integer }
        count: { type: integer }
        key: { type: string }
        title: { type: string }
        status: { type: string, enum: [pending, running, done, flagged, failed] }
    RunBlocker:
      type: object
      properties:
        kind: { type: string, enum: [approval, question, error] }
        approval_id: { type: string }
        title: { type: string }
        question: { type: string }
        message: { type: string }
    Run:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        process_id: { type: [string, "null"] }
        process:
          type: [object, "null"]
          properties:
            id: { type: string }
            name: { type: [string, "null"] }
        playbook: { type: [string, "null"] }
        title: { type: string }
        status: { $ref: "#/components/schemas/RunStatus" }
        settled: { type: boolean, description: No longer queued, running or waiting. }
        stage: { $ref: "#/components/schemas/RunStage" }
        steps:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              title: { type: string }
              status: { type: string }
              problems: { type: array, items: { type: string } }
        blockers:
          type: array
          items: { $ref: "#/components/schemas/RunBlocker" }
        allowed_transitions:
          type: array
          items: { type: string, enum: [continue, cancel] }
        input: { type: [object, "null"], additionalProperties: true }
        rehearsal: { type: boolean }
        review: { type: string }
        links: { $ref: "#/components/schemas/Links" }
        created_by: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
    FileRef:
      type: object
      properties:
        id: { type: string, description: "`document:<id>` or `upload:<id>` - a reference into Framed's own store." }
        kind: { type: string, enum: [document, upload] }
        name: { type: string }
        media_type: { type: string }
        size_bytes: { type: [integer, "null"] }
        url: { type: [string, "null"], format: uri, description: Signed, short-lived. }
        url_expires_at: { type: [string, "null"], format: date-time }
    Result:
      type: object
      required: [id, run_id, kind, schema_ref]
      properties:
        id: { type: string }
        process_id: { type: [string, "null"] }
        run_id: { type: string }
        step:
          type: [object, "null"]
          properties:
            key: { type: string }
            title: { type: string }
        kind: { type: string, enum: [record, list, file] }
        schema_ref:
          type: string
          description: |
            What the payload is shaped like. A step's own `schema_ref` when the
            playbook declared one; otherwise a Framed built-in
            (`framed:document/<type>`, `framed:research_table`,
            `framed:insight/<type>`, `framed:approval/<kind>`,
            `framed:step_conclusion`, `framed:upload`).
        schema_version: { type: string }
        title: { type: string }
        payload:
          description: For record and list. A list's payload carries `columns` and `rows`.
          type: object
          additionalProperties: true
        file_ref: { $ref: "#/components/schemas/FileRef" }
        approval:
          type: object
          description: For a record that is an approval card the run raised.
          properties:
            id: { type: string }
            status: { type: string, enum: [open, decided] }
            decision: { type: [string, "null"], enum: [approved, rejected, closed, null] }
            links: { $ref: "#/components/schemas/Links" }
        produced_at: { type: [string, "null"], format: date-time }
        links: { $ref: "#/components/schemas/Links" }
    Approval:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        kind: { type: string }
        title: { type: string }
        summary: { type: [string, "null"] }
        queue:
          type: string
          enum: [needs_you, ready, notice]
          description: needs_you = a person has to judge it; ready = finished work behind one yes; notice = nothing is asked.
        status: { type: string, enum: [open, decided] }
        decision: { type: [string, "null"], enum: [approved, rejected, closed, null] }
        reason: { type: [string, "null"] }
        decided_by: { type: [string, "null"] }
        decided_at: { type: [string, "null"], format: date-time }
        execution:
          type: object
          properties:
            status: { type: string, enum: [none, queued, running, succeeded, failed] }
            result: { type: object, additionalProperties: true }
        run_id: { type: [string, "null"] }
        links: { $ref: "#/components/schemas/Links" }
        payload:
          type: object
          additionalProperties: true
          description: What is being decided, as the card stores it (on the single-approval read).
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
