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

MethodEndpointDescription
GET/api/v1/workflowsWorkflows installed in the active workspace
GET/api/v1/workflows/:slugOne installed workflow, definition included
GET/api/v1/workflows/definitionsWhat this workspace can launch, and with what inputs
POST/api/v1/workflows/:slug/installInstall (or overwrite) a definition
POST/api/v1/workflows/install-from-artifactInstall from a saved artifact bundle
POST/api/v1/workflows/:slug/uninstallRemove an installed workflow
DELETE/api/v1/workflows/:slugSame operation, addressed as a deletion
POST/api/v1/workflows/:id/launchStart a run; returns a runId immediately
GET/api/v1/workflows/runsRecent runs for the workspace
GET/api/v1/workflows/runs/:runIdOne run, plus per-step results
GET/api/v1/workflows/runs/:runId/eventsSSE snapshot stream for a run
POST/api/v1/workflows/runs/:runId/resumeResume a suspended run
POST/api/v1/workflows/runs/:runId/cancelCancel a run
GET/api/v1/workflows/queueQueued/running jobs underneath the runs
DELETE/api/v1/workflows/queue/:runIdCancel 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.

KeyTypeRequiredDescription
idstringYeskebab-case slug; also the launch id
grapharrayYesOrdered list of step entries
inputSchemaJSON SchemaYesThe workflow’s input object
outputSchemaJSON SchemaYesThe workflow’s output object
descriptionstringNoOne sentence of prose
metadataobjectNoCatalog fields — name, category, requiresConnector, triggers[], integrationTrigger
stateSchemaJSON SchemaNoShape 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.

EntryShape
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 tool entry has no args field. It is { type, id, toolId, description?, options? }, and options holds only { retries, metadata }. A tool step’s input is entirely the preceding entry’s output. Shape a tool call by putting a mapping in front of it.
  • mapConfig is 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.
  • mapping entries are legal only at the top level of graph. Inside a parallel / loop / foreach body 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 — over initData, inputData, state and stepResults. There is no iteration counter, so a loop cannot 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:

BodyWhen to use
{ "content": "<json string>" }You are holding the raw bytes
{ "definition": { … } }You are holding the parsed object
the definition as the whole bodyPlain “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"
  }
}
StatuscodeCause
400INVALID_INPUTNo definition in the body, or the definition does not compile
404NOT_FOUNDReferenced resource missing
500INTERNAL_ERRORThe 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.

StatuscodeCause
400INVALID_INPUTartifactId missing, or the artifact is not of type workflow
404NOT_FOUNDNo such artifact
500INTERNAL_ERRORBundle 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 400 when installed by slug and a 500 when 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

FieldTypeRequiredDescription
inputsobjectNoMust 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.

StatuscodeCause
400INVALID_INPUTThe workflow could not be loaded or dispatched
404NOT_FOUNDNo such workflow in this workspace
503SERVICE_UNAVAILABLEThe durable backend is unconfigured or down — nothing is wrong with your request, and no run was created
500INTERNAL_ERRORUnexpected failure; details.cause carries the message

Retrieving results

A launched run is asynchronous. Get its outcome one of these ways:

  1. Poll the runGET /api/v1/workflows/runs/:runId until the status is terminal.
  2. Stream it — open the SSE endpoint below for live snapshots.
  3. 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
QueryTypeDefaultDescription
limitnumber50Max 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 suspendedsuspended 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

FieldTypeRequiredDescription
resumeDataanyNoPayload satisfying the suspended step’s resume schema
stepIdstringNoExplicit 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.

StatuscodeCause
404NOT_FOUNDNo such run in this workspace
409NOT_SUSPENDEDThe run is running or already finished
409NO_SUSPEND_FRAMEMarked suspended but carrying no suspended step
409STEP_MISMATCHYour stepId is not where the run is suspended
409RESUME_IN_PROGRESSAnother resume for this run is mid-flight
500RESUME_FAILEDDispatch 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.

StatusDescription
runningWritten the moment the launch is accepted, and again on resume
suspendedParked waiting for human input — resume to continue
successAll steps finished; the workflow’s output is on snapshot.result
failedA step failed; the reason is on snapshot.error
canceledCancelled 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.