app.yaml Schema Reference
The authoritative reference for a kitsoki application definition. Generated from the Go types in internal/app/types.go — if something here disagrees with that file, the Go types win.
YAML parsing is strict: unknown fields cause a load error. All field names below are the literal yaml:"..." tag names.
Top level: AppDef
app: <AppMeta> # required
world: { <name>: <VarDef>, ... } # optional
intents: { <name>: <Intent>, ... } # optional (global intent library)
root: <string> | <State> # required — initial state name or inline state
states: { <name>: <State>, ... } # optional
off_path: <OffPathDef> # optional
hosts: [ <string>, ... ] # optional allow-list of host handler names
operations: { <name>: <OperationPolicy> } # optional session-level operation run policies
proposals: { <name>: <ProposalKind> } # optional
include: [ <glob>, ... ] # optional — merge other YAMLs relative to this file
imports: { <alias>: <ImportDef>, ... } # optional — aliased sub-story composition
exits: { <name>: <ExitDef>, ... } # optional — child-side exit contract (only meaningful when this app is imported)
exports: <ExportsBlock> # optional — what an imported child surfaces (intents:)
host_interfaces: { <name>: <HostInterfaceDef> } # optional — named capability surfaces
rootmay be a string (name of a state instates:) or an inlineStatefor a compound/parallel root.includeglobs are expanded relative to the directory of the main YAML. Duplicate state/intent names across includes error out; thehostslist is unioned.imports:,exits:,exports:,host_interfaces:together implement the story-imports surface — aliased composition with private worlds, projected world_in/world_out, named exits, intent re-export, and rebindable host_interfaces. Full reference and worked examples indocs/stories/imports.md.
operations: — session operation policies
Top-level operations: declares named policies for workflow-shaped runs such as bugfix triage, direct ship, PR refinement, or protected-branch sync. A transition starts a run by naming one of these policies with operation: <policy-id>.
operations:
bugfix_full:
title: "Fix bug"
mode: autonomous # interactive | autonomous | supervised
execution_mode: one-shot # optional: one-shot | staged
run_in_background: true
stop_on: [needs-human, host-error, gate-failed]
pause_on: [operator-ask]
terminal_artifact: done_artifact
phase_summary:
from: [triage_artifact, done_artifact]
This is distinct from state-level operation: overlays. Operation policies are session lifecycle metadata. When a transition starts a policy, the runtime emits operation lifecycle trace rows and writes the engine-owned world.operation_run handle with status: running; the handle is updated to status: completed when the run reaches a terminal state, even on a later turn. Imported child policies are lifted under the import alias (<alias>__<policy-id>), and child transition operation: refs are rewritten to match. State overlays control abandonable task-local world writes.
If a run enters a state-level operation: overlay with the same id as the active session policy, commit_operation and persist_draft also complete the session run at the settled target state. Leaving that overlay without committing or drafting marks the matching session run failed, so UI surfaces do not keep a stale running handle for an abandoned command-room operation.
AppMeta
app:
id: <string> # required — must be non-empty
version: <string> # optional, recommended (semver)
title: <string>
author: <string>
license: <string>
VarDef (world variable)
world:
counter: { type: int, default: 0 }
name: { type: string, default: "" }
status: { type: enum, values: [idle, running, done], default: idle }
wearing_cloak: { type: bool, default: true }
Fields:
| Field | Required | Notes |
|---|---|---|
type | yes | string / int / bool / enum / other |
default | no | Zero value for the type when omitted |
values | no | Enum values; only meaningful when type: enum |
State
states:
foyer:
type: atomic | compound | parallel # default: atomic
mode: <string> # e.g. "conversational" → Agent Room
description: <string> # shown in location indicator
view: |- # Go template over {{ world.* }}
You are in the foyer. Counter = {{ world.counter }}.
terminal: false
initial: <child-name> # required for compound; supports {{...}}
states: { ... nested States ... } # compound/parallel only
on: # intent → transition list
go:
- <Transition>
- <Transition>
on_enter: [ <Effect>, ... ] # fires on entry
intents: { ... local Intent overrides ... }
menu: [ <intent>, ... ] # override default menu ordering
prerequisites: [ <Prerequisite>, ... ] # room setup/readiness checks
relevant_world: [ <world-key>, ... ] # pinned in TUI location indicator
relevant_slots: [ <slot-name>, ... ]
timeout: <TimeoutDef> # optional auto-transition
agent_off_ramp: <OffRampDef> # optional no-match door (see below)
operation: <OperationDef> # optional abandonable task-local world overlay
Rules:
- State names are case-sensitive. Nested paths use dot notation internally (
bar.dark); in YAML you may write../../foyer(slash form) for relative references — the loader resolves them. on: { <intent>: [...] }tries transitions in order; the first whosewhen:evaluates true (or which hasdefault: true) wins.relevant_worldentries must exist in the top-levelworld:schema.- Intent names in
on:must be declared globally (intents:), locally (states.X.intents:), or be the wildcard"*".
prerequisites: — room setup/readiness checks
Use prerequisites: when a room is usable but should clearly warn that a deterministic setup item is missing. The check is room-centric: it can live on any state, including a buried room or an imported child story. Parent prerequisites are inherited by nested child states.
states:
landing:
prerequisites:
- id: project-onboarding
title: "Project onboarding"
severity: warning # info | warning | warn | error; default warning
when: "world.project_id != ''"
satisfied_when: "world.onboarding_status == 'applied'"
summary: "Project setup has not been applied."
help: "Run onboarding to write .kitsoki config and readiness checks."
action:
label: "onboard"
hint: "discover, review, and apply local setup"
intent: go_init # must be a declared intent
slots: { target: "{{ world.repo_root }}" }
when: and satisfied_when: are bare expr-lang boolean expressions. title, summary, help, action.label, action.hint, and action slot values may use {{ ... }} templates. At render time the engine computes prerequisites.all, prerequisites.met, prerequisites.unmet, and prerequisites.has_unmet for templates, and automatically prepends a warning block when any relevant prerequisite is unmet. If the current typed view already has a single-choice action widget, prerequisite actions are prepended there as recommended setup actions; they do not create new transitions.
operation: — abandonable task-local world
operation:
scope: gitops.sync_main # optional stable operation id; defaults to state path
An operation state opens an engine-owned world overlay. set:, increment:, and host bind: write to that overlay while guards, views, and host args keep reading ordinary world.* as committed base plus overlay. Exiting the operation state without commit_operation or persist_draft abandons the overlay and restores durable world.
Rules:
scopemust be a stable literal, not a template.commit_operation,persist_draft, anddiscard_operationare valid only inside an operation state.- Background jobs are rejected inside operation states until the story declares an explicit completion policy.
- Drafts created by
persist_draftare written under the reservedworld.operation_draftsmap.
view: — string form vs typed elements
A state's view: accepts two shapes. Loader normalises both into the same []ViewElement slice; the runtime renders them through the same pipeline.
# String form — single pongo2 template body.
view: |
You are in the foyer. Counter = {{ world.counter }}.
# Typed form — a sequence of typed elements.
view:
- prose: "You are in the foyer."
- kv:
pairs:
Counter: "{{ world.counter }}"
The typed form is preferred (story-style guidance: docs/stories/story-style.md) because each element renders in isolation — a single broken {{ ... }} cannot zero the whole view.
Expressions vs. templates.
when:guards take a bare expr-lang expression;view:,say:, and every string-valued effect/arg (set:,with:/inputs:/args:) take a{{ … }}template. A bare expression in a template position is passed verbatim as a literal string, not evaluated — the most common authoring footgun. A sole{{ expr }}preserves the typed value. The authoritative rule (and which positions are which) is state-machine.md §7.1.
A typed view may also reference a shared chrome:
view:
extends: "base" # views/base.pongo
blocks:
body: [ <ViewElement>, ... ]
choices: [ <ViewElement>, ... ]
View element kinds
| Kind | Body shape | Use for |
|---|---|---|
prose: | <string> (pongo2 body) | One paragraph of narration. Reflows. |
heading: | <string> | Section break. No trailing colon. |
list: | { items: [<ListItem>, ...] } | Bulleted actions / enumerations. Optional hint: column. |
kv: | { pairs: { <key>: <value>, ... } } | Short key/value status. Key column auto-aligns. |
code: | <string> | Layout-preserved content (tables, ASCII art, includes). |
template: | <string> (pongo2 body) | Raw pongo escape hatch. |
choice: | { mode, prompt, items / fields, ... } (see below) | Interactive picker / multi-select / mad-lib form. |
media: | { handle, caption?, kind? } (see below) | Display a recorded media artifact; TUI pointer / web inline. |
Every element accepts an optional element-level when: guard (expr-lang) evaluated against world.* / slots.*. Items in a list: accept their own when:. Examples: see docs/stories/story-style.md §2.
media: — media artifact display
A media: element references a media artifact recorded by a prior step. It is display-only (no input, no dispatch). The TUI renders a labeled pointer block showing the artifact kind icon, handle, and resolved path. The web UI renders it inline (<video>, <img>, PDF/HTML embed) via the GET /artifact/{id} route.
view:
- media:
handle: "{{ world.walkthrough_handle }}" # required — artifact id from host.artifacts_dir
caption: "Walkthrough recording" # optional one-line label
kind: video # optional hint: video/image/pdf/html/slideshow
| Field | Required | Notes |
|---|---|---|
handle | yes | Artifact id/handle bound into world by host.artifacts_dir (bind: { my_handle: handle }). May be a pongo2 template. |
caption | no | One-line label shown beneath the artifact. Defaults to handle when absent. |
kind | no | Selects the pointer icon in the TUI and the embed strategy in the web UI. Values: video, image, pdf, html, slideshow. Unknown/absent kinds use a generic attachment icon. |
The optional element-level when: guard (expr-lang) suppresses the element when false, e.g. when: "world.walkthrough_handle != ''".
How to emit an artifact and reference it:
Run a render step (e.g.
host.slidey.renderorhost.runcalling ffmpeg).Register the output file with
host.artifacts_dirusing thesrc_path/kindfields — this records anartifact.emittedtrace event and returns a stablehandle. Bind the handle into world:- invoke: host.artifacts_dir with: src_path: "{{ world.output_path }}" kind: video label: "Session walkthrough" thread: walkthrough bind: { walkthrough_handle: handle }Reference the handle in a
media:element (see example above).
For the artifact.emitted trace event shape see docs/tracing/trace-format.md §Artifact event kind. For the full host.artifacts_dir media-emit arg reference see docs/architecture/hosts.md §host.artifacts_dir.
choice: — interactive picker / multi / form
A choice: element is an interactive transcript widget that dispatches an intent + slots through the same SubmitDirect path the right-pane action menu and kitsoki turn --intent ... --slots ... use. Three modes share one envelope:
| Mode | Interaction | Dispatches |
|---|---|---|
single (default) | ▸ cursor; Enter picks ONE item. | One intent (per item) with item's slots: ∪ {param: <buf>}. |
multi | [ ] / [x] checkboxes; Space toggles. | One intent with one list-valued slot of selected value:s. |
form | Mad-lib template: with {field} blanks; Tab cycles. | One intent with one slot per editable field. |
A choice element is always interactive in the TUI and renders as a static numbered-list / template-echo fallback under kitsoki render and the Jira / Bitbucket transports — flow fixtures (intent: blocks) bypass the widget and submit the intent directly, so the same app.yaml runs across every transport.
Worked, runnable reference: testdata/apps/choice_smoke/app.yaml covers every feature combo across 23 demo spokes. See its README for the per-spoke walkthrough; specific line refs appear in the field tables below.
single mode
view:
- choice:
prompt: "Choose a profession" # optional heading line
when: "world.party_size > 0" # optional element-level guard
items:
- label: "Banker"
hint: "$1,600 starting cash — easy" # optional right-column
intent: pick_profession # required
slots: { profession: banker } # optional pre-bound slots
- label: "Generate names from a theme"
intent: generate_names
param: # optional one-shot slot capture
slot: theme
type: string # string | int | enum
placeholder: "e.g. norse mythology"
required: true
# values: [...] # required when type: enum
- label: "✗ start_journey — {{ blocked_reason('start_journey') }}"
intent: start_journey
when: "!available('start_journey')" # per-item guard (renderer-side)
| Field | Required | Notes |
|---|---|---|
mode | no (default single) | Discriminator. Omit for single mode. |
prompt | no | Heading line shown above the picker. |
when | no | Element-level guard (expr-lang). |
items | yes | ≥1 item. |
items[].label | yes | Display text. |
items[].intent | yes | Intent dispatched on Enter. Must be in on: / intents:. |
items[].hint | no | Right-aligned auxiliary text (cost / consequence). |
items[].slots | no | Pre-bound slot map. Keys must be declared slots of intent. |
items[].param | no | One-shot free-form slot capture. One slot only. |
items[].param.slot | yes (if param) | Slot name; must be a declared slot of intent. |
items[].param.type | yes (if param) | string / int / enum. |
items[].param.values | yes (if enum) | Cycle list for Space. |
items[].param.placeholder | no | Shown when buffer is empty. |
items[].param.required | no | Refuse commit on empty buffer. |
items[].when | no | Renderer-side guard — hide the row when false. NOT behavioural: also gate the on: arc if you need to block dispatch. |
Single-mode keymap:
| Key | Behavior |
|---|---|
↑ / ↓ | Move cursor. |
Enter | Pick current item → dispatch intent. On an item with param:, enter param mode if the buffer is empty; commit if non-empty. |
Tab | Off-ramp to chat — close the widget and restore the prompt textarea. /input recalls any draft you typed before the widget opened. |
Esc | Cancel; focus prompt textarea. |
Backspace | In param mode: edit buffer. |
Space | In param mode with type: enum: cycle to next value. Bool field: toggle. |
| Printable | Ignored at the picker level. In param mode (text/int): edit buffer. On enum/bool fields: ignored. |
Reference spokes: single_basic (testdata/apps/choice_smoke/app.yaml:323), single_per_item_when (line 394), single_templated_slots (line 485), single_param_string (line 529), single_param_enum (line 605).
multi mode
view:
- choice:
mode: multi
prompt: "Select symptoms"
intent: report_symptoms # required
slot: symptoms # required — list-valued slot name
min: 1 # optional (default 0)
max: 5 # optional (default len(visible items))
items:
- { value: fever, label: "Fever", hint: ">100.4°F" }
- { value: cough, label: "Cough" }
- { value: rash, label: "Rash", when: "world.day > 3" }
| Field | Required | Notes |
|---|---|---|
mode | yes | multi. |
intent | yes | Dispatched once on commit. |
slot | yes | Receives the list of selected value:s. |
min / max | no | Selection bounds. Widget enforces; flow fixtures bypass. |
items[].value | yes | Literal string. Templated values are rejected at load. |
items[].label | no | Defaults to value. |
items[].hint | no | Right-column hint. |
items[].when | no | Renderer-side guard. |
Multi-mode keymap:
| Key | Behavior |
|---|---|
↑ / ↓ | Move cursor. |
Space | Toggle current item. |
Enter | Submit the whole selection. |
Tab | Off-ramp to chat — close the widget and restore the prompt textarea. |
Esc | Cancel; focus prompt textarea. |
| Printable | Ignored — the widget owns focus. |
Reference spokes: multi_basic (testdata/apps/choice_smoke/app.yaml:647), multi_min_zero (line 685), multi_no_max (line 722), multi_per_item_when (line 762).
form mode
view:
- choice:
mode: form
prompt: "Compose your purchase"
intent: propose_purchase
template: "Buy {items} for ${total_cost}, leaving ${remaining}."
fields:
items:
type: string
placeholder: "oxen=4, food=1500"
required: true
total_cost:
type: int
min: 1
max: "{{ world.money }}" # templated bounds are OK
default: 0
remaining:
type: int
expr: "world.money - world.total_cost" # required when readonly
readonly: true # not editable; see note
| Field | Required | Notes |
|---|---|---|
mode | yes | form. |
intent | yes | Dispatched once on Enter. |
template | yes | Mad-lib body. Every {name} placeholder must have a matching fields: entry. |
fields | yes | ≥1 field. Author order is preserved. |
fields.<n>.type | yes | string / int / float / bool / enum. |
fields.<n>.values | yes (if enum) | Cycle list for Space. |
fields.<n>.hint | no | One-line description. |
fields.<n>.placeholder | no | Shown when buffer is empty. |
fields.<n>.default | no | Initial buffer. May be templated. |
fields.<n>.min / max | no | Numeric bounds. May be templated. |
fields.<n>.required | no | Widget refuses commit on empty buffer. |
fields.<n>.readonly | no | Display-only. Requires expr:. Submitted as a slot when the intent declares it. |
fields.<n>.expr | yes (if readonly) | expr-lang expression over world.* / slots.* (NOT live over sibling form fields). |
fields.<n>.when | no | Renderer-side guard — hidden fields are not submitted. |
Form-mode keymap:
| Key | Behavior |
|---|---|
Tab / S-Tab | Next / previous editable field. |
Space | Enum field: cycle next value. Bool field: toggle. Text field: insert space. |
Printable | Edit current text field. |
Backspace | Edit current text field. |
Enter | Submit the form. |
Esc | Cancel; focus prompt textarea. |
Reference spokes: form_basic (testdata/apps/choice_smoke/app.yaml:850), form_bool (line 901), form_enum (line 970), form_required (line 1037), form_per_field_when (line 1107), form_readonly_expr (line 1146).
Static rendering fallback
Non-TUI transports (kitsoki render, Jira, Bitbucket) and flow-test output get the same view but with the widget rendered statically — a numbered list with intent name in parentheses for single / multi, and a plain template echo with placeholders for form. Flow fixtures dispatch intent: { name, slots } directly and never traverse the widget; the same app.yaml works across every surface.
Validation
Layered at load time:
- JSON Schema — structural shape and per-mode required fields. Source:
docs/embedded/schemas/choice.schema.json(mirror ofinternal/app/schemas/choice.schema.json). Consumable by yaml-language-server for inline IDE validation. - expr-lang compile-pass — every
when:(element / per-item / per-field) is compiled viaexpr.CompileBool. Everyexpr:on a readonly form field is compiled viaexpr.Compile. Undefined identifiers, AST violations, and typos surface as load errors. - pongo2 compile-pass — templated leaves (slot values, placeholders, defaults, min/max, the form template body) are compile-checked for syntax. Undefined-identifier errors are NOT surfaced (they're runtime concerns).
- Loader cross-references —
items[].intentresolves against the surrounding state/globalintents:;slots:keys,param.slot,slot(multi), andfields.<name>(form, non-readonly) must be declared slots of the chosen intent; every{name}in a formtemplate:must have a matchingfields:entry; multi-modeitems[].valuemust be a literal (not templated).
Errors surface with the same state → view → element index path prefix the loader uses for other view-element errors.
Limitations
- One
choiceper view. Loader-enforced. - No
choiceinsideblocks:(extends-form views). Typed metadata is lost throughAppRenderer.RenderExtended. Workaround: put theextends:at the wrapping view and thechoice:as a sibling element in the sameview:, not inside a block. - Mode is fixed per element. No blending. Use two states.
param:captures exactly one slot. Useformmode for ≥2.formfields are flat scalars only. Lists and maps needmultimode or post-dispatch clarify.- Multi dispatches a single intent. Not a shorthand for "fire one intent per selected item."
form.<other_field>is NOT live inexpr:. Readonly fields seeworld.*/slots.*at widget-open time only — sibling buffer changes don't re-trigger the eval. Seedocs/stories/choice-widget.md§3.3 for the worked example and precompute-into-world workaround.when:on items / fields is renderer-side, not behavioural. A guard-false item is hidden but the underlyingon:arc still fires if the intent is dispatched some other way (flow test, menu). For true gating, also guard the transitionwhen:.
Author-facing cookbook: docs/stories/choice-widget.md. Story-style guidance: docs/stories/story-style.md §3.6.
Transition
on:
go:
- target: bar # required — dest state path, "." = self
operation: bugfix_full # optional — top-level/folded operation policy id
when: "slots.direction == 'south'"
effects: [ <Effect>, ... ]
guard_hint: "Head outside first." # shown when guard fails
view: "You slip past the usher..." # overrides target's view for this turn
emit: [ lights_dimmed ] # events broadcast to parallel regions
push_history: true # default true; false for stackless transitions
- default: true # catch-all
target: foyer
effects:
- say: "You can't go that way."
targetmust resolve to an existing state unless it contains a template expression ({{ world.dynamic_room }}), in which case validation is deferred to runtime.when:is an expr-lang expression; variables:world.*,slots.*,$host_error(when inside anon_errortarget).default: trueis the catch-all. Put it last in the list.
Effect
effects:
- set: { counter: "{{ world.counter + 1 }}", last_dir: "{{ slots.direction }}" }
- increment: { counter: 1 }
- say: "You head {{ slots.direction }}."
- invoke: host.run
with: { cmd: "git status", cwd: "{{ world.workspace_root }}" }
bind: { last_output: stdout, last_code: exit_code }
on_error: error_room
- commit_operation:
world: { last_output: "{{ world.last_output }}" }
- persist_draft:
id: "task:{{ world.task_id }}"
title: "Task {{ world.task_id }}"
world: [last_output, task_id]
- discard_operation: { reason: cancelled }
- emit: lights_dimmed
Fields (any subset):
| Field | Purpose |
|---|---|
set | Assign world variables (value may be expr, literal, or templated) |
increment | Integer delta on numeric world variables |
say | Append narrative line (Go template over world/slots) |
invoke | Call a host handler; must appear in top-level hosts: list |
id | Author-assigned call-site address; threaded into args as call so flow stubs (by_call:) and cassettes (match: { call: … }) can tell two same-handler invokes apart |
with | Arguments for invoke |
bind | { world_key: result_key } — copy host result into world |
on_error | Transition target if host invoke errors; sets $host_error |
once | true → skip the invoke on re-entry when every bind: target is already set (non-empty); makes on_enter host calls idempotent across /reload, self-transitions, and on_error:. Requires a non-empty bind:. Clear the bind target to re-run. |
emit | Broadcast named event to parallel regions |
background | true → dispatch invoke as a background job (see §Background jobs) |
on_complete | Effect list fired when the background job terminates (see §Background jobs) |
commit_operation | Operation-only: copy selected overlay values into durable world and close the operation |
persist_draft | Operation-only: save selected overlay values under world.operation_drafts[id] and close the operation |
discard_operation | Operation-only: explicitly abandon the active overlay |
Conventional order within a single effect: set → increment → say → invoke → operation close-out → emit.
Operation close-out shapes:
commit_operation:
world:
sync_result: "{{ world.sync_result }}" # selected durable patch
clear: true # optional, default true
persist_draft:
id: "sync_main:{{ world.sync_result.branch }}"
title: "sync_main {{ world.sync_remote }}/{{ world.sync_remote_branch }}" # optional
world: [sync_result, sync_remote, sync_remote_branch] # optional; default whole overlay
discard_operation:
reason: back
Background jobs
Detailed reference: docs/stories/background-jobs/
Background jobs let a state machine fire a long-running shell command or LLM call without blocking the current turn. The job runs in a goroutine; when it finishes a synthetic turn fires on_complete: effects in the originating state's context and posts an inbox notification.
Lifecycle
sequenceDiagram
participant Turn as submit turn
participant Job as background job
participant Orchestrator as orchestrator listener
participant World as world
participant Inbox as inbox
Turn->>World: bind last_job_id
Turn->>Job: start async invoke
Job-->>Orchestrator: exits done / failed / cancelled
Orchestrator->>World: set last_job_id
Orchestrator->>World: set last_job_status
Orchestrator->>World: set last_job_result
Orchestrator->>Turn: fire on_complete effects
Orchestrator->>Inbox: post notification
Effect fields for background dispatch
| Field | Purpose |
|---|---|
background | true → dispatch the invoke: handler as a background job. Requires invoke: (validated at load time). |
bind | { world_key: job_id } — captures the job ID synchronously into world. Omit to use the default key last_job_id. |
on_complete | Ordered Effect list fired once the job terminates. May not itself contain background: true (validated at load time). |
World variables injected on_complete
These are set by the orchestrator's synthetic turn and available inside on_complete: effects (and the state's view template on the next render):
| Variable | Type | Value |
|---|---|---|
last_job_id | string | Job ULID (same value bound at dispatch) |
last_job_status | string | "done" / "failed" / "cancelled" |
last_job_result | map (any) | Result.Data from the handler — e.g. {stdout, exit_code, ok} for host.run |
last_job_result is not declared in the app's world: schema; it is injected dynamically and accessible only inside on_complete: templates. Do not reference it in a regular state view: — it will be empty outside of the synthetic turn.
Same-turn race
A background: true effect followed by another effect in the same on_enter: or effects: block executes in the same turn and sees world.last_job_id (bound synchronously when the job is submitted), but does not see world.last_job_result — the result is only available in the on_complete: chain, which runs in a later synthetic turn once the job terminates.
Minimal runnable example
# See also: testdata/apps/background_jobs/app.yaml
on_enter:
- invoke: host.run
with: { cmd: "sleep 1 && echo done" }
background: true
bind: { last_job_id: job_id }
on_complete:
- set: { result: "{{ world.last_job_result.stdout }}" }
- say: "Job complete. Output: {{ world.result }}"
A full three-state example (lobby → running → done) lives at testdata/apps/background_jobs/app.yaml.
Mid-flight clarifications
A background handler can pause mid-execution to ask the user a question before resuming. The machinery is transparent to the room YAML author once the clarifying sub-state is wired.
Handler side (Go)
// Inside a host.Handler running as a background job:
rawJSON, err := host.RequestClarification(ctx, jobs.ClarificationSchema{
Prompt: "Which branch should I use?",
Fields: map[string]string{"branch": "string"},
})
// rawJSON is the JSON-encoded answer submitted by the user, e.g. `"main"`.
host.RequestClarification is a blocking call that:
- Writes the schema to the DB and flips the job row to
awaiting_input. - Signals the scheduler to fan out a
JobAwaitingInputevent. - Polls every 200 ms until the answer is stored, then returns the raw JSON.
Orchestrator side (automatic)
When the orchestrator's session listener receives JobAwaitingInput it calls handleJobAwaitingInput, which fetches the schema and posts an action_required notification. The notification's TeleportJobID and TeleportState carry the job ID and origin state so the TUI can surface a banner and teleport the user back.
YAML side (app author)
Add a *_clarifying sub-state to the originating room with an answer_clarification intent:
hosts:
- host.jobs.answer_clarification # built-in; no extra registration needed
intents:
answer_clarification:
title: "Answer clarification"
slots:
job_id: { type: string, required: true }
answer: { type: string, required: true }
states:
lobby_running:
view: "Running…"
on_enter:
- invoke: host.my_long_task
background: true
bind: { last_job_id: job_id }
on_complete:
- say: "Done!"
lobby_clarifying:
view: |
Job {{ world.last_job_id }} needs your input.
{{ world.clarification_prompt }}
on:
answer_clarification:
- target: lobby_running
effects:
- invoke: host.jobs.answer_clarification
with:
job_id: "{{ slots.job_id }}"
answer: "{{ slots.answer }}"
Round-trip summary
- Handler calls
host.RequestClarification(ctx, schema)and blocks. - User sees an
action_requiredbanner in the inbox. - Selecting it teleports to
lobby_clarifying(theTeleportState). - User submits
answer_clarificationwith{job_id, answer}. host.jobs.answer_clarificationcallsAnswerClarification(jobID, value).- The handler's poll loop returns the answer; the job resumes.
- On completion,
on_completeeffects fire normally.
Important: the *_clarifying state must be reachable from the origin state or be a distinct state with the answer_clarification intent. The TeleportState in the notification is set to the job's OriginState — the state where the job was submitted — so make sure that state (or a teleport alias) handles answer_clarification.
Flow tests with background jobs
Flow fixtures can test the full background-job lifecycle deterministically using host_handlers:, advance_clock:, and expect_inbox:.
How it works
When a fixture declares host_handlers: (or any turn uses advance_clock: or expect_inbox:), the flow runner automatically switches to the orchestrator-backed path:
- An in-memory SQLite store and fake clock are created.
- The scheduler and session listener are wired together.
- Each
host_handlers:entry becomes a stub closure (no real I/O). advance_clock: "2s"moves the fake clock forward and then drains the scheduler + listener, soon_completeeffects are applied before assertions run.
The legacy path (no host_handlers, no advance_clock) is unchanged.
Fixture fields
# Declare stub handlers. Keys are the handler name declared in `hosts:`.
host_handlers:
host.run:
data: { stdout: "hello", exit: 0 } # host.Result.Data returned on success
delay: "1s" # optional: block for this virtual duration
error: "something_went_wrong" # optional: domain-level error (Result.Error)
infra_error: "connection refused" # optional: infrastructure error (Go error)
On a turn:
turns:
- intent: { name: start }
advance_clock: "2s" # move fake time forward; waits for scheduler+listener
expect_state: running
expect_world: # assertions run AFTER advance_clock drains
result: "hello"
expect_inbox:
unread: 2 # total unread count
needs_attention: 0 # action_required severity count
severities: ["info", "success"] # sorted severity list for all unread items
host_handlers fields
| Field | Purpose |
|---|---|
data | Map returned in host.Result.Data on a successful invocation. |
error | Non-empty → host.Result.Error set (domain error; job terminates as failed). |
infra_error | Non-empty → (Result{}, error) returned (infrastructure failure). |
delay | Duration string (e.g. "1s") — the stub blocks for this virtual time. |
delay and infra_error/error are independent: you can combine delay with an error to simulate a slow then failing handler.
advance_clock + notification counts
The stub's delay: field simulates a long-running handler. To make it complete in the test, set advance_clock: to a duration ≥ the handler delay on the same turn (or a subsequent turn). After advancing the clock:
- The scheduler drains (all job goroutines reach terminal state).
- The session listener drains (
on_completeeffects are applied). - The inbox receives a notification with severity
info(job submitted) andsuccess/error/warn(job terminal state). Both are counted inexpect_inbox.unread.
Minimal example
test_kind: flow
app: ../app.yaml
initial_state: lobby
initial_world:
result: ""
last_job_id: ""
host_handlers:
host.run:
data: { stdout: "hello" }
delay: "1s"
turns:
- intent: { name: start }
advance_clock: "2s"
expect_world:
result: "hello"
expect_inbox:
unread: 2
severities: ["info", "success"]
expect_no_errors: true
A full working example lives at testdata/apps/background_jobs/flows/happy_path.yaml.
Intent
intents:
go:
title: "Go"
description: "Move in a compass direction."
examples: ["go south", "head north", "n"]
priority: 100 # higher → more prominent in menu
hidden: false # if true, usable but not listed
synonyms: # optional — see note below
- "head out" # bare-string synonym
- "set off in {direction}" # template synonym
slots:
direction: <Slot>
synonyms is a list of alternate phrasings declared by the author. Each entry is either:
- A bare phrase — matched bag-style by stem-set containment. The matcher resolves it at confidence 0.90 when the synonym's stems are a subset of the input's stems.
- A
{slot_name}template — a positional phrase where each{slot_name}captures a contiguous run of input tokens to be parsed by the slot's typed parser. The slot must exist in the intent'sslots:map; the compiler refuses unknown references and rejects adjacent captures ({a}{b}— authors must put a literal token between captures). Templates resolve at confidence 0.80 when every capture parses, 0.65 when a capture is named but unparseable.
Both shapes feed the same *semroute.Matcher and emit a Verdict the orchestrator routes via TrySemantic. See ../../docs/architecture/semantic-routing.md for the full reference (slot parser types, calibration workflow, cache behaviour).
Slot
slots:
direction:
type: enum # required
required: true
default: ""
values: [north, south, east, west]
description: "Which direction to move."
examples: ["south", "n"]
format_hint: "One of n/s/e/w."
prompt: "Which direction?"
validator: "value != 'down'" # expr — value is the slot value
synonyms: # optional, type: enum only
north: ["north", "n", "up"]
south: ["south", "s", "down"]
east: ["east", "e"]
west: ["west", "w"]
synonyms on a slot is a map from each enum value to alternate phrasings. Each key must appear in values. The enum parser tries three tiers in order — direct stem match, synonym word-bag containment, then Damerau-Levenshtein-1 fuzzy — so "banker", "rich guy", "money man", and the typo "bankr" all resolve to banker. See ../../docs/architecture/semantic-routing.md for the slot-parser tier order.
OffPathDef
Global escape hatch from any state back to a named one.
off_path:
trigger: "help" # intent name / pattern that activates off-path
banner: "(help mode)"
return: main # state to re-enter after exiting off-path
OffRampDef (agent_off_ramp:)
Per-state no-match door. When a free-text utterance routes to no declared intent in a room that sets agent_off_ramp, the orchestrator hands the original text to an agent converse turn (the same voice off_path: reaches) instead of bouncing it back as a rejection — without advancing the state machine or mutating world. The menu still renders; the resting state is unchanged. The TurnResult mode is offpath. Contrast with off_path:: that is an automatic room-scoped door triggered by a typed string that does change state; the off-ramp fires on a no-match and never changes state.
Opt-in per room; two author forms (the struct mirrors the subset of OffPathDef that styles the voice — trigger/return are off-path-only and have no analogue here):
agent_off_ramp: true # bare scalar — use the off-path voice
agent_off_ramp: { agent: discovery-guide, banner: "(thinking)" } # struct form
agent_off_ramp: { agent: discovery-guide, capture_free_text: true } # preserve rejected text
| Field | Required | Notes |
|---|---|---|
agent | no | Names an entry in top-level agents: whose system prompt + model style the converse call. Validated at load against the agents: map (mirrors off_path.agent). |
persona | no | Inline system-prompt-style instruction for the off-ramp voice. When both persona and agent are set, persona wins. |
banner | no | One-line label shown when the off-ramp engages. |
capture_free_text | no | Preserves the rejected free text on the off-ramp input. |
Load-time invariants (a violating agent_off_ramp fails the load):
- Rejected on
terminal: true— a terminal state has no resting menu to return to. - Rejected on
mode: conversational— an Agent Room already routes all free text; an off-ramp would be redundant. - A named
agent:must exist in the top-levelagents:map. - The struct is strict — off-path-only keys (e.g.
trigger:,return:) are rejected.
A nil/absent agent_off_ramp (or agent_off_ramp: false) means no off-ramp — the default — and rejections behave exactly as before. See docs/stories/architecture.md §9 and docs/stories/state-machine.md §11 for the full design, and stories/off-ramp-demo/ for a runnable example.
Rendering note (web): for the free-text floor to render on a menu room, the off-ramp room's
view:must be a flat typed-element list, not theextends:+blocks:chrome form — the inheritance pipeline strips the typed-view choice metadata the webInputBarneeds to show the always-present text box. Seestories/off-ramp-demo/rooms/desk.yaml.
TimeoutDef
states:
waiting:
timeout:
after: "30s" # Go duration string: 500ms, 5m, 2h
target: timeout_room
ProposalKind (advanced — proposal pattern)
A three-step draft → review → execute pattern.
proposals:
git_commit:
schema: { message: string, files: string } # typed draft fields
draft: { prompt: "prompts/commit_draft.tmpl" }
refine: { prompt: "prompts/commit_refine.tmpl" }
execute:
invoke: host.run
with: { cmd: "git commit -m {{ draft.message }}" }
repeatable: false
on_success: stay # "stay" | "back" | <state-name>
background: false
on_complete: [ <Effect>, ... ]
views:
drafting: "Proposed commit:\n{{ draft.message }}"
reviewing: "Accept?"
policy:
auto_accept_if: "draft.files.length < 3"
require_confirm: false
ProposalStep:
| Field | Purpose |
|---|---|
prompt | Path to prompt template file |
ProposalExecute:
| Field | Purpose |
|---|---|
invoke | Host handler name |
with | Templated args (see Effect.with) |
repeatable | Allow rerun/modify_and_rerun after success |
on_success | stay / back / named state |
background | Run as background job (internal/jobs) |
on_complete | Effects fired when background job finishes |
ProposalPolicy:
| Field | Purpose |
|---|---|
auto_accept_if | Expr over {$proposal, $world, $slots}; skips review when true |
require_confirm | Always require explicit user confirmation before execute |
ImportDef (story-imports composition)
imports:
<alias>:
source: <string> # required: ./path | @kitsoki/<name> | /abs
version: <string> # optional metadata (v1: not enforced)
entry: <child-state> # required: where the child starts when invoked
world_in: { <child-key>: <expr> } # parent → child projection (eval'd in parent scope)
exits: # child-exit → parent-state mapping
<child-exit>:
to: <parent-state>
set: { <parent-key>: <child-expr> } # per-exit world_out projection
hosts: inherit | declared # default: inherit
host_bindings: { <iface>: <handler> } # rebind a child or grandchild iface
intents:
export: [<parent-intent>, ...] # parent → child intent re-export
import: [<child-intent>, ...] # child → parent intent re-export
overrides:
states: { <child-state>: <State> } # whole-state replacement
intents: { <child-intent>: <Intent> } # whole-intent replacement
prompts: { <child-rel-path>: <parent-rel-path> } # prompt-file substitution
ExitDef
exits:
<name>:
description: <string> # optional
requires: [<world-key>, ...] # optional, statically checked at load
Only meaningful when this app is imported by another. Standalone load synthesises __exit__<name> terminal states for any @exit:<name> target the app uses.
HostInterfaceDef
host_interfaces:
<name>:
description: <string>
operations:
<op>:
input: <shape> # metadata; not validated against handler v1
output: <shape>
default: <handler> # default binding when no importer overrides
Invoked from state effects via invoke: iface.<name>.<op>. At top-level Load, every iface.<name>.<op> is rewritten to <binding>.<op>; the runtime host registry's prefix-fallback maps <binding>.<op> → <binding> when no per-op handler is registered.
Full reference, including the multi-layer composition surface, is in docs/stories/imports.md.
Validation (what the loader enforces)
The loader (internal/app/loader.go) performs these checks and collects all errors with errors.Join so you see the complete problem set on first load:
app.idmust be non-empty.- Strict YAML: unknown fields are errors.
- Every
target:that is not a template and not.must resolve to an existing state (relative-path resolution happens first). - Every
invoke:value must appear in the top-levelhosts:list. - Every
relevant_world:key must exist inworld:. - Every intent referenced in
on:must be declared globally, locally, or be"*". - For
type: compoundstates with a literalinitial:, the named child must exist. (Templateinitial:values skip the check.) includeglobs cannot produce duplicate state/intent names (thehostslist is the only mergeable list).- For each
imports.<alias>:- the alias must not collide with an existing parent state name;
- every
@exit:<name>in the child must be mapped inexits:, unless the app is loaded standalone (where they materialise as__exit__<name>terminals); overrides.states.<X>/.intents.<X>/.prompts.<X>keys must name existing child elements;intents.exportreferences must exist in the parent'sintents:;intents.importreferences must be listed in the child'sexports.intents;host_bindings.<name>must match either an iface the immediate child declares or one accessible by alias-prefix from a grandchild;hosts: declaredmode requires every child host to be in the parent's ownhosts:list;- every transition into
@exit:<name>must set every key in the child'sexits.<name>.requires; ..relative targets inside the child cannot walk above the alias wrapper (cross-boundary parent targets are forbidden);- import cycles (any number of layers) are detected and rejected.
What the loader does not check (runtime only)
when:andvalidator:expression correctness — bad expr fails at runtime.- World-variable type coercion — a
default: "0"ontype: intpasses the loader. target:values containing{{ ... }}— resolved at runtime.- Host handler availability — only the allow-list is checked; missing handlers error at invoke time.
Minimal runnable app
app:
id: tiny
version: 0.1.0
title: "Tiny App"
world:
counter: { type: int, default: 0 }
intents:
increment:
description: "Add one to the counter."
examples: ["add one", "++", "bump"]
show:
description: "Show the counter."
examples: ["show", "what's the count?"]
root: main
states:
main:
view: |
counter = {{ world.counter }}
on:
increment:
- target: main
effects:
- increment: { counter: 1 }
show:
- target: main
Bigger examples in-tree
testdata/apps/cloak/app.yaml— classic IF game; guards, enums, default branchestestdata/apps/dev-story/app.yaml— multi-room dev workflow; hosts, proposals, Agent Room, background jobstestdata/apps/proposal_smoke/app.yaml— minimal proposal-pattern example