Workflow Spec
The YAML reference for Router9 workflows — combining several LLMs and generation models into one callable tool
A workflow is a YAML file describing a graph of model calls. Router9 runs it server-side and bills it as one run against your subscription.
It exists because the thing a router is uniquely able to do — send one request to several vendors, compare the results, and charge it once — has no expression in an ordinary chat API. Doing it yourself means holding N provider accounts and writing the orchestration. Here it is a file.
The same file becomes three things: a form in the Studio, a tool in your harness over MCP, and a REST endpoint. You write the inputs once.
name: image-council
title: Image Council
inputs:
prompt: { type: longtext, required: true }
drawn_by: { type: "model[]", default: "auto:diverse:3" }
steps:
- id: drawings
uses: image.generate
fanout: { over: "${{ inputs.drawn_by }}", as: m }
with:
model: "${{ m }}"
prompt: "${{ inputs.prompt }}"
outputs:
images: "${{ steps.drawings.results }}"Three image models draw your prompt at once, and you pick. That is the whole file.
Three rules that shape everything else
Dependencies are never declared. A step that mentions ${{ steps.x.… }}
runs after x. Everything else runs as early as it can, in parallel. You never
write an ordering.
There is no loop. No while, no recursion, no calling another workflow. The
only way to run something more than once is fanout, whose width is capped.
That is a deliberate trade: because nothing can iterate, Router9 can compute the
worst-case number of model calls before the run starts — which is what lets
you see a cost estimate on the Run button, lets your harness see a cost band
before it calls the tool, and lets a run carry a real spending ceiling.
Mixing model kinds needs no special syntax. A chat step and an image step are the same kind of node. Feeding one into the other is the same kind of reference.
This page is the author's version of the specification. The normative one, with
every rule numbered and pinned by a test, lives in the repository at
docs/tech/workflow-spec.md.
The document
name: my-workflow # required — the slug
title: My Workflow # required — what the tool is called
tagline: One sentence. # optional
categories: [reasoning] # optional
mcp: true # optional — ask to appear in your harness
inputs: { … }
examples: [ … ]
steps: [ … ]
outputs: { … }Those nine keys, and no others. An unknown key is an error naming it.
inputs
What the caller fills in. One declaration drives the Studio form, the MCP tool schema, and REST validation, so they cannot disagree.
inputs:
question:
type: longtext
required: true
label: Question
help: Give it the context you would give a colleague.
members:
type: "model[]"
default: "auto:diverse:3"| Type | What it takes |
|---|---|
text | one line |
longtext | several — this becomes the big box in the Studio |
number, bool | as they sound |
enum | one of a list you declare with enum: [a, b] |
model | a model id, or an auto: expression |
model[] | several — a single auto: expression expands to N |
image, image[] | an image URL or a data: URL |
audio, file | a URL or a data: URL |
json | anything |
Two things people hit on the first try:
- Quote a list type inside
{ }.{ type: "model[]" }, not{ type: model[] }—[starts a YAML list. Router9 detects this exact case and says so, because YAML's own message does not. requiredanddefaultcannot both be set. A default makes an input optional, so the pair says two opposite things.
steps
steps:
- id: answer # required, unique
uses: llm.chat # required — which capability
with: { … } # its parameters
fanout: { over: "${{ … }}", as: m } # optional — run it N times
if: "${{ … }}" # optional — skip when falsy
output: { schema: { … } } # optional — ask for JSON back
on_error: continue|fail # optionalWhat you can uses:
* marks a required parameter. The Produces column is what later steps can
read off it — a name that is not in that column is an error when you publish, not
a surprise mid-run.
uses | Parameters | Produces |
|---|---|---|
llm.chat | prompt*, model, system, vars, maxTokens, temperature | text, model, provider |
image.generate | prompt*, model, vars, n, aspectRatio, outputFormat, quality, background, seed | image, images, keys, model, provider |
image.edit | prompt*, images*, model, vars, n, aspectRatio, outputFormat, seed | image, images, keys, model, provider |
image.describe | image*, prompt, vars, model | text, model, provider |
audio.synthesize | input*, voice, format, speed, model | audio, key, mimeType, characters, model, provider |
audio.transcribe | audio*, language, model | text, model, provider |
web.search | query*, maxResults | query, results, provider |
Image and audio steps return URLs, never inline data — a base64 payload would be copied into every step that references it.
fanout — running a step N times
The one control-flow construct. It runs a step in parallel over a list and binds each element to a variable:
- id: opinions
uses: llm.chat
fanout: { over: "${{ inputs.members }}", as: m }
with:
model: "${{ m }}"
prompt: "${{ inputs.question }}"over takes a list from one of three places:
| Example | Who decides how many | |
|---|---|---|
| an input | ${{ inputs.members }} | the caller |
| an earlier fanout | ${{ steps.opinions.results }} | that step |
| a field a step declares as an array | ${{ steps.script.panels }} | a model |
The third is the one worth knowing: a model reads your article, decides it is a six-panel comic, and the workflow draws six panels. You did not have to know the number when you wrote the file.
overmust be a single${{ … }}naming a list — not a sentence with one inside it.- The variable (
mhere) exists only inside its own step. If a branch holds an object, read a field off it:${{ panel.scene }}. A field the element does not have is caught when you publish. - Results keep the order of the list, never the order they finished, so
results[0]means the same thing on every run. - One value fans out over one branch. A council of one is odd, not an error.
Read the results with .results, and who produced them with .models — the two
line up by index:
vars: { answers: "${{ steps.opinions.results }}" }
# or one of them:
image: "${{ steps.drawings.results[0].image }}"if
if: "${{ inputs.deep_research }}"A truthiness test, not an expression. Empty string, 0, false, [], {}
and absent are false; anything else is true. There are no comparison operators —
${{ inputs.n }} > 3 becomes the text "5 > 3", which is always true. A false
if skips the step; it does not fail the run.
output.schema — asking for JSON
output:
schema:
consensus: [string]
contested: [{ point: string, positions: [{ member: string, stance: string }] }]
answer: stringA shape sketch, not JSON Schema. Router9 turns it into an instruction, and parses
the reply back — a code fence or a sentence in front of the JSON is fine, a
missing declared field is not. These fields are added alongside what the
capability already produces, so a chat step with a schema still has text.
on_error
Defaults differ by shape, because the two cases are not alike:
| Default | What happens | |
|---|---|---|
| a fanout branch | continue | that branch is lost, the run finishes degraded and says what was lost |
| a plain step | fail | the run stops |
Losing one member of a three-model council leaves a two-model council. Losing a single step leaves no answer.
References — ${{ … }}
${{ inputs.question }} an input
${{ steps.answer.text }} a field of a step
${{ steps.opinions.results }} every branch of a fanout
${{ steps.opinions.results[0] }} one of them
${{ m }} the fanout variable, inside its own step
${{ steps.opinions.results | anonymize | shuffle }}One rule worth knowing: a value that is exactly one reference keeps its type —
a list stays a list. A string that merely contains one becomes text. That is why
over: "${{ inputs.members }}" gets a list and over: "the ${{ inputs.members }}"
is an error.
Two filters exist, both for blind peer review:
anonymizestrips who wrote what and labels entries with stable letters.shufflereorders, seeded per reviewer — two reviewers see different orders, and re-running the same run reproduces both. It is never random.
Prompt files
A step's prompt may name a Markdown file carried inside the workflow instead of
an inline string, which is how a prompt long enough to matter stays readable and
diffable:
with:
prompt: prompts/restyle.md
vars: { style: "${{ inputs.style }}" }Placeholders are {{style}}, filled from vars. Substitution happens in one
pass: a value that itself contains {{…}} is inserted as written, never
re-scanned, so nothing a caller types can pull another variable into the prompt.
An unknown placeholder is left alone.
There are no conditionals and no loops — a prompt is content. Give an optional
input a default if the sentence around its placeholder needs to read correctly
when nobody filled it in.
Choosing models
Write a model id, or let the router choose:
auto the same as auto:best
auto:best the highest-priority model available to you
auto:cheap the lowest-priced
auto:diverse:3 three models, one per vendor
auto:diverse:3@frontier … within a price band: frontier | mid | budgetauto:diverse is the one that matters for a council. Two models from the same
vendor share training data and failure modes: three of them agreeing is one
opinion stated three times.
These resolve once, when the run starts, and stay pinned for the whole run — otherwise a council could take answers from three models and have three different ones review them. Resolution is deterministic, so the same catalog convenes the same members. Too few models available? The run proceeds with what there is and tells you what it settled for.
auto: never widens access: it can only choose among models your key and plan
already allow.
Text steps choose from the chat catalog; image steps choose from the image
catalog. auto:diverse:3 on an image.generate step means three image models.
outputs
outputs:
answer: "${{ steps.verdict.answer }}"
contested: "${{ steps.verdict.contested }}"
members: "${{ steps.opinions.results }}"Every value must contain a reference — a constant is not an output.
examples
Ship starting points with the workflow, so whoever opens it is not staring at a blank box:
examples:
- label: Neon alley
inputs:
prompt: A rain-slick alley in Shibuya just after midnight…Up to eight. Choosing one fills the form; it does not submit. Names and enum values are checked when you publish, so a renamed input fails there rather than silently filling in nothing.
Limits
| steps per workflow | 20 |
| fanout width | 8 |
| model calls, worst case | 64 |
The catalog card shows the nominal cost — what you get if you change nothing — rather than the worst case, because quoting a ceiling nobody reaches is not information.
The fanout cap is real at run time, not just at publish. Hand a step more items than the cap — twelve models, or a script a model wrote twenty panels for — and it runs the first eight, finishes degraded, and tells you what it dropped. Your estimate is capped the same way, so the number you were shown and the calls that ran agree.
If your workflow lets a caller choose a count, declare it as an enum bounded by
the cap rather than as a free number. Then nobody can ask for twelve and quietly
receive eight.
A complete example: several models, then a verdict
name: council
title: LLM Council
inputs:
question: { type: longtext, required: true }
members: { type: "model[]", default: "auto:diverse:3" }
chair: { type: model, default: "auto:best" }
steps:
# ① Each member answers alone. They cannot see each other, which is what
# makes the disagreement in ③ mean something.
- id: opinions
uses: llm.chat
fanout: { over: "${{ inputs.members }}", as: m }
with:
model: "${{ m }}"
prompt: "${{ inputs.question }}"
# ② Blind peer review — names stripped, order shuffled per reviewer. A
# reviewer is also asked what only one answer said and what none of them
# said, which costs no extra call: it is already reading all of them.
- id: reviews
uses: llm.chat
fanout: { over: "${{ inputs.members }}", as: m }
with:
model: "${{ m }}"
prompt: "Score each answer 1-10 with a reason."
vars: { answers: "${{ steps.opinions.results | anonymize | shuffle }}" }
output:
schema:
rankings: [{ ref: string, score: number, reason: string }]
unique: [{ ref: string, insight: string }]
missing: [string]
# ③ One model fuses them and, more importantly, names the disagreements and
# what the panel as a whole never reached.
- id: verdict
uses: llm.chat
with:
model: "${{ inputs.chair }}"
prompt: "Fuse these answers. Name every point they disagree on."
vars:
answers: "${{ steps.opinions.results }}"
rankings: "${{ steps.reviews.results }}"
output:
schema:
consensus: [string]
contested: [{ point: string, positions: [{ member: string, stance: string }] }]
partial: [{ point: string, members: [string] }]
unique: [{ member: string, insight: string }]
blindspots: [string]
answer: string
outputs:
answer: "${{ steps.verdict.answer }}"
contested: "${{ steps.verdict.contested }}"
blindspots: "${{ steps.verdict.blindspots }}"
members: "${{ steps.opinions.results }}"Seven model calls for a caller who changes nothing. The headline outputs are
contested and blindspots, not answer: the fused answer is the part
one strong model could have produced on its own, and those two are the part it
could not.
consensus, partial, unique and blindspots are one axis, not four ideas —
how far the panel actually reached. Every member got there, or several did, or
exactly one did, or none did. The last is worth the most and is the only one that
is a fact about the panel rather than about the answer: a model cannot report
what it did not think of, so no single model could have produced it even in
principle.
Four things this shape is good at
One question, several minds
Several models answer alone, review each other blind, then one fuses the result — with the disagreements named. Shipped as LLM Council; the file is at the end of this page.
One story, N drawings
A model reads your article and decides what the panels are; one image call per panel, all in parallel, all in the same style.
steps:
- id: script
uses: llm.chat
with:
model: "auto:best"
prompt: prompts/script.md
vars: { source: "${{ inputs.source }}", panels: "${{ inputs.panels }}" }
output:
schema:
title: string
panels: [{ caption: string, scene: string }]
- id: art
uses: image.generate
fanout: { over: "${{ steps.script.panels }}", as: panel }
with:
prompt: prompts/panel.md
vars: { scene: "${{ panel.scene }}", style: "${{ inputs.style }}" }Shipped as Comic Strip. One honest caveat: independent generations share no state, so the style and cast description going to every branch is the only thing holding the strip together. Describe your characters concretely and it mostly works; expect faces to drift.
One logo, a whole ad
A vision model reads the mark, a language model works out the positioning, an image model shoots it — none of them asked to do another's job.
steps:
- id: look # vision: what is there
uses: image.describe
with: { image: "${{ inputs.logo }}", prompt: prompts/look.md }
- id: position # language: what it means
uses: llm.chat
with:
model: "auto:best"
prompt: prompts/position.md
vars: { look: "${{ steps.look.text }}", about: "${{ inputs.about }}" }
output:
schema: { tone: string, audience: string, scene: string, palette: string }
- id: shot # image: the photograph
uses: image.edit
with:
images: "${{ inputs.logo }}"
prompt: prompts/shot.md
vars: { scene: "${{ steps.position.scene }}", palette: "${{ steps.position.palette }}" }Shipped as Product Shot from a Logo. The logo travels to the last step as a reference, so the mark in the picture is the one you uploaded.
This year's facts, in your layout
Search the live web, have a model decide what the four things worth showing are, then lay them into a template you supply.
steps:
- id: research
uses: web.search
with: { query: "${{ inputs.topic }}", maxResults: "${{ inputs.sources }}" }
- id: brief
uses: llm.chat
with:
model: "auto:best"
prompt: prompts/brief.md
vars: { topic: "${{ inputs.topic }}", sources: "${{ steps.research.results }}" }
output:
schema:
title: string
sections: [{ heading: string, figure: string, detail: string }]
- id: art
uses: image.edit
with:
images: "${{ inputs.template }}"
prompt: prompts/lay-out.md
vars: { title: "${{ steps.brief.title }}", sections: "${{ steps.brief.sections }}" }Shipped as Infographic from a Template. Organising is the hard part and it is text work — an image model handed raw search results draws something that looks like an infographic and says nothing.
And one to build yourself: generate, then judge
Two fanouts, the second over the first's results — N models draw, then a vision model scores each against the brief that produced it.
steps:
- id: drafts
uses: image.generate
fanout: { over: "${{ inputs.drawn_by }}", as: m }
with: { model: "${{ m }}", prompt: "${{ inputs.brief }}" }
- id: critique
uses: image.describe
fanout: { over: "${{ steps.drafts.results }}", as: draft }
with:
image: "${{ draft.image }}"
prompt: prompts/score.md
vars: { brief: "${{ inputs.brief }}" }A model cannot judge its own output this way; a different one can.
Across modalities
Nothing about combining a text model with an image and a speech model needs new syntax:
steps:
- id: script
uses: llm.chat
with: { model: "auto:best", prompt: "${{ inputs.brief }}" }
- id: art
uses: image.generate
with: { prompt: "${{ steps.script.text }}", aspectRatio: "16:9" }
- id: narration
uses: audio.synthesize
with: { input: "${{ steps.script.text }}" }
outputs:
script: "${{ steps.script.text }}"
image: "${{ steps.art.image }}"
audio: "${{ steps.narration.audio }}"art and narration both depend only on script, so they run at the same time —
you did not have to say so. Three calls, one bill.
Next Steps
- Open the Studio and run one — no account needed for the first few.
- Install the MCP server to call your workflows from Claude Code, Codex, or any MCP harness.