AI harness guide
Machine-readable CLI contract (0.43)
Section titled “Machine-readable CLI contract (0.43)”Every JSON-capable command now emits one object with command, schemaVersion, ok, and its command-specific payload. Errors use ok: false plus an error object (code, message, and optional path, argument, or hint); they are not diagnostic arrays. Branch on the process exit code before parsing stdout, and keep stderr for project-resolution and usage failures. Use lute schema <command> (or lute schema; its output is always JSON) to discover the current command IDs and schema versions. Examples in this guide assume LUTE_TELEMETRY=0 when output must be reproducible.
Lute can be driven by a model or any other authoring tool, but the tool never becomes an authority. A harness reads exit codes and JSON, never prose, while the host independently selects the project and capability-permission ceiling. A working loop is: trusted context in → generate → check under the same ceiling → promote → tag. Lute itself makes no AI or remote service call.
Prompt context: lute context --json
Section titled “Prompt context: lute context --json”Seed the model from the project’s authoring surface, never from guesswork:
lute context scene.lute --json --project . \ --permission-profile generatedIt emits the project-resolved directives, attrs, enums, asset kinds, providers, state schema, relational vocabulary, imported components, inline textModifiers, effective permission layers, and a capabilitySnapshot. In JSON, permissions is { "layers": [...] }, bridges contains allowed bridge objects, rewardKinds is the allowed name-keyed object, and questsAllowed is a boolean; directives also excludes entries blocked by directive or bridge policy. Read-only external state remains visible. It is a capability query, not validation: it emits regardless of the document’s own diagnostics (exit 0), and — the key property — works on an empty file, because the surface comes from the resolved project and plugins, not the document body. Use capabilitySnapshot as a prompt-cache key: restrictive effective permissions change it, while policy-free projects retain their prior hashes byte-for-byte.
Since 0.22.0 the surface also carries what a model otherwise reads out of sibling files or guesses:
defs— every named condition the document can read as@name, each{ name, type, params, body }(the shorthanddefs: { firstDay: "run.day == 1" }arrives with its inferredtype: "bool"and emptyparams).builtinDirectives— the language’s own::set,::assert,::retract,::accept, and::use, each{ name, syntax, meaning };directiveslists only the project’s and plugins’ ones.ids—{ scenes, quests, entries }: every scene id a guard reads asvisited('<id>'), every quest idquest.<id>.stateand::accepttake, and every lore entry identry.<id>.read/entry.<id>.everReadtake, across the whole--project(without--project, only the document’s own ids).- On
relations, each relation’stierand whether it isreserved; onstateSchema,owner: "engine"for a path the engine writes (a content::setof it isE-ENGINE-OWNED-WRITE). - On
occasions, each occasion’sdescriptionand itstarget—false,true(shape-only), or a{ prefix, entity }domain whose beat targets must be<prefix>.<member>. - Component signatures in the human outline — each imported component with its parameters and their types, as JSON
componentsalready carried them — so a model writes::use{component="…" …}against the signature, not just the name. textModifiers— the inline modifier surface a line’s text may use: the core:pause{s=…}leaf and:speed[…]{rate=…}span, each with itssyntaxandmeaning, plus how a modified line lowers. Any other:name[…]must be a member of the project’stextStyledomain (inprojectEnums/enums); never invent one.
The human outline prints the same additions — defs (N):, builtinDirectives (N):, the scenes/quests/entries id lists, run.day: int (owner: engine), knows/1(item) [run], and talk (select: first, target: npc.<npc>).
Feedback loop: lute check --json
Section titled “Feedback loop: lute check --json”After each generation, check and feed the serialized diagnostics back:
lute check scene.lute --json --project . \ --permission-profile generatedUse the same trusted --permission-profile on context, check, compile, and
compile --stream. The name is resolved from host/project configuration; never let
generated source choose it. A source-authored profile: authored cannot widen
the additional host ceiling. Denied source is a non-suppressible
E-PERMISSION-* error at its authored span.
A pipeline judges by exit code. To make a warning block the loop, promote it with the rustc/clippy-style flags (0.6.1 §5), also on whole-project check:
lute check scene.lute --json --deny W-LUTE-VERSION-STALE --deny-warnings--deny <CODE> (repeatable) treats exactly that code as an error for the verdict and exit code; --deny-warnings promotes every warning. A promoted diagnostic reports severity error and carries "denied": true in JSON, distinguishing it from a native error. An unknown code is a usage error (exit 2). Errors are never demotable.
For an append-only generated continuation, do not invent a model-specific JSON
grammar and do not treat parser framing as compilation. Give
lute compile --stream a host-owned, checked
scene template and stream ordinary final-section Lute body text on stdin:
lute compile --stream scene.lute --project . \ --permission-profile generatedIt flushes a full checked artifact snapshot for every accepted complete unit.
Project, capability, and permission inputs are frozen for the stream; the same
ceiling checks the template and every cumulative body candidate. A forbidden
unit produces a terminal error record before denied IR is emitted. The host
still owns trust, runtime authorization, effects, persistence, and output
idempotency; permissions are not a runtime sandbox.
Capability-resolution errors gate the exit code
Section titled “Capability-resolution errors gate the exit code”Some errors describe the project, not a span in a document: a plugin option that does not exist or fails its declared type, an identity: template naming an unknown token, a profile activating a plugin that is not installed, a malformed permission object, or a missing --permission-profile. Under check, they are part of the 0.48.0.check report: a plugin or profile resolve error for a document is that document’s entry in files[] with ok: false and the resolve diagnostics in its diagnostics, and an invalid manifest in whole-project mode gives files: [] with the manifest diagnostics in project_diagnostics. Other commands print them on the lute: channel — stderr, lute: <CODE>: <message> — because they have no document position to attach to. Permission configuration errors never fall back to unrestricted behavior.
Since 0.8.0 they set the exit code. Previously they printed and the build passed, so a typo’d plugin option shipped silently:
$ lute check episode01.lute --project .lute: E-PLUGIN-OPTION-TYPE: option `showcase.pack.resultScope` expects enum(scene|run), got "galaxy"$ echo $?1The ordinary capability-resolution behavior is verified on check
(one document or the whole project), compile (including the whole-project form), context, and trace; the
trusted --permission-profile option is intentionally narrower and exists on
check, compile (including the whole-project form and --stream), context, trace, expr, patch, play, test, and calendar. The one
deliberate exemption in ordinary project reconciliation is the
forced-single-root scan behind compile --project: a sibling document belonging
to a nested subproject legitimately mis-resolves under a forced root, and that
is not the target document’s fault.
The harness consequence is the important part. Read lute check --json through files[].diagnostics (files[0] for a single document) and, in whole-project mode, project_diagnostics; a resolve error is an entry with ok: false, not an empty body. For commands that still report such errors only on stderr, exit 1 with an empty stdout means a project-level error: a loop that parses stdout and treats “no diagnostics” as “clean” would report success on a broken project. Branch on the exit code first, parse JSON second, and surface stderr when the two disagree.
Plugin resolution codes on this channel include E-PLUGIN-OPTION-UNKNOWN, E-PLUGIN-OPTION-TYPE, E-PLUGIN-MISSING-ACTIVE, E-IDENTITY-TEMPLATE, and the plugin load/assembly family (E-PLUGIN-MANIFEST, E-PLUGIN-PARSE, E-PLUGIN-DUP-ID, …). Permission loader/profile lookup errors use the same project-resolution channel and explicitly identify their permission context.
Exit-code contract
Section titled “Exit-code contract”| Command | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
check (document or --project) | clean | error present | I/O | — |
compile (incl. whole-project) | success | failed gate | I/O / serialization | — |
compile --stream | successful EOF finalization | syntax / semantic / service rejection | usage / I/O / invalid UTF-8 | — |
trace | complete | refused | I/O | incomplete |
test | every test passed | a test failed | I/O | — |
loc import | bundle written | E-LOCALE-BUNDLE | I/O | — |
For trace, distinguish the two failure modes: 1 = refused (check errors or invalid mocks — fix the document, then retry) versus 3 = incomplete (an unknown guard halted the walk — supply more mock seeds, then retry). They demand different retry strategies.
compile --project DIR -o OUT (no document) is all-or-nothing: exit 1 means nothing was written, so a partially-updated output directory is never a state the harness has to reason about.
Permission denials
Section titled “Permission denials”These errors are native, non-suppressible failures. Do not retry with a wider source profile; either change the authored operation or have a trusted host change its policy:
| Code | Denied source |
|---|---|
E-PERMISSION-DIRECTIVE | directive |
E-PERMISSION-STATE | state write or initialized default |
E-PERMISSION-FACT | fact write or seed fact |
E-PERMISSION-BRIDGE | bridge service/operation |
E-PERMISSION-REWARD | declarative reward |
E-PERMISSION-QUEST | quest declaration |
They cover nested and unreachable source plus invoked component bodies. Context filtering reduces bad generations; the checker and compiler gate remain the authority.
Diagnostics a generator will meet
Section titled “Diagnostics a generator will meet”These are the diagnostics a model-driven loop actually trips, grouped by what to do about them.
E-DOMAIN-UNKNOWN — the one that will bite first. Since 0.9.0 the compiler declares the seven content-vocabulary slots and ships no members, so @mira{emotion="delighted"} fails until the project declares emotion. A model has no way to guess a project’s palette, and guessing is exactly the wrong move: the members are in the capability snapshot, so read them, never invent them. lute context --json splits them by route — projectEnums for the members an enums: block declares (inline or through uses:/extends:), enums for the ones an active plugin exports:
$ lute context docs/examples/property-tracks.lute --project docs/examples --json{ …, "enums": {}, "projectEnums": { "action": ["fadeInUp", …, "hide"], "anchor": ["left", "center", "right"], "emotion": ["neutral", "surprised", "delighted", "shy", "content", "angry", "sad"], … } }Both keys are folded into capabilitySnapshot, so the prompt-cache key already moves when a vocabulary does. If the union is empty for a slot the document needs, the fix is a declaration, not a different attribute value — see Content vocabulary for the three routes. Two follow-on codes catch a model writing the declaration itself: E-ENUM-MISSING-SEMANTICS (action declared without exits:, or anchor without default: — the compiler branches on those members and no longer infers them from a name prefix) and E-DOMAIN-DUP (one slot declared by both a plugin and a project route in the same root; pick one route per slot).
E-MONO-POV / E-MONO-NO-POV — mono belongs to the POV. A {mono} line (inner monologue) is valid only when its speaker is the effective POV — frontmatter pov:, falling back to the project’s defaults.pov — or is listed in the effective monoSpeakers (frontmatter or project defaults). Any other speaker is E-MONO-POV; when no POV resolves at all it is E-MONO-NO-POV. A model that wants another character’s thoughts must not reach for mono: write the line as dialogue, os, or vo, or have the author add the speaker to monoSpeakers. Never let the generator choose pov: or monoSpeakers itself — those are corpus-owner decisions, and lute fix will not guess them either. Component lines are checked at each ::use site against that caller’s POV and allow-list.
---kind: scenecharacter: miraseason: 1episode: 1pov: mira---
## The Counter
@mira{mono}: He is lying.@jun{mono}: She knows.$ lute check scene.lutescene.lute:12:6: error [E-MONO-POV] `@jun{mono}` is an interior monologue, but `jun` is not this document's point of view (`mira`) and not in `monoSpeakers:` (which lists none) — only the POV character and the speakers `monoSpeakers:` lists may speak `mono`; add `jun` to `monoSpeakers:`, or write the line as dialogue, `{os}` or `{vo}`failed: scene.lute (1 error(s), 0 warning(s))E-STATE-COLLECTION. Author state: is scalar: bool | int | double | string | enum. A list, record, or map declaration is rejected. Models reach for type: { list: string } constantly, and this was the single case where a document clean under 0.7.0 newly failed at 0.8.0. The fix is never to coerce the type — it is to model the collection as relations:, which is what the relational layer exists for. Feed the diagnostic back verbatim; it names the remedy.
---kind: scenecharacter: miraseason: 1episode: 1state: run.inventory: { type: { list: string }, default: [] }---
## The Counter
@mira: What have you got in there?$ lute check scene.lutescene.lute:7:3: error [E-STATE-COLLECTION] state path `run.inventory` cannot declare a collection type (`list`/`record`/`map`); author state is (int|double|bool|string|enum) — model collections as `relations:` or a plugin `state_shapes` slotfailed: scene.lute (1 error(s), 0 warning(s))W-CODE-AFTER-END — content after ::end in the same straight-line body (section body, <choice> body, <when> arm, objective body, <on> body) is unreachable. Reported once per body, at the first such node. A generator that emits a terminator and then keeps writing produces exactly this. Promote it with --deny W-CODE-AFTER-END if dead content should block the loop.
E-IDENTITY-TEMPLATE — a lute.project.yaml identity: template using a token other than {prefix}, {speaker}, {code}. Project-level, so it arrives on the lute: channel (see above), not in JSON.
W-L10N-MISSING / E-LOCALE-BUNDLE — the localization round trip. The warning is one per (lineId, locale) pair a compile --locale-bundle bundle fails to cover; the error is a malformed bundle at loc import (unparseable input, a lineId appearing twice within one locale, an empty locale tag). A harness that owns translation should run --deny W-L10N-MISSING so an incomplete bundle cannot reach an artifact.
The plugin family — these fire when the model authors a plugin, not a document:
| Code | Cause | Channel |
|---|---|---|
E-PLUGIN-OPTION-UNKNOWN | an activated option name the plugin manifest never declares | lute: |
E-PLUGIN-OPTION-TYPE | a merged option value that fails its declared type | lute: |
E-PLUGIN-RESERVED-STAMP-ATTR | an attr named at/duration/delay/wait/timeline/timing/provenance/source — reserved stamp keys, on both the stampAttrs and per-directive surfaces | lute: |
E-LOWER-RECORD-UNKNOWN | lower: { record } naming something that is not a core staging kind | lute: |
E-LOWER-RECORD-FIELD | lower: { fields } naming a field the record kind lacks, a fromAttr the directive never declares, or a literal that cannot fill the field | lute: |
E-FRONTMATTER-SCHEMA | a plugin-owned frontmatter key whose value violates the declared schema | JSON, with a span |
Only E-FRONTMATTER-SCHEMA has a document position and therefore a JSON diagnostic; the rest are project-level: check --json reports them as ok: false entries or in project_diagnostics, and other commands reach you on stderr with an empty JSON body. Every code on this page is a valid --deny argument — an unknown code is a usage error (exit 2), so the flag cannot silently protect nothing.
Fixits
Section titled “Fixits”Diagnostics carry fixits with a kind. kind: "migrate" is machine-applicable — apply it unprompted (this is what lute fix does). kind: "refactor" is an author choice — surface it as an LSP code action, never auto-apply.
The verification boundary
Section titled “The verification boundary”check is sound but deliberately incomplete over the relational layer, so know which regions are proof-covered and which are review-covered:
- Scalar gates are proof-covered — reachability and Guaranteed/Possible envelopes (§5) statically decide them.
- Relational fact gates are proof-covered at the extremes, in whole-project
checkonly — everyholds(…)/count(…)in a guard is decided impossible, guaranteed, or possible over the whole project (dsl 0.20.0). A gate that can never hold is its slot’s dead-code error (E-ARM-DEAD,E-ENTRY-UNREACHABLE,E-OBJECTIVE-UNSATISFIABLE,E-QUEST-UNREACHABLE); a guard that always holds isW-FACT-GUARANTEED. A possible gate — some routes have the fact, some do not — is silent and stays review-covered, and single-filecheckleaves every relational query undecided, so runlute check --project DIRbefore trusting a generated fact gate. (W-UNPROVEN-RELATIONAL, which marked every relational gate, was removed in 0.20.0; naming it in--denyis a usage error.) W-TRACE-MOCK-UNPRODUCIBLEwarns when alute tracemock seeds a fact over a relation no authored producer can ever assert — the walk proves nothing about reachable play.W-LUTE-VERSION-STALEcatches a model reproducing a staleluteVersionstamp copied from an old example.
Close with lute tag
Section titled “Close with lute tag”Run lute tag scene.lute at the pipeline end to back-fill a stable code into every untagged line. Never let the model hand-write code= values — line identity is the tool’s job, not the model’s.
Inspect → Plan → Patch → Check → Replay → Review (0.36.6)
Section titled “Inspect → Plan → Patch → Check → Replay → Review (0.36.6)”$ lute tag /tmp/drowned-crown$ lute context /tmp/drowned-crown/quests/library.lute --project /tmp/drowned-crown --node quest:libraryKey --max-items 20 --json > context.json$ lute patch --project /tmp/drowned-crown planned-patch.json --dry-run --json$ lute check --project /tmp/drowned-crown$ lute diff /tmp/drowned-crown-before /tmp/drowned-crown --jsonlute tag must run before editing when lines lack IDs: generated IDs are
positional and must not be invented or renumbered. Copy projectRevision and
file revisions from context into the patch base. Review diff.changes, then
apply the same patch without --dry-run; replay with the project’s own test,
play, or trace scripts. context --at <file>:<line>:<column> accepts
--max-items <N> and --script <FILE> (a script file, or a directory selecting every script under it; --all-scripts executes every play and test script of the project).
Lute is a deterministic CLI boundary, not a chat application or MCP server.
The patch request (0.47)
Section titled “The patch request (0.47)”lute patch <FILE> --project <DIR> [--dry-run] [--json] reads FILE, one JSON object. lute schema patch describes it under inputs (argument: "FILE", kind: "request", format: "json", then fields, ops, preserve, nodeKinds); that description is read from the same table as the request parser, so a harness can build requests from it. Unknown fields are refused (E-PATCH-EDIT).
| Field | Required | Type | Meaning |
|---|---|---|---|
base | yes | object | project (required): the project revision sha256:…, copied from lute context --json projectRevision. files (optional): a map from project-relative path to that file’s revision sha256:… (context files). A mismatch refuses the patch (E-PATCH-STALE). |
targets | no | node keys | The nodes the edits may touch. Every node or span an edit names must lie inside a target. |
edits | no | edits | Applied all together, or not at all. |
preserve | no | preserve items | Semantic claims the patch must keep (E-PATCH-PRESERVE when broken). |
Every edit is an object with op and the op’s fields, all required:
op | Fields | Effect |
|---|---|---|
replaceNode | node, text | Replace the node’s source span with text. |
insertBefore | node, text | Insert text at the start of the node’s span. |
insertAfter | node, text | Insert text at the end of the node’s span. |
replaceAttr | node, attr, value | Replace one attribute’s value inside the node (quotes are kept). |
removeNode | node | Delete the node’s source span. |
replaceText | file, rev, span, text | Replace exactly span of file, whose revision must be rev. span is a source span object shaped like affected[].reasons[].sourceSpan in lute context --json (byte_start, byte_end, line, column, utf16_range); it must lie inside a target. |
createFile | path, text | Create a new file (formatted whole). |
moveFile | from, to | Move a file; refused while another file still references the old path. |
Paths are project-relative and may not leave the project. Each preserve item is an object with exactly one key:
| Key | Value | Claim |
|---|---|---|
ids | node keys | The nodes keep their identity. |
lineIds | strings (not read) | No line id changes. |
voiceKeys | strings (not read) | No voice key changes. |
choiceEffects | node keys | The choices’ effects are unchanged. |
rewards | node keys | The rewards are unchanged. |
hostContracts | true | The host contract surface is unchanged. |
conditions | node keys | The nodes’ conditions are unchanged. |
reachability | node keys | The nodes’ reachability is unchanged. |
constraints | true | The project’s constraint verdicts are unchanged. |
A node key is kind:key. Take keys from lute context <file> --node <kind:key> --json: every entry of affected[] carries its canonical key (and reasons[].sourceSpan); the target itself is target.kind:target.key (target.key alone is the bare key). The kinds are project, document, scene, beat, shot, line, choice, quest, objective, reward, entry, occasion, relation, state, def, component, expanded, fact, clock, engine. A patch may target only nodes with an editable source span: project, occasion, relation, state, expanded, fact and engine are refused as targets (E-PATCH-TARGET; edit schema YAML with replaceText inside a targetable node instead). nodeKinds[].targetable in lute schema patch says the same.
{ "base": { "project": "sha256:dc7f8a0c…" }, "targets": ["line:fair.narrator_0020", "line:fair.pell_0010"], "edits": [ { "op": "replaceNode", "node": "line:fair.narrator_0020", "text": "@narrator{code=\"0020\"}: Someone runs past, shouting." }, { "op": "replaceNode", "node": "line:fair.pell_0010", "text": "@pell{code=\"0010\"}: Paper, a frame, and a pencil." } ], "preserve": [{ "lineIds": [] }, { "reachability": ["line:fair.pell_0010"] }]}All edits of a file are resolved against the original text, so several edits in one file are independent of each other’s growth. The patch formats the lines it touched and nothing else. Before it reports accepted, every .lute file it region-formatted must hold: a file that was lute fmt --check clean is still clean, and in any other file formatting the touched lines again changes nothing. Otherwise the patch is refused with E-PATCH-FORMAT naming the file, and nothing is written. Refusals (E-PATCH-STALE, E-PATCH-TARGET, E-PATCH-EDIT, E-PATCH-FORMAT, E-PATCH-CHECK, E-PATCH-PRESERVE) exit 2 with accepted: false; an accepted patch exits 1 when it changes semantics and 0 when it does not.