The Deliverable
Every workflow run ends with one artifact you can hand a person: the deliverable. It is a file — written by an agent during the run, read off disk when the run completes, stored on the run row, and served from there to Telegram and the dashboard.
One file per run. That is the whole model, and it holds for every format — there is no second copy of your document riding inside the agent’s structured output. What the engine checks is the file, and what a human receives is the same file.
How you declare one
Almost none of it is yours to write. Two optional fields in your scenario frontmatter constrain the planner; the planner fills in the rest.
---
name: Competitor Pricing
enabled: true
deliverable:
storage: inline # inline | blob | none — default inline
format: json # optional HARD constraint on the planner
---
format is injected into the planner’s deliverable stage as a hard constraint
(“do NOT override”); storage never reaches the LLM at all — the engine
injects it when it assembles the plan. There is no frontmatter field for
filename, description or schema: those are the planner’s job.
Planning writes the resolved block into <name>.blueprint.yml, sitting next to
your <name>.scenario.md in the same feature directory:
deliverable:
description: Competitor pricing table, per tier
filename: pricing.json
format: json
schema: '{"type":"object","properties":{"competitors":{"type":"array"}},"required":["competitors"]}'
storage: inline
| Field | Who writes it | What it is |
|---|---|---|
description | planner | what the user receives — becomes the caption when the file is sent |
filename | planner | a plain basename: no directories, no .., no path separators |
format | planner, or your frontmatter constraint | free string — markdown, json, csv, pdf, image, … |
schema | planner | a string — see below |
storage | your frontmatter | injected by the engine, never by the LLM |
The .blueprint.yml is an ordinary file in your repo, and it is yours to
edit. Hand-edit the deliverable: block and the next run uses it. Add a
top-level locked: true and the planner will refuse to regenerate the file at
all. A run freezes its blueprint at dispatch, so an edit applies to the next
run, never to one already in flight.
deliverable.schema is a STRING
Not a nested YAML mapping — one string field, and what belongs in it depends on the format:
format: json→ a serialized JSON Schema. The engine parses that string, compiles it, and checks the file against it after the task’s turn.- every other format → prose. A section outline for
markdown, column headers forcsv, a description of the image. It reaches the agent as “Expected structure” text and nothing checks it.
Because it is a string, a JSON Schema has to be quoted in YAML. A block scalar is the readable way to hand-write one:
schema: |
{"type":"object","properties":{"competitors":{"type":"array"}},"required":["competitors"]}
The check on a JSON deliverable
For format: json with a schema that compiles, after the deliverable task’s
turn the engine reads the file, parses it, and validates it against your schema
verbatim — nothing stripped, nothing tightened. A mismatch, or a file that
is not valid JSON, comes back to the agent as one recovery re-prompt naming
exactly what was wrong; if the second turn still does not conform, the task
fails with that reason (and § 10.2 of contract.md covers what
a failed task does to the run).
Verbatim cuts both ways, and it is worth knowing which way:
- Your
required,enum,minimum, nested$defsall bite. - An open object stays open: a plain
{"type":"object", …}accepts extra properties, exactly as JSON Schema says it should. Add"additionalProperties": falseif you want them rejected. - Your schema is not sent to the model provider, so it spends none of the
provider’s structured-output budget and is not held to the safe subset
(
$ref, unions, deep nesting are all fine). - A
$schemaline naming an older draft is dropped and the constraints compile as 2020-12.
It quietly does nothing in the cases below — and in each of them the agent’s prompt promises no check either, so the instruction and the enforcement never disagree:
| Situation | Result |
|---|---|
| a non-JSON format | structure described as text, never checked |
schema is not valid JSON, not a JSON object, or a document the validator refuses | same — described as text, never checked |
| the file is not on disk at all | not reported by this check — see below |
One case does NOT reach the prompt: a schema that compiles but throws while
checking — a self-recursive $ref, a $dynamicRef. The prompt has already
promised a check by then, and the file ships unchecked. The engine cannot tell a
throw apart from a genuine mismatch, and reporting a mismatch would send the
agent off to rewrite a file that may be perfectly correct, so it records the
schema as unusable and lets the file through. If you want a contract that is
certain to be enforced, keep the schema non-recursive.
storage does not enter into it. inline, blob and none all get the same
check: storage picks where the file is persisted, not whether its declared
structure is a contract.
Which task writes it, and where
Exactly one task carries the deliverable: the last task in execution order
(highest phase, then last within that phase). That task — and no other — gets a
## Final Deliverable section in its prompt, carrying the absolute path it
must write to:
## Final Deliverable
This is the final task. Its output file will be delivered directly to the user.
Save the result to the specified filename in the exact format and structure described below.
Description: Competitor pricing table, per tier
Filename: /artifacts/runs/competitor-pricing/pricing.json
Format: json
Expected structure: {"type":"object","properties":{"competitors":{"type":"array"}},"required":["competitors"]}
Write valid JSON. Once you have written the file, its content is checked
against that JSON Schema — a mismatch may come back to you as a follow-up
asking you to fix the file, so get it right the first time.
That directory is the run’s own — <artifacts root>/runs/<run-slug>/, shared
by every task of the run. It is how phase N hands files to phase N+1, and it is
not permanent storage: the run’s directory is cleared on the run’s lifecycle,
and the durable copy is the one on the run row.
outputArtifacts are a different list
Every task also declares outputArtifacts — filenames it must write into that
same directory. After the turn the engine checks each one exists on disk; a
missing file becomes a missing-artifacts issue and rides the same
one-recovery-then-fail seam. Those are the run’s working products (phase N’s
research feeding phase N+1); the deliverable is the single file that reaches a
human.
The two lists overlap when you want them to. List the deliverable’s filename
in the last task’s outputArtifacts and it gets both checks — existence from
the artifact check, content from yours. Without it, a deliverable the agent
never wrote is not caught while the agent can still fix it: at completion the
engine logs a warning, stores nothing, and the run stays completed with an
empty deliverable.
storage — how the file is kept
storage | What the run row holds | Use it for |
|---|---|---|
inline (default) | the file’s text, in the run row | any text output |
blob | a ref into the engine’s content-addressed blob store, plus the size | large or binary output |
none | nothing — the column stays null | confirmation runs, where the action itself is the result |
You rarely pick blob by hand. A format outside the engine’s known-text set
(markdown, text, json, yaml, csv, html, xml, sql, log, … —
pdf and image formats are not in it) takes the blob route regardless, and
so does a text file over the 1 MB inline cap.
none is the one value that changes the outcome for a user: nothing is stored,
so the run’s result summary is all they get. The agent is still told to write
the file, and the file is still content-checked — it just is not kept past the
run directory.
How it reaches a human
agent writes the file
→ run completes → engine reads the file off disk
→ omnislash.workflow_runs.deliverable (inline content, or a blob ref)
→ Telegram "Result" → sent as a FILE, caption = description
→ dashboard → rendered preview / download
Three consequences worth knowing:
- Only a
completedrun stores a deliverable. A failed run leaves whatever the agent wrote sitting in the run directory, but nothing is persisted to the row, and neither surface serves anything. - Telegram gates on size before fetching. A deliverable over the platform’s file cap gets a “too large” reply instead of the file — the blob is never even retrieved.
- You may read the deliverable yourself.
omnislash.workflow_runsis engine state: readable from your own code, never writable (database.md).
Where the rest of the story is
The deliverable is the run-level output. The task-level contract — the outcome envelope every task fills in when it finishes, including the honest-failure arm — is § 10 of contract.md.