Skip to content

Workflows through the API

The concept behind workflows is explained in Workflows.

On this page you write a business process down as a workflow, check it, start a run and follow it to its end. A workflow is a graph you store: its nodes are pieces of work -- an agent, a one-shot prompt, a tool call, a condition, a join -- and its edges carry one node's output to the next. Core runs the graph you saved, one step at a time in the background, so a process that takes an agent, a lookup and a second agent side by side runs without your application staying up for it.

Your key needs the workflows capability, which needs runs and threads: every agent or prompt step is a background run on a thread of its own. A tool step also needs tooling. Your operator switches workflows on for the whole deployment; until they do, every route below answers 404. You do the same with a mouse in Workflows in the console.

Write a workflow

curl -s -X POST "$CORE_URL/workflows" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"name": "intake",
         "description": "Classify a message and draft a reply",
         "input_schema": {"type": "object",
                          "properties": {"message": {"type": "string"}},
                          "required": ["message"]},
         "graph": {
           "nodes": [
             {"id": "start", "kind": "start"},
             {"id": "classify", "kind": "agent", "config": {"agent": "triage"},
              "input": "run.input.message"},
             {"id": "route", "kind": "condition"},
             {"id": "draft", "kind": "agent", "config": {"agent": "writer"},
              "input": "run.input.message"},
             {"id": "reply", "kind": "end"},
             {"id": "spam", "kind": "end", "input": "'\''dropped'\''"}
           ],
           "edges": [
             {"source": "start", "target": "classify"},
             {"source": "classify", "target": "route"},
             {"source": "route", "target": "draft",
              "when": "nodes.classify.output != '\''spam'\''"},
             {"source": "route", "target": "spam"},
             {"source": "draft", "target": "reply"}
           ]}}'

A successful request answers 201 and a Location, with the workflow and its first version. A name is unique within your organisation: a second workflow called intake is 409.

Node kind What it does config
start the run's input, checked against input_schema none
end its input becomes the run's output; where two end nodes complete, the output is an object keyed by their ids none
agent runs an agent; its final text, or the parsed object where the agent declares a JSON schema agent (a name), version, thread: {"continue": "<node id>"}
prompt one turn of a model with a stored prompt or instructions prompt or instructions, model, text_format
tool calls one tool; its JSON answer, or its text tool (a qualified name such as api__crm_lookup), version
condition takes every edge whose when holds, or the one edge without when none
join waits for the branches that meet in it mode: all, any or n_of with n
transform its input is its output: reshape data between steps none
wait holds the run until a time comes; its input is its output seconds, or until: an expression giving an instant such as 2026-11-01T09:00:00+01:00
workflow starts a run of another workflow and waits for it; its input is that run's input, and that run's output is its output workflow (a name), version
map starts one run of another workflow per item of a list; a list of those runs' outputs, in item order items (an expression giving a list), workflow (a name), version, concurrency
decision holds the run until someone answers, or asks an agent; {outcome, fields, decided_by} outcomes, instruction, assignee, and optionally form, deadline_seconds, on_deadline (below)

A run a workflow or map node started is an ordinary run with "trigger_kind": "workflow"; GET /workflow_runs?parent_run_id=... lists the runs one run started. Each of them is a start of its own, one use of workflow_run, so a map over a hundred items bills a hundred starts beside the run it is in. A map takes no input and no retry. How such runs look in the console and which limits apply is described in Workflows in the console.

Every node may give an input, a JMESPath expression over the run's context: run.input and nodes.<id>.output for each step that ended (nodes.<id>.error.code for one that failed). Left out, a node receives its one predecessor's output, and a join an object keyed by each branch that completed. A tool, agent or prompt node may set timeout_seconds and retry ({"max_attempts": 3, "backoff_seconds": 10}). A tool node may set retry only when the tool's registration says it is safe to send twice (retry_safe); otherwise saving refuses the node's retry. An edge with "on_error": true is a node's catch: a step that failed follows it instead of its other edges.

Everything is checked when you save. A missing agent, a tool your key may not call, a cycle, two edges meeting outside a join or an expression that does not parse is 422 naming the field, such as graph.nodes[1].config.agent -- not a failed run three days later. A key without the tooling capability, or whose policy requires an agent, may not have a tool node at all: a tool step runs outside every agent, so the save is 422 on graph.nodes[i].config.tool. A tool step cannot name an agent (agent__<name>) either: run an agent with a step of kind agent. References are names: a value shaped like an id, such as agt_01J..., is refused. To check without storing, POST /workflows/validate takes the same graph and input_schema.

Start a run

curl -s -X POST "$CORE_URL/workflows/wf_01J.../runs" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -H "Idempotency-Key: ticket-4711" \
    -d '{"input": {"message": "My invoice is wrong."}}'

The answer is 202 with the run, queued. A key starts a run as itself: every step acts as your key, is decided by your key's policy at the moment it runs, and spends your key's budget. A run start is one use of the capability on your bill; what its steps run is billed as it always is. version pins an older version, subject_id names the person every step is for, labels labels every conversation its steps open and every run they start (inheritance), and the same Idempotency-Key answers the run it started instead of starting another; the same key used by another API key is 409. An Idempotency-Key that starts with schedule:, trigger:, webhook: or task: is 422: those four prefixes are the keys Core gives the runs a schedule, a trigger or a task starts. An input larger than the deployment allows is 413. When your organisation already has as many open runs as the deployment allows, or your key has reached its monthly cap, the answer is 429.

Start it when something happens

An event trigger starts a run whenever Core produces an event of one kind, the same events the feed and webhooks carry. Name the workflow, the kind, and optionally which events of that kind and what the run's input is:

curl -s -X POST "$CORE_URL/workflow_triggers" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"workflow": "intake",
         "event_kind": "knowledge.source.ingested",
         "filter": {"status": "completed"},
         "input": "{source: event.refs.source_id}"}'

filter is equality over the event's refs: every name you give must be there with exactly that value, and {} matches every event of the kind. input is a JMESPath expression over event (id, type, refs, subject_id, created_at); leave it out and the run's input is that event document. Every run acts as the key that wrote the trigger and reads "trigger_kind": "event"; GET /workflow_runs?trigger_id=wft_... lists the runs one trigger started. One event starts at most one run per trigger, however often it is delivered.

Whom a run is for follows the event. A trigger that names nobody starts each run for the person the event belongs to, so a run made from Anna's event is Anna's: only her subject token and your own keys read it, and it is erased with her. An event that belongs to nobody starts a run for nobody. A trigger that names a person in subject_id starts runs for her from her own events and from shared ones, and an event that belongs to somebody else does not match it at all.

New mail

A mail connector whose version sets a watch (folder, poll_seconds of at least 60 unless your operator set another floor, and enabled, written with POST /mail_connectors or PATCH /mail_connectors/{id}) produces one mail.received event for every new message in that folder. The event carries the connector's id in mail_connector, the folder and the message's uid, never an address, a subject or a body. It belongs to nobody, so the run it starts is for nobody unless the trigger names a person. Filter on the connector to start a workflow for each new message:

curl -s -X POST "$CORE_URL/workflow_triggers" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"workflow": "triage-mail",
         "event_kind": "mail.received",
         "filter": {"mail_connector": "mail_01J..."},
         "input": "{folder: event.refs.folder, uid: event.refs.uid}"}'

The workflow's agent step reads the message itself with the connector's mail__<name>__read tool. Mail that was already in the folder when the watch began starts no run. How to set up the connector and its watch is in Mail connectors through the API.

Limits and failures

Two limits keep a trigger from feeding itself. The kinds every agent step produces (response.*, run.*, thread.*) are refused as event_kind. And a trigger on a workflow event, workflow.run.completed say, may chain runs only a few deep (three unless your operator set otherwise): the run past that is not started.

Nothing stops a workflow that writes to a datastore its own trigger listens on: each run feeds the next until the hourly limit of starts (rate_limited, 600 unless your operator set otherwise) ends it.

A start that could not happen is not retried. It is on your audit trail as workflow.trigger.failed with a short code:

Code What stopped the start
target_gone The workflow is not there for the trigger's key: deleted, or withheld by the key's rules
request_invalid The workflow refused the run, for example because its input does not fit the input schema
expression_error The trigger's input expression failed on this event or request
policy_refused Your organisation's policy refused the run
cap_exceeded The trigger's key reached its monthly cap
capability_withdrawn Your organisation lost a capability running a workflow needs
key_revoked The trigger's key was deleted or expired
tenant_unavailable Your organisation is suspended or being deleted
subject_mismatch The trigger names a person and the event belongs to someone else
source_gone The workflow run that produced the event no longer exists
depth_exceeded The run would chain workflows deeper than allowed
too_many_open Your organisation already has as many open runs as the deployment allows
rate_limited Your organisation's triggers of this source started as many runs this hour as the deployment allows (600 unless your operator set otherwise); event and webhook starts are counted separately
expired_unstarted A webhook trigger's request could not be started within an hour (unless your operator set otherwise)

An organisation holds at most 50 triggers, event and webhook triggers together, unless your operator set otherwise; a create past that is 429. PATCH /workflow_triggers/{id} with {"enabled": false} switches a trigger off, and DELETE removes it; the runs it started stay. Any key that can see a trigger may switch it off, even after the trigger's own key was revoked. Switching it back on with {"enabled": true} is for the key the trigger starts runs as: another key gets 403 with not_the_acting_key.

Start it from another system

A webhook trigger gives a workflow an address. A system of yours posts JSON to it, signed with a secret, and each request starts a run with that JSON as its input. The sender needs no API key.

curl -s -X POST "$CORE_URL/workflow_triggers" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"workflow": "intake", "source": "webhook",
         "input": "{message: webhook.body.text}"}'

The answer holds the trigger's url and, this once, its secret (whsec_...). Store the secret now: no later read shows it, and POST /workflow_triggers/{id}/rotate-secret answers a new one, after which only the new one works. The url is $CORE_URL/workflow_webhooks/<organisation id>/<trigger id>, and it stays the same for as long as the trigger exists. A webhook trigger takes no event_kind and no filter. input is a JMESPath expression over webhook (body, the JSON you sent; delivery; received_at); leave it out and the run's input is the body itself. Every run acts as the key that wrote the trigger, is for the person in the trigger's subject_id or for nobody, and reads "trigger_kind": "webhook".

The sender signs every request the way Core signs the webhooks it sends you: an Artific-Signature header holding t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256, under the secret, of the timestamp, a dot and the exact bytes of the body.

import hashlib, hmac, json, time
import httpx

def post_to_core(url: str, secret: str, payload: dict, delivery: str) -> int:
    body = json.dumps(payload).encode()
    stamp = int(time.time())
    digest = hmac.new(secret.encode(), f"{stamp}.".encode() + body, hashlib.sha256).hexdigest()
    answer = httpx.post(
        url,
        content=body,
        headers={
            "Artific-Signature": f"t={stamp},v1={digest}",
            "Idempotency-Key": delivery,
            "Content-Type": "application/json",
        },
    )
    return answer.status_code

Send exactly the bytes you signed. Idempotency-Key is your own name for the delivery, at most 128 characters without spaces: when you try again after a timeout, sign again and send the same name, and every attempt is one run. The same signed request arriving twice starts one run too.

Answer What it means
202, no body Accepted. The run is started just afterwards; the answer does not carry its id
404 Not accepted, for a reason the answer does not give: a wrong address, a trigger that is switched off, a missing or wrong signature, or a timestamp more than five minutes from Core's clock
413 The body is larger than the deployment allows (256 KiB unless your operator set otherwise)
422 The signature is right, and the body is not JSON, nests deeper than 64 levels or holds a null character (U+0000), or the Idempotency-Key is not a valid name
429 More than 20 requests a second to this address

A 202 says the request was accepted, not that the run started. Whether it started is on your audit trail, as for an event trigger: workflow.trigger.fired with the run, or workflow.trigger.failed with a code. The code expired_unstarted means Core accepted the request and could not start its run within an hour; it is not started late, so send it again. Webhook triggers have an hourly share of starts of their own, as large as the event triggers' (600 unless your operator set otherwise), and GET /workflow_runs?trigger_id=wft_... lists the runs one trigger started. Only the key the trigger starts runs as may rotate its secret or switch it back on; any key that can see it may switch it off. A system that can only sign its own way, such as a repository host or a payment provider, cannot post to the address directly: let a small service of yours receive its call, check it, and post to Core signed as above.

Let an agent start it

An agent can start a workflow in the middle of a conversation and hear back when it is done. Name the workflow in the agent's tools, or in the tools of one request:

curl -s -X POST "$CORE_URL/responses" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"model": "agent:support", "thread_id": "thr_01J...",
         "input": "Please take this customer in: Anna, anna@example.com",
         "tools": [{"type": "workflow", "name": "intake"}]}'

The model is offered a tool called workflow__intake. Its arguments are the input your workflow's newest version takes, so that version's input_schema must be an object ("type": "object"), and its description is the workflow's own: write there when the agent should use it.

A workflow always runs in the background. The call answers a task id at once and the conversation goes on. The run starts on the workflow's newest version, as the key and for the person of the turn, and reads "trigger_kind": "task". When it ends, its output is added to the thread as the task's result, and Core starts a turn to answer it if nobody is asking at that moment. That may be days later: a run an agent started lasts as long as any run of the workflow may, a decision waiting for a manager included. Meanwhile GET /tasks/{id} shows the task with its workflow_run_id and, as progress, the steps the run is waiting on.

What you want How
See what the agent started GET /threads/{id}/tasks, or GET /workflow_runs?trigger_id=task_...
Stop it POST /tasks/{id}/cancel, or POST /workflow_runs/{id}/cancel; either one ends both
Keep the result from starting a turn "task_wake": false on the request; the result waits on the thread for your next turn

Your key needs tasks beside workflows, and the turn must be stored on a thread: a result needs somewhere to land. A turn that is not, or a key without tasks, runs without the tool and is not refused. A key without workflows gets a 400 naming the entry, and a workflow that does not exist a 404. If the arguments the model wrote do not fit the input schema, nothing starts and the model reads that they do not fit, so it can try again.

Deleting the thread does not stop the run. A run may already have asked somebody or called another system, so it goes on to its end and only its result is dropped; cancel the run itself if you want it stopped. A thread holds at most eight open tasks (unless your operator or your key says fewer), and a workflow that waits a week for an answer counts for that whole week.

Follow it

curl -s "$CORE_URL/workflow_runs/wfr_01J..." -H "Authorization: Bearer $CORE_KEY"

A run is queued, running, waiting while nothing is open but a wait or a decision, and then completed with its output, failed with an error_code, cancelled or expired. A run that is still open at its deadline_at expires: by default 30 days after it started, sooner where your organisation keeps content for a shorter time. Its steps say what each node received and produced, how often it was tried and, for an agent step, the response_id and thread_id it ran on, which you open as any background run. GET /workflow_runs?workflow_id=...&status=running lists runs, newest first. A schedule starts runs on a clock, and ?trigger_id= narrows the list to the runs one schedule started. The event feed and webhooks carry workflow.run.started, .completed, .failed, .cancelled and .expired, and workflow.decision.requested, .decided and .expired for each decision.

POST /workflow_runs/{id}/cancel stops a run and every open step, the agent runs it started included; asking again answers the cancelled run, and a run that already ended otherwise is 409.

Let someone decide

A decision node stops the run until one answer arrives. It names the outcomes to choose between, each with exactly one outgoing edge marked "outcome": "<name>", and who may answer:

{"id": "approve", "kind": "decision",
 "config": {
   "outcomes": ["approve", "reject"],
   "instruction": "join(' ', ['Refund for', run.input.customer])",
   "form": {"type": "object", "properties": {"note": {"type": "string"}}},
   "assignee": {"kind": "members"},
   "deadline_seconds": 86400,
   "on_deadline": {"outcome": "reject"}}}
assignee Who answers How
{"kind": "members"} an administrator of your organisation; "users": [...] narrows it to the ones you name by their user id in the console, which also notifies your organisation's notification address
{"kind": "subject", "subject_id": "anna"}, or "subject": "run" for the person the run is for that person your application, with a subject token for her
{"kind": "key", "key_id": "key_01J..."} that API key: another system, or a robot the routes below, with that key
{"kind": "agent", "agent": "refund-judge"}, with "version": 3 to pin one that agent, in a turn of its own nobody answers for it (below)

instruction is an expression that gives the text the assignee reads; beside run and nodes it reads the node's own input as input. It is rendered once, when the step starts waiting.

curl -s "$CORE_URL/workflow_decisions" -H "Authorization: Bearer $CORE_KEY"

curl -s -X POST "$CORE_URL/workflow_runs/wfr_01J.../steps/wfs_01J.../decision" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"outcome": "approve", "fields": {"note": "Checked the invoice."}}'

GET /workflow_decisions lists the waiting decisions the caller may answer, newest first, each with its id (the step), run_id, workflow, instruction, outcomes, form and deadline_at. The answer names one of the outcomes and, where the node has a form, fields that fit it; anything else is 422 naming the field. Only the assignee may answer: another caller who can read the run gets 403, and one who cannot gets 404. A decision is answered once: a second answer, and an answer after the deadline, is 409. The step then completes with {outcome, fields, decided_by} and the run follows that outcome's edge.

When deadline_seconds passes with no answer, on_deadline decides: {"outcome": "..."} takes that outcome, "error" fails the step with decision_expired for its on_error edge, and "fail", the default, fails the run with that code. A wait, and a decision a person or a key answers, take no timeout_seconds and no retry; the run's own deadline bounds them. A decision assigned to an agent is the exception (below).

An agent decides

A decision assigned to an agent does not wait for anyone. The agent reads one message, {"instruction": ..., "input": ...}: the rendered instruction and what the node received. It must answer with one of the node's outcomes and, where the node has a form, fields that fit it. Core checks the answer itself, whatever the model wrote, and only an answer that passes takes an outcome's edge.

To a caller such a step looks different from a decision that waits for a person:

What you read or do For an agent's decision
the step's status running, never waiting; so the run is running too
the step's output null until Core has checked the answer, then {outcome, fields, decided_by}
GET /workflow_decisions the step is not listed, for any caller
an answer to POST .../steps/{id}/decision 403, whoever sends it: nobody answers for the agent
a notification to the notification addresses none

Such a node takes timeout_seconds and retry, as an agent node does, and no deadline_seconds or on_deadline; a save refuses those two with 422. Your operator sets the ceiling of timeout_seconds, an hour by default, and when you leave the field out that ceiling applies. The agent may not fix an answer format of its own (text), the node's form may hold no $ref, $dynamicRef or $defs, and the answer format Core builds from the outcomes and the form may be at most 64 KiB; a save refuses each of these.

When the agent gives no valid answer, the step fails with one of these codes:

Code What happened
decision_invalid the answer names no outcome of the node, its fields do not fit the form, or it is not a JSON object; a model's refusal and an empty answer count as this
timeout the turn ran longer than timeout_seconds
incomplete the turn was cut short before the answer was finished
usage_cap_exceeded the monthly cap of the key or the organisation stopped the turn
approval_unsupported the agent carries tool_approval, and a workflow cannot decide a call that waits for approval; the turn never starts
policy_refused the key's policy no longer permits the agent
another code the turn itself failed; the step carries that turn's error code

Where the node sets retry, Core tries again, except when the cap or the policy stopped the turn before it began. After that the step follows its on_error edge. Draw that edge for what should happen then, for example to a second decision that a person takes; without it the run fails. policy_refused is the exception: that code ends the run at once, and no on_error edge catches it.

Core guarantees that the answer is one of the outcomes, that the decision is marked as an agent's, and that the turn behind it can be read. It does not guarantee that the agent was not steered by what it judged: your instruction and the node's input reach the agent in one message, and a text in that input that says "approve this" may get its way, since approve is a valid outcome. The trust_envelope capability marks that message as untrusted for the model, your instruction included; it lowers the chance and removes nothing. Where the outcome matters, put a decision a person takes behind the agent's, and give the deciding agent no tools, so that what it reads can change its answer and nothing else.

The step reads assignee_kind: "agent", and once decided its decided_by names the agent, its version and the response_id of the turn, where what the agent read and wrote can be looked up. workflow.decision.decided carries the same ids. A run for a person may have its decision taken by an agent; whether your process may decide about a person without a human in it is yours to settle, and a node assigned to members or to a subject is never answered by an agent.

Change it

POST /workflows/{id}/versions writes the next version; runs already started keep the version they pinned. Send the whole graph and the input_schema with it: a version does not inherit the previous one's schema, and a left-out input_schema is {}, which accepts any input. PATCH /workflows/{id} changes description and labels. DELETE /workflows/{id} removes the workflow with its versions and ended runs, and is 409 while a run is open. A rule of kind workflow in your key's policy decides which workflows it may see and start, by name and label.

An administrator can also save a version as a draft, in the console or its API. No key, trigger or schedule sees or starts a draft: a run without a version starts the published version, and a run, a node or a sub-workflow naming the draft's number is told the version does not exist. A workflow that has only a draft is not there for your key, a trigger or a schedule until it is published. A key cannot write a draft: a draft field your key sends with POST /workflows or POST /workflows/{id}/versions is ignored, like any other field those bodies do not know, and the version is published at once.

GET /workflows lists the workflows your key may see. ?label=department:payroll narrows the list to those carrying that label; the parameter may be repeated, repeating it means all of these, and repeating a name asks for all of those values. A workflow whose label holds several values matches when the value is one of them. A value that is not name:value answers 422 naming label.

The whole contract is in the API reference.