What a step leaves behind
A step finishing is not the same as a step producing something. This page is about the second half — where the thing a step made actually lives, who owns it afterwards, and what the next step can read.
There are four answers and they are not interchangeable. Picking the wrong one is how you end up with the real state of a workflow sitting in a JSONB column that nobody can query.
| Class | The output is | It lives in | The next step reads |
|---|---|---|---|
| Payload | a small JSON value | tasks.output, copied into runs.context |
{{steps.<id>.result}} |
| State | rows | your tables | ids and counts, a receipt |
| Shape | a validated structured answer from a model | agentic.messages |
the value, plus the message it came from |
| Bytes | a file | object storage, registered in content |
{{steps.<id>.$artifact}} |
1. The inline payload
What we are trying to do here is pass a small value from one step to the next.
- id: judge
rest:
url: https://api.openai.com/v1/chat/completions
method: POST
credential_ref: LLM_API_KEY
jsonpath: choices.0.message.content
body:
model: gpt-4o-mini
messages:
- {role: user, content: 'Answer only "detain" or "release": {{run.finding}}'}
- id: route
needs: [judge]
sql: {function: route_by_verdict, args: ['{{steps.judge.result}}']}
Why it works — every byte is copied twice, which is what bounds the class
{{steps.<id>.result}} is interpolated into the next task's input when it is
dispatched. The cost is easy to miss: every byte a step returns is copied into
another task row, and again into runs.context, and stays there for the life of
the run.
So this class is bounded by workflow.max_payload_bytes — 64KB by default, and
a hard limit rather than a warning. That is sized for what it is for: a status
code, a count, a document id, or a 768-dimension embedding on its way from an
embed step to a search step, which is around 10KB as JSON and is why the limit
moved up from 8KB. Anything bigger is one of the other three classes wearing the
wrong hat.
2. The database is the state
What we are trying to do here is have a step write rows and hand the next step a receipt rather than the data.
{"result": {"resource_id": "…", "chunks": 412}}
Why it works — returning the rows makes the task table the storage layer
A sql step writes rows — with write: true on its own statement, or through
a registered function — and the rows are the result. The next step re-reads by
key.
If a step returns its rows instead, you have made workflow.tasks the storage
layer for data that already has a home — with no index, no types and no RLS
policy of its own. It also caps at 64KB, so the design fails at exactly the
scale where it mattered.
This is the same instinct behind a fan-in reading matrix_outputs(task_id)
rather than a payload: five hundred results are for aggregating inside a
function, not for passing through a step argument.
3. A model produced a shape
What we are trying to do here is get an answer from a model that the next step can treat as an object rather than as prose.
- id: classify
agent: triage
output_schema:
type: object
required: [verdict]
properties: {verdict: {type: string, enum: [SAFETY, FINANCE, OTHER]}}
Why it works — it is the only output both written outside the system and shaped by a contract
The engine validates before storing, and a violation retries — the one failure in this engine deliberately classified that way, because the same prompt genuinely can conform on the next attempt.
The answer is validated by the workflow engine and stored in
workflow.tasks.output. It does not yet land as an assistant message row in
agentic.messages, which has no JSON column — so a structured answer would go
in as a string with no link to the schema it satisfied. There are four small
pieces to close this and they are listed in the specs.
4. Everything else is bytes
A rendered report, a scraped page kept verbatim, a model response too large to inline. It goes to object storage and comes back as a ref.
What we are trying to do here is hand the next step something too big to carry, and resolve it without a worker.
{"status": 200, "result": {"pages": 12}, "$artifact": "9f3c…-uuid"}
- id: index_it
needs: [render]
sql: {function: artifact, args: ['{{steps.render.$artifact}}']}
Why it works — the ref sits beside `result`, not inside it, and the earlier spelling could not
Three things follow from putting it there:
{{steps.<id>.result}}is unchanged, and{{steps.<id>.$artifact}}resolves the ref with no change to the template resolver.output_schemavalidatesresultand never sees the engine's key, so a contract withadditionalProperties: falsestill works.- A step that produced only bytes writes no
resultat all, so{{steps.<id>.result}}fails loudly — which is what you want, since there is no value.
It is a resource id, not an s3:// URI. workflow.register_artifact(…)
gives back a row in content.resources, which is what makes an artefact
RLS-scoped, deduplicated by checksum, servable, and visible to
content.check_drift. A bucket path in a JSONB column is none of those, and it
puts a bucket name into a row you wanted to keep inspectable and replayable — the
same argument that makes credential_ref a name rather than a secret.
Returning {"$ref": "s3://…"} in its place does not work, and nothing tells
you so. The size check uses the jsonb ? operator, which only looks at
top-level keys, while every step nests its payload under result. Both ways
against a 64KB cap:
A: oversized, {"$ref": …} at the top level -> accepted, and breaks every
{{steps.x.result}} reading it
B: oversized, {"status":200,"result":{"$ref": …}} -> refused as oversized
No shape satisfied both. The size bypass that came with it is gone as well: a
real ref is about thirty bytes and was never going to trip a 64KB cap, so
not (output ? '$ref') only ever fired on a payload that had offloaded its
bytes and then inlined them anyway.
Next: reading a plan.