Steps
Every step has a kind, a config, outputs (which artifact each output slot writes) and usually inputs (which artifact each input slot reads). A when guard is a small expression such as inputs.orders.rows > 0; a step whose guard is false is skipped, not failed. Templates in params may use ${run.logical_date}, an offset such as ${run.logical_date - P7D}, and ${partition.<dim>}. Nothing else, and never the clock.
The registry of kinds and whether each runs on your deployment: workflow_kinds over MCP, or the generated table in the agent guide: workflow kinds.
query
One declared query from the workflow's queries block, run against the workflow's model.
"fetch": {
"kind": "query",
"config": { "query": "failed_orders",
"params": { "from": "${run.logical_date - P7D}", "to": "${run.logical_date + P1D}" } },
"outputs": { "rows": "failed" }
}
sql
DuckDB over the step's input tables (each input slot is a table name), in a locked connection: SELECT only, no clock, no files, no network. Pass dates in as params ($as_of).
"summarise": {
"kind": "sql",
"config": { "file": "sql/summary.sql", "params": { "as_of": "${run.logical_date}" } },
"inputs": { "failed": "failed" },
"outputs": { "summary": "summary" }
}
function
A published Code, Ask AI, Sort into categories or Agent function, pinned fn:<tenant>/<name>@<n>. It must be shared with the workspace and exposed to workflows. It receives { inputs, params, run } and runs as the workspace's service identity. The output slot value is its whole answer (a JSON artifact); any other slot takes that key of the answer (a list of objects becomes a table). volatile: true re-runs it even when nothing changed.
"score": {
"kind": "function",
"config": { "ref": "fn:<tenant>/score_orders@3", "params": { "threshold": 0.8 } },
"inputs": { "failed": "failed" },
"outputs": { "value": "scores" }
}
Agents. There is no separate agent step: call an Agent function from a function step. It runs with the limits and connections the agent declares, and its trace is a child of the run. It is slow and costs the most, so guard it with when and use it only where judgement is the job.
llm
One AI call per input (or per rows_per_call rows), with a strict output shape and one repair attempt. The prompt is a markdown file that reads {{ inputs.<slot>.rows | tojson }} and {{ run.logical_date }}. The built-in shape builtin:message gives { subject, body } for a send. effort is how hard it thinks (low, medium, high). The output is untrusted text.
"write": {
"kind": "llm",
"config": { "prompt": "prompts/digest.md", "effort": "low", "max_output_tokens": 800,
"max_cost_usd": 0.05, "output": { "schema": "builtin:message" } },
"inputs": { "summary": "summary" },
"outputs": { "digest": "digest" }
}
classify
Sort rows into a fixed label set, batched and consistent. Rows below threshold stay unlabelled for a person. The output is untrusted.
"triage": {
"kind": "classify",
"config": { "fields": ["reason"], "instructions": "Which team should fix this failed order?",
"labels": { "payments": "Card declines, payment gateway errors",
"stock": "Out of stock, allocation", "other": "Anything else" },
"threshold": 0.6, "keep": ["order_id"] },
"inputs": { "rows": "failed" },
"outputs": { "rows": "triaged" }
}
snapshot
A PNG of one tab of a data app (tabs: ["default"] for an app without tabs), with filters as the link state's filters. It needs at least one input, which only orders it after the data it should show. The app must be shared with the whole workspace: the capture runs as the workspace's service identity, which holds no personal grant. Validate and plan warn snapshot_app_not_shared for any other app, and a run fails that step with the same code before any screenshot is taken. A page that renders but never loads (an error page, a blank page) fails the step as snapshot_render_failed.
A failed screenshot sends nothing. Whatever the reason, the send that reads it is skipped: it never falls back to the last good screenshot, never goes out without the picture, and no approval is opened. The run is failed or partial, and only the failure notice goes out, to the author. A snapshot skipped by its when guard is not a failure: the send goes without a picture.
"snap": {
"kind": "snapshot",
"config": { "app": "<app id>", "tabs": ["default"], "formats": ["png"] },
"inputs": { "summary": "summary" },
"outputs": { "report": "screenshot" }
}
action (Send)
Sends the payload input (a message) once per idempotency_key. Every other input is context: a report is attached and shown inline, a table is shown under the message (20 rows at most).
"send": {
"kind": "action",
"config": { "to": { "channel": "email", "recipients": ["ops@yourcompany.com"] },
"payload": "digest", "idempotency_key": "failed-orders-${run.logical_date}",
"max_sends_per_run": 1,
"approval": { "mode": "manual", "approvers": { "roles": ["workflow_owner"] },
"timeout": "P1D", "on_timeout": "reject", "review": ["summary"] } },
"inputs": { "digest": "digest", "summary": "summary", "screenshot": "screenshot" },
"outputs": { "receipt": "digest_receipt" }
}
Recipients, approval, the action policy, the mail policy and the caps are on Sends and approvals.
Kinds that validate but do not run here
python (write a Code function and call it from a function step), input, source, detect and explain (use Detect and Explain from a panel, chat or MCP). workflow_plan reports such a step as not runnable; do not build a workflow around one.
Putting steps together
- Order comes from inputs and outputs: a step runs after the steps that produce its inputs.
- Give every output an artifact name; anything a step returns but no output slot keeps is lost.
- Guard the expensive and the loud steps:
whenon the llm, the snapshot and the send, so an empty week costs nothing and sends nothing. - A step's fingerprint (its inputs, config, files and pinned references) decides caching: a sql, llm, function or classify step with the same fingerprint is a cache hit at no cost; query always runs; snapshot and action never cache.
Full example: Weekly digest email with a screenshot.