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
FieldWho writes itWhat it is
descriptionplannerwhat the user receives — becomes the caption when the file is sent
filenameplannera plain basename: no directories, no .., no path separators
formatplanner, or your frontmatter constraintfree string — markdown, json, csv, pdf, image, …
schemaplannera string — see below
storageyour frontmatterinjected 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 for csv, 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 $defs all bite.
  • An open object stays open: a plain {"type":"object", …} accepts extra properties, exactly as JSON Schema says it should. Add "additionalProperties": false if 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 $schema line 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:

SituationResult
a non-JSON formatstructure described as text, never checked
schema is not valid JSON, not a JSON object, or a document the validator refusessame — described as text, never checked
the file is not on disk at allnot 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

storageWhat the run row holdsUse it for
inline (default)the file’s text, in the run rowany text output
bloba ref into the engine’s content-addressed blob store, plus the sizelarge or binary output
nonenothing — the column stays nullconfirmation 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 completed run 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_runs is 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.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.