Skip to content

Plugin concepts

Lute has a fixed grammar and a total, non-Turing core. A plugin never changes either. It adds only vocabulary and capability surfaces that the grammar already refers to — directive names, attributes, enum domains, state shapes, providers, bridge signatures, definitions, frontmatter keys, asset kinds, and diagnostics. Everything a plugin declares is data, validated against the manifest schemas and resolved, together with a selected profile, into a single deterministic capability snapshot that the checker, LSP, and compiler all consume.

Plugins add vocabulary; the core owns grammar and behavior. Control flow, lowering algorithms, exhaustiveness, and auto-injection all live in the compiler — never in a manifest.

  • Directive vocabulary — new ::name staging or bridge directives, with typed attrs, semantics flags, and declared state effects (see Manifests).
  • State shapes — reusable typed records and structured path templates that open typed slots.
  • Providers — snapshot-first id registries (character ids, asset ids, minigame ids) resolved against a pinned catalog, never a live network call.
  • Bridge signatures — typed runtime calls whose declared result fields are the only values a directive may write back into story state (see Bridge).
  • Enums, defs, frontmatter keys, asset kinds, world events, host tokens, and stamp attributes — closed value sets, shared typed-CEL @refs, plugin-owned --- meta keys, structured asset-id templates, <on event> triggers, names the engine substitutes into {{…}} text, and stampAttrs. A stamp is either cross-cutting (no on:: admissible on every directive that has a stamp carrier and on every content line) or scoped (on: names the surfaces it applies to: content lines, specific directives, <branch>, <hub>, <choice>; since 0.50).
  • Diagnostics — per-attribute messages, deprecations, aliases, and fix-its.

Grammar productions, new bracket forms, new block kinds, new control flow, or any behavior. A capability is registrable as plugin data only if all hold: fixed syntax; validation local to its attrs plus catalog lookup; lowering is a finite attrs→records mapping; no new control flow; no cross-sibling or global reasoning beyond declared resource conflicts; no AST-shape change; no ordering-sensitive interpretation beyond the existing timeline/wait model. If any condition fails, the capability is code — a compiler-core change — and does not belong in a plugin.

The trap to name: authors most want things that feel like vocabulary but are compiler behavior — “have her leave naturally”, “keep the same pose unless mood changes”, “hit this SFX exactly as the line appears”. Those are served, if at all, by a named builtin lowering hook added to the core, gated by a golden test per directive. If a directive can’t get a clean golden test, it isn’t just data — it’s code.

The character/cast capability is the canonical example: it adds the cast: frontmatter key, a character provider, scene.cast.<id>.* state, the CH asset kind, and the ::seal / ::reveal / ::wear directives — all pure desugaring to ::set, no new semantics.

Declaring a surface in a manifest does not make the toolchain believe it. Every plugin-owned surface is validated before an author can reach it: option values at activation (E-PLUGIN-OPTION-UNKNOWN, E-PLUGIN-OPTION-TYPE), attribute names against the core stamp (E-PLUGIN-RESERVED-STAMP-ATTR), the surfaces a stamp’s on: names (E-PLUGIN-STAMP-SURFACE), and declarative lowering against the target record’s real field table (E-LOWER-RECORD-UNKNOWN, E-LOWER-RECORD-FIELD). All of them fail at load, assembly, or activation — before a broken manifest can mislead a writer.

Plugin-owned frontmatter keys are the same story. A frontmatter/*.yaml export declares { key, schema } pairs; the checker admits those keys past E-META-UNKNOWN-KEY and validates their values against the declared schema. A violation is E-FRONTMATTER-SCHEMA, reported at the key’s own span:

$ lute check scene.lute --project .
scene.lute:10:1: error [E-FRONTMATTER-SCHEMA] frontmatter key `bonusTrack` expects bool (declared by an active plugin), got "yesplease"
failed: scene.lute (1 error(s), 0 warning(s))

A value with no manifest-literal representation at all — null, a !tag, a non-string mapping key — is the same error, never a panic and never a silent pass. Core frontmatter keys (the universal set, the scene-only set, and the component-only set) keep their existing bespoke handling and are not routed through this check.