# mse — Blueprint authoring guide
A **Blueprint** is the unified package of a `flow.ir` program plus the Swarm
extension layers (agent bindings, Operator role definitions, compiler
hints/strategy, metadata). This guide covers the shape you write by hand or
generate programmatically; for the exact, always-current JSON Schema fetch
`mse://api/blueprint-schema`.
## Top-level shape
```jsonc
{
"schema_version": "0.1.0", // optional, defaults to the current schema version
"id": "my-blueprint", // required, unique within your namespace
"flow": { "kind": "seq", "children": [] }, // required, a flow.ir Node (see below)
"agents": [ /* AgentDef[] */ ], // optional, default []
"operators": [ /* OperatorDef[] */ ], // optional, default []
"hints": { "per_agent": {}, "global": {} }, // optional
"strategy": { "strict_refs": true, "strict_kind": true }, // optional
"metadata": { "description": "...", "tags": [] }, // optional
"spawner_hints": { "layers": [] }, // optional, middleware capability keys
"default_agent_kind": "operator", // optional, defaults to "operator"
"default_operator_kind": "automate", // optional, no default (falls through the cascade)
"degradation_policy": "warn" // optional, "warn" (default) | "fail" (opt-in, schema-only today — see the worker degradation reporting section in `mse://guides/operator-execution-model`)
}
```
All fields except `id` and `flow` are optional and fall back to sensible
defaults. `deny_unknown_fields` is enforced throughout the schema — a typo in
a field name is a hard parse error, not a silently-ignored key.
## Flow node kinds (`flow.ir` `Node`)
Every node is tagged with a `kind` discriminator:
| `step` | `ref`, `in`, `out` | Dispatch the agent named `ref` with the evaluated `in` expr as input; write the result to `out` (must be a `path` expr). |
| `seq` | `children` (`Node[]`) | Evaluate children in order, threading the ctx through each. |
| `branch` | `cond`, `then`, `else` | Evaluate `cond` (must resolve to a JSON bool); run `then` if true, `else` if false. |
| `loop` | `counter`, `cond`, `body`, `max` | Writes `0` to `counter`, then repeats `body` while `cond` is truthy and `counter < max`, incrementing `counter` after each iteration. |
| `fanout` | `items`, `bind`, `body`, `join`, `out` | Evaluate `items` to an array; run `body` once per element (bound to `bind` in a branch-local ctx); aggregate into `out` per `join` mode: `all` (every branch runs, array of final ctx), `any` (first success wins), `race` (first to settle wins), `all_settled` (never raises, per-item `{status, value|reason}` record). |
| `try` | `body`, `catch`, `err_at?` | Run `body`; on error, roll back ctx writes, optionally write the error message to `err_at`, then run `catch`. |
| `assign` | `at`, `value` | Pure ctx transform: evaluate `value` against the ctx snapshot and write it to `at`. No agent dispatch. |
`out` / `at` / `counter` must always be `path` exprs (write targets).
### Fanout lanes, `$.results`, and the aggregate gate
`fanout` is the one node whose result is not readable the way it looks.
Read this before writing a flow that branches on a fanout's outcome.
**A lane is a disjoint ctx.** `body` runs once per item against a *copy*
of the parent ctx plus the `bind` write. Lane writes never reach the
parent ctx and never reach each other; only `out` crosses back.
**Dispatch arithmetic.** `body` runs once per item, whole. K steps in
the body and N items is K x N dispatches, not K. The canonical body is
one step. Per-item *agent* selection needs a `branch` on the bound item
inside the body (`Step.ref` is a static string on the wire, so it cannot
be computed from the item) — that is the shape `mse bp new fanout`
scaffolds.
**Feeding `items` from a worker's output.** A submit body is raw text, so
a planner step's OUTPUT folds as one string and `items = $.planner.lanes`
raises `PathNotFound` — the array is inside a string, not in the ctx.
Declare `submit_format: "json"` on that planner's meta channel
(`AgentMeta.ctx` / `Blueprint.default_agent_ctx` / `$step_meta`) to have
the server parse its final body and fold the structured value instead;
undeclared steps keep the string fold, and a declared step whose body
does not parse is rejected with `422`. See
`mse://guides/worker-io-contract` § Structured final bodies.
**What `$.results` contains.** Under `join = "all"`, one element per
item, each element the lane's *final ctx object* — not the step's
output. The step output sits inside it, under whatever path that step's
`out` named. So with `out = $.branch_out` in the lane, the value you
want is `$.results[i].branch_out`, not `$.results[i]`. Under
`all_settled` that same object is nested one deeper, under `.value`,
beside a `status`.
**You cannot index it.** flow.ir paths have exactly one segment kind, a
string key. The supported grammar is `$`, `$.a.b`, and `$["a.b"]` for
keys containing a dot — there is no array-index segment:
- `$.results[0]` is a **parse error**: the Blueprint is rejected on
deserialize (`mse bp build`, `POST /v1/blueprints/:id`), not at run time.
- `$.results.0` parses as the string key `"0"` and raises `PathNotFound`
at eval — a string key never indexes an array.
**No quantifier either.** No cond op ranges over an array: there is no
`any` / `all` / wildcard. `in` is not an escape hatch — it compares
*whole elements* structurally, and an element here is a full lane ctx
object, so it cannot express "some lane returned BLOCKED". `len` gives
you the lane count and nothing about the lanes.
**Therefore the aggregate step is the gate.** Hand `$.results` to one
agent and let it collapse the array into a scalar you *can* compare:
```jsonc
{"kind": "fanout", "items": {"op":"path","at":"$.d.targets"},
"bind": {"op":"path","at":"$.item"}, "join": "all",
"out": {"op":"path","at":"$.results"},
"body": {"kind":"step","ref":"check",
"in": {"op":"path","at":"$.item"},
"out": {"op":"path","at":"$.branch_out"}}},
{"kind": "step", "ref": "aggregate",
"in": {"op":"path","at":"$.results"},
"out": {"op":"path","at":"$.aggregate"}},
{"kind": "branch",
"cond": {"op":"eq","lhs":{"op":"path","at":"$.aggregate.verdict"},
"rhs":{"op":"lit","value":"PASS"}},
"then": {"...": "on pass"}, "else": {"...": "on block"}}
```
Give the aggregate agent a verdict contract so the gate compares a
scalar the engine already validated:
```jsonc
{ "name": "aggregate", "verdict": { "channel": "part", "values": ["PASS", "BLOCKED"] } }
```
See § Returning verdicts to drive BP flow below for the two channels, and
`mse://blueprints/samples/10-fanout` for a runnable version of this
shape.
**The one non-agent option.** `call_extern` (see § Expr ops) can fold
`$.results` in a host-registered pure function — the only way to reduce
the array without dispatching an agent. It is host-dependent: it works
only when the embedding host wired an externs registry
(`TaskLaunchService::with_externs`), so a Blueprint using it is not
portable to a plain `mse serve`.
**Writing this shape in the Lua DSL.** `bp_dsl`'s `B.stage "id" { fanout =
{...} }` emits exactly the node described here — items / bind / join / out
defaults, the one-step homogeneous body or the heterogeneous branch cascade
— while the aggregate stage stays an ordinary stage carrying the gate. The
semantics above are unchanged by the sugar; see
`mse://guides/dsl-authoring` § Fanout stages.
## Worker output: `out` vs named parts (GH #36)
A `step` node's OUTPUT is normally a single JSON value — the worker's
final `mse_worker_submit` `body` — addressable downstream via `{"op":
"path", "at": "$.<step>"}`.
A worker may additionally stage any number of *named* output parts
before completing the attempt: call `mse_worker_submit` with `name` set
(see `mse://guides/mcp-tool-reference` § Named multi-part output) once
per part, then finish with an ordinary plain (no-`name`) submit. The
set of `name` values a worker submits is its **staged-names allowlist**
— that allowlist alone determines what the engine folds into the
`parts` map on that step's OUTPUT. Any other artifact the worker (or a
middleware) emits — most notably the after-run audit sidecar
`audit:<step_ref>` (see § Reading prior-step OUTPUT below) — bypasses
the fold and is only reachable via the Worker axis. A step that staged
at least one part ends up with OUTPUT shape
```jsonc
{ "out": /* the final plain-submit body */ "...", "parts": { "plan.md": "...", "notes": { "todo": "..." } } }
```
instead of the plain final-submit body alone. A downstream step reads a
part with RFC 9535-style bracket-notation path syntax — required for any
key containing a literal `.`, like a filename:
```jsonc
{ "op": "path", "at": "$.<step>.parts[\"plan.md\"]" }
```
Bracket segments chain directly (`$.<step>.parts["a"]["b"]`) or combine
with dot segments in either order (`$.<step>.parts["notes"].todo`); keys
support no escaping (a literal `"` inside a name cannot be represented).
**Author caution**: once a step stages any parts, its OUTPUT becomes an
Object (`{"out": ..., "parts": {...}}`) instead of the plain final-submit
value — a downstream `eq`/`ne` expr comparing `$.<step>` directly against
a string (or other scalar) no longer matches; address `$.<step>.out`
instead (or a `parts[...]` entry). Keeping a worker's staging behavior in
sync with the Blueprint's `in` exprs that read its output is the
Blueprint author's responsibility — nothing in the schema enforces it
automatically.
## Reading prior-step OUTPUT (Worker axis): `context.steps`
The `$.<step>` / `$.<step>.parts[...]` paths above are the **BP axis** —
a downstream step declares what to read at Blueprint-authoring time and
the folded value flows into its `in`. Fine when the caller knows the
shape in advance.
The **Worker axis** is the complementary read-back path for a SubAgent
that decides at runtime which prior-step OUTPUT to pull. Every step's
OUTPUT is dual-recorded in the engine's `OutputStore` and surfaced via
`WorkerPayload.context.steps` as a `StepPointer` (GH #20 Contract C; see
`mse://guides/operator-execution-model` § Hop 4), filtered by the
current step's `ContextPolicy.steps` allowlist / `steps_exclude`
denylist:
```jsonc
"context": {
"steps": {
"planner": { "name": "planner", "size_bytes": 1834, "file_path": "/…/ctx/planner.json", "content_url": "…", "sha256": "…" },
"audit:planner": { "name": "audit:planner", "size_bytes": 412, "file_path": "/…/ctx/audit-planner.json", "content_url": "…", "sha256": "…" }
}
}
```
Key facts:
- **Pointer-only invariant** — a `StepPointer` never carries the OUTPUT
content itself (no preview, no content bytes inline). The SubAgent
fetches the actual bytes via `file_path` (local FS `Read`) or
`content_url` (server HTTP GET, verifiable against `sha256`). Choose
`file_path` on same-host SubAgents (the common case); choose
`content_url` when the SubAgent runs elsewhere.
- **`audit:<step_ref>` is a first-class entry** — after-run audit
artifacts (GH #34) surface as top-level `context.steps` keys named
`audit:<step_ref>`, **not** as a nested field of the audited step. The
BP axis (`$.<step>.audit[...]` or similar) does not reach them — audit
findings are observational sidecars that the fold-final path drops
from the BP-chain value but the Data-plane dual-write preserves for
Worker-axis consumers. See `mse://guides/operator-execution-model` §
After-run audits.
- **Keys are canonical step names** — the map key is the step's
canonical name resolved from `AgentMeta.projection_name` (GH #23), so
`ContextPolicy.steps` / `steps_exclude` entries are matched against
canonical names, not any renamed alias.
- **BP-chain scope vs Data-plane scope** — the fold-final path
(`fold_final_and_parts`, `src/core/engine.rs`) only stages a step's
`mse_worker_submit`-with-`name` artifacts (the `staged_names`
allowlist from § Worker output) into the BP-chain value. Other
artifacts (audits, out-of-band submissions) bypass fold but remain
reachable via `context.steps`.
## Returning verdicts to drive BP flow (canonical pattern)
A **verdict** is a small scalar (e.g. `"PASS"`, `"BLOCKED"`, `"ALLOW"`) an
agent emits so that a downstream `branch` or `loop` node can compare it
via `eq($.<step>, lit("BLOCKED"))` and pick a path. `eq` is a
**structural** compare — the whole value at `$.<step>` must equal the
whole `lit(...)` value. That constraint decides how the agent shapes its
submit body.
Two shapes are canonical; a third is a frequently-attempted anti-pattern.
### Pattern A — plain body carries the verdict scalar
The agent's `mse_worker_submit` body is the verdict literal, nothing
else:
```
mse_worker_submit(body="BLOCKED")
```
- Step OUTPUT is exactly the string `"BLOCKED"` — no `parts` field.
- BP-side: a downstream `"in": {"op": "path", "at": "$.gate"}` observes
the scalar directly, and
`{"op": "eq", "lhs": {"op": "path", "at": "$.gate"}, "rhs": {"op": "lit", "value": "BLOCKED"}}`
matches.
- Trade-off: the submit body has room for the verdict only — no
human-readable report co-exists with it on that step.
- When to use: pure gates where the verdict is the whole point. Working
example: `mse://blueprints/samples/03-fn-override` — the `mock-gate`
agent's system prompt is literally `` Always reply `BLOCKED` ``, and
the top-level `branch` fires on `eq($.verdict, lit("BLOCKED"))`.
### Pattern B — named part carries the verdict, plain body carries the report
The agent stages the verdict as a named part first, then finishes the
attempt with a plain (unnamed) submit whose body is the human-readable
report:
```
mse_worker_submit(name="verdict", body="BLOCKED") # stage the verdict
mse_worker_submit(body=<full YAML / markdown report>) # finish the attempt
```
- Step OUTPUT shape becomes
`{"out": <the full report>, "parts": {"verdict": "BLOCKED"}}`.
- BP-side: the verdict is addressed with bracket notation —
`{"op": "eq", "lhs": {"op": "path", "at": "$.gate.parts[\"verdict\"]"}, "rhs": {"op": "lit", "value": "BLOCKED"}}`
— while the full report stays reachable as `$.gate.out` for
downstream consumers (a resolver agent that needs the failure detail,
or a report artifact for humans).
- Trade-off: the agent issues two `mse_worker_submit` calls, and the BP
author must know to address the `parts["verdict"]` entry, not the
plain step name.
- When to use: gates whose verdict must drive flow *and* whose full
report is a first-class artifact.
### Anti-pattern — full report in the plain body, `eq` against the step name
An agent that submits a full YAML/markdown report as its only submit
body and expects `eq($.gate, lit("BLOCKED"))` to fire **cannot work**:
`$.gate` resolves to the whole report string; `lit("BLOCKED")` is the
four-character literal; they never compare equal. The `branch`'s `else`
path fires on every dispatch, which reads to a caller like the verdict
path was silently swallowed even though the agent said `BLOCKED`.
Debug rule: if a gate's `then` path never fires while the agent visibly
outputs a verdict word, check the submit shape first. It must be a
scalar (Pattern A) or a named part (Pattern B) — `$.gate` cannot be a
report body that *contains* the verdict word.
### Enforcing verdict contracts (opt-in)
Pattern A/B above are conventions — until an agent opts in, nothing
checks that its submit shape actually matches how a downstream `cond`
addresses it. `AgentDef.verdict` is an **optional** field that turns
that convention into two machine checks. It is strictly additive: an
agent that declares no `verdict` behaves exactly as before, byte for
byte, at both boundaries described below. The working sample
`mse://blueprints/samples/02-verdict-loop` is a live example of this —
its `mock-gate` agent declares no `verdict` field and continues to
register and run unchanged; Pattern A's convention alone is still
enough for it.
Declare a contract on the agent whose output a `cond` will compare:
```jsonc
// channel: "body" — Pattern A, the plain step OUTPUT IS the verdict
"agents": [{
"name": "gate",
"verdict": {
"channel": "body",
"values": ["PASS", "BLOCKED"]
}
}]
```
```jsonc
// channel: "part" — Pattern B, the verdict is staged as the named part
"agents": [{
"name": "gate",
"verdict": {
"channel": "part",
"values": ["PASS", "BLOCKED"]
}
}]
```
`channel: "part"` addresses one literal part name only —
`mse_worker_submit(name="verdict", body=...)` / `$.gate.parts["verdict"]`
— the way Pattern B is documented above. `values` is a closed set of
tokens; a comparison against anything outside it is a violation.
**Register time (compile, read-only lint).** `Compiler::compile` walks
every `Branch`/`Loop` `cond`'s `Eq`/`Ne`/`In` comparisons of a step
output `Path` against a literal, resolves the `Path` back to its
producing agent, and — only for agents that declared a `verdict` —
checks two things:
- The `Path` addresses the channel the agent declared (bare `$.<step>`
for `channel: "body"`, `$.<step>.parts.verdict` /
`$.<step>.parts["verdict"]` for `channel: "part"`). A mismatch fails
the compile with `CompileError::VerdictChannelMismatch`, naming the
step, the declared channel, and the channel the `cond` actually
addressed.
- Every literal compared against that `Path` (including every entry of
an `In` haystack) is a member of the declared `values`. A literal
outside the set fails the compile with
`CompileError::VerdictValueNotInContract`, naming the offending
literal and the declared set.
Compile fails on the **first** violation found (same posture as the
compiler's other static checks). If the `cond` references an agent
that declared **no** `verdict` field, nothing is rejected — at most a
`tracing::warn!` is emitted, and compilation still succeeds. This is
what keeps every pre-existing Blueprint, and every Blueprint whose
authors haven't opted in yet, compiling unchanged.
**Completion time (server, fail-loud producer gate — all 3 completion
routes).** A contract-bearing agent's attempt can complete through 3
different routes: `POST /v1/worker/submit`, the older
`POST /v1/worker/result`, or the WS Operator fallback (a worker
process that never POSTs at all). GH #50 originally gated only the
first of these, and only for `channel: "body"`; GH #51 closes the
remaining gaps by moving the check to the single choke point every
route funnels through — `Engine::submit_worker_result_trusted` /
`Engine::submit_output`, embedded immediately before the value is
written to `output_tail`, not re-implemented per route handler:
- `channel: "body"` — the completing value must be a member of
`values`.
- `channel: "part"` — a staged `"verdict"` artifact must exist for the
attempt (**presence**, not just membership — a worker that never
calls `POST /v1/worker/artifact?name=verdict` at all is now rejected,
the gap GH #51 exists to close) AND its value must be a member of
`values`.
- `ok=false` completions are exempt on every route, identically — a
transport-level failure (`DispatchOutcome::Blocked`, the flow.ir Try
path) is not a verdict and is never validated against the contract.
- An agent that declared no contract, or declared a contract for the
other channel, sees this gate as a no-op — behavior unchanged from
before GH #50/#51.
A violation is rejected **before** the value reaches `output_tail` / the
flow ctx: `POST /v1/worker/submit` and `POST /v1/worker/result` both
surface HTTP 422, echoing the declared `values` (`channel: "body"`
violations) or naming the missing `"verdict"` part (`channel: "part"`
violations). The WS Operator route has no HTTP response to return a 422
on — a rejected completion there simply never writes its `Final`; the
attempt's `output_tail` has no `Final` and the downstream dispatch path
naturally treats it as incomplete (a `tracing::warn!` is logged
server-side). No new WS protocol message is introduced for this — the
deliberate "zero flow-ir changes" design choice, not a gap left to
fill.
The staging-time check at `POST /v1/worker/artifact?name=verdict`
(`channel: "part"` membership only, not presence) still runs — it gives
the worker the fastest possible feedback the moment it stages a bad
token. The completion-time check above is the backstop that guarantees
enforcement no matter which of the 3 routes an agent's attempt actually
completes through.
Together, the register-time and completion-time boundaries turn both
halves of the silent never-match anti-pattern above into loud
failures: an authoring mistake (`cond` addressing the wrong channel, or
comparing against a token the agent will never emit) stops at register
time; a worker that emits a full report where a token was expected, or
skips staging the verdict part entirely, stops at completion time on
every route it could have completed through. Neither boundary touches
`flow.ir` itself — no new `Expr` forms, no eval hooks, no `FlowNode`
rewriting; `Blueprint.flow` stays exactly what the author wrote, and
the contract lives entirely in the Blueprint/schema/compiler/server
layers described here.
#### Symptom → cause: `dispatch failed: no Final in output_tail`
The rejection above is what an author most often meets from the *other*
end — as a missing-Final symptom on a step that looks like it ran fine.
This table is the reverse lookup, because the symptom string does not
contain the word "verdict":
| `no Final in output_tail`, and the worker's own logs show it emitted a result | `channel: "body"` contract + a terminal value that is not one of `values` (a report / JSON object / prose where a bare token was required) | switch the agent to `channel: "part"` and stage the token separately, or emit the bare token as the body |
| `no Final in output_tail` on a `channel: "part"` agent | nothing was staged under the name `verdict` for that attempt | stage it before the terminal emit (`bus.emit("artifact", …)` in-process, `POST /v1/worker/artifact?name=verdict` over HTTP) |
| `verdict contract violation: … is not a member of the declared values …` | same as row 1, seen with the cause attached | as above |
Rows 1 and 2 are the same rejection as row 3 — only the diagnostic
differs by route. In-process (`kind: agent_block` / `kind: lua`) and HTTP
completions carry the cause into the dispatch error; the WS Operator
fallback emit logs a `tracing::warn!` server-side and leaves the attempt
without a `Final`, so on that route the bare symptom is still what the
caller sees.
Both halves are also visible **before** any dispatch: `bp_doctor`'s
`verdict_contract_lint` family reports every declared verdict value that
no downstream `cond` reads, which is the state a decorative contract is
in. See the next section.
### Declared verdict values must be handled downstream (opt-in strict mode)
The two register-time checks above are the **forward-direction** lint:
"every `Lit` a `cond` compares against must be a member of the agent's
declared `verdict.values`" and "every `cond` must address the declared
channel." The **reverse-direction** lint — "every entry of the declared
`verdict.values` set must be referenced by at least one downstream
`Branch`/`Loop` `cond`" — catches the complementary drift where a flow
author declares a verdict value (e.g. `"BLOCKED"`) but forgets to write
a branch that handles it.
By default the reverse-direction lint only surfaces
`tracing::warn!` at compile — the compile still succeeds. This preserves
back-compat with existing Blueprints that intentionally leave some
declared values as silent-pass informational tokens (an agent may want to
document "we may emit `INFO` as well" without demanding every caller
branch on it).
That default is quiet in the wrong place, though: a `tracing::warn!` goes
to the server's log, not to the response the author is reading. So the
same check is also exposed as a **report-only `bp_doctor` family**,
`verdict_contract_lint`, which runs on an already-registered Blueprint
with no opt-in and reports one `verdict_value_unhandled` WARN per
unhandled declared value:
```
bp_doctor(id = "<bp>")
→ verdict_contract_lint: { findings: [
{ check: "verdict_value_unhandled", severity: "WARN",
agent: "gate-danger", value: "PASS", channel: "body",
declared_values: ["PASS", "BLOCKED"], step_ref: "gate-danger",
message: "… no downstream Branch/Loop cond ever compares against it …" }
] }
```
Findings also appear in the unified `diagnostics` array under the kind
`verdict-value-unhandled` — the same kind the strict compile error
projects to. Disable the family with `disable_verdict_contract_lint=true`
for a Blueprint that deliberately declares informational tokens.
The state worth catching this way is a flow with **no `Branch` at all**
plus a `channel: "body"` contract on every gate: it compiles clean, every
declared value is unhandled, and the contract is not merely decorative —
`channel: "body"` constrains the terminal OUTPUT value too, so each gate
that returns a report has its `Final` rejected at completion time. The
lint reports it as N findings before the first dispatch; without it the
first signal is a missing-Final symptom at run time.
#### Aggregate: `verdict_contract_never_read`
A normal halt gate always leaks one `verdict_value_unhandled` finding per
agent (the halt cond only reads the halt token, so the always-unread
`PASS` shows up as a per-value WARN even in a healthy Blueprint). That
baseline noise structurally hides the actual defect this section is about
— the whole gate being dropped so **every** declared value on an agent
goes unread. The concrete regression: a bp.lua authored against the pre-
`bafe47d4` cascade rules (pipeline-level `halt_on` implicitly gates every
stage) rebuilt against the post-flip rules (stages must opt in explicitly
via `gate = true` / stage-level `halt_on` / `retry`) — the opt-OUT stages
silently stopped emitting gates, but each still leaks one baseline WARN,
indistinguishable from a normal one.
To separate the two, the family additionally emits a per-agent aggregate
finding `verdict_contract_never_read` (WARN — one per agent whose entire
declared `verdict.values` set is unread). The count of these equals the
number of agents whose gate is fully dead; the per-value baseline stays
in place for parity with `strict_verdict_handling`. Aggregate findings
appear first in `findings[]`:
```
bp_doctor(id = "<bp>")
→ verdict_contract_lint: { findings: [
{ check: "verdict_contract_never_read", severity: "WARN",
agent: "gate-danger", channel: "body",
declared_values: ["PASS", "BLOCKED"], step_ref: "gate-danger",
message: "… no downstream Branch/Loop cond reads any of them — the
contract is decorative and this step cannot halt the
flow. Add a gate that reads the verdict (e.g.
`gate = true` on the B.pipeline stage) …" },
{ check: "verdict_value_unhandled", severity: "WARN",
agent: "gate-danger", value: "PASS", … },
{ check: "verdict_value_unhandled", severity: "WARN",
agent: "gate-danger", value: "BLOCKED", … }
] }
```
The aggregate projects to the `verdict-contract-never-read` diagnostic
kind and carries a concrete `Suggestion { patch: "gate = true,",
applicability: MaybeIncorrect }` — `MaybeIncorrect` because the fix
presumes a `B.pipeline` stage record (a hand-rolled `Branch` needs the
equivalent shape by hand). Both aggregate and per-value findings fold
into `verdict_contract_lint_warn_count` and the top-level aggregate
verdict; disabling the family drops both.
To promote the warning to a hard `CompileError::VerdictValueUnhandled`,
opt in via `Blueprint.metadata`:
```json
{
"metadata": {
"strict_verdict_handling": true
}
}
```
Under `strict_verdict_handling: true`, `Compiler::compile` rejects any
Blueprint where a contract-bearing agent declares a `verdict.values`
entry that no downstream `Branch`/`Loop` `cond` references. The
diagnostic names the agent, the unhandled value, the full declared
`values` set (so the fix is unambiguous — either add a handler branch
or drop the value from the declaration), and the `Step.ref_` where the
agent is invoked (best-effort — when the agent is invoked at multiple
sites, the first-encountered site is reported).
Two ways to satisfy the strict lint:
1. **Add a branch per declared value.** The canonical shape — one
`Branch` per value, or one `Branch` per value pair (e.g. `"PASS"` in
the `then_`, `"BLOCKED"` in the `else_`).
2. **Cover the whole set with one `In`.** An `In` cond whose `Lit`
haystack lists every declared value counts every entry as handled in
one node — useful when the flow author wants a single "any of these
verdict values ⇒ proceed" branch.
The forward and reverse lints run in the same walk, so the strict
setting has no extra runtime cost; either both fire or neither does.
The setting is a Blueprint-level opt-in (per BP, not per agent), so a
flow author who wants the strict check on some agents but not others
can either split those agents into a separate Blueprint or leave the
setting off and rely on the default `tracing::warn!` output.
### Cross-links
- Named-parts wire format and OUTPUT shape: § Worker output: `out` vs
named parts (above).
- Working samples that exercise Pattern A end-to-end:
`mse://blueprints/samples/02-verdict-loop` (a `loop` that retries
while `$.verdict == "BLOCKED"`) and
`mse://blueprints/samples/03-fn-override` (a `branch` that hands a
BLOCKED gate result to an approver step).
- Agent-side declaration (which pattern the agent's own Output format
section commits to): `mse://guides/agent-md-authoring` § Output
contract: inline body vs `@file:` sentinel.
- The static verifier that surfaces some verdict-related drift
(`declared_tools` vs wrapper grants, projection-name/parts-shape
changes downstream): `mse://guides/agent-md-authoring` § Verifying
how your agent materializes.
## Expr ops (`flow.ir` `Expr`)
Every expr is tagged with an `op` discriminator:
| `path` | `at` (e.g. `"$.x.y"`) | Read a value from ctx. Raises if the path is missing. |
| `lit` | `value` | A literal JSON value. |
| `eq` | `lhs`, `rhs` | Structural equality. |
| `ne` | `lhs`, `rhs` | Structural inequality. |
| `lt` / `lte` / `gt` / `gte` | `lhs`, `rhs` | Comparison: both numbers (`f64`) or both strings (lexicographic, Lua `<` parity). Mixed types raise. |
| `not` | `arg` | Boolean negation (truthy-based). |
| `and` | `args` (array) | Short-circuit conjunction; empty array → `true`. |
| `or` | `args` (array) | Short-circuit disjunction; empty array → `false`. |
| `exists` | `arg` (expr) | `true` iff `arg` resolves to a non-`null` value (missing path → `false`, present-but-`null` → `false`). |
| `add` / `sub` / `mul` / `div` / `mod` | `lhs`, `rhs` | Numeric arithmetic (`f64`); `div` / `mod` by zero raises. `mod` follows Lua `%` (result takes the sign of `rhs`). |
| `len` | `arg` | Element count (array), char count (string), or key count (object). |
| `in` | `needle`, `haystack` | `true` if `needle` equals any element of the `haystack` array. |
| `call_extern` | `ref`, `args` (array) | Invoke a host-registered pure function (`Externs` registry) with the evaluated `args`. Unregistered `ref` raises. Value-shape only — no side effects, no flow control. |
No op quantifies over an array — there is no `any` / `all` / wildcard,
and `in` compares whole elements structurally. Gating on a collection
(a `fanout`'s `out`, most commonly) goes through a step that reduces it
to a scalar first: see § Fanout lanes, `$.results`, and the aggregate
gate.
`call_extern` requires the host to register an externs registry
(`TaskLaunchService::with_externs`); without one every `call_extern`
raises an extern error.
Truthy semantics match Lua/JS: `null`/`false` are falsy, everything else
(including `0` and `""`) is truthy.
## Agents (`AgentDef`) and kind resolution
### Two authoring paths
An `AgentDef` can be written in two places, and either is fine:
- **Direct JSON literal (this guide's default form)** — the
`AgentDef` object appears inline inside the Blueprint JSON. All
fields (`name`, `kind`, `spec`, `profile.system_prompt`,
`profile.worker_binding`, `profile.tools`, `meta`, …) are set
literally in the JSON tree. This is the default authoring shape
for the samples under `mse://blueprints/samples/*` and for
programmatic authoring (algocline strategies, skills, dogfood
harnesses).
- **`$agent_md` file ref** — the entry is a single-key object
`{ "$agent_md": "agents/foo.md" }` and the loader parses the
target file's frontmatter (+ Markdown body) into a
fully-populated `AgentDef`. See the `$agent_md file-ref
expansion` section below.
Compile-time error messages that name a field (e.g.
`profile.worker_binding`) are actionable on either path — for JSON
authors, add the field to the JSON literal; for `$agent_md` authors,
add it to the `.md` frontmatter. The messages themselves spell both
paths out.
### `AgentDef` shape (JSON-direct form)
Each entry in `agents` maps a name (referenced from `flow.Step.ref`) to a
backend:
```jsonc
{
"name": "my-agent",
"profile": { "system_prompt": "...", "model": "...", "tools": [] }, // optional
"meta": { "description": "...", "tags": [] } // optional
}
```
`AgentKind` is a closed enum (`lua`, `rust_fn`, `agent_block`, `subprocess`,
`operator`) — there is no string-escape-hatch variant. `spec` is free-form
per kind; the keys each kind reads are:
| `lua` / `rust_fn` | `fn_id` (factory registry key), or an inline `source` chunk for `lua` |
| `agent_block` | `script_path` / `project_root` / `mcp_rpc_timeout_ms` / `mcp_servers` — see the section below |
| `subprocess` | `program` + `args` (or a `Runner::Subprocess` template, GH #83) |
| `operator` | `operator_ref` |
When an agent omits
`kind`, resolution falls through a four-tier cascade (highest to lowest
priority): (1) per-`AgentDef.kind` literal, (2) the Blueprint's top-level
`default_agent_kind`, (3) a CLI-level default (e.g. `mse serve
--default-agent-kind`), (4) the schema `Default` impl (`operator`).
### `$agent_md` file-ref expansion
Instead of writing an `AgentDef` object inline, you can reference an
`agent.md` file (frontmatter + Markdown body) and let the loader expand it:
```jsonc
{ "agents": [ { "$agent_md": "agents/researcher.md" } ] }
```
This parses the file's frontmatter + body into a fully-populated `AgentDef`
(`profile.system_prompt`, `meta`, `spec`, etc.). Sibling keys alongside
`$agent_md` are shallow-merged onto the expanded object afterward — handy for
overriding just `spec.operator_ref` or `meta` while keeping the rest of the
`agent.md` content:
```jsonc
{ "$agent_md": "agents/researcher.md", "spec": { "operator_ref": "role-a" } }
```
**Path hygiene**: refs are resolved relative to the Blueprint file's own
directory. Absolute paths and any `..` parent-directory component are
rejected — refs are sandboxed inside the Blueprint's base-directory subtree.
The same rule applies to the more general `$file` ref (`{"$file": "path"}`),
which substitutes a referenced file's raw string contents anywhere in the
JSON tree (e.g. externalizing a large prompt out of a `Step.in` literal).
### Runners (GH #46): `Blueprint.runners` / `AgentDef.runner` / `runner_ref`
A `Runner` declares the execution shell an agent's Worker IMPL dispatches
into — tool grant, model selection, and runtime capabilities for the
backend it targets. Three variants exist today: `ws_operator` (the
platform-neutral standard for a Claude Code, Codex, or other joined MainAI;
`variant` is provider-defined and `tools` is the minimum requested grant),
`ws_claude_code` (the compatibility backend for existing Claude Code wrapper
Blueprints), and `agent_block_in_process`
(agent-block in-process runtime; `tools` is the effective, enforced tool
set). `AgentDef.kind = agent_block` pairs with an `agent_block_in_process`
Runner; every other `AgentDef.kind` normally pairs with `ws_operator`.
Runners are declared through a named, BP-level registry
(`Blueprint.runners: [{ "name": ..., "runner": {...} }]`) — the same
registry shape as `Blueprint.metas` — and resolved per-agent through a
5-tier cascade (highest priority first):
1. `AgentDef.runner` — an inline `Runner` object on the agent itself.
2. `AgentDef.runner_ref` — a name looked up in `Blueprint.runners`.
3. Legacy fallback: `profile.worker_binding` synthesizes a
`ws_claude_code` Runner from `{ variant: worker_binding, tools:
profile.tools }` — **deprecated** while Blueprints migrate onto
`runner` / `runner_ref`.
4. `Blueprint.default_runner` — a BP-wide registry name, used only when
no tier above (1–3) applies to this agent.
5. No Runner declared through any tier — the agent has none.
Note tier 3 outranks tier 4: an agent's own `profile.worker_binding`
still wins over the Blueprint's `default_runner`, mirroring the
`AgentInline > MetaRef > BpGlobal` precedence the ctx-supply cascade
already follows (agent-level declarations always beat BP-global ones).
```jsonc
{
"runners": [
{ "name": "review-worker", "runner": {
"backend": "ws_operator", "variant": "mse-worker-reviewer", "tools": ["Read", "Grep"]
} }
],
"default_runner": "review-worker",
"agents": [
{ "name": "reviewer", "kind": "operator", "spec": { "operator_ref": "main-ai" }, "runner_ref": "review-worker" }
]
}
```
At Run start MSE resolves this cascade once into an immutable `BoundAgent`
snapshot. The snapshot pins the full Agent definition (including role prompt
and verdict contract), the resolved Runner, and the effective static context
policy. Its `binding_digest` is persisted with the Run launch snapshot and
copied onto each step trace; replay keys include the digest, so identical
step input under a different binding is not treated as the same execution.
The legacy `profile.worker_binding` tier is projected only at the Claude Code
compatibility boundary. New Blueprints should use `runner` or `runner_ref`.
Servers default to `legacy_worker_binding_policy = "allow"`; setting it to
`"reject"` (or passing `--legacy-worker-binding-policy reject`) turns this
fallback into a launch-time error. The policy affects fresh resolution only;
persisted Run snapshots retain their pinned Runner.
The Runner's `tools` remain requested/declarative for `ws_operator` and
`ws_claude_code` until
an injected `AgentBindingProvider` attests the execution environment's
effective grant. The generic path is for the Operator/MainAI to implement
that interface. `ManifestBindingProvider` is the reusable reference
implementation: Claude Code and Codex plugins inspect only their own
environment, produce `AgentProviderManifest`, and delegate the common
request-to-receipt mapping to it. The standard Server maps
`AgentDef.spec.operator_ref` to the role claimed by `mse_operator_join` and
resolves the submitted `capability_manifest`; it never reads wrapper files
from the Server filesystem. Core validates one receipt
per requested agent, requires every requested tool and the exact launch
variant, then pins the accepted model, tools, provider revision, and optional
capability snapshot digest as `BindingAttestation`. That attestation is included in the
final `binding_digest` and persisted in the Run snapshot. Resume and replay
reuse it without asking the provider to resolve mutable environment state
again. MSE does not misreport declaration data as an enforced capability.
### In-process agents: `kind = agent_block` (GH #86)
An `agent_block` agent runs headless inside the server process over the
agent-block-core SDK — no operator round-trip, no child process. It is the
backend for deterministic in-process lanes (validation gates, after-run
audits like `mse://blueprints/samples/05-after-run-audit-agent-block`).
Two modes, selected by whether `spec.script_path` is present:
| **PromptBasedAgent** | `spec.script_path` absent | The host embeds an invoker that calls the SDK's `agent` module with the declared MCP servers. |
| **ScriptBasedAgent** | `spec.script_path = "<path>"` | Your Lua script runs instead; it owns its own MCP connections. |
`spec` keys (all optional):
```jsonc
{
"script_path": "gates/danger.lua", // absent => PromptBasedAgent mode
"project_root": "/abs/path", // compile-time fallback cwd
"mcp_rpc_timeout_ms": 30000, // default 30s
"mcp_servers": [ // pool the tool grant selects from
{ "name": "outline", "command": "outline-mcp", "args": [] }
]
}
```
**`mcp_servers[].command` resolution.** The MCP server process is spawned
as a child of the **`mse serve` process**, so a bare command name is
looked up on that process's `PATH` — not your shell's. Under `mse serve`
started from a login shell that is usually the same thing. Under the
`mse server install` LaunchAgent it is not: launchd gives the daemon the
fixed `EnvironmentVariables.PATH` from
`~/Library/LaunchAgents/com.mse.server.plist`, which lists the
`--cargo-bin` directory plus the standard system dirs and nothing else.
For anything installed elsewhere, give an absolute path:
```jsonc
{ "name": "docs", "command": "<your-mcp-binary>", "args": [] } // bare: must be on the daemon's PATH
{ "name": "docs", "command": "/opt/homebrew/bin/<your-mcp-binary>", "args": [] } // absolute: always resolves
```
The alternative is to extend `EnvironmentVariables.PATH` in that plist
and reload the job (`launchctl kickstart -k gui/$(id -u)/com.mse.server`).
A command that fails to resolve surfaces at MCP-connect time, not at
Blueprint compile time.
**Input.** Three Lua globals, all per-task and none via the server process
env:
| `_PROMPT` | the step's evaluated `in`, as a **String** — a structured `in` arrives JSON-stringified, so use `std.json.decode(_PROMPT)` if you want a table |
| `_CONTEXT` | `profile.system_prompt` |
| `_TASK_METADATA` | the launch's `init_ctx.task_metadata` bag, as a real Lua table |
| `_AGENT_CTX` | the Blueprint-declared agent context (`default_agent_ctx` / `AgentMeta.ctx`) after `ContextPolicy` filtering, as a real Lua table |
An absent field sets no global at all, so a script can branch on `nil`.
A `kind: lua` agent gets the same `_TASK_METADATA` / `_AGENT_CTX` pair, so
a gate is portable between the two in-process backends.
Prior-step OUTPUT is not delivered as a global: an in-process gate reads
it through its own `in` expression (`in: $.<prior_step>`), which is more
direct than the pointer list a WebSocket worker has to fetch.
The per-task working directory is not a global: `init_ctx.work_dir` /
`init_ctx.project_root` outrank `spec.project_root` and become the SDK's
`project_root`, which a script reads as `std.env.project_root()` and which
is the default cwd for `sh.exec` and for MCP servers started by
`mcp.connect`. It does **not** `chdir` the server process, so a bare
`io.open("rel/path")` still resolves against the server's own cwd.
**Result.** A script returns its result by calling `bus.emit(<kind>,
payload)` — **not** by returning a value from the chunk. One kind is
reserved:
| `"artifact"` | stages a named part — `{ name = "...", content = ... }`, `name` required — and lets the script keep running. Any number of these. |
| anything else | the terminal result, **first emit wins**. The host takes `payload.content`, else `payload.response`, else the whole payload, as the step OUTPUT body. |
Both verdict channels work. `channel: "body"` compares the terminal value:
```lua
bus.emit("worker_result", { ok = true, response = "PASS" })
```
`channel: "part"` compares a staged `verdict` part, leaving the body free
for the report:
```lua
bus.emit("artifact", { name = "verdict", content = "PASS" })
bus.emit("worker_result", { ok = true, response = "the full prose report" })
```
The step's value is then `{"out": "the full prose report", "parts":
{"verdict": "PASS"}}` — downstream reads the report at `$.<step>.out` and
branches on `$.<step>.parts["verdict"]`. A step that stages nothing keeps
the plain body, unwrapped.
Pick the channel by what the body has to carry. A gate whose body IS the
report needs `channel: "part"` — under `channel: "body"` that same script
has its `Final` rejected at completion time, and the step fails with
`dispatch failed: no Final in output_tail` (see § Symptom → cause above;
the in-process lane carries the contract violation into the dispatch
error, so the cause travels with it).
**Tool grant.** The effective set is the resolved
`agent_block_in_process` Runner's `tools` when a Runner is declared,
otherwise `profile.tools`. A declared-but-empty list is an enforced-empty
grant, not "unset" — that is how a Blueprint revokes an agent.md's
inherited `tools:` line. The override is pinned into the Run's immutable
`BoundAgent` snapshot at launch, so editing `Blueprint.runners` afterwards
does not change an in-flight Run's grant.
Enforcement is **server-granular**, and differs by mode. PromptBasedAgent
embeds only the `spec.mcp_servers` entries named by an
`mcp__<server>__<tool>` entry of the effective set — an unlisted server is
unreachable, but *every* tool of a listed server is reachable (a connected
MCP server exposes its full tool list to the model). ScriptBasedAgent
cannot be enforced at all, because the script calls `mcp.connect` itself;
declaring `mcp__` entries alongside `spec.script_path` is therefore a
compile error rather than a silent no-op — drop them and let the script own
its connections, or drop `spec.script_path` to get an enforced grant.
Non-`mcp__` names (`Read`, `WebSearch`, …) select no server and are inert
in both modes.
```jsonc
{
"name": "gate-danger",
"kind": "agent_block",
"spec": { "script_path": "gates/danger.lua" },
"runner": { "backend": "agent_block_in_process", "tools": [] }
}
```
### Execution assurance: `strategy.strict_binding`
Runner-backed agents describe *requested* capabilities; whether MSE demands
a Core-validated provider attestation for them before the Run may launch is
one Blueprint-level switch, `strategy.strict_binding` (default **false**):
- **`strict_binding = false` (default)** — the Blueprint runs without any
capability manifest at all. An agent whose provider offers no attestation
(no manifest, the role never joined, or the manifest declares no matching
launch variant) stays `DeclarationOnly` — its `runner.tools` / `model`
remain requested/declarative, the Run launches, and the unattested state is
recorded on `RunRecord.degradations` for after-the-fact observation. The
requesting side's declaration is carried into the spawn frame so the
Operator can self-check its own environment (see
`mse://guides/operator-execution-model` § Operator self-check).
- **`strict_binding = true`** — launch requires a Core-validated provider
attestation for **every** Runner-backed agent. A missing manifest, a missing
variant, an insufficient tool grant, or no provider at all fails the launch
before any Spawn, and the error names the agent plus the requested
variant/tools it could not satisfy.
This default is deliberately the opposite of `strict_refs` / `strict_kind`
(both default `true`): those guard the Blueprint's *structural* integrity,
which is always resolvable at compile time, whereas binding attestation is an
*execution-assurance opt-in* that needs a live execution environment to attest
against — not available for embed-only or manifest-less launches.
```jsonc
{
"strategy": { "strict_refs": true, "strict_kind": true, "strict_binding": true }
}
```
Whichever mode a Blueprint is in, the semantics rule is the same: **attestation
is optional, but never wrong** — a receipt that *exists* and contradicts the
request (a tool short of the grant, the wrong launch variant, a digest or model
mismatch) fails in both modes. `strict_binding` controls only whether an
*absent* attestation is tolerated, never whether a *contradicting* one is.
Two tools discover what a Blueprint's Runner-backed agents require, so an
operator can build (or audit) a manifest before launch:
- **Requirements introspection** — `GET /v1/blueprints/:id/binding-requirements`
returns `{blueprint_id, strict_binding, requirements: [BindRequest, …]}`, one
entry per Runner-backed agent with its declared variant / tools / model (the
reverse lookup an operator machine-generates a manifest from).
- **`bp_doctor` `binding_lint` family** — the static pass surfaces
`binding_requirements_info` (INFO: what each Runner-backed agent requests),
`strict_binding_without_runners` (WARN: `strict_binding = true` with no
Runner-backed agent — a no-op strict), and `legacy_worker_binding` (WARN:
`profile.worker_binding` in use) on the top-level `binding_lint.findings`
array. See `mse://guides/mcp-tool-reference`.
## Versioning
`metadata.version_label` is an optional free-form SemVer string (e.g.
`"1.2.3"`) used as the match target when reading a stored Blueprint by
version. Store readers select a version via one of three selectors:
- `Latest` — the store's current head (the default when unspecified).
- `Fixed { value }` — one exact, previously-committed version.
- `SemverReq { req }` — resolve to the newest stored version whose
`version_label` satisfies a `semver::VersionReq` (e.g. `"^1.2"`).
`version_label` is rewritten automatically by the Enhance loop on
PATCH/MINOR/MAJOR bumps; you do not need to hand-maintain it once a
Blueprint is under Enhance management.
## Where to go next
- Three worked examples: `mse://blueprints/samples/01-pure-ctx-eval` (zero
agent dispatch, pure ctx math), `mse://blueprints/samples/02-verdict-loop`
(retry loop with a self-managed counter), `mse://blueprints/samples/03-fn-override`
(a blocked verdict overridden by an approver step).
- The exact, always-current JSON Schema: `mse://api/blueprint-schema` (note:
`flow` itself is opaque in the schema — its grammar is owned by the
`mlua-flow-ir` crate, referenced above).
- Tool-level operations (running, archiving, schema fetch): `mse://guides/mcp-tool-reference`.
- Verifying an `AgentDef`'s materialized tools/ctx/output before a run
(`bp_explain_agent`): `mse://guides/agent-md-authoring` §
Verifying how your agent materializes.
- The DSL surface for authoring Blueprints directly in Lua (Expr method chains, Node builders, bp_dsl pipeline sugar): mse://guides/dsl-authoring, with two DSL samples: mse://blueprints/samples/06-dsl-verdict-loop and mse://blueprints/samples/07-dsl-pipeline.