Workflows API
A workflow is a JSON graph that chains agents, tools and data-shaping steps into one durable, multi-step run. The graph is Mastra’s own serialized step-flow: there is no Clanker dialect and no interpreter in between, so anything the JSON cannot express is a thing the runtime deliberately cannot do.
Runs execute on a durable backend, one memoized step at a time. A replica dying mid-run loses nothing — the run resumes at the first step that never completed.
Workflows are started two ways:
- Manual — launch via the REST API or MCP
- In chat — the workspace agent assembles and starts a workflow from a natural-language request
Results are retrieved by polling the run, or live via the SSE stream. There is no inbound-webhook trigger and no result callback — see Retrieving results.
Every endpoint below is workspace-scoped by your credential (an API key has one
workspace baked in; a session token selects one with x-workspace-id). There is
no workspace id in any path. Unauthenticated calls return 401
UNAUTHORIZED.
For a walkthrough, see the Workflows Guide.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/workflows | Workflows installed in the active workspace |
| GET | /api/v1/workflows/:slug | One installed workflow, definition included |
| GET | /api/v1/workflows/definitions | What this workspace can launch, and with what inputs |
| POST | /api/v1/workflows/:slug/install | Install (or overwrite) a definition |
| POST | /api/v1/workflows/install-from-artifact | Install from a saved artifact bundle |
| POST | /api/v1/workflows/:slug/uninstall | Remove an installed workflow |
| DELETE | /api/v1/workflows/:slug | Same operation, addressed as a deletion |
| POST | /api/v1/workflows/:id/launch | Start a run; returns a runId immediately |
| GET | /api/v1/workflows/runs | Recent runs for the workspace |
| GET | /api/v1/workflows/runs/:runId | One run, plus per-step results |
| GET | /api/v1/workflows/runs/:runId/events | SSE snapshot stream for a run |
| POST | /api/v1/workflows/runs/:runId/resume | Resume a suspended run |
| POST | /api/v1/workflows/runs/:runId/cancel | Cancel a run |
| GET | /api/v1/workflows/queue | Queued/running jobs underneath the runs |
| DELETE | /api/v1/workflows/queue/:runId | Cancel one queued/running job |
The definition format
A stored definition is a JSON object. Four keys are required; the parser rejects
the document with 400 INVALID_INPUT if any is missing or the wrong type.
| Key | Type | Required | Description |
|---|---|---|---|
id | string | Yes | kebab-case slug; also the launch id |
graph | array | Yes | Ordered list of step entries |
inputSchema | JSON Schema | Yes | The workflow’s input object |
outputSchema | JSON Schema | Yes | The workflow’s output object |
description | string | No | One sentence of prose |
metadata | object | No | Catalog fields — name, category, requiresConnector, triggers[], integrationTrigger |
stateSchema | JSON Schema | No | Shape of run state (read-only from the graph) |
metadata is the right home for the catalog fields precisely because the engine
ignores that key entirely — it round-trips verbatim.
Entry types
agent, tool, mapping, sleep, sleepUntil, parallel, conditional,
loop, foreach.
| Entry | Shape |
|---|---|
agent | { type, id, agentId, description?, outputSchema? } |
tool | { type, id, toolId, description?, options? } |
mapping | { type, id, mapConfig } — mapConfig is a JSON string |
A complete worked example
This is the workflow every workspace ships with, verbatim
(workspace/workflows/message-triage/workflow.json — the base layer, compiled
into shared/builtin-workflows.generated.ts). Two agent steps, each fed by a
mapping step that builds its prompt:
{
"id": "message-triage",
"description": "Read an inbound message, name its subject, then draft a short reply that references that subject.",
"metadata": { "name": "Message Triage", "category": "productivity" },
"inputSchema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The inbound message to triage."
}
},
"required": ["message"]
},
"outputSchema": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "The drafted reply." }
},
"required": ["text"]
},
"graph": [
{
"type": "mapping",
"id": "subject-prompt",
"mapConfig": "{\"prompt\":{\"template\":\"Reply with the subject of the message below in five words or fewer.\\n\\nMESSAGE:\\n${initData.message}\"}}"
},
{
"type": "agent",
"id": "subject-extractor",
"agentId": "${workspaceId}:clanka-01",
"description": "Names the subject of the inbound message."
},
{
"type": "mapping",
"id": "reply-prompt",
"mapConfig": "{\"prompt\":{\"template\":\"Draft a reply of at most three sentences. Open by naming the subject, then answer the message.\\n\\nSUBJECT:\\n${stepResults.subject-extractor.text}\\n\\nMESSAGE:\\n${initData.message}\"}}"
},
{
"type": "agent",
"id": "reply-drafter",
"agentId": "${workspaceId}:clanka-01",
"description": "Drafts the reply, using the subject the previous step produced."
}
]
}
Read the shape off that: every entry’s input is the previous entry’s output.
There is no wiring language and no per-step argument list. A step that needs
something other than what came before it gets a mapping entry in front of it,
and that mapping is where ${initData.…} and ${stepResults.<stepId>.…} are
read.
The ${workspaceId} token
A definition is portable — the same bytes install into any workspace, and an
artifact carries them between workspaces. But an agent entry must name a
concrete agent, and agent ids are compound ({workspaceUuid}:{slug}), so a
portable definition cannot hard-code the uuid half.
${workspaceId} is the one substitution a definition gets. It is replaced in
every string in the graph at load time, before validation and before the graph
is built:
{ "type": "agent", "id": "draft", "agentId": "${workspaceId}:clanka-01" }
Nothing else is interpolated. This is a single named substitution, not an
expression language. ${...} was chosen because it is already the syntax
mapConfig templates use — one dialect, not two.
Running a skill from a step
A skill runs through the execute-skill tool, fed by a mapping in front of
it. The mapping must produce slug and input — the argument is
slug, not skill_slug:
[
{
"type": "mapping",
"id": "fetch-input",
"mapConfig": "{\"slug\": {\"value\": \"pr-fetcher\"}, \"input\": {\"template\": \"{\\\"owner\\\":\\\"${initData.owner}\\\",\\\"repo\\\":\\\"${initData.repo}\\\"}\"}}"
},
{
"type": "tool",
"id": "fetch",
"toolId": "execute-skill",
"description": "Fetch the PR and build one bounded review prompt."
}
]
input accepts a string, an object or an array; anything that is not already a
string is JSON-stringified before it reaches the skill runtime. Inside a
workflow, execute-skill blocks until the skill reaches a terminal status, so a
later step can read its output at ${stepResults.fetch.…}.
Constraints that bite an author
These are inherited from the engine. None of them are negotiable, and each one is a thing authors reliably try first:
- A
toolentry has noargsfield. It is{ type, id, toolId, description?, options? }, andoptionsholds only{ retries, metadata }. A tool step’s input is entirely the preceding entry’s output. Shape a tool call by putting amappingin front of it. mapConfigis a JSON string, and the record inside it is flat. Each value is exactly one of{value},{template},{requestContextPath},{initData, path},{step, path}. One level only — a nested object that contains a reference cannot be expressed.mappingentries are legal only at the top level ofgraph. Inside aparallel/loop/foreachbody they are rejected at install time.- Conditions cannot be closures. Only the declarative predicate grammar
round-trips through JSON:
eq/ne/lt/lte/gt/gte,in/notIn,exists/notExists,truthy/falsy,and/or/not— overinitData,inputData,stateandstepResults. There is no iteration counter, so aloopcannot bound itself by attempt count. - State is read-only. A graph can read
state; no entry type writes it. - A container may hold only single-step entries (
agent,tool,sleep,sleepUntil). A container nested inside a container is refused.
Every one of these is checked at install time, not at run time — see below.
Install a workflow
POST /api/v1/workflows/:slug/install
Three body shapes are accepted, and all three store the same definition:
| Body | When to use |
|---|---|
{ "content": "<json string>" } | You are holding the raw bytes |
{ "definition": { … } } | You are holding the parsed object |
| the definition as the whole body | Plain “PUT the document” — recognised by id + graph |
curl -X POST https://clanker.net/api/v1/workflows/message-triage/install \
-H "x-api-key: ck_live_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "definition": { "id": "message-triage", "inputSchema": {"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}, "outputSchema": {"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}, "graph": [ { "type": "agent", "id": "reply-drafter", "agentId": "${workspaceId}:clanka-01" } ] } }'
Response: 201 Created — the installed record, with definition read back
out of storage rather than echoed from your request:
{
"id": 42,
"slug": "message-triage",
"name": "Message Triage",
"description": "Read an inbound message, name its subject, then draft a short reply that references that subject.",
"category": "productivity",
"storagePath": "workspaces/7c2e…/workflows/message-triage.json",
"installedAt": "2026-08-01T10:12:03.441Z",
"workspaceId": "7c2e…",
"requiresConnector": null,
"definition": "{\"id\":\"message-triage\", … }"
}
Install compiles the definition
An install is not a byte drop. The definition is fully compiled before anything
is written: parsed → ${workspaceId} interpolated → every agentId
pre-resolved against this workspace’s agents → validated against the tools
this deployment can actually resolve → built into a runnable graph. Only then
are the two stores written.
So an unresolvable toolId or agentId is a 400 here, with the offending
path in the message — not a mysterious failure three steps into a run:
{
"error": {
"code": "INVALID_INPUT",
"message": "Workflow \"message-triage\" references agent(s) that do not exist in workspace 7c2e…: 7c2e…:clanka-99"
}
}
| Status | code | Cause |
|---|---|---|
| 400 | INVALID_INPUT | No definition in the body, or the definition does not compile |
| 404 | NOT_FOUND | Referenced resource missing |
| 500 | INTERNAL_ERROR | The write failed after validation passed |
For large definitions, prefer POST /api/v1/install with a git or r2 source —
that path fetches the bytes server-side instead of carrying them through the
API zone.
Install from an artifact
POST /api/v1/workflows/install-from-artifact
curl -X POST https://clanker.net/api/v1/workflows/install-from-artifact \
-H "x-api-key: ck_live_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "artifactId": "a1b2c3d4-5678-90ab-cdef-1234567890ab" }'
Reads workflow.json from the artifact bundle — that name only; there is no
workflow.yaml fallback. The slug comes from the artifact. Response is the same
201 record as above.
| Status | code | Cause |
|---|---|---|
| 400 | INVALID_INPUT | artifactId missing, or the artifact is not of type workflow |
| 404 | NOT_FOUND | No such artifact |
| 500 | INTERNAL_ERROR | Bundle has no workflow.json, or the definition is invalid |
The last row is a wart worth knowing: a definition that fails to compile is a
400when installed by slug and a500when the same bytes arrive by artifact. Do not branch on the status to decide whether the definition is at fault — install by slug if you need that answer.
List installed workflows
GET /api/v1/workflows
Returns a bare JSON array (not an envelope), one entry per installed
workflow, each with its definition hydrated from storage:
[
{
"id": 42,
"slug": "message-triage",
"name": "Message Triage",
"description": "Read an inbound message, name its subject, then draft a short reply.",
"category": "productivity",
"storagePath": "workspaces/7c2e…/workflows/message-triage.json",
"installedAt": "2026-08-01T10:12:03.441Z",
"workspaceId": "7c2e…",
"requiresConnector": null,
"definition": "{\"id\":\"message-triage\", … }"
}
]
Get one workflow
GET /api/v1/workflows/:slug
One record in the shape above. 404 NOT_FOUND if the slug is not installed in
this workspace.
List what you can launch
GET /api/v1/workflows/definitions
Reads the runtime registry for your workspace — this is the “what can I
launch, and with what inputs” endpoint, so it answers with schemas rather than
graphs. The graph is deliberately excluded; fetch it from
GET /api/v1/workflows/:slug if you need it.
{
"workflows": [
{
"id": "message-triage",
"slug": "message-triage",
"name": "Message Triage",
"description": "Read an inbound message, name its subject, then draft a short reply.",
"category": "productivity",
"inputSchema": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"]
},
"outputSchema": {
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
},
"triggers": [],
"requiresConnector": null
}
],
"format": "clanker-v1",
"exportable": true,
"count": 1
}
Launch a run
POST /api/v1/workflows/:id/launch
:id resolves against your workspace as the library slug, the namespaced
runtime id, or the definition’s own declared id.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
inputs | object | No | Must satisfy the workflow’s inputSchema; defaults to {} |
inputs is the only field read. Anything else in the body is ignored.
curl -X POST https://clanker.net/api/v1/workflows/message-triage/launch \
-H "x-api-key: ck_live_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "inputs": { "message": "Can we move the Thursday review to Friday?" } }'
Response: 201 Created
{
"runId": "run-1770000000000-8fq2k1",
"workflowId": "message-triage",
"status": "started"
}
conversationId is present only when the run was started from a chat thread; a
run launched over the API has no conversation and the field is absent. Run IDs
are opaque strings — treat them as identifiers, not a fixed format.
The workflow is compiled again on launch, so a definition that has stopped resolving (an agent was deleted since install) fails the launch request rather than a run.
| Status | code | Cause |
|---|---|---|
| 400 | INVALID_INPUT | The workflow could not be loaded or dispatched |
| 404 | NOT_FOUND | No such workflow in this workspace |
| 503 | SERVICE_UNAVAILABLE | The durable backend is unconfigured or down — nothing is wrong with your request, and no run was created |
| 500 | INTERNAL_ERROR | Unexpected failure; details.cause carries the message |
Retrieving results
A launched run is asynchronous. Get its outcome one of these ways:
- Poll the run —
GET /api/v1/workflows/runs/:runIduntil the status is terminal. - Stream it — open the SSE endpoint below for live snapshots.
- In chat — when a workflow is started from a conversation, completion is delivered back into that thread automatically as a notification.
List runs
GET /api/v1/workflows/runs
| Query | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Max runs returned |
Most recent runs for the workspace, whatever started them:
{
"runs": [
{
"runId": "run-1770000000000-8fq2k1",
"workflowId": "message-triage",
"status": "success",
"resourceId": "7c2e…",
"updatedAt": "2026-08-01T10:13:11.002Z",
"snapshot": { "status": "success", "context": {}, "value": {} }
}
]
}
A workspace with no run history answers { "runs": [] }.
Get one run
GET /api/v1/workflows/runs/:runId
curl https://clanker.net/api/v1/workflows/runs/run-1770000000000-8fq2k1 \
-H "x-api-key: ck_live_xxxxxxxxxxxxx"
{
"run": {
"runId": "run-1770000000000-8fq2k1",
"workflowId": "message-triage",
"status": "success",
"resourceId": "7c2e…",
"updatedAt": "2026-08-01T10:13:11.002Z",
"snapshot": { "status": "success", "context": { … }, "value": {} }
},
"steps": {
"subject-extractor": {
"status": "success",
"output": { "text": "Moving Thursday review" }
},
"reply-drafter": {
"status": "success",
"output": { "text": "On moving Thursday's review — Friday works…" }
}
}
}
steps is a map keyed by step id, not an array, and it is the run’s own
per-step results. It is {} while the run is still on its first step, and it
stays {} rather than failing the whole response if step hydration hiccups.
A run belonging to another workspace answers 404 NOT_FOUND — identical to a
run that does not exist, deliberately: the caller is not entitled to learn that
the id exists.
Stream a run (SSE)
GET /api/v1/workflows/runs/:runId/events
Emits the current snapshot on connect, then one frame per change, polling once a
second. Wire format is plain data: frames:
data: {"type":"workflow-snapshot","runId":"run-1770000000000-8fq2k1","workflowId":"message-triage","status":"running","updatedAt":"2026-08-01T10:12:44.100Z","snapshot":{ … }}
The server closes the stream once the run reaches success, failed or
suspended — suspended is semi-terminal (the run may be waiting on a human
for hours), so the client renders the resume controls from that last frame and
re-subscribes after posting a resume. Close the stream yourself on canceled.
A : heartbeat comment is written while the snapshot is not yet visible.
Resume a suspended run
POST /api/v1/workflows/runs/:runId/resume
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
resumeData | any | No | Payload satisfying the suspended step’s resume schema |
stepId | string | No | Explicit step to resume; defaults to the run’s sole suspended step |
curl -X POST https://clanker.net/api/v1/workflows/runs/run-1770000000000-8fq2k1/resume \
-H "x-api-key: ck_live_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "resumeData": { "approved": true } }'
{ "runId": "run-1770000000000-8fq2k1", "status": "running" }
Racing resumes are serialised — only one wins, the other gets
RESUME_IN_PROGRESS.
| Status | code | Cause |
|---|---|---|
| 404 | NOT_FOUND | No such run in this workspace |
| 409 | NOT_SUSPENDED | The run is running or already finished |
| 409 | NO_SUSPEND_FRAME | Marked suspended but carrying no suspended step |
| 409 | STEP_MISMATCH | Your stepId is not where the run is suspended |
| 409 | RESUME_IN_PROGRESS | Another resume for this run is mid-flight |
| 500 | RESUME_FAILED | Dispatch failed |
The resume codes are specific to this route and sit outside the shared
ErrorCodes set — branch on them by string, as always, never on the message.
One shape is also exceptional: a resume against a run that has already finished
answers 409 { "cancelled": true, "error": "Workflow run is already success" }
rather than the error envelope.
Cancel a run
POST /api/v1/workflows/runs/:runId/cancel
{ "success": true, "runId": "run-1770000000000-8fq2k1" }
Cancellation is two halves and both happen here: the in-flight execution is
aborted wherever it is running, and the run’s snapshot is written as canceled
(an aborted execution never gets to write its own terminal state). If the run
was started from chat and the workspace has an active sandbox execution, that
execution is torn down too.
404 NOT_FOUND when the run does not exist or belongs to another
workspace.
Uninstall
POST /api/v1/workflows/:slug/uninstall
DELETE /api/v1/workflows/:slug
One operation behind two verbs — POST …/uninstall pairs with
POST …/install, DELETE is the same thing addressed the way HTTP addresses a
deletion. Both remove the runtime row and the workspace file.
{ "success": true }
404 NOT_FOUND when the slug is not installed in this workspace.
Run queue
GET /api/v1/workflows/queue
DELETE /api/v1/workflows/queue/:runId
The durable jobs underneath the runs — queued and running, for this workspace
only. The canonical view of a workflow run is /api/v1/workflows/runs; this is
the layer below it, and it also covers durable agent runs.
{
"queued": [
{
"runId": "01JQ…",
"workflowSlug": "clanker-json-workflow",
"enqueuedAt": "2026-08-01T10:12:03.441Z",
"status": "running"
}
],
"total": 1
}
DELETE /queue/:runId cancels one job and answers { "success": true }, or
404 NOT_FOUND when the job does not exist or belongs to another workspace.
Composing a workflow in chat
There is no public compose REST endpoint. To build a workflow from a description, ask your workspace’s primary agent in chat — it assembles the steps on the fly, optionally surfaces a proposal for approval, then starts the run.
Run statuses
These are the values on run.status and in every snapshot frame.
| Status | Description |
|---|---|
running | Written the moment the launch is accepted, and again on resume |
suspended | Parked waiting for human input — resume to continue |
success | All steps finished; the workflow’s output is on snapshot.result |
failed | A step failed; the reason is on snapshot.error |
canceled | Cancelled by the user (one l — it is the engine’s spelling) |
Errors
Every error body is the standard envelope:
{ "error": { "code": "NOT_FOUND", "message": "Run not found" } }
Branch on error.code. error.message is human prose — it may be reworded or
translated at any time, so matching on it is a bug. The two documented
exceptions on this surface are the resume-terminal 409 and the resume codes
outside the shared set, both noted above.