Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Writ Agent Runs API
version: 0.1.0
license:
name: MIT
identifier: MIT
description: 'Loopback HTTP API of the Writ local agent (`writ-agentd`), the daemon embedded in the
Writ desktop app and the self-host build. This document is generated against the Rust
daemon source (`writ-agent/src/local/`), which is the wire truth.
## Transport
- Plain HTTP on `127.0.0.1:8131` (default; `WRIT_PORT` env overrides).
- Optional HTTPS twin on `127.0.0.1:8132`, served with a certificate minted by a
per-install, name-constrained local CA (`~/.writ/tls/ca.pem`). Clients must trust that
CA file (or pin it) to use the TLS port; the payloads are identical on both ports.
- The daemon enforces a DNS-rebind guard: `Host`/`Origin` must be loopback. Talk to
`127.0.0.1`, not `localhost`.
## Errors
Domain errors are JSON `{"error": "", "code": ""}` (see the
`Error` schema). Codes include `bad_request`, `unauthorized`, `forbidden`, `not_found`,
`vault_locked` (HTTP 423), `too_many_requests`, `device_capacity` (HTTP 409),
`captcha_required` (HTTP 402), `internal`.
**Caveat:** some 4xx bodies are `text/plain`, not JSON — axum framework rejections
(e.g. a JSON body that fails to deserialize returns
`Failed to deserialize the JSON body into the target type: missing field ''url''` as
plain text with a 4xx status). Clients must tolerate non-JSON error bodies.
## List envelopes
The wire is deliberately documented as-is (SDKs normalize; this spec does not):
- `{"data": [...], "count": n}` — workflows, personas, persona runs, secrets, files, keys
- `{"data": [...], "count": n, "total": n}` — runs
- **bare JSON array** — monitors, monitor selectors, extractors, automations,
`/v1/changes/recent`
- bespoke objects — `/v1/monitors/{id}/changes`, `/v1/data`, the workflow-data table'
servers:
- url: http://127.0.0.1:8131
description: Plain-HTTP loopback listener (default port; WRIT_PORT overrides).
- url: https://127.0.0.1:8132
description: HTTPS twin of the same API, terminated with a certificate from the per-install local CA at ~/.writ/tls/ca.pem. Trust that CA (or pin it) in your HTTP client.
security:
- bearerAuth: []
tags:
- name: Runs
description: Read-only run feed, per-run results/data, live SSE events, and cancel-by-run-id.
paths:
/v1/runs:
get:
operationId: listRuns
tags:
- Runs
summary: List runs (enriched feed)
description: Newest-first run feed in the `{data, count, total}` envelope (`total` is the post-filter total before pagination; `count` is the page length). Each item is an enriched `RunFeedItem` projection, not the raw row. Without a `run_type` filter, successful monitor checks are suppressed (only failed checks surface in the unified feed).
parameters:
- name: entity_id
in: query
description: Scope to one entity, interpreted per `run_type` (workflow_id / target_id / automation_id). Aliases `workflow_id` for workflow-type queries.
schema:
type: integer
format: int64
- name: workflow_id
in: query
description: Legacy alias for `entity_id` on workflow-type queries.
schema:
type: integer
format: int64
- name: run_type
in: query
description: Filter by feed run type. Only workflow runs persist locally today, so `check`/`automation` yield an empty page.
schema:
type: string
enum:
- workflow
- check
- automation
- name: status
in: query
description: Filter by run status (e.g. `running`, `success`, `failed`).
schema:
type: string
- name: limit
in: query
description: Page size (clamped to 1..=1000; default 100).
schema:
type: integer
format: int64
default: 100
- name: offset
in: query
description: Pagination offset (applied after scoping/projection).
schema:
type: integer
format: int64
default: 0
responses:
'200':
description: Run feed page in the `{data, count, total}` envelope.
content:
application/json:
schema:
$ref: '#/components/schemas/RunList'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/runs/{id}:
get:
operationId: getRun
tags:
- Runs
summary: Get one run
description: One run as the enriched `RunFeedItem` projection (same shape as the list). The path id is the NUMERIC run row id — the part after the last dash of the feed item's composite string `id`.
parameters:
- $ref: '#/components/parameters/RunId'
responses:
'200':
description: The enriched run feed item.
content:
application/json:
schema:
$ref: '#/components/schemas/RunFeedItem'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/v1/runs/{id}/results:
get:
operationId: getRunResults
tags:
- Runs
summary: Raw run result payload
description: The run's raw `result_data` JSON. `result` is `null` when the run produced no payload (still running, or none written).
parameters:
- $ref: '#/components/parameters/RunId'
responses:
'200':
description: Run result envelope.
content:
application/json:
schema:
$ref: '#/components/schemas/RunResults'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/v1/runs/{id}/data:
get:
operationId: getRunData
tags:
- Runs
summary: Extracted data of one run
description: The run's extracted output (the `extracted_data` key of `result_data`, falling back to the whole result payload). With `?format=csv` the response is a CSV attachment (`text/csv`) flattening the run's records — one row per extracted record.
parameters:
- $ref: '#/components/parameters/RunId'
- name: format
in: query
description: '`json` (default) or `csv`.'
schema:
type: string
enum:
- json
- csv
default: json
responses:
'200':
description: Extracted data (JSON envelope, or CSV when `?format=csv`).
content:
application/json:
schema:
$ref: '#/components/schemas/RunData'
text/csv:
schema:
type: string
description: Flattened per-record CSV (run metadata columns + data columns).
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/v1/runs/{id}/events:
get:
operationId: streamRunEvents
tags:
- Runs
summary: Live run event stream (SSE)
description: 'Server-Sent Events (`text/event-stream`) stream of the run''s lifecycle.
- Frames are **named** SSE events (`event: ` + `data: `) with name one
of `started | step | progress | finished | error`. The JSON payload also carries
the same discriminant in an `"event"` field (serde tag), so parsing `data` alone
is sufficient.
- `finished` and `error` are **stream-closing** — the connection ends after one of
them is delivered.
- Keep-alive **comment frames** (lines starting with `:`) are emitted every 15
seconds and must be ignored by parsers.
- A run that is already finished (or not live in the engine registry) yields exactly
one synthetic terminal frame, then the stream closes immediately.
- A live subscription is seeded with a synthetic `started` frame
(`total_steps` may be 0 in that seed) since the broadcast channel does not replay.'
parameters:
- $ref: '#/components/parameters/RunId'
responses:
'200':
description: SSE stream. Each event's `data:` line is one JSON document matching the `RunEvent` schema (tagged by its `event` field).
content:
text/event-stream:
schema:
$ref: '#/components/schemas/RunEvent'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/v1/runs/{id}/cancel:
post:
operationId: cancelRun
tags:
- Runs
summary: Cancel a live run by run id
description: 'Cancels any run row directly (including ad-hoc runs with no workflow). Returns `202 {run_id, status: "cancel_requested"}` when a live run was signalled, or `409 {status: "not_running"}` when the run is already terminal or the engine holds no live handle. The 409 is a valid domain answer, not a failure.'
parameters:
- $ref: '#/components/parameters/RunId'
responses:
'202':
description: The live run was signalled to cancel.
content:
application/json:
schema:
$ref: '#/components/schemas/RunCancelAccepted'
'409':
description: The run is not in a cancellable state.
content:
application/json:
schema:
$ref: '#/components/schemas/RunCancelConflict'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
RunEvent:
description: One SSE frame's JSON payload, tagged by its `event` field (which also matches the SSE `event:` name). `finished` and `error` are stream-closing.
oneOf:
- $ref: '#/components/schemas/RunEventStarted'
- $ref: '#/components/schemas/RunEventStep'
- $ref: '#/components/schemas/RunEventProgress'
- $ref: '#/components/schemas/RunEventFinished'
- $ref: '#/components/schemas/RunEventError'
discriminator:
propertyName: event
mapping:
started: '#/components/schemas/RunEventStarted'
step: '#/components/schemas/RunEventStep'
progress: '#/components/schemas/RunEventProgress'
finished: '#/components/schemas/RunEventFinished'
error: '#/components/schemas/RunEventError'
RunEventStarted:
type: object
description: The run is registered and about to drive its steps. `total_steps` counts the raw steps the engine will iterate (a synthetic subscription seed may report 0).
required:
- event
- run_id
- total_steps
properties:
event:
type: string
const: started
run_id:
type: integer
format: int64
total_steps:
type: integer
RunList:
type: object
description: '`{data, count, total}` list envelope (runs only).'
required:
- data
- count
- total
properties:
data:
type: array
items:
$ref: '#/components/schemas/RunFeedItem'
count:
type: integer
description: Number of items in this page.
total:
type: integer
description: Post-filter total before pagination.
Error:
type: object
description: 'The daemon''s domain error body. NOTE: some 4xx responses (axum framework rejections) are `text/plain` instead of this JSON shape. Certain codes attach extra fields (e.g. `device_capacity` adds `would_use_units_per_min` / `budget_units_per_min`; `captcha_required` adds `captcha_type` / `url`).'
required:
- error
- code
properties:
error:
type: string
description: 'Human-readable message (e.g. `not found: workflow 999999`).'
code:
type: string
description: 'Stable machine code: `bad_request`, `unauthorized`, `forbidden`, `not_found`, `vault_locked`, `too_many_requests`, `captcha_required`, `device_capacity`, `cloud_unreachable`, `db_error`, `io_error`, `json_error`, `internal`, …'
additionalProperties: true
RunCancelAccepted:
type: object
description: 202 body of `POST /v1/runs/{id}/cancel`.
required:
- run_id
- status
properties:
run_id:
type: integer
format: int64
status:
type: string
const: cancel_requested
RunFeedItem:
type: object
description: Enriched run-feed projection (`api/v1/runs.rs::RunFeedItem`). `id` is a COMPOSITE STRING `"<run_type>-<row_id>"` (e.g. `"workflow-3"`); the numeric id for get/cancel/events is the part after the last dash.
required:
- id
- run_type
- entity_id
- entity_name
- status
- started_at
- finished_at
- duration_ms
- trigger_source
- error
- detail_url_hint
- data_url_hint
- rows_extracted
- change_detected
properties:
id:
type: string
description: 'Composite feed id: `"<run_type>-<row_id>"`.'
run_type:
type: string
enum:
- workflow
- check
- automation
entity_id:
type:
- integer
- 'null'
format: int64
description: The associated workflow / target / automation id, per `run_type`.
entity_name:
type:
- string
- 'null'
description: Resolved entity name (workflow runs; `Run <id>` fallback).
status:
type: string
description: 'Open string enum. Known values: `running`, `success`, `failed`, `cancelled`, `timeout`, `captcha_required`, `twofa_required`, `interrupted`.'
started_at:
type:
- string
- 'null'
description: RFC3339; falls back to the row's created time.
finished_at:
type:
- string
- 'null'
duration_ms:
type:
- integer
- 'null'
format: int64
trigger_source:
type:
- string
- 'null'
description: The run's trigger type (`manual`, `schedule`, …).
error:
type:
- string
- 'null'
description: The failure message for failed runs.
detail_url_hint:
type:
- string
- 'null'
description: Frontend deep-link to the owning entity (e.g. `/workflows/3`).
data_url_hint:
type:
- string
- 'null'
description: Deep-link to the Data view — present only for successful, row-producing, non-streaming workflow runs.
rows_extracted:
type:
- integer
- 'null'
format: int64
description: Workflow runs only — extracted record count; null for other types.
change_detected:
type:
- boolean
- 'null'
description: Check runs only — whether the check detected a change; null otherwise.
engine:
type: string
description: Which lane ran this (`http` | `browser` | `hybrid`), from the result payload. OMITTED (not null) for non-workflow runs and older results.
RunData:
type: object
description: Extracted-data envelope (JSON lane). `data` is the `extracted_data` value of the result payload, falling back to the whole result.
required:
- run_id
- status
- data
properties:
run_id:
type: integer
format: int64
status:
type: string
data:
description: The extracted data (any JSON value; null when none).
RunCancelConflict:
type: object
description: 409 body of `POST /v1/runs/{id}/cancel` — the run is terminal or has no live engine handle. `run_status` (the row's actual status) is present only on the already-terminal arm.
required:
- run_id
- status
properties:
run_id:
type: integer
format: int64
status:
type: string
const: not_running
run_status:
type: string
description: The run row's actual status (present when the row is already terminal).
RunEventProgress:
type: object
description: Coarse progress hint — `completed` of `total` steps done.
required:
- event
- run_id
- completed
- total
properties:
event:
type: string
const: progress
run_id:
type: integer
format: int64
completed:
type: integer
total:
type: integer
RunStatus:
type: string
description: Terminal/in-flight run status (serde snake_case of `engine::RunStatus`). Treat as an open string enum in clients.
enum:
- running
- success
- failed
- cancelled
- timeout
- captcha_required
- twofa_required
RunEventError:
type: object
description: 'TERMINAL: the run failed around the step loop. `message` is an operator-facing classification, never a raw secret/value. Stream-closing.'
required:
- event
- run_id
- message
properties:
event:
type: string
const: error
run_id:
type: integer
format: int64
message:
type: string
RunEventFinished:
type: object
description: 'TERMINAL: the run reached a final status. Stream-closing.'
required:
- event
- run_id
- status
properties:
event:
type: string
const: finished
run_id:
type: integer
format: int64
status:
$ref: '#/components/schemas/RunStatus'
RunEventStep:
type: object
description: A step transitioned. `index` is 0-based into the raw step list; `step_type` is the recorded step kind — never a value.
required:
- event
- run_id
- index
- step_type
- status
properties:
event:
type: string
const: step
run_id:
type: integer
format: int64
index:
type: integer
step_type:
type: string
status:
$ref: '#/components/schemas/StepStatus'
StepStatus:
type: string
description: Coarse per-step lifecycle marker.
enum:
- running
- succeeded
- failed
- skipped
RunResults:
type: object
description: Raw run result envelope.
required:
- run_id
- status
- result
properties:
run_id:
type: integer
format: int64
status:
type: string
result:
description: The parsed `result_data` payload; null when none exists.
responses:
NotFound:
description: The addressed resource does not exist.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: Missing/invalid bearer token, or the Origin/Host guard rejected the request.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
parameters:
RunId:
name: id
in: path
required: true
description: Numeric run row id (for feed items with a composite string id like `workflow-3`, this is the part after the last dash).
schema:
type: integer
format: int64
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: "Every route (including `/v1/agent` and `/v1/health`) requires\n`Authorization: Bearer <token>`. Token families:\n\n- `wlt_…` — the runtime token from `runtime.json` (full access, used by the desktop\n UI and SDK discovery).\n- `wlk_…` — a scoped API key minted via `POST /v1/keys` (CSV scopes `read|run|admin`;\n `/v1/keys` itself requires the full-access token).\n- `wlo_…` — an OAuth 2.1 access token from the daemon's local authorization server\n (`run` scope).\n\nTreat the token as an opaque string. The daemon additionally enforces a loopback\nOrigin/Host DNS-rebind guard.\n"