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.