Skip to content

AI harness guide

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.

Seed the model from the project’s authoring surface, never from guesswork:

Terminal window
lute context scene.lute --json --project . \
--permission-profile generated

It 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 shorthand defs: { firstDay: "run.day == 1" } arrives with its inferred type: "bool" and empty params).
  • builtinDirectives — the language’s own ::set, ::assert, ::retract, ::accept, and ::use, each { name, syntax, meaning }; directives lists only the project’s and plugins’ ones.
  • ids — { scenes, quests, entries }: every scene id a guard reads as visited('<id>'), every quest id quest.<id>.state and ::accept take, and every lore entry id entry.<id>.read / entry.<id>.everRead take, across the whole --project (without --project, only the document’s own ids).
  • On relations, each relation’s tier and whether it is reserved; on stateSchema, owner: "engine" for a path the engine writes (a content ::set of it is E-ENGINE-OWNED-WRITE).
  • On occasions, each occasion’s description and its target — 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 components already 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 its syntax and meaning, plus how a modified line lowers. Any other :name[…] must be a member of the project’s textStyle domain (in projectEnums/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>).

After each generation, check and feed the serialized diagnostics back:

Terminal window
lute check scene.lute --json --project . \
--permission-profile generated

Use 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:

Terminal window
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:

Terminal window
lute compile --stream scene.lute --project . \
--permission-profile generated

It 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:

Terminal window
$ lute check episode01.lute --project .
lute: E-PLUGIN-OPTION-TYPE: option `showcase.pack.resultScope` expects enum(scene|run), got "galaxy"
$ echo $?
1

The 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.

Command0123
check (document or --project)cleanerror presentI/O—
compile (incl. whole-project)successfailed gateI/O / serialization—
compile --streamsuccessful EOF finalizationsyntax / semantic / service rejectionusage / I/O / invalid UTF-8—
tracecompleterefusedI/Oincomplete
testevery test passeda test failedI/O—
loc importbundle writtenE-LOCALE-BUNDLEI/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.

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:

CodeDenied source
E-PERMISSION-DIRECTIVEdirective
E-PERMISSION-STATEstate write or initialized default
E-PERMISSION-FACTfact write or seed fact
E-PERMISSION-BRIDGEbridge service/operation
E-PERMISSION-REWARDdeclarative reward
E-PERMISSION-QUESTquest declaration

They cover nested and unreachable source plus invoked component bodies. Context filtering reduces bad generations; the checker and compiler gate remain the authority.

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:

Terminal window
$ 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: scene
character: mira
season: 1
episode: 1
pov: mira
---
## The Counter
@mira{mono}: He is lying.
@jun{mono}: She knows.
Terminal window
$ lute check scene.lute
scene.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: scene
character: mira
season: 1
episode: 1
state:
run.inventory: { type: { list: string }, default: [] }
---
## The Counter
@mira: What have you got in there?
Terminal window
$ lute check scene.lute
scene.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` slot
failed: 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:

CodeCauseChannel
E-PLUGIN-OPTION-UNKNOWNan activated option name the plugin manifest never declareslute:
E-PLUGIN-OPTION-TYPEa merged option value that fails its declared typelute:
E-PLUGIN-RESERVED-STAMP-ATTRan attr named at/duration/delay/wait/timeline/timing/provenance/source — reserved stamp keys, on both the stampAttrs and per-directive surfaceslute:
E-LOWER-RECORD-UNKNOWNlower: { record } naming something that is not a core staging kindlute:
E-LOWER-RECORD-FIELDlower: { fields } naming a field the record kind lacks, a fromAttr the directive never declares, or a literal that cannot fill the fieldlute:
E-FRONTMATTER-SCHEMAa plugin-owned frontmatter key whose value violates the declared schemaJSON, 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.

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.

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 check only — every holds(…)/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 is W-FACT-GUARANTEED. A possible gate — some routes have the fact, some do not — is silent and stays review-covered, and single-file check leaves every relational query undecided, so run lute check --project DIR before trusting a generated fact gate. (W-UNPROVEN-RELATIONAL, which marked every relational gate, was removed in 0.20.0; naming it in --deny is a usage error.)
  • W-TRACE-MOCK-UNPRODUCIBLE warns when a lute trace mock seeds a fact over a relation no authored producer can ever assert — the walk proves nothing about reachable play.
  • W-LUTE-VERSION-STALE catches a model reproducing a stale luteVersion stamp copied from an old example.

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)”
Terminal window
$ 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 --json

lute 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.

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).

FieldRequiredTypeMeaning
baseyesobjectproject (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).
targetsnonode keysThe nodes the edits may touch. Every node or span an edit names must lie inside a target.
editsnoeditsApplied all together, or not at all.
preservenopreserve itemsSemantic claims the patch must keep (E-PATCH-PRESERVE when broken).

Every edit is an object with op and the op’s fields, all required:

opFieldsEffect
replaceNodenode, textReplace the node’s source span with text.
insertBeforenode, textInsert text at the start of the node’s span.
insertAfternode, textInsert text at the end of the node’s span.
replaceAttrnode, attr, valueReplace one attribute’s value inside the node (quotes are kept).
removeNodenodeDelete the node’s source span.
replaceTextfile, rev, span, textReplace 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.
createFilepath, textCreate a new file (formatted whole).
moveFilefrom, toMove 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:

KeyValueClaim
idsnode keysThe nodes keep their identity.
lineIdsstrings (not read)No line id changes.
voiceKeysstrings (not read)No voice key changes.
choiceEffectsnode keysThe choices’ effects are unchanged.
rewardsnode keysThe rewards are unchanged.
hostContractstrueThe host contract surface is unchanged.
conditionsnode keysThe nodes’ conditions are unchanged.
reachabilitynode keysThe nodes’ reachability is unchanged.
constraintstrueThe 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.