Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

smllm

smllm guides an LLM agent through a workflow with a state machine: the agent sees one state at a time, and moves on only by firing one of the state’s events. This documentation teaches you to write a machine, connect it to your agent, and look up each rule and finding of smllm validate.

Where to start

The sections

  • Tutorials: lessons that build one working thing, step by step.
  • How-to guides: one task each, from start to finish.
  • Reference: the facts to look up, such as each rule of smllm validate and the findings under it.
  • Explanations: why smllm works the way it does.

Your first state machine

In this tutorial you write a state machine that guides an agent through a bug fix, from a test that reproduces the bug to the fix. You check the machine, then fire its events from the shell, as the agent fires them through the smllm tool. At the end you can write a machine for a workflow of your own.

Before you begin

  • smllm is installed, as the README shows.
  • Claude Code is installed, and you have run smllm harness install claude in your project’s folder to connect smllm to it, as the install section of Harnesses shows.
  • You have a project folder that is a git repository.

Step 1: create a config

smllm reads its state machines from the config of the project, so a project starts with one. Run this in the project’s folder:

$ smllm init
created .smllm/config.toml

Step 2: scaffold a machine

smllm new writes a template machine and lists it in the config, so that smllm finds it:

$ smllm new fix --write
created .smllm/fix.smllm.yaml and listed it in .smllm/config.toml

Step 3: write the states and check them

Replace the template with this machine. It has three states. In TRIAGE the agent writes a test that reproduces the bug, in FIX it makes the test pass, and DONE ends the work. Each event moves the agent to the next state, and setRef names the instance after the bug.

.smllm/fix.smllm.yaml

id: fix
description: Fix a bug, from a test that reproduces it to the fix.
initial: TRIAGE
meta:
  smllm: 1
  instance:
    kind: bug
    ref: { param: bugId, pattern: "^BUG-[0-9]+$", description: "The bug's id, such as BUG-7." }
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
    fixed:
      description: Fire after the test passes.
states:
  TRIAGE:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug, then fire reproduced with the bug's id." } }
    on:
      reproduced: { target: FIX, actions: setRef }
  FIX:
    entry: { type: prompt, params: { text: "Change the code until the test passes, then fire fixed." } }
    on:
      fixed: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Summarise the cause of the bug and the fix." } }

smllm validate checks every machine of the config before an agent runs it:

$ smllm validate
0 errors, 0 warnings, 0 info

A finding names the file and the line of the problem, and ends with the code of the rule that it breaks, such as (FMT-1). The rules of smllm validate says what each rule checks.

Step 4: start a session

Start Claude Code in the project. smllm gives the agent a session and lists the machines that it can enter. The agent sees this:

<smllm>
session sm-nqvfvw · idle
State machines:
- fix: Fix a bug, from a test that reproduces it to the fix.
    bug id param bugId (pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.
    starts at: TRIAGE; entry points: TRIAGE
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: fix): The state machine to enter.
    bugId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

sm-nqvfvw is the key of the session. Your key differs.

Step 5: enter the machine

The agent enters the machine with the event enter. Fire it from the shell, with your session’s key:

$ smllm fire --session sm-nqvfvw enter --param stateMachine=fix
<smllm>
session sm-nqvfvw · fix › TRIAGE · bug i-5z4x7n
Arrived by: enter (new bug)
Params: stateMachine = "fix"
<instructions>
Write a test that reproduces the bug, then fire reproduced with the bug's id.
</instructions>
</smllm>

The block is what the agent reads: where it is, how it got there, and what the state asks.

Step 6: try to stop

When the agent tries to stop in TRIAGE, smllm refuses, and lists the events that the agent can fire instead:

<smllm>
session sm-nqvfvw · fix › TRIAGE · bug i-5z4x7n
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- reproduced: Fire after a test reproduces the bug.
    bugId (required, pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.
- yield: Stop for now and stay in TRIAGE.
    note (optional): What you are waiting for.
- pause: Put bug i-5z4x7n aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>

The agent fires one of these events before it stops: yield waits for you, and pause puts the bug aside. If the agent tries to stop again without firing one, smllm lets it stop, so that an agent that can’t go on doesn’t loop.

Step 7: finish the fix

Fire the two events of the machine, as the agent does after each step of the work:

$ smllm fire --session sm-nqvfvw reproduced --param bugId=BUG-7
<smllm>
session sm-nqvfvw · fix › FIX · bug BUG-7
Arrived by: reproduced from TRIAGE
Params: bugId = "BUG-7"
<instructions>
Change the code until the test passes, then fire fixed.
</instructions>
</smllm>
$ smllm fire --session sm-nqvfvw fixed
<smllm>
session sm-nqvfvw · fix › DONE · bug BUG-7
Arrived by: fixed from FIX
<instructions>
Summarise the cause of the bug and the fix.
</instructions>
Completed bug BUG-7. You are now in idle.
State machines:
- fix: Fix a bug, from a test that reproduces it to the fix.
    bug id param bugId (pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.
    starts at: TRIAGE; entry points: TRIAGE
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: fix): The state machine to enter.
    bugId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

The two replies show how the instance ends:

  • The instance takes the name BUG-7 from setRef.
  • DONE is a final state, so the bug is complete, and the session returns to idle with the list of machines that it can enter.

What’s next

Connect an agent

In this tutorial you install smllm into Claude Code for a project with a state machine, and you follow the machine as the agent sees it: the text at the start of a session, the reply to each event, and a stop that smllm blocks. At the end you can connect smllm to a project of your own and read what the agent reads.

Before you begin

  • You have written and checked a machine, as Your first state machine shows.
  • smllm is installed, as the README shows.
  • Claude Code is installed.
  • You have a new project folder for this tutorial, which is a git repository. A new folder has no smllm files yet, so each run prints what the page shows.

Step 1: add a machine to the project

The agent can follow only the machines that the project’s config lists. Write a config that lists one machine, and the machine itself. The machine guides the agent through a small feature: in PLAN the agent writes a plan, in BUILD it builds the feature, and DONE ends the work.

.smllm/config.toml

[machines]
files = ["feature.smllm.yaml"]

.smllm/feature.smllm.yaml

id: feature
description: Build a small feature, from a plan to working code.
initial: PLAN
meta:
  smllm: 1
  instance:
    kind: feature
  events:
    planned:
      description: Fire after the user agrees to the plan.
    built:
      description: Fire after the feature works and its tests pass.
states:
  PLAN:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a short plan for the feature, and ask the user to agree to it." } }
    on:
      planned: BUILD
  BUILD:
    entry: { type: prompt, params: { text: "Build the feature as the plan says, with a test for it." } }
    on:
      built: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Tell the user what changed." } }

Check the machine before an agent runs it:

$ smllm validate
0 errors, 0 warnings, 0 info

Step 2: install smllm into Claude Code

smllm harness install claude writes the files that connect Claude Code to smllm. Run it in the project’s folder:

$ smllm harness install claude
claude:
  created CLAUDE.md (instructions)
  created .claude/settings.json (hooks)
  created .mcp.json (mcp)
  created .claude/settings.json (permissions)
  created .claude/skills/smllm-statusline (statusline)
note: smllm is not in your Claude Code status line; ask Claude to add it (skill smllm-statusline)

Each line names a part and the file that holds it:

  • The instructions in CLAUDE.md tell the agent to follow the text in <smllm> blocks, and to fire an event when its work is done.
  • The hooks in .claude/settings.json run smllm when a session starts, when you send a prompt, and when the agent tries to stop.
  • .mcp.json gives the agent the smllm tool, with which it fires events, and the permission lets the agent call the tool without asking you.
  • The skill smllm-statusline adds smllm to your status line when you ask Claude to, as Show smllm in your status line shows.

Commit these files, so that everyone who works on the project gets the same setup. After you upgrade smllm, run the command again: smllm harness status claude shows whether each part is current.

$ smllm harness status claude
claude:
  current CLAUDE.md (instructions)
  current .claude/settings.json (hooks)
  current .mcp.json (mcp)
  current .claude/settings.json (permissions)
  current .claude/skills/smllm-statusline (statusline)
note: smllm is not in your Claude Code status line; ask Claude to add it (skill smllm-statusline)

Step 3: start a session

Start Claude Code in the project, and approve the smllm server when Claude Code asks. The session start hook gives the agent a session and the list of machines that it can enter. The agent sees this text before your first prompt:

<smllm>
session sm-nqvfvw · idle
State machines:
- feature: Build a small feature, from a plan to working code.
    feature id param ref
    starts at: PLAN; entry points: PLAN
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: feature): The state machine to enter.
    ref (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

sm-nqvfvw is the key of the session. Your key differs. The agent passes the key on every call of the smllm tool.

Step 4: ask for the work

Ask Claude for a feature, such as “Add a --verbose flag to the build script”. The agent picks the machine whose description fits the request, and fires enter through the smllm tool. The tool’s reply is the text that smllm fire prints for the same event, so this tutorial shows each call of the agent as a run of smllm fire. You don’t run these commands: the agent makes the calls.

$ smllm fire --session sm-nqvfvw enter --param stateMachine=feature
<smllm>
session sm-nqvfvw · feature › PLAN · feature i-x96snr
Arrived by: enter (new feature)
Params: stateMachine = "feature"
<instructions>
Write a short plan for the feature, and ask the user to agree to it.
</instructions>
</smllm>

The agent now writes a plan and asks you to agree to it.

Step 5: watch a blocked stop

The agent tries to stop, to wait for your answer. Before it stops, the stop hook checks where it is. The agent fired no event, so smllm blocks the stop and gives the agent the events that it can fire:

<smllm>
session sm-nqvfvw · feature › PLAN · feature i-x96snr
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- planned: Fire after the user agrees to the plan.
- yield: Stop for now and stay in PLAN.
    note (optional): What you are waiting for.
- pause: Put feature i-x96snr aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>

The agent fires yield, because it is waiting for you, and then stops. A second stop without an event is allowed, so that an agent that can’t go on doesn’t loop.

Step 6: come back to the work

When Claude Code resumes the conversation, or compacts it, the session start hook runs again for the same session. This time the agent sees where it is, with the state’s instructions and its events:

<smllm>
session sm-nqvfvw · feature › PLAN · feature i-x96snr
<instructions>
Write a short plan for the feature, and ask the user to agree to it.
</instructions>
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- planned: Fire after the user agrees to the plan.
- yield: Stop for now and stay in PLAN.
    note (optional): What you are waiting for.
- pause: Put feature i-x96snr aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>

Step 7: finish the feature

Agree to the plan. The agent fires planned, builds the feature, and fires built:

$ smllm fire --session sm-nqvfvw planned
<smllm>
session sm-nqvfvw · feature › BUILD · feature i-x96snr
Arrived by: planned from PLAN
<instructions>
Build the feature as the plan says, with a test for it.
</instructions>
</smllm>
$ smllm fire --session sm-nqvfvw built
<smllm>
session sm-nqvfvw · feature › DONE · feature i-x96snr
Arrived by: built from BUILD
<instructions>
Tell the user what changed.
</instructions>
Completed feature i-x96snr. You are now in idle.
State machines:
- feature: Build a small feature, from a plan to working code.
    feature id param ref
    starts at: PLAN; entry points: PLAN
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: feature): The state machine to enter.
    ref (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

The two replies show how a machine ends:

  • Each reply starts with the state that the event entered, and with how the agent got there.
  • DONE is a final state, so the feature is complete, and the session returns to idle with the list of machines.

What’s next

Check the agent’s work

In this tutorial you write a state machine that checks the agent’s work with a command, which the agent can’t skip. The agent finishes only when the test passes, and after two failed attempts the machine sends it to you for help. At the end you can guard any step of a machine with a check of your own.

Before you begin

  • You have written and run a machine, as Your first state machine shows.
  • smllm is installed, as the README shows.
  • You have a project folder that is a git repository.

Step 1: write the test and the machine

A command guard runs a command, and passes when the command exits with status 0. Any test of your project can be the command. This tutorial uses a test in a shell script, which checks that greeting.txt says hello. Write the script, and a greeting.txt that fails it:

test.sh

grep -qx "hello" greeting.txt || { echo "greeting.txt doesn't say hello"; exit 1; }

greeting.txt

hi

Write the config, which lists the machine:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

The machine has three states. In WORK the agent makes the test pass, in ASK it asks you what to try, and DONE ends the work. The event fixed takes the param cause, in which the agent says what made the test fail.

.smllm/fix.smllm.yaml

id: fix
description: Make the failing test pass.
initial: WORK
meta:
  smllm: 1
  events:
    fixed:
      description: Fire after you change the code, with the cause of the failure.
      params:
        type: object
        properties:
          cause: { description: "What made the test fail, in one sentence." }
        required: [cause]
    retry:
      description: Fire after the user tells you what to try.
states:
  WORK:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Change the code until sh test.sh passes, then fire fixed." } }
    on:
      fixed:
        - target: DONE
          guard: { type: command, params: { run: "sh test.sh" } }
        - target: ASK
          guard: { type: visits, params: { state: WORK, atLeast: 2 } }
        - target: WORK
          reenter: true
  ASK:
    entry: { type: prompt, params: { text: "The test still fails. Ask the user what to try, then fire retry." } }
    on:
      retry: WORK
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Tell the user the cause of the failure." } }

The event fixed has three transitions, and smllm takes the first whose guard passes:

  • The command guard runs sh test.sh in the session’s folder, and goes to DONE when the test passes.
  • The visits guard counts how many times the agent has entered WORK, and goes to ASK from the second visit.
  • The last transition has no guard. It leaves WORK and enters it again, which counts as a visit.

Check the machine:

$ smllm validate
0 errors, 0 warnings, 0 info

Step 2: start the work

Enter the machine, as the agent does through the smllm tool. Without --session, smllm fire enter starts a new session, and prints its key in the header:

$ smllm fire enter --param stateMachine=fix
<smllm>
session sm-efajqg · fix › WORK · instance i-2q5chp
Arrived by: enter (new instance)
Params: stateMachine = "fix"
<instructions>
Change the code until sh test.sh passes, then fire fixed.
</instructions>
</smllm>

sm-efajqg is the key of the new session, and i-2q5chp is the instance. Your key and your instance differ: use yours in each of the following runs.

Step 3: fire an event without its param

The param cause is required, so smllm refuses fixed without it. The agent stays in WORK, and the reply lists the events and their params:

$ smllm fire --session sm-efajqg fixed
<smllm>
session sm-efajqg · fix › WORK · instance i-2q5chp
error: fixed needs param cause
Fire one event: smllm({ session: "sm-efajqg", event, params })
<events>
- fixed: Fire after you change the code, with the cause of the failure.
    cause (required): What made the test fail, in one sentence.
- yield: Stop for now and stay in WORK.
    note (optional): What you are waiting for.
- pause: Put instance i-2q5chp aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>
[exit 1]

Step 4: claim a fix that fails the test

Fire fixed with a cause, but leave greeting.txt as it is. The test fails, so the agent goes back to WORK:

$ smllm fire --session sm-efajqg fixed --param "cause=The greeting was too short."
<smllm>
session sm-efajqg · fix › WORK (visit 2) · instance i-2q5chp
Arrived by: fixed from WORK
Params: cause = "The greeting was too short."
Guard: command `sh test.sh` → false (exited 1: greeting.txt doesn't say hello)
Guard: visits WORK ≥ 2 → false (visits: 1)
<instructions>
Change the code until sh test.sh passes, then fire fixed.
</instructions>
</smllm>

The reply shows the guards and the visit:

  • Each Guard: line shows a guard that smllm checked, and its result. A failed command adds the end of its output, so that the agent can see why the test failed.
  • (visit 2) in the header says that this is the second visit to WORK.

Step 5: fail the test again

Fire fixed again without a change. This time the visits guard passes, and the machine sends the agent to you:

$ smllm fire --session sm-efajqg fixed --param "cause=The greeting was too short."
<smllm>
session sm-efajqg · fix › ASK · instance i-2q5chp
Arrived by: fixed from WORK
Params: cause = "The greeting was too short."
Guard: command `sh test.sh` → false (exited 1: greeting.txt doesn't say hello)
Guard: visits WORK ≥ 2 → true (visits: 2)
<instructions>
The test still fails. Ask the user what to try, then fire retry.
</instructions>
</smllm>

Step 6: make the test pass

Tell the agent what to try, and fire retry, as the agent does after your answer:

$ smllm fire --session sm-efajqg retry
<smllm>
session sm-efajqg · fix › WORK (visit 3) · instance i-2q5chp
Arrived by: retry from ASK
<instructions>
Change the code until sh test.sh passes, then fire fixed.
</instructions>
</smllm>

Change greeting.txt so that it passes the test:

greeting.txt

hello

Fire fixed once more. The test passes, so the agent reaches DONE:

$ smllm fire --session sm-efajqg fixed --param "cause=The greeting said hi, not hello."
<smllm>
session sm-efajqg · fix › DONE · instance i-2q5chp
Arrived by: fixed from WORK
Params: cause = "The greeting said hi, not hello."
Guard: command `sh test.sh` → true
<instructions>
Tell the user the cause of the failure.
</instructions>
Completed instance i-2q5chp. You are now in idle.
State machines:
- fix: Make the failing test pass.
    instance id param ref
    starts at: WORK; entry points: WORK
Fire one event: smllm({ session: "sm-efajqg", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: fix): The state machine to enter.
    ref (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

The cause is now in the instance’s history. smllm instance show prints each event with its time, its params, and its guards:

$ smllm instance show i-2q5chp
fix i-2q5chp (i-2q5chp)
state: DONE  status: completed  holder: -
visits: ASK=1 DONE=1 WORK=3
history:
  2026-01-05 09:01 UTC  sm-efajqg  enter  → WORK
      stateMachine = fix
  2026-01-05 09:04 UTC  sm-efajqg  fixed  WORK → WORK
      cause = The greeting was too short.
      Guard: command `sh test.sh` → false (exited 1: greeting.txt doesn't say hello)
      Guard: visits WORK ≥ 2 → false (visits: 1)
  2026-01-05 09:05 UTC  sm-efajqg  fixed  WORK → ASK
      cause = The greeting was too short.
      Guard: command `sh test.sh` → false (exited 1: greeting.txt doesn't say hello)
      Guard: visits WORK ≥ 2 → true (visits: 2)
  2026-01-05 09:06 UTC  sm-efajqg  retry  ASK → WORK
  2026-01-05 09:07 UTC  sm-efajqg  fixed  WORK → DONE
      cause = The greeting said hi, not hello.
      Guard: command `sh test.sh` → true

What’s next

Run the engine in Node.js

In this tutorial you run smllm’s engine inside your own program. You compile a state machine to JSON, load it in Node.js with @six5536/smllm-wasm, and fire events as an agent does, with no smllm process and no harness. At the end you can build a host of your own that gives an agent the smllm tool.

Before you begin

  • You have written and run a machine, as Your first state machine shows.
  • smllm is installed, as the README shows.
  • A current long-term support (LTS) release of Node.js is installed.

Step 1: write the machine

In an empty folder, install the package:

npm install @six5536/smllm-wasm

Write a machine in the folder. It guides an agent through the review of a change: in READ the agent reads the change, in REPORT it writes its findings, and the event reported takes the agent’s verdict.

review.smllm.yaml

id: review
description: Review a change and give a verdict.
initial: READ
meta:
  smllm: 1
  instance:
    kind: review
  events:
    read:
      description: Fire after you have read the whole change.
    reported:
      description: Fire after you write your findings, with your verdict.
      params:
        type: object
        properties:
          verdict: { enum: [approve, reject], description: "Your verdict on the change." }
        required: [verdict]
states:
  READ:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Read the change and the code around it." } }
    on:
      read: REPORT
  REPORT:
    entry: { type: prompt, params: { text: "Write your findings, then fire reported with your verdict." } }
    on:
      reported: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Post your findings for the author." } }

Check the machine:

$ smllm validate review.smllm.yaml
0 errors, 0 warnings, 0 info

Step 2: compile the machine

The engine reads compiled machines, not YAML. smllm compile checks a machine file, or a config with all its machines, and writes it as JSON. It prints nothing when it succeeds:

$ smllm compile review.smllm.yaml -o review.json

Compile the machine again after each change to it, because the engine reads only review.json.

Step 3: drive the machine from Node.js

Write a script that loads review.json into an engine, binds a session, and makes the calls that an agent makes through the smllm tool:

review.mjs

import { readFileSync } from "node:fs";
import init, { Engine } from "@six5536/smllm-wasm";

// Load the engine's WebAssembly once.
await init();

let n = 0;
const engine = new Engine(readFileSync("review.json", "utf8"), {
  // Fixed random numbers give the same keys on every run.
  random: () => ++n,
});

const { session, text } = engine.bind("node");
console.log(text);
console.log(engine.callTool({ session, event: "enter", params: { stateMachine: "review" } }).text);
const stop = engine.stop(session);
console.log(stop.decision);
console.log(stop.text);
console.log(engine.callTool({ session, event: "read" }).text);
console.log(engine.callTool({ session, event: "reported", params: { verdict: "approve" } }).text);

The script makes these calls:

  • init loads the engine’s WebAssembly, once for each process.
  • bind starts a session for the harness node, and returns its key and the text that a session start hook gives the agent.
  • callTool takes the same arguments as the smllm tool, and returns the tool’s reply.
  • stop decides whether the agent can stop, as the stop hook does.

Run the script:

$ node review.mjs
<smllm>
session sm-200000 · idle
State machines:
- review: Review a change and give a verdict.
    review id param ref
    starts at: READ; entry points: READ
Fire one event: smllm({ session: "sm-200000", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: review): The state machine to enter.
    ref (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

<smllm>
session sm-200000 · review › READ · review i-400000
Arrived by: enter (new review)
Params: stateMachine = "review"
<instructions>
Read the change and the code around it.
</instructions>
</smllm>

block
<smllm>
session sm-200000 · review › READ · review i-400000
Fire one event: smllm({ session: "sm-200000", event, params })
<events>
- read: Fire after you have read the whole change.
- yield: Stop for now and stay in READ.
    note (optional): What you are waiting for.
- pause: Put review i-400000 aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>

<smllm>
session sm-200000 · review › REPORT · review i-400000
Arrived by: read from READ
<instructions>
Write your findings, then fire reported with your verdict.
</instructions>
</smllm>

<smllm>
session sm-200000 · review › DONE · review i-400000
Arrived by: reported from REPORT
Params: verdict = "approve"
<instructions>
Post your findings for the author.
</instructions>
Completed review i-400000. You are now in idle.
State machines:
- review: Review a change and give a verdict.
    review id param ref
    starts at: READ; entry points: READ
Fire one event: smllm({ session: "sm-200000", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: review): The state machine to enter.
    ref (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

The output shows three things about the engine:

  • The texts are the same as those of the smllm command: the engine is the same.
  • The stop is block, because the agent is in READ and fired no event, so a host shows the agent the events and keeps it working.
  • Without random, the engine draws random keys, so your keys differ from the page’s.

The engine keeps its state in memory. To keep it between runs, pass a storage as the third argument of Engine, such as nodeFileStorage from @six5536/smllm-wasm/node. The README of @six5536/smllm-wasm describes the whole API: storage, hosts that run command guards, and hosts that drive a model without tools.

What’s next

Show smllm in your status line

When you want to see where an agent’s work is without asking it, show smllm in Claude Code’s status line. The row shows the state machine, the state, and the instance of the session, such as smllm dev › WORK (visit 3) · issue GH-123.

To let Claude make the change, ask it to “add smllm to my status line”. The smllm-statusline skill makes the change and shows it to you first. smllm harness install claude installs the skill, and so does smllm harness install all, unless you add --without statusline. The Claude Code plugin also ships the skill. To make the change by hand, follow these steps.

Steps

  1. If you have no status line script yet, create ~/.claude/statusline-command.sh:

    #!/usr/bin/env bash
    input=$(cat)
    printf '%s' "$input" | smllm statusline
    

    Then point Claude Code at the script in ~/.claude/settings.json, and skip the next step:

    { "statusLine": { "type": "command", "command": "bash ~/.claude/statusline-command.sh" } }
    
  2. If you have a script, add this line at the end of it, after its other output:

    if row=$(printf '%s' "$input" | smllm statusline 2>/dev/null) && [ -n "$row" ]; then printf '\n%s' "$row"; fi
    

    $input is the status JSON that your script read from stdin (input=$(cat)). Claude Code runs one statusLine command, and each line that the command prints is a row.

  3. Check the setup:

    smllm harness status claude
    

    The command reminds you when your status line doesn’t call smllm statusline.

Troubleshooting

  • The whole status line goes blank: Claude Code blanks it when the script exits with a non-zero status. Keep the if form of the line in step 2, because a trailing [ -n "$row" ] && printf … exits with a non-zero status whenever smllm has nothing to show.
  • The row doesn’t appear: smllm prints nothing when the project has no smllm config, or before Claude Code’s session start hook has bound a session to smllm. The row appears as soon as Claude Code starts a session in a project with a config.
  • smllm harness install didn’t add the row: it never changes the statusLine setting, because the setting is yours, and a setting at project level would override the status lines of your collaborators.

See also

Build a status line row of your own

When the default row shows more or less than you want, build a row of your own from the JSON fields of smllm statusline. You read the fields with jq and print the row with printf.

Before you begin

Steps

  1. Read the fields of the session into a variable, in your status line script:

    s=$(printf '%s' "$input" | smllm statusline --json)
    

    $input is the status JSON that your script read from stdin. The status line row lists each field.

  2. Print the row from the fields. This row shows only the state, in bold yellow:

    state=$(jq -r '.state // empty' <<<"$s")
    if [ -n "$state" ]; then printf '\n\033[1;33m%s\033[0m' "$state"; fi
    

    This one puts the state and the instance on the same row as your other fields, and shows nothing when the session is idle:

    where=$(jq -r 'select(.state) | "\(.state) \(.instance.label)"' <<<"$s")
    printf '%s%s' "$my_other_fields" "${where:+ | $where}"
    

    This one shows a warning colour while the agent has yielded:

    if [ "$(jq -r '.yielded' <<<"$s")" = true ]; then
      printf '\n\033[43;30m waiting for you \033[0m'
    fi
    

See also

Pause and resume work

When an urgent task interrupts the work of an agent, pause the instance that it works on, and resume the instance when the urgent task is done. smllm keeps the paused instance at its state, so the agent, in this session or in another, continues where it stopped.

Steps

The steps use this config and the machine of Your first state machine:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
description: Fix a bug, from a test that reproduces it to the fix.
initial: TRIAGE
meta:
  smllm: 1
  instance:
    kind: bug
    ref: { param: bugId, pattern: "^BUG-[0-9]+$", description: "The bug's id, such as BUG-7." }
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
    fixed:
      description: Fire after the test passes.
states:
  TRIAGE:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug, then fire reproduced with the bug's id." } }
    on:
      reproduced: { target: FIX, actions: setRef }
  FIX:
    entry: { type: prompt, params: { text: "Change the code until the test passes, then fire fixed." } }
    on:
      fixed: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Summarise the cause of the bug and the fix." } }
  1. Start work on a bug. The agent enters the machine with the bug’s id, as it does when you ask it to fix BUG-7:

    $ smllm fire --session sm-nqvfvw enter --param stateMachine=fix --param bugId=BUG-7
    <smllm>
    session sm-nqvfvw · fix › TRIAGE · bug BUG-7
    Arrived by: enter (new bug)
    Params: stateMachine = "fix", bugId = "BUG-7"
    <instructions>
    Write a test that reproduces the bug, then fire reproduced with the bug's id.
    </instructions>
    </smllm>
    

    sm-nqvfvw is the key of the session. Your key differs.

  2. To pause the bug, ask the agent to put it aside. The agent fires pause, and smllm returns the session to idle:

    $ smllm fire --session sm-nqvfvw pause
    <smllm>
    session sm-nqvfvw · idle
    Paused bug BUG-7 at TRIAGE.
    State machines:
    - fix: Fix a bug, from a test that reproduces it to the fix.
        bug id param bugId (pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.
        starts at: TRIAGE; entry points: TRIAGE
    Paused:
    - bug BUG-7 (fix) at TRIAGE
    Fire one event: smllm({ session: "sm-nqvfvw", event, params })
    <events>
    - enter: Start a new instance of a state machine, or continue one.
        stateMachine (required, one of: fix): The state machine to enter.
        bugId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
        state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
    </events>
    </smllm>
    

    The idle list shows the bug under Paused:, so the agent sees it each time the session is idle. The session is free for the urgent task.

  3. List the instances of the project, to see the paused bug from the shell:

    $ smllm instance list
    fix  BUG-7 (i-ex2bq1)  TRIAGE  paused
    

    Each line shows the machine, the instance’s ref and its ID, its state, and its status. To read the history of the bug, run smllm instance show with its ref or its ID:

    $ smllm instance show BUG-7
    fix BUG-7 (i-ex2bq1)
    state: TRIAGE  status: paused  holder: -
    visits: TRIAGE=1
    history:
      2026-01-05 09:01 UTC  sm-nqvfvw  enter  → TRIAGE
          bugId = BUG-7
          stateMachine = fix
      2026-01-05 09:02 UTC  sm-nqvfvw  pause  TRIAGE →
          Paused bug BUG-7 at TRIAGE.
    
  4. To resume the bug, ask the agent to continue it. The agent fires enter with the bug’s id, and the bug continues at its saved state:

    $ smllm fire --session sm-nqvfvw enter --param stateMachine=fix --param bugId=BUG-7
    <smllm>
    session sm-nqvfvw · fix › TRIAGE (visit 2) · bug BUG-7
    Arrived by: enter
    Params: stateMachine = "fix", bugId = "BUG-7"
    <instructions>
    Write a test that reproduces the bug, then fire reproduced with the bug's id.
    </instructions>
    </smllm>
    
  5. To pick the bug up in another session, such as a new Claude Code session the next day, pause it in this session first:

    $ smllm fire --session sm-nqvfvw pause --json
    {"ok":true,"session":"sm-nqvfvw","location":{"machine":null,"state":null,"instance":null,"ref":null},"text":"<smllm>\nsession sm-nqvfvw · idle\nPaused bug BUG-7 at TRIAGE.\nState machines:\n- fix: Fix a bug, from a test that reproduces it to the fix.\n    bug id param bugId (pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.\n    starts at: TRIAGE; entry points: TRIAGE\nPaused:\n- bug BUG-7 (fix) at TRIAGE\nFire one event: smllm({ session: \"sm-nqvfvw\", event, params })\n<events>\n- enter: Start a new instance of a state machine, or continue one.\n    stateMachine (required, one of: fix): The state machine to enter.\n    bugId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.\n    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.\n</events>\n</smllm>\n"}
    

    Then the agent of the other session enters the bug, as in step 4. From the shell, smllm fire enter without --session starts a new session, as a second agent would:

    $ smllm fire enter --param stateMachine=fix --param bugId=BUG-7
    <smllm>
    session sm-6yv68n · fix › TRIAGE (visit 3) · bug BUG-7
    Arrived by: enter
    Params: stateMachine = "fix", bugId = "BUG-7"
    <instructions>
    Write a test that reproduces the bug, then fire reproduced with the bug's id.
    </instructions>
    </smllm>
    

    A session that enters a bug that another session still holds takes the bug over, and the reply names the session that held it. Pause first, so that the first agent doesn’t go on working on the bug.

  6. To reopen a bug that is complete, enter it at one of its entry points. Finish the bug first:

    $ smllm fire --session sm-6yv68n reproduced --param bugId=BUG-7 --json
    {"ok":true,"session":"sm-6yv68n","location":{"machine":"fix","state":"FIX","instance":"i-ex2bq1","ref":"BUG-7"},"text":"<smllm>\nsession sm-6yv68n · fix › FIX · bug BUG-7\nArrived by: reproduced from TRIAGE\nParams: bugId = \"BUG-7\"\n<instructions>\nChange the code until the test passes, then fire fixed.\n</instructions>\n</smllm>\n"}
    $ smllm fire --session sm-6yv68n fixed --json
    {"ok":true,"session":"sm-6yv68n","location":{"machine":null,"state":null,"instance":null,"ref":null},"text":"<smllm>\nsession sm-6yv68n · fix › DONE · bug BUG-7\nArrived by: fixed from FIX\n<instructions>\nSummarise the cause of the bug and the fix.\n</instructions>\nCompleted bug BUG-7. You are now in idle.\nState machines:\n- fix: Fix a bug, from a test that reproduces it to the fix.\n    bug id param bugId (pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.\n    starts at: TRIAGE; entry points: TRIAGE\nFire one event: smllm({ session: \"sm-6yv68n\", event, params })\n<events>\n- enter: Start a new instance of a state machine, or continue one.\n    stateMachine (required, one of: fix): The state machine to enter.\n    bugId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.\n    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.\n</events>\n</smllm>\n"}
    $ smllm instance list
    fix  BUG-7 (i-ex2bq1)  DONE  completed
    

    Then enter the bug with state, the entry point to reopen it at:

    $ smllm fire --session sm-6yv68n enter --param stateMachine=fix --param bugId=BUG-7 --param state=TRIAGE
    <smllm>
    session sm-6yv68n · fix › TRIAGE (visit 4) · bug BUG-7
    Arrived by: enter (reopened from DONE)
    Params: stateMachine = "fix", bugId = "BUG-7", state = "TRIAGE"
    Reopened bug BUG-7 at TRIAGE.
    <instructions>
    Write a test that reproduces the bug, then fire reproduced with the bug's id.
    </instructions>
    </smllm>
    

    The bug keeps its history and its visits, and TRIAGE counts its fourth visit, because each enter that continues or reopens the bug is a visit of its state.

Troubleshooting

  • smllm refuses enter with bug BUG-7 is completed. To reopen it, fire enter with state: TRIAGE: the bug is complete, and enter without state doesn’t reopen it. Add state with an entry point that the error names.
  • pause isn’t offered: the session is idle, and holds no instance to pause. Run smllm instance list to see which instances are paused.

See also

Name work by your issue id

When each piece of work has an id in your issue tracker, such as GH-123, give the instance that id as its ref. The agent then enters the work by the id, and smllm shows the id in each header and list. You find the instance by the id instead of by its ID, such as i-ysh871.

Steps

The steps use this config and machine. The machine declares the ref in meta.instance, where param names the param that carries the id. pattern sets the form that the id must have, and description tells the agent what to pass. kind is the word for an instance in the text that the agent reads:

.smllm/config.toml

[machines]
files = ["dev.smllm.yaml"]

.smllm/dev.smllm.yaml

id: dev
description: Work on one issue, from a plan to a merged pull request.
initial: PLAN
meta:
  smllm: 1
  instance:
    kind: issue
    ref: { param: issue, pattern: "^GH-[0-9]+$", description: "The GitHub issue, such as GH-123." }
  events:
    planned:
      description: Fire after you write the plan.
    merged:
      description: Fire after the pull request is merged.
states:
  PLAN:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Read the issue and write a plan." } }
    on:
      planned: WORK
  WORK:
    entry: { type: prompt, params: { text: "Carry out the plan, and open a pull request." } }
    on:
      merged: DONE
  DONE:
    type: final

Write [0-9] rather than \d in a pattern. In smllm, \d matches every Unicode digit, and in a JavaScript host it matches only the ASCII digits.

  1. Check the machine:

    $ smllm validate
    0 errors, 0 warnings, 0 info
    
  2. Start a session. The idle list shows the param of the ref, its description, and its pattern, so the agent knows to pass the id:

    <smllm>
    session sm-nqvfvw · idle
    State machines:
    - dev: Work on one issue, from a plan to a merged pull request.
        issue id param issue (pattern: ^GH-[0-9]+$): The GitHub issue, such as GH-123.
        starts at: PLAN; entry points: PLAN
    Fire one event: smllm({ session: "sm-nqvfvw", event, params })
    <events>
    - enter: Start a new instance of a state machine, or continue one.
        stateMachine (required, one of: dev): The state machine to enter.
        issue (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
        state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
    </events>
    </smllm>
    
  3. Ask the agent to work on the issue. The agent enters the machine with the issue’s id, and the new instance takes the id as its ref:

    $ smllm fire --session sm-nqvfvw enter --param stateMachine=dev --param issue=GH-123
    <smllm>
    session sm-nqvfvw · dev › PLAN · issue GH-123
    Arrived by: enter (new issue)
    Params: stateMachine = "dev", issue = "GH-123"
    <instructions>
    Read the issue and write a plan.
    </instructions>
    </smllm>
    

    sm-nqvfvw is the key of the session. Your key differs.

  4. Find the instance by its ref:

    $ smllm instance list
    dev  GH-123 (i-ysh871)  PLAN  active  held by sm-nqvfvw
    

    smllm instance show GH-123 shows the instance with its history, and an enter with issue=GH-123 continues it, as Pause and resume work shows.

Troubleshooting

  • smllm refuses enter with enter param issue must match ^GH-[0-9]+$ (got "123"): the id doesn’t have the form that pattern sets. Pass the whole id, such as GH-123, or widen the pattern.
  • The id isn’t known when the work starts, such as for a bug that has no issue yet: enter without the ref, and name the instance later with a setRef action on the transition of an event that carries the ref’s param. Your first state machine names a bug this way.

See also

Use your machines in every project

When you use the same workflow in many projects, such as a review that you do on every pull request, put its machine in your user config. smllm combines the user config with the config of each project, so every session on your computer can enter the machine, and a project needs no copy of it.

Steps

  1. Create the user config:

    $ smllm init --user
    created ~/.config/smllm/config.toml
    

    The user config is ~/.config/smllm/config.toml on Linux and macOS, and %APPDATA%\smllm\config.toml on Windows. smllm prints the full path, and this guide shortens your home folder to ~. The following steps use the path of Linux and macOS.

  2. Write a template machine beside the user config, and list it there:

    $ smllm new review --write --config ~/.config/smllm/config.toml
    created ~/.config/smllm/review.smllm.yaml and listed it in ~/.config/smllm/config.toml
    

    --config makes smllm new write review.smllm.yaml beside that config, ~/.config/smllm/review.smllm.yaml, and list it in the config’s [machines] table. Replace the template with your machine, as in Your first state machine. This guide uses a short review machine:

    ~/.config/smllm/review.smllm.yaml

    id: review
    initial: READ
    meta:
      smllm: 1
    states:
      READ:
        on:
          read: COMMENT
      COMMENT:
        on:
          commented: DONE
      DONE:
        type: final
    

    The command lists the machine in the user config:

    ~/.config/smllm/config.toml

    [machines]
    files = ["review.smllm.yaml"]
    
  3. Check the machine from any project:

    $ smllm validate
    0 errors, 0 warnings, 0 info
    $ smllm graph review
    review (initial READ)
      READ
        read → COMMENT
      COMMENT
        commented → DONE
      DONE [final]
    

    Both commands read the user config as well as the project’s, and so does every session. A folder without a config of its own runs the machines of the user config alone.

  4. To change the machine for one project, define a machine with the same ID in the project’s config. Create the project’s config and a template machine review:

    $ smllm init
    created .smllm/config.toml
    $ smllm new review --write
    created .smllm/review.smllm.yaml and listed it in .smllm/config.toml
    

    Replace the template with the project’s machine. This one adds a step that runs the tests:

    .smllm/review.smllm.yaml

    id: review
    initial: READ
    meta:
      smllm: 1
    states:
      READ:
        on:
          read: TEST
      TEST:
        on:
          passed: COMMENT
      COMMENT:
        on:
          commented: DONE
      DONE:
        type: final
    

    The project’s machine replaces the user’s, and smllm validate --info reports which one it replaces:

    $ smllm validate --info
    .smllm/review.smllm.yaml: info: machine review here replaces the one in ~/.config/smllm/review.smllm.yaml (FMT-7)
    0 errors, 0 warnings, 1 info
    

Troubleshooting

  • An instance of a user machine shows in smllm instance list of every project: smllm keeps the instances of each config beside that config, in its state folder, so the instances of a user machine are in ~/.config/smllm/state/. To keep them elsewhere, set dir in the [state] table of the user config, relative to the config file.
  • A machine of the user config doesn’t appear: the project defines a machine with the same ID, which replaces it. Run smllm validate --info to see the replacement.

See also

Take JSON in a param

When an event needs more than a line of text from the agent, such as a plan with a list of steps, give the event a param of type: json. The agent then passes a JSON value, and a JSON Schema tells it the shape of the value.

Steps

The steps use a schema of the value, in a JSON file beside the machine. This schema describes a plan as an object with a list of steps:

.smllm/plan.schema.json

{
  "type": "object",
  "properties": {
    "steps": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
  },
  "required": ["steps"]
}

The config lists the machine. The machine declares the param in the event’s params, with type: json and the schema’s path, relative to the machine file:

.smllm/config.toml

[machines]
files = ["plan.smllm.yaml"]

.smllm/plan.smllm.yaml

id: plan
description: Plan a change as a list of steps, then carry it out.
initial: PLAN
meta:
  smllm: 1
  events:
    planned:
      description: Fire with the plan, after you write it.
      params:
        type: object
        properties:
          plan:
            type: json
            description: The plan, as an object with its steps in order.
            schema: plan.schema.json
        required: [plan]
    done:
      description: Fire after the last step.
states:
  PLAN:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a plan, then fire planned with it." } }
    on:
      planned: WORK
  WORK:
    entry: { type: prompt, params: { text: "Carry out the plan, one step at a time." } }
    on:
      done: DONE
  DONE:
    type: final

schema can also hold the schema itself as a map, in place of a path. A schema declares draft-07 or 2020-12 in $schema, or no draft, which smllm reads as 2020-12.

  1. Check the machine. smllm validate reads the schema, and reports an error when the schema is invalid or names a file that smllm can’t read:

    $ smllm validate
    0 errors, 0 warnings, 0 info
    
  2. Enter the machine, as the agent does:

    $ smllm fire --session sm-nqvfvw enter --param stateMachine=plan
    <smllm>
    session sm-nqvfvw · plan › PLAN · instance i-0ekd6p
    Arrived by: enter (new instance)
    Params: stateMachine = "plan"
    <instructions>
    Write a plan, then fire planned with it.
    </instructions>
    </smllm>
    

    sm-nqvfvw is the key of the session. Your key differs.

    The events list shows the param as a JSON value, with its schema, so the agent knows what to pass:

    <smllm>
    session sm-nqvfvw · plan › PLAN · instance i-0ekd6p
    Fire one event: smllm({ session: "sm-nqvfvw", event, params })
    <events>
    - planned: Fire with the plan, after you write it.
        plan (required, a JSON value, schema: {"type":"object","properties":{"steps":{"type":"array","items":{"type":"string"},"minItems":1}},"required":["steps"]}): The plan, as an object with its steps in order.
    - yield: Stop for now and stay in PLAN.
        note (optional): What you are waiting for.
    - pause: Put instance i-0ekd6p aside and return to idle.
    - unmatched: The request fits none of these; handle it from idle, then resume.
    </events>
    </smllm>
    
  3. Fire the event with a JSON value. The agent passes the value as JSON in the smllm tool’s params. From the shell, --param reads the text after = as JSON, so quote it for the shell. smllm checks the value against the schema, and refuses a value that doesn’t match:

    $ smllm fire --session sm-nqvfvw planned --param 'plan={"steps": []}'
    <smllm>
    session sm-nqvfvw · plan › PLAN · instance i-0ekd6p
    error: planned param plan doesn't match its schema. At /steps: [] has less than 1 item
    Fire one event: smllm({ session: "sm-nqvfvw", event, params })
    <events>
    - planned: Fire with the plan, after you write it.
        plan (required, a JSON value, schema: {"type":"object","properties":{"steps":{"type":"array","items":{"type":"string"},"minItems":1}},"required":["steps"]}): The plan, as an object with its steps in order.
    - yield: Stop for now and stay in PLAN.
        note (optional): What you are waiting for.
    - pause: Put instance i-0ekd6p aside and return to idle.
    - unmatched: The request fits none of these; handle it from idle, then resume.
    </events>
    </smllm>
    [exit 1]
    

    Each sentence after the error names a part of the value that fails, by its JSON path. A value that matches the schema moves the instance on:

    $ smllm fire --session sm-nqvfvw planned --param 'plan={"steps": ["Write a test that fails", "Fix the parser"]}'
    <smllm>
    session sm-nqvfvw · plan › WORK · instance i-0ekd6p
    Arrived by: planned from PLAN
    Params: plan = {"steps":["Write a test that fails","Fix the parser"]}
    <instructions>
    Carry out the plan, one step at a time.
    </instructions>
    </smllm>
    

    A command action or guard of the transition reads the value as compact JSON text from the variable SMLLM_PARAM_PLAN, the param’s name in upper snake case.

Troubleshooting

  • smllm refuses the event with planned param plan must be a JSON value: the text isn’t JSON. Quote a string as JSON, such as plan="\"one step\"", and quote the whole param for your shell.
  • smllm refuses the event with planned param plan doesn't match its schema: the value is JSON, but a part of it fails the schema. Correct each part that the error names by its JSON path, and fire the event again.
  • A string param with schema, or a JSON param with enum or pattern, is an error: schema belongs only to a JSON param, and enum and pattern only to a string param.

See also

See a machine as a diagram

When you review a machine, or show it to someone who doesn’t read YAML, draw it with smllm graph. The command prints the states and transitions as text or as a Mermaid diagram, opens an interactive diagram in your browser, or writes the diagram to a file.

Steps

The steps use this config and the machine of Your first state machine:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
description: Fix a bug, from a test that reproduces it to the fix.
initial: TRIAGE
meta:
  smllm: 1
  instance:
    kind: bug
    ref: { param: bugId, pattern: "^BUG-[0-9]+$", description: "The bug's id, such as BUG-7." }
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
    fixed:
      description: Fire after the test passes.
states:
  TRIAGE:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug, then fire reproduced with the bug's id." } }
    on:
      reproduced: { target: FIX, actions: setRef }
  FIX:
    entry: { type: prompt, params: { text: "Change the code until the test passes, then fire fixed." } }
    on:
      fixed: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Summarise the cause of the bug and the fix." } }
  1. Print the machine as text. Each state lists its transitions, with the event, the target, and the actions:

    $ smllm graph fix
    fix (initial TRIAGE)
      TRIAGE [entry point]
        reproduced → FIX (setRef)
      FIX
        fixed → DONE
      DONE [final]
    

    Without an ID, smllm graph prints every machine of the config.

  2. To put the diagram in a Markdown file, such as a pull request or a README, print it as Mermaid:

    $ smllm graph fix --mermaid
    ---
    title: fix
    ---
    stateDiagram-v2
        [*] --> TRIAGE
        TRIAGE --> FIX: reproduced
        FIX --> DONE: fixed
        DONE --> [*]
    

    GitHub and GitLab draw a fenced code block of language mermaid as a diagram.

  3. To explore the machine, open it in your browser:

    smllm graph fix --open
    

    The page shows the diagram. Select a state or a transition to read its prompts, commands, guards, and events. A note beside the diagram lists the events that every state but a final one offers, yield, pause, and unmatched, and the resume of the fallback state. Select the note to read their guidance. smllm opens the page with the default browser, or with the program that the variable BROWSER names.

  4. To share the interactive diagram, write it to an HTML page, which needs only a browser:

    $ smllm graph fix --html fix.html
    wrote fix.html
    
  5. To put the diagram in a slide or a document, write it as a PNG picture:

    $ smllm graph fix --png fix.png
    wrote fix.png
    

    When you name no ID and the config has several machines, each machine goes to a file of its own: smllm graph --png diagram.png writes diagram-ID.png for each machine ID, such as diagram-fix.png.

See also

Check machine files in your editor

When you write a machine file, let your editor check it as you type, before you run smllm validate. smllm describes the machine format as a JSON Schema. An editor that reads JSON Schema for YAML then completes each key, shows its description, and marks a key that the format doesn’t have.

Before you begin

  • An editor that runs the YAML language server, such as Visual Studio Code with the YAML extension by Red Hat.

Steps

  1. Start each machine from smllm new. Its template begins with a comment that tells the YAML language server where the schema is:

    $ smllm new review
    # yaml-language-server: $schema=https://raw.githubusercontent.com/six5536/smllm/main/schema/smllm.schema.json
    # An XState v5 machine config, in the subset that smllm reads. smllm's own data is under `meta`.
    id: review
    description: What this state machine is for.
    initial: START
    meta:
      smllm: 1
      instance:
        kind: task
        ref:
          param: taskId
          description: "The task's id."
      events:
        done:
          description: Fire when the work that this state asks for is finished.
    states:
      START:
        meta: { entryPoint: true }
        entry: { type: prompt, params: { text: "Describe what the agent should do here." } }
        on:
          done: DONE
      DONE:
        type: final
        entry: { type: prompt, params: { text: "Summarise what you did." } }
    

    The comment points at the schema of the latest smllm. For a machine file that you wrote without the template, copy the first line into it.

  2. To check against the schema of the smllm that you have installed, and to work offline, write the schema into the project:

    smllm info schema > .smllm/smllm.schema.json
    

    Then point the comment of each machine file at that file, by a path relative to the machine file:

    # yaml-language-server: $schema=./smllm.schema.json
    

    Run the command again after you upgrade smllm, because each version prints the schema of its own format.

  3. To check every machine file without a comment in each, map the schema to the files’ names in your editor’s settings. In Visual Studio Code, add this to .vscode/settings.json in the project:

    { "yaml.schemas": { "./.smllm/smllm.schema.json": "**/*.smllm.yaml" } }
    

    The key is the schema’s path, relative to the folder that you opened, or a URL, such as the URL of step 1. The value is the pattern of the files that the schema checks.

  4. Open a machine file, and type a key under states.START. The editor offers the keys of a state, such as entry, on, and type, and marks a misspelt key, such as entri.

Troubleshooting

  • The editor marks nothing: the comment of the file comes before yaml.schemas, so a comment with a wrong path turns the check off. Check the path of the comment, or remove the comment to use the setting.
  • The editor accepts a file that smllm validate rejects: the schema describes the keys of the file and their types, and smllm validate checks more, such as that each target names a state. Run smllm validate before an agent runs the machine.

See also

Drive smllm from a script

When an agent runs in a harness that has no MCP, or when a script or a test drives a machine, call smllm fire from the shell. smllm fire does what the smllm tool does: it fires one event, prints the reply that the agent reads, and exits with a code that says whether smllm accepted the event.

Before you begin

  • jq is installed, for the shell snippets that read the JSON.

Steps

The steps use this config and the machine of Your first state machine:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
description: Fix a bug, from a test that reproduces it to the fix.
initial: TRIAGE
meta:
  smllm: 1
  instance:
    kind: bug
    ref: { param: bugId, pattern: "^BUG-[0-9]+$", description: "The bug's id, such as BUG-7." }
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
    fixed:
      description: Fire after the test passes.
states:
  TRIAGE:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug, then fire reproduced with the bug's id." } }
    on:
      reproduced: { target: FIX, actions: setRef }
  FIX:
    entry: { type: prompt, params: { text: "Change the code until the test passes, then fire fixed." } }
    on:
      fixed: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Summarise the cause of the bug and the fix." } }
  1. Start a session: fire enter without --session. With --json, smllm prints one JSON object. Its field ok says whether smllm accepted the event, and session holds the session’s key. location says where the session is, and text holds the reply that the agent reads:

    $ smllm fire enter --param stateMachine=fix --json
    {"ok":true,"session":"sm-rpnmhe","location":{"machine":"fix","state":"TRIAGE","instance":"i-bzj1bm","ref":null},"text":"<smllm>\nsession sm-rpnmhe · fix › TRIAGE · bug i-bzj1bm\nArrived by: enter (new bug)\nParams: stateMachine = \"fix\"\n<instructions>\nWrite a test that reproduces the bug, then fire reproduced with the bug's id.\n</instructions>\n</smllm>\n"}
    

    In a script, keep the key and hand the text to the agent:

    reply=$(smllm fire enter --param stateMachine=fix --json)
    key=$(jq -r .session <<<"$reply")
    jq -r .text <<<"$reply"
    
  2. Fire each event with the key. Each --param gives one param, as NAME=VALUE:

    $ smllm fire --session sm-rpnmhe reproduced --param bugId=BUG-7 --json
    {"ok":true,"session":"sm-rpnmhe","location":{"machine":"fix","state":"FIX","instance":"i-bzj1bm","ref":"BUG-7"},"text":"<smllm>\nsession sm-rpnmhe · fix › FIX · bug BUG-7\nArrived by: reproduced from TRIAGE\nParams: bugId = \"BUG-7\"\n<instructions>\nChange the code until the test passes, then fire fixed.\n</instructions>\n</smllm>\n"}
    
  3. Check the exit code of each run. smllm exits with 1 when it refuses the event, and the reply says why and lists the events that the state offers:

    $ smllm fire --session sm-rpnmhe reproduced --param bugId=BUG-8
    <smllm>
    session sm-rpnmhe · fix › FIX · bug BUG-7
    error: reproduced is not offered here (offered: fixed, yield, pause, unmatched)
    Fire one event: smllm({ session: "sm-rpnmhe", event, params })
    <events>
    - fixed: Fire after the test passes.
    - yield: Stop for now and stay in FIX.
        note (optional): What you are waiting for.
    - pause: Put bug BUG-7 aside and return to idle.
    - unmatched: The request fits none of these; handle it from idle, then resume.
    </events>
    </smllm>
    [exit 1]
    

    smllm exits with 2 on a usage error, such as a key that names no session. It prints the error to standard error, and prints no reply:

    $ smllm fire --session sm-zzzzzz fixed
    error: no smllm session sm-zzzzzz: pass the key shown in the latest <smllm> header, or fire enter with no session to start a new one
    [exit 2]
    
  4. To see where a session is without firing an event, show it. The reply is the one that the smllm tool gives when the agent calls it with no event:

    $ smllm session show sm-rpnmhe
    <smllm>
    session sm-rpnmhe · fix › FIX · bug BUG-7
    <instructions>
    Change the code until the test passes, then fire fixed.
    </instructions>
    Fire one event: smllm({ session: "sm-rpnmhe", event, params })
    <events>
    - fixed: Fire after the test passes.
    - yield: Stop for now and stay in FIX.
        note (optional): What you are waiting for.
    - pause: Put bug BUG-7 aside and return to idle.
    - unmatched: The request fits none of these; handle it from idle, then resume.
    </events>
    </smllm>
    

    smllm session show --json prints the same object as smllm fire --json.

  5. Put the steps together. This bash script shows each reply and fires the event that you type, with its params, until the session is idle again:

    reply=$(smllm fire enter --param stateMachine=fix --json)
    key=$(jq -r .session <<<"$reply")
    while [ "$(jq -r '.location.state // empty' <<<"$reply")" != "" ]; do
      jq -r .text <<<"$reply"
      read -r -p 'event and params: ' event params
      args=()
      for p in $params; do args+=(--param "$p"); done
      reply=$(smllm fire --session "$key" "$event" "${args[@]}" --json)
    done
    jq -r .text <<<"$reply"
    

    location.state is null when the session is idle. A run that smllm refuses exits with 1 and still prints the reply, so the loop shows why and asks again. In a harness, the agent’s reply takes the place of read.

Troubleshooting

  • smllm refuses fire with no session given: only enter starts a session. Pass --session with the key of an earlier reply for every other event.
  • The agent of a harness doesn’t see the instructions: smllm fire prints the reply, and the harness has to show it to the agent. Give the agent the text of the JSON, or the whole output without --json.

See also

Read the audit of a session

When you want to know what an agent did in a session, such as why it skipped a step or how often smllm refused an event, read the session’s audit. smllm writes one line for each call that drove the session: each event that the agent fired, each stop that the harness asked about, and the session’s start, with the outcome of each.

Steps

The steps use this config and the machine of Your first state machine:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
description: Fix a bug, from a test that reproduces it to the fix.
initial: TRIAGE
meta:
  smllm: 1
  instance:
    kind: bug
    ref: { param: bugId, pattern: "^BUG-[0-9]+$", description: "The bug's id, such as BUG-7." }
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
    fixed:
      description: Fire after the test passes.
states:
  TRIAGE:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug, then fire reproduced with the bug's id." } }
    on:
      reproduced: { target: FIX, actions: setRef }
  FIX:
    entry: { type: prompt, params: { text: "Change the code until the test passes, then fire fixed." } }
    on:
      fixed: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Summarise the cause of the bug and the fix." } }
  1. Let the agent work in a session. In this session, the agent enters the machine, fires fixed too early, tries to stop, and then reproduces the bug:

    $ smllm fire --session sm-nqvfvw enter --param stateMachine=fix --json
    {"ok":true,"session":"sm-nqvfvw","location":{"machine":"fix","state":"TRIAGE","instance":"i-419405","ref":null},"text":"<smllm>\nsession sm-nqvfvw · fix › TRIAGE · bug i-419405\nArrived by: enter (new bug)\nParams: stateMachine = \"fix\"\n<instructions>\nWrite a test that reproduces the bug, then fire reproduced with the bug's id.\n</instructions>\n</smllm>\n"}
    $ smllm fire --session sm-nqvfvw fixed --json
    {"ok":false,"session":"sm-nqvfvw","location":{"machine":"fix","state":"TRIAGE","instance":"i-419405","ref":null},"text":"<smllm>\nsession sm-nqvfvw · fix › TRIAGE · bug i-419405\nerror: fixed is not offered here (offered: reproduced, yield, pause, unmatched)\nFire one event: smllm({ session: \"sm-nqvfvw\", event, params })\n<events>\n- reproduced: Fire after a test reproduces the bug.\n    bugId (required, pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.\n- yield: Stop for now and stay in TRIAGE.\n    note (optional): What you are waiting for.\n- pause: Put bug i-419405 aside and return to idle.\n- unmatched: The request fits none of these; handle it from idle, then resume.\n</events>\n</smllm>\n"}
    [exit 1]
    

    When the agent tries to stop, Claude Code’s stop hook asks smllm, and smllm refuses the stop with this text:

    <smllm>
    session sm-nqvfvw · fix › TRIAGE · bug i-419405
    Fire one event: smllm({ session: "sm-nqvfvw", event, params })
    <events>
    - reproduced: Fire after a test reproduces the bug.
        bugId (required, pattern: ^BUG-[0-9]+$): The bug's id, such as BUG-7.
    - yield: Stop for now and stay in TRIAGE.
        note (optional): What you are waiting for.
    - pause: Put bug i-419405 aside and return to idle.
    - unmatched: The request fits none of these; handle it from idle, then resume.
    </events>
    </smllm>
    
    $ smllm fire --session sm-nqvfvw reproduced --param bugId=BUG-7 --json
    {"ok":true,"session":"sm-nqvfvw","location":{"machine":"fix","state":"FIX","instance":"i-419405","ref":"BUG-7"},"text":"<smllm>\nsession sm-nqvfvw · fix › FIX · bug BUG-7\nArrived by: reproduced from TRIAGE\nParams: bugId = \"BUG-7\"\n<instructions>\nChange the code until the test passes, then fire fixed.\n</instructions>\n</smllm>\n"}
    
  2. Find the key of the session. Each <smllm> header shows it, after session, and smllm session list lists every session, the most recently active first, with its harness, where it is, when it was last active, and its folder. smllm prints the full path of each folder, and this guide shortens your home folder to ~:

    $ smllm session list
    sm-nqvfvw  claude  fix i-419405  last active 2026-01-05 09:04 UTC  ~/project
    
  3. Print the audit of the session:

    $ smllm session show sm-nqvfvw --audit
    2026-01-05 09:00 UTC  agent  bind  idle  accepted
    2026-01-05 09:01 UTC  agent  fire enter  fix i-419405 idle → TRIAGE  accepted
    2026-01-05 09:02 UTC  agent  fire fixed  fix i-419405 TRIAGE  refused: fixed is not offered here (offered: reproduced, yield, pause, unmatched)
    2026-01-05 09:03 UTC  agent  stop  fix i-419405 TRIAGE  block
    2026-01-05 09:04 UTC  agent  fire reproduced  fix i-419405 TRIAGE → FIX  accepted
    

    Each line starts with the time of the call, in UTC, and its source: agent, or host for an event that only the host that embeds smllm can fire. The call follows: bind when a harness binds its session to smllm, fire and its event, or stop when the harness asks whether the agent can stop. Then come the machine, the instance, and the state before the call. When the call moved the session, → and the state after the call follow.

    The outcome ends the line: accepted, refused with the reason, block for a stop that smllm refused, or runaway for a stop that smllm allowed because the agent tried to stop again without firing an event.

  4. To filter the audit, or to keep it with a report, print it as JSON lines, one object for each call:

    $ smllm session show sm-nqvfvw --audit --json
    {"at":1767603600000,"session":"sm-nqvfvw","source":"agent","call":"bind","event":null,"machine":null,"instance":null,"from":null,"to":null,"params":{},"offered":["enter"],"outcome":"accepted","reason":null,"trace":[],"binding":{"harness":"claude","hostSession":"cc-1","cwd":"~/project","configs":["~/project/.smllm/config.toml"]},"reply":"sha256:e32bba214c735287af4ff7a2590c135f3c5c643ebd26df19f906428b1dc4cc23"}
    {"at":1767603660000,"session":"sm-nqvfvw","source":"agent","call":"fire","event":"enter","machine":"fix","instance":"i-419405","from":null,"to":"TRIAGE","params":{"stateMachine":"fix"},"offered":[],"outcome":"accepted","reason":null,"trace":[],"binding":null,"reply":"sha256:ea7f7859c16348a748d815d7479e0a07af296be7572d634718dfc4ca8f1f1255"}
    {"at":1767603720000,"session":"sm-nqvfvw","source":"agent","call":"fire","event":"fixed","machine":"fix","instance":"i-419405","from":"TRIAGE","to":"TRIAGE","params":{},"offered":["reproduced","yield","pause","unmatched"],"outcome":"refused","reason":"fixed is not offered here (offered: reproduced, yield, pause, unmatched)","trace":[],"binding":null,"reply":"sha256:4f74289a359e206952a50a14a262ac1d163cbb48796beadda42644f890f8d96b"}
    {"at":1767603780000,"session":"sm-nqvfvw","source":"agent","call":"stop","event":null,"machine":"fix","instance":"i-419405","from":"TRIAGE","to":"TRIAGE","params":{},"offered":["reproduced","yield","pause","unmatched"],"outcome":"block","reason":null,"trace":[],"binding":null,"reply":"sha256:03ef41907cf05fd767716289eed9eb53feeb34c86ad415595a17dc74bfaf40cf"}
    {"at":1767603840000,"session":"sm-nqvfvw","source":"agent","call":"fire","event":"reproduced","machine":"fix","instance":"i-419405","from":"TRIAGE","to":"FIX","params":{"bugId":"BUG-7"},"offered":[],"outcome":"accepted","reason":null,"trace":[],"binding":null,"reply":"sha256:2b22dc5d256dfc28505f76bbfb423df2232dced33f0096a21fa4c86a30508aaa"}
    

    Each object has the fields of the line, and more. at holds the time in Unix milliseconds, and params the params of the call. offered lists the events that the reply offered. trace records what the call ran, such as the result of each guard.

    The call that created the session also has binding: the harness, its session, the folder, and the configs. That call is a bind, or a fire enter that started a session without a key.

    This command lists only the calls that smllm didn’t accept:

    smllm session show sm-nqvfvw --audit --json | jq -c 'select(.outcome != "accepted") | {call, event, from, outcome, reason}'
    

    For the session of step 1, it prints:

    {"call":"fire","event":"fixed","from":"TRIAGE","outcome":"refused","reason":"fixed is not offered here (offered: reproduced, yield, pause, unmatched)"}
    {"call":"stop","event":null,"from":"TRIAGE","outcome":"block","reason":null}
    

Troubleshooting

  • The audit has no line for a reply’s text: each line holds reply, the hash of the reply that the agent was shown, as sha256: and the hex of its SHA-256, not the text. To check that the agent was shown a text, compare the SHA-256 of the text with reply.
  • The audit takes space: smllm keeps the file of each session, audit/KEY.jsonl in the folder of the sessions, until you remove it. On Linux and macOS the folder is smllm/ in $XDG_STATE_HOME, which is ~/.local/state/ by default. On Windows it is smllm\ in %APPDATA%.

See also

The command

This page lists the commands of smllm, then the forms of their output and their exit codes, then the files that smllm reads and writes. Commands and options lists the options of each command, as smllm COMMAND --help prints them.

Commands

smllm has these commands:

  • smllm init [--user] creates .smllm/config.toml in the project, or with --user the user config ~/.config/smllm/config.toml.
  • smllm new ID [--write] prints a template state machine with the ID ID. With --write, it writes ID.smllm.yaml beside the config and lists it in the config.
  • smllm validate [PATHS]… checks the configs, their state machines, and the saved instances, or only the files that PATHS names. It prints each finding as file:line: level: message (CODE), where CODE is the rule that the finding breaks, as The rules of smllm validate lists. --warnings and --info list the warnings and the info findings too.
  • smllm graph [ID] prints the states and transitions of a state machine, or of all of them, as text. --json prints JSON, and --mermaid a Mermaid state diagram. --open opens the machine as a diagram in your web browser, where you select a state or a transition to read its prompts, commands, guards, and events. --html FILE writes the same page to a file, and --png FILE writes the diagram as a picture. With several machines, --png m.png writes m-ID.png for each machine ID.
  • smllm fire --session KEY EVENT [--param k=v]… fires an event from a shell, as the agent does through the smllm tool.
  • smllm statusline [--json] prints where a session is, as The status line row describes.
  • smllm session list and smllm session show KEY list the sessions, or show where one is, as the smllm tool does with no event. smllm session show KEY --audit [--json] prints the session’s audit: one line for each call that drove the session, accepted or refused, with its time, its source (agent or host), the call, the state, and the outcome.
  • smllm instance list and smllm instance show ID list the instances of the configured state machines, or show one, by its ID or its ref, with its history.
  • smllm harness install HARNESS… and smllm harness status HARNESS… install smllm into each harness, or show what is installed, as Harnesses describes.
  • smllm info schema prints the JSON Schema of the state machine format, for completion in your editor, and smllm info rules prints the rules page.
  • smllm compile FILE writes a config or a state machine as JSON for @six5536/smllm-wasm.
  • smllm completions SHELL prints a completion script for bash, elvish, fish, powershell, or zsh.

In these commands, KEY is a session key, EVENT is the name of an event, and k=v gives the param k the value v. A JSON param reads v as JSON text, such as plan={"steps":[]}.

Output and exit codes

smllm exits with one of three codes:

  • 0 on success.
  • 1 when it finds errors: an error in a config, a rejected event, or a failed hook.
  • 2 on a usage error or an internal error.

smllm statusline always exits with 0, so that it never blanks a status line. Errors go to standard error with the prefix error: . With --json, a command prints exactly one JSON object, except for an audit, which it prints as JSON lines.

Files

smllm reads and writes these files:

  • Configs: .smllm/config.toml in the project and ~/.config/smllm/config.toml, which smllm combines. When both define a state machine with the same ID, the project’s wins.
  • Instances: .smllm/state/ in the project, which git ignores.
  • Sessions: ~/.local/state/smllm/.
  • Audits: audit/KEY.jsonl in the folder of the sessions, one for each session, which smllm keeps until you remove it. Each line holds the hash of the reply that the agent was shown, as sha256: and the hex of the SHA-256 of the reply’s UTF-8 text, not the text itself.
  • Cache: ~/.cache/smllm/, a copy of each load of your machines, which is safe to delete.

Commands and options

This page lists each command of smllm, with its arguments and its options, as smllm COMMAND --help prints them.

The commands:

smllm

State machines for LLM agents: declarative XState-style machines that drive an agent’s turn loop through its hooks and one MCP tool.

Usage: smllm [OPTIONS] [COMMAND]

Exit codes: 0 success, 1 errors found (a config error, a rejected event, or a failed hook), 2 a usage or internal error.

Documentation: https://github.com/six5536/smllm

Subcommands

  • init — Create .smllm/config.toml in this directory, or the user config.
  • new — Print a template state machine, or write it to <ID>.smllm.yaml.
  • validate — Check the configs, their state machines, and the saved instances.
  • fire — Fire an event from the shell, as the smllm tool does.
  • session — List sessions, or show where one is.
  • instance — List instances, or show one with its history.
  • harness — Install or check a harness integration (Claude Code, Codex, Cursor, and others).
  • graph — Print the states and transitions of the state machines.
  • info — Print reference information.
  • compile — Compile a config or state machine to compact JSON, for smllm-wasm hosts.
  • statusline — Print where a session is, as a status line row or as JSON.
  • completions — Print a shell completion script to stdout.

Options

  • --config <FILE> — Use only this config file (SMLLM_CONFIG also sets it).
  • -V, --version — Print the version and exit.

smllm init

Create .smllm/config.toml in this directory, or the user config

Usage: smllm init [OPTIONS]

Options

  • --user — Create the user config (~/.config/smllm/config.toml) instead.
  • --json — Print JSON.

smllm new

Print a template state machine, or write it to <ID>.smllm.yaml

Usage: smllm new [OPTIONS] <ID>

Arguments

  • <ID> — The state machine id.

Options

  • --dir <DIR> — The directory to write it to (default: the directory of the config).
  • --write — Write the file and list it in the config, instead of printing it.
  • --json — Print JSON.

smllm validate

Check the configs, their state machines, and the saved instances

Usage: smllm validate [OPTIONS] [PATHS]...

Arguments

  • <PATHS> — Config (.toml) or state machine (.yaml) files (default: the configs found).

Options

  • --json — Print JSON.
  • --warnings — List the warnings too.
  • --info — List the info findings too.

smllm fire

Fire an event from the shell, as the smllm tool does

Usage: smllm fire [OPTIONS] <EVENT>

Arguments

  • <EVENT> — The event to fire.

Options

  • --session <KEY> — The session key (omit it only to start a session with enter).
  • --param <KEY=VALUE> — A param of the event (repeatable).
  • --json — Print JSON.

smllm session

List sessions, or show where one is

Usage: smllm session <COMMAND>

Subcommands

  • list — List sessions, the most recently active first.
  • show — Show where a session is, as the smllm tool does with no event, or with –audit every call that drove it.

smllm session list

List sessions, the most recently active first

Usage: smllm session list [OPTIONS]

Options

  • --json — Print JSON.

smllm session show

Show where a session is, as the smllm tool does with no event, or with –audit every call that drove it

Usage: smllm session show [OPTIONS] <KEY>

Arguments

  • <KEY> — The session key.

Options

  • --audit — Print the session’s audit: one line for each call, or with –json the audit file’s JSON lines.
  • --json — Print JSON.

smllm instance

List instances, or show one with its history

Usage: smllm instance <COMMAND>

Subcommands

  • list — List instances of the configured state machines.
  • show — Show an instance, by id or ref, with its history.

smllm instance list

List instances of the configured state machines

Usage: smllm instance list [OPTIONS]

Options

  • --json — Print JSON.

smllm instance show

Show an instance, by id or ref, with its history

Usage: smllm instance show [OPTIONS] <KEY>

Arguments

  • <KEY> — The session key, or instance id or ref.

Options

  • --json — Print JSON.

smllm harness

Install or check a harness integration (Claude Code, Codex, Cursor, and others)

Usage: smllm harness <COMMAND>

Subcommands

  • install — Install or update a harness integration.
  • status — Show the state of each part of a harness integration.

smllm harness install

Install or update a harness integration

Usage: smllm harness install [OPTIONS] <HARNESS>...

Arguments

  • <HARNESS> — The harnesses: claude, codex, factory, gemini, copilot, cursor, pi, opencode, kilo, qwen, devin, agents, or all.

Options

  • --scope <S> — The scope: project, user, or local (local for Claude Code and Copilot only).

    Default value: project

  • --without <PART> — Leave out a part, such as instructions, hooks, mcp, permissions, or statusline (repeatable).

  • --force — Overwrite parts edited by hand.

  • --json — Print JSON.

smllm harness status

Show the state of each part of a harness integration

Usage: smllm harness status [OPTIONS] <HARNESS>...

Arguments

  • <HARNESS> — The harnesses: claude, codex, factory, gemini, copilot, cursor, pi, opencode, kilo, qwen, devin, agents, or all.

Options

  • --scope <S> — The scope: project, user, or local (local for Claude Code and Copilot only).

    Default value: project

  • --without <PART> — Leave out a part, such as instructions, hooks, mcp, permissions, or statusline (repeatable).

  • --force — Overwrite parts edited by hand.

  • --json — Print JSON.

smllm graph

Print the states and transitions of the state machines

Usage: smllm graph [OPTIONS] [ID]

Arguments

  • <ID> — A state machine id (default: all of them).

Options

  • --json — Print JSON.
  • --mermaid — Print a Mermaid stateDiagram-v2.
  • --open — Open the interactive diagram in the default web browser (BROWSER sets the program).
  • --html <FILE> — Write the interactive diagram to FILE, an HTML page that needs only a browser.
  • --png <FILE> — Write the diagram as a PNG picture to FILE, or for several machines to STEM-ID.EXT beside it.

smllm info

Print reference information

Usage: smllm info <COMMAND>

Subcommands

  • schema — Print the JSON Schema of the state machine format.
  • rules — Print the rules that validate checks, with the code that each finding ends with, as Markdown.

smllm info schema

Print the JSON Schema of the state machine format

Usage: smllm info schema

smllm info rules

Print the rules that validate checks, with the code that each finding ends with, as Markdown

Usage: smllm info rules

smllm compile

Compile a config or state machine to compact JSON, for smllm-wasm hosts

Usage: smllm compile [OPTIONS] <FILE>

Arguments

  • <FILE> — A config.toml or a state machine file.

Options

  • -o, --out <OUT> — The file to write to, instead of stdout.

smllm statusline

Print where a session is, as a status line row or as JSON.

The statusline command reads Claude Code’s status JSON on stdin and shows the session bound to its session_id. It prints nothing when there is nothing to show. It always exits 0, because a non-zero exit blanks Claude Code’s whole status line.

Usage: smllm statusline [OPTIONS]

Options

  • --session <KEY> — The session key (default: the session bound to the session_id of the status JSON on stdin).

  • --json — Print JSON ({} when there is nothing to show).

  • --color <WHEN> — When to colour the row (default: auto).

    Possible values:

    • auto: Colour, unless NO_COLOR is set and not empty
    • always: Colour
    • never: No colour

smllm completions

Print a shell completion script to stdout

Usage: smllm completions <SHELL>

Arguments

  • <SHELL> — The shell to print a completion script for.

    Possible values: bash, elvish, fish, powershell, zsh

The machine file

This page lists each key of a machine file, *.smllm.yaml, as the JSON Schema that smllm info schema prints describes it. Each section is one object of the file, named by its path from the top of the file, in the order that you meet them. In a path, <STATE> stands for the name of a state, <EVENT> for the name of an event, <PARAM> for the name of a param, and [] for an item of a list. Actions, guards, and transitions, which several keys take, have sections of their own. A key without yes under Required can be left out.

The machine

KeyTypeRequiredDescription
idstringyesThe machine’s id, unique within the config: letters, digits, _, and -.
descriptionstringThe machine’s description, which the idle list shows.
initialstringyesThe state in which a new instance starts.
metaobjectyessmllm’s data for the machine.
statesmap of objectsyesThe machine’s states. smllm does not support nested states.

meta

smllm’s data for the machine.

KeyTypeRequiredDescription
smllmintegeryesThe smllm format version, which is 1.
instanceobjectWhat the machine calls an instance, and the instance’s ref.
eventsmap of objectsThe machine’s events, each declared once. A built-in event can set only its description.
sharedActionslist of objectsActions added to the entry or exit actions of several states.

states.<STATE>

One state.

KeyTypeRequiredDescription
descriptionstringThe state’s description.
typeatomic or finalThe state’s type. A final state completes the instance. atomic: An ordinary state, the default. final: A state that completes the instance.
metaobjectsmllm’s data for the state.
entryaction, or a list of themThe actions that run when the state is entered. Without a prompt among them, smllm shows the prompt file enter-<STATE>.md, if it exists. The action setRef can be written as its bare name.
exitaction, or a list of themThe actions that run when the state is left. The action setRef can be written as its bare name.
onmap of (transition, or a list of them)The transitions for each event. A transition can be written as the name of its target state.
alwaystransition, or a list of themTransitions without an event. smllm leaves a state with always at once. A transition can be written as the name of its target state.
invokeobject, or a list of themThe host’s work while an instance is in this state. The state handles an event of source: host, through which the host fires the work’s result.

meta.instance

KeyTypeRequiredDescription
kindstringThe word for an instance in the text that smllm writes, such as issue. The default is instance.
refobjectThe instance’s ref: its id outside smllm, such as an issue number.

meta.events.<EVENT>

KeyTypeRequiredDescription
descriptionstringThe event’s guidance for the agent, unless a transition gives its own.
paramsobjectThe event’s params: a JSON Schema object of string and JSON properties.
sourceagent or hostWho fires the event: agent, the default, or host. Only the host that embeds the engine fires a host event, and the agent never sees it.

meta.sharedActions[]

One meta.sharedActions entry.

KeyTypeRequiredDescription
stateslist of stringsyesThe states that the actions are added to.
positionbefore or afteryesWhether the actions run before or after the state’s own entry or exit actions. before: Before the state’s own actions. after: After the state’s own actions.
entryaction, or a list of themActions added to each state’s entry. The action setRef can be written as its bare name.
exitaction, or a list of themActions added to each state’s exit. The action setRef can be written as its bare name.

states.<STATE>.meta

smllm’s data for a state.

KeyTypeRequiredDescription
entryPointbooleanWhether the agent can enter this state directly from idle. Default: false.
fallbackbooleanWhether the unmatched event enters this state. At most one state of a machine can be the fallback. Default: false.
paramDescriptionsmap of map of stringsPrompts for params in this state, by event and then by param.

Actions

These keys take actions: states.<STATE>.entry, states.<STATE>.exit, meta.sharedActions[].entry, meta.sharedActions[].exit, <transition>.actions.

An action, written as {type, params}.

type: prompt

Shows text to the agent.

It needs params, an object with these keys:

KeyTypeRequiredDescription
textstringThe text itself.
filestringA file of text, relative to the machine file, which smllm reads each time it shows the prompt.

type: command

Runs a command.

It needs params, an object with these keys:

KeyTypeRequiredDescription
runstring or list of stringsyesA string that a shell runs, or a list of a program and its arguments, run without a shell. String: A string that sh -c runs, or cmd /C on Windows. List of strings: A program and its arguments, run without a shell.
timeoutSecsintegerThe time limit in seconds. The default is 60.
cwdstringThe working directory, relative to the machine file. The default is the session’s working directory.

type: setRef

Sets the instance’s ref from the event’s ref param.

It takes no params.

Transitions

These keys take transitions: states.<STATE>.on.<EVENT>, states.<STATE>.always.

A transition object.

KeyTypeRequiredDescription
targetstringThe target state. A transition without a target stays in the state without re-entering it.
guardguardA condition that must pass for the transition to be taken.
actionsaction, or a list of themThe actions that run when the transition is taken. The action setRef can be written as its bare name.
reenterbooleanWhether a transition to its own state leaves and re-enters it, as in XState.
descriptionstringGuidance for the event in this state.

states.<STATE>.invoke

The host’s work while an instance is in its state: XState’s invoke, with only src and input. The host runs the work and fires its result as an event of source: host.

KeyTypeRequiredDescription
srcstringyesThe name of the work, by which the host finds its code.
inputobjectFixed values that the host passes to the work.

meta.instance.ref

KeyTypeRequiredDescription
paramstringThe name of the ref’s param, which enter and setRef use. The default is ref.
descriptionstringThe prompt that asks for the ref.
patternstringA pattern that the ref must match, in the syntax of a param’s pattern.

meta.events.<EVENT>.params

A subset of a JSON Schema of type: object.

KeyTypeRequiredDescription
typestringyesAlways object.
propertiesmap of objectsThe params, by name.
requiredlist of stringsThe names of the required params.

Guards

These keys take guards: <transition>.guard.

A guard, written as {type, params}.

type: command

Passes when the command exits with status 0.

It needs params, an object with these keys:

KeyTypeRequiredDescription
runstring or list of stringsyesA string that a shell runs, or a list of a program and its arguments, run without a shell. String: A string that sh -c runs, or cmd /C on Windows. List of strings: A program and its arguments, run without a shell.
timeoutSecsintegerThe time limit in seconds. The default is 60.
cwdstringThe working directory, relative to the machine file. The default is the session’s working directory.

type: visits

Passes when state has been entered at least atLeast times.

It needs params, an object with these keys:

KeyTypeRequiredDescription
statestringyesThe state whose entries are counted.
atLeastintegeryesThe least number of entries, counting the current one.

meta.events.<EVENT>.params.properties.<PARAM>

One param’s schema.

KeyTypeRequiredDescription
typestring or jsonstring, the default, or json, for a param that takes any JSON value.
descriptionstringThe prompt for the param.
enumlist of stringsThe allowed values of a string param.
patternstringA pattern that a string param’s value must match, in Rust regex syntax: the common subset of JSON Schema’s ECMA-262 patterns, without lookaround or backreferences. In smllm, \d, \w, and \s match Unicode, and in a JavaScript host they match only ASCII. To match ASCII digits in both, write [0-9].
schemastring or objectThe JSON Schema of a json param’s value, draft-07 or 2020-12: a path relative to the machine file, or the schema itself as a map. String: A JSON file, relative to the machine file. The path can lead outside the machine’s folder. Object: The schema itself.

The agent’s text

This page lists what smllm says to the agent: first the parts of a <smllm> block in the order in which they appear, then the header, the idle list, the entry block, and the events list, then the built-in events, and last the text of a reply, a refused event, and a blocked stop. The agent reads this text in the hooks of its harness and in the replies of the smllm tool, and smllm fire prints the same text in a shell.

The example machine

The examples of this page come from one session, in this order, with this config and this machine. The machine guides the agent through an issue: in PLAN the agent writes a plan, and a command guard checks that the plan exists. In WORK the agent makes the change, and DONE ends the work.

.smllm/config.toml

[machines]
files = ["dev.smllm.yaml"]

.smllm/dev.smllm.yaml

id: dev
description: Work on an issue, from a plan to a change.
initial: PLAN
meta:
  smllm: 1
  instance:
    kind: issue
    ref: { param: issueId, pattern: "^GH-[0-9]+$", description: "The issue's id, such as GH-12." }
  events:
    planned:
      description: Fire after you write the plan in PLAN.md.
      params:
        type: object
        properties:
          size: { description: "How large the change is.", enum: [small, large] }
        required: [size]
    done:
      description: Fire after you make the change.
states:
  PLAN:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Write a plan for the issue in PLAN.md, then fire planned." } }
    on:
      planned:
        - { target: WORK, guard: { type: command, params: { run: "test -f PLAN.md" } } }
        - { target: PLAN, reenter: true }
  WORK:
    entry: { type: prompt, params: { text: "Make the change that PLAN.md describes, then fire done." } }
    on:
      done: DONE
  DONE:
    type: final
    entry: { type: prompt, params: { text: "Summarise the change for the issue." } }

The block

Each text that smllm gives the agent is one block, fenced in <smllm> and </smllm>. A block holds these parts, in this order, and leaves out each part that it doesn’t need:

  1. The header.
  2. Arrived by: HOW, which says how the instance reached its state, as the entry block lists.
  3. Params: NAME = "VALUE", …, the params of the event that the agent fired.
  4. The notes, one on each line, such as Paused issue GH-12 at WORK.
  5. The trace of the guards that smllm checked, and the failures of actions and prompts.
  6. error: MESSAGE, when smllm refused the event, as a refused event lists.
  7. <instructions>, which holds the text of the prompts that the state’s actions gave, in the order in which they ran, with a blank line between two prompts.
  8. After a final state, Completed KIND LABEL. You are now in idle., then the idle list.
  9. The events list.

The text of a machine’s author goes into <instructions> as it is. Every other line goes into the block on one line of its own: a line break shows as \n, and a < that starts the tag smllm, instructions, or events shows as &lt;. A ref or a param value therefore can’t close the block. smllm validate warns about author text that contains a closing tag, as rule AGT-1 says.

The header

The first line of every block says where the session is. It takes one of three forms:

WhereHeader
In a machinesession KEY · MACHINE › STATE[ (visit N)] · KIND LABEL
In idlesession KEY · idle
No session was madeno session · idle

The parts mean this:

  • KEY is the session key, such as sm-nqvfvw, which the agent passes on every call of the smllm tool.
  • MACHINE is the state machine, and STATE is the instance’s current state.
  • (visit N) appears from the second visit to a state, and N is the number of the visit. Each time the instance enters the state counts as a visit, also when enter or resume brings it there.
  • KIND is what the machine calls an instance, from meta.instance.kind, and instance when the machine doesn’t say.
  • LABEL is the instance’s ref, such as GH-12, after the ref is set, and its ID, such as i-k0xzjt, before.
  • no session · idle heads the refusal of an enter that the agent fired without a session key, because smllm made no session for it.

The idle list

A session that holds no instance is in idle. The idle list is the block of idle, which the agent sees when its session starts:

<smllm>
session sm-nqvfvw · idle
State machines:
- dev: Work on an issue, from a plan to a change.
    issue id param issueId (pattern: ^GH-[0-9]+$): The issue's id, such as GH-12.
    starts at: PLAN; entry points: PLAN
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: dev): The state machine to enter.
    issueId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

After the header, the notes, and any error, the idle list has these lines:

  • <instructions> holds the text that the config’s [idle] table gives, when it gives one.
  • State machines: starts the list of the machines of the config, or No state machines are configured. replaces it.
  • - ID: DESCRIPTION names a machine.
  • KIND id param PARAM (pattern: PATTERN): DESCRIPTION names the param that holds the machine’s ref.
  • starts at: INITIAL; entry points: STATE, … names the initial state and the states that enter can start in.
  • Interrupted: KIND LABEL (MACHINE) at STATE names the instance that unmatched put aside, when there is one.
  • Paused: starts the list of paused instances, with one - KIND LABEL (MACHINE) at STATE line for each. The list shows at most 10, the most recently updated first.
  • …and N more paused: fire listPaused to list them all. follows a list that leaves some out.
  • The events list of idle comes last.

The entry block

When an event moves the instance to a state, the reply is the state’s entry block: the header, Arrived by, the params, the notes and the trace, and <instructions>. The entry block has no events list:

$ smllm fire --session sm-nqvfvw enter --param stateMachine=dev --param issueId=GH-12
<smllm>
session sm-nqvfvw · dev › PLAN · issue GH-12
Arrived by: enter (new issue)
Params: stateMachine = "dev", issueId = "GH-12"
<instructions>
Write a plan for the issue in PLAN.md, then fire planned.
</instructions>
</smllm>

Arrived by takes one of these forms:

  • EVENT from STATE: the event EVENT moved the instance from STATE.
  • enter (new KIND): enter started a new instance.
  • enter: enter continued an instance at its saved state.
  • enter (jump from STATE): enter with state moved an instance from STATE to an entry point.
  • enter (reopened from STATE): enter with state reopened a completed instance.
  • enter (saved state STATE no longer exists): enter with state moved an instance whose saved state the machine no longer has.
  • resume: resume returned the session to the instance that it put aside.
  • unmatched from STATE: unmatched moved the instance to the machine’s fallback state.

A form ends with via STATE, … when the instance passed through states with always transitions on its way.

The notes and the trace take these forms:

  • Took over from session KEY (last active TIME): the instance belonged to another session, which loses it.
  • Reopened KIND LABEL at STATE.
  • Guard: command `RUN` → true, or Guard: command `RUN` → false (DETAIL): the result of a command guard, where DETAIL is the exit status and the end of the command’s output.
  • Guard: visits STATE ≥ N → BOOL (visits: COUNT): the result of a visits guard.
  • Action failed: LIST[INDEX] KIND: DETAIL: an action failed, and smllm skipped the commands after it in the same list.
  • Prompt failed: FILE: REASON: smllm couldn’t read the file of a prompt.
  • No always transition of STATE matched; stopped there and Stopped after 32 always transitions; check for a loop: the always transitions couldn’t leave a state.

The events list

The events list names each event that the agent can fire in the session’s current state, with its params. The agent sees it when it calls the smllm tool with no event, when its session starts again, after a refused event, and when smllm blocks a stop. smllm session show prints the same block:

$ smllm session show sm-nqvfvw
<smllm>
session sm-nqvfvw · dev › PLAN · issue GH-12
<instructions>
Write a plan for the issue in PLAN.md, then fire planned.
</instructions>
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- planned: Fire after you write the plan in PLAN.md.
    size (required, one of: small, large): How large the change is.
- yield: Stop for now and stay in PLAN.
    note (optional): What you are waiting for.
- pause: Put issue GH-12 aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>

The list has these forms:

  • Fire one event: smllm({ session: "KEY", event, params }) shows the call that fires an event. Without a session, it reads Fire one event: smllm({ event, params }).
  • <events> holds one item for each event: - NAME: DESCRIPTION.
  • Each param of the event follows its event on a line of its own, indented by four spaces: NAME (required|optional[, one of: VALUE, …][, pattern: PATTERN][, a JSON value[, schema: SCHEMA]]): DESCRIPTION.

In a machine state, the list names the state’s own events in the order of its on, then the built-in events. An event’s description comes from its transition’s description, or else from meta.events. A param’s description comes from the state’s meta.paramDescriptions, or else from the param’s description. The list leaves out each event that the machine marks with source: host, because only the host fires it.

The built-in events

smllm adds its built-in events to every machine:

EventOfferedParamsWhat it does
enterIn idlestateMachine, the ref param, and stateStarts a new instance, or continues, jumps, or reopens one.
resumeIn idle with an interrupted instance, and in the fallback state after unmatchedNoneReturns to the state that unmatched left.
listPausedIn idle, when the idle list leaves paused instances outNoneLists every paused instance, and changes nothing.
yieldIn each state that isn’t finalnote, optionalKeeps the instance in its state and lets the agent stop.
pauseIn each stateNonePuts the instance aside as paused, and returns the session to idle.
unmatchedIn each stateNoneMoves the instance to the fallback state, or puts it aside as interrupted and returns the session to idle.

A machine can change the description of a built-in event in meta.events, but not its params or its source, as rule AGT-2 says. A state can’t handle a built-in event under on.

A refused event

When smllm refuses an event, nothing changes, and the reply is the header, error: MESSAGE, and the events list. smllm fire exits with 1:

$ smllm fire --session sm-nqvfvw done
<smllm>
session sm-nqvfvw · dev › PLAN · issue GH-12
error: done is not offered here (offered: planned, yield, pause, unmatched)
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- planned: Fire after you write the plan in PLAN.md.
    size (required, one of: small, large): How large the change is.
- yield: Stop for now and stay in PLAN.
    note (optional): What you are waiting for.
- pause: Put issue GH-12 aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>
[exit 1]
$ smllm fire --session sm-nqvfvw planned --param size=huge
<smllm>
session sm-nqvfvw · dev › PLAN · issue GH-12
error: planned param size must be one of: small, large (got "huge")
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- planned: Fire after you write the plan in PLAN.md.
    size (required, one of: small, large): How large the change is.
- yield: Stop for now and stay in PLAN.
    note (optional): What you are waiting for.
- pause: Put issue GH-12 aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>
[exit 1]

In a machine state, the message takes one of these forms:

  • EVENT is not offered here (offered: EVENT, …)
  • EVENT is an event that only the host fires (offered: EVENT, …)
  • EVENT needs param NAME
  • EVENT has no param NAME (params: NAME, …)
  • EVENT takes no params, but got NAME
  • EVENT param NAME must be one of: VALUE, … (got "VALUE")
  • EVENT param NAME must match PATTERN (got "VALUE")
  • EVENT param NAME must be a string
  • EVENT param NAME must be a JSON value: REASON
  • EVENT param NAME doesn't match its schema. At PATH: ERROR, with one sentence for each part of the value that fails, the root as the PATH of the whole value, and at most ten sentences, then And N more
  • KIND ID already has ref REF, and smllm sets a ref only once
  • another KIND of MACHINE already has ref REF
  • the interrupted state STATE no longer exists; leave with one of this state's events, or pause and enter with a state: STATE, …

In idle, the message takes one of these forms:

  • EVENT is not offered in idle (offered: EVENT, …)
  • enter needs param stateMachine
  • enter param stateMachine must be one of: MACHINE, … (got "VALUE")
  • enter has no param NAME for MACHINE (params: stateMachine, PARAM, state)
  • enter got param NAME more than once
  • enter param PARAM must match PATTERN (got "VALUE")
  • STATE is not an entry point of MACHINE (entry points: STATE, …)
  • KIND LABEL is completed. To reopen it, fire enter with state: STATE, or one of STATE, … when the machine has several entry points
  • KIND LABEL is completed, and no state is an entry point to reopen it at
  • state machine MACHINE needs a host to drive it: only the host fires its events EVENT, …
  • MACHINE has no state STATE
  • saved state STATE of KIND LABEL no longer exists. Fire enter with state: one of STATE, …
  • listPaused takes no params, but got NAME
  • resume takes no params, but got NAME
  • state machine MACHINE is not configured (its file might have errors); fix the config, then resume
  • LABEL moved to session KEY
  • the interrupted instance is no longer interrupted
  • saved state STATE no longer exists. Fire enter with stateMachine MACHINE, PARAM LABEL, state: one of STATE, …

When the session’s instance has left it, smllm returns the session to idle, and the reply is the idle list with one of these messages:

  • KIND LABEL moved to session KEY; you are in idle: another session took the instance over.
  • KIND LABEL is STATUS; you are in idle: the instance is no longer active.
  • another session changed KIND LABEL; you are in idle: another session wrote the instance first.
  • instance ID no longer exists; you are in idle

A blocked stop

When the agent tries to end its turn in a state that isn’t final, the stop hook of the harness asks smllm, and smllm blocks the stop. The harness gives the agent this block, which is the header and the events list, without the instructions:

<smllm>
session sm-nqvfvw · dev › PLAN · issue GH-12
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- planned: Fire after you write the plan in PLAN.md.
    size (required, one of: small, large): How large the change is.
- yield: Stop for now and stay in PLAN.
    note (optional): What you are waiting for.
- pause: Put issue GH-12 aside and return to idle.
- unmatched: The request fits none of these; handle it from idle, then resume.
</events>
</smllm>

smllm lets a stop through without a block in these cases:

  • The session is in idle.
  • The agent fired yield after the user’s last prompt, and no transition has happened since.
  • The agent tries to stop a second time in a row without firing an event. smllm records this stop as runaway in the session’s audit, and writes the block to standard error instead.
  • The harness can’t block a stop, as Harnesses lists. In GitHub Copilot the session start binds no session, so the stop hook allows every stop without a block. The agents integration runs no hooks. In opencode run the run ends with the agent’s turn, so the agent never reads the block.

What happens when the agent stops explains these rules.

The replies to a fired event

A fired event that smllm accepts gets one of these replies:

  • A transition gets the entry block of the state that the instance reaches.
  • A transition to a final state gets the state’s block, Completed KIND LABEL. You are now in idle., and the idle list.
  • yield gets the header and Yielded: staying in STATE. You can end your turn.
  • pause and unmatched without a fallback state get the idle list, with a note that names the instance that the event put aside.
  • listPaused gets the header of idle, the list of every paused instance, and the events list of idle.

A guard that fails moves the instance along the event’s next transition. Here the plan doesn’t exist yet, so planned re-enters PLAN, and the trace shows the guard:

$ smllm fire --session sm-nqvfvw planned --param size=small
<smllm>
session sm-nqvfvw · dev › PLAN (visit 2) · issue GH-12
Arrived by: planned from PLAN
Params: size = "small"
Guard: command `test -f PLAN.md` → false (exited 1)
<instructions>
Write a plan for the issue in PLAN.md, then fire planned.
</instructions>
</smllm>

After the agent writes the plan, the guard passes:

PLAN.md

Rename the option `--colour` to `--color`.
$ smllm fire --session sm-nqvfvw planned --param size=small
<smllm>
session sm-nqvfvw · dev › WORK · issue GH-12
Arrived by: planned from PLAN
Params: size = "small"
Guard: command `test -f PLAN.md` → true
<instructions>
Make the change that PLAN.md describes, then fire done.
</instructions>
</smllm>

These are the replies to yield, to pause, and to an enter that continues the paused issue:

$ smllm fire --session sm-nqvfvw yield --param "note=Waiting for the user to approve the plan."
<smllm>
session sm-nqvfvw · dev › WORK · issue GH-12
Yielded: staying in WORK. You can end your turn.
</smllm>
$ smllm fire --session sm-nqvfvw pause
<smllm>
session sm-nqvfvw · idle
Paused issue GH-12 at WORK.
State machines:
- dev: Work on an issue, from a plan to a change.
    issue id param issueId (pattern: ^GH-[0-9]+$): The issue's id, such as GH-12.
    starts at: PLAN; entry points: PLAN
Paused:
- issue GH-12 (dev) at WORK
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: dev): The state machine to enter.
    issueId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>
$ smllm fire --session sm-nqvfvw enter --param stateMachine=dev --param issueId=GH-12
<smllm>
session sm-nqvfvw · dev › WORK (visit 2) · issue GH-12
Arrived by: enter
Params: stateMachine = "dev", issueId = "GH-12"
<instructions>
Make the change that PLAN.md describes, then fire done.
</instructions>
</smllm>

This is the reply to the event that reaches the final state:

$ smllm fire --session sm-nqvfvw done
<smllm>
session sm-nqvfvw · dev › DONE · issue GH-12
Arrived by: done from WORK
<instructions>
Summarise the change for the issue.
</instructions>
Completed issue GH-12. You are now in idle.
State machines:
- dev: Work on an issue, from a plan to a change.
    issue id param issueId (pattern: ^GH-[0-9]+$): The issue's id, such as GH-12.
    starts at: PLAN; entry points: PLAN
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: dev): The state machine to enter.
    issueId (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

Harnesses

This page lists what smllm harness install writes, then what each harness supports, then the Claude Code plugin. A harness is the program that runs the agent, such as Claude Code or Codex.

Install

smllm harness install connects smllm to one or more harnesses:

smllm harness install claude          # CLAUDE.md/AGENTS.md block, .mcp.json, hooks, permission,
                                      # status line skill
smllm harness install claude codex    # several at once; AGENTS.md and .mcp.json written once
smllm harness install all             # every harness: claude codex factory gemini copilot cursor pi opencode kilo qwen devin agents
smllm harness status all              # what is installed, part by part

--scope says where the integration goes: the project (--scope project, the default), your user (--scope user), or git-ignored local settings (--scope local, for Claude Code and Copilot only).

Each harness gets as much as it can take of three parts: the instructions block, the hooks (smllm harness hook HARNESS EVENT), and the smllm MCP server. smllm writes each part where the harness reads it. The report lists what a harness can’t take (unsupported) and what you still have to do (note), for example trust the folder in Codex or Pi.

What each harness supports

The hooks differ by harness:

  • Claude Code, Codex, Factory, Gemini CLI, Qwen Code, Devin, and Cursor: smllm uses their own hooks. The agent gets the session when it starts, and smllm blocks a stop.
  • Pi, OpenCode, and Kilo Code: these harnesses have no hooks config, so smllm installs a Pi extension or an OpenCode or Kilo plugin that runs the same hook command. The agent gets the session with the first prompt. A blocked stop works in OpenCode’s terminal user interface (TUI) and server, but not in opencode run.
  • GitHub Copilot: Copilot gets the tool and the instructions. smllm can’t show Copilot the session, so its hooks stay quiet: the agent starts with enter, and smllm doesn’t block a stop.
  • agents: any other agent that reads AGENTS.md gets only the instructions block, with no hooks and no MCP server. The agent fires events with smllm fire from the shell.

The Claude Code plugin

Claude Code can also take smllm as a plugin:

claude plugin marketplace add six5536/smllm && claude plugin install smllm@smllm

Use either the plugin or smllm harness install claude, not both, because with both the hooks and the MCP server run twice. With the plugin, the tool is named mcp__plugin_smllm_smllm__smllm. harness install approves the tool in advance in the project settings, and with the plugin, Claude Code asks for permission the first time instead.

The status line row

This page lists the forms of the row that smllm statusline prints, then its options, its JSON fields, and its use from harnesses other than Claude Code.

smllm statusline prints where the current session is, as one row for your harness’s status line. The command reads Claude Code’s status JSON on stdin and finds the smllm session bound to its session_id. When there is nothing to show, because there is no smllm config or smllm hasn’t bound a session yet, it prints nothing. The command only reads, takes a few milliseconds, and always exits with 0, so it can’t blank the rest of your status line.

The default row

The row takes one of two forms:

WhereRow
In a machinesmllm MACHINE › STATE[ (visit N)] · KIND LABEL[ · yielded]
In idlesmllm idle[ · N interrupted][ · N paused]

These rows show each form:

smllm dev › WORK (visit 3) · issue GH-123
smllm dev › REVIEW · issue GH-123 · yielded
smllm idle · 1 interrupted · 2 paused

The parts mean this:

  • MACHINE is the state machine, and STATE is its current state.
  • (visit N) appears from the second visit to a state, and N is the number of the visit.
  • KIND LABEL is the instance, for example issue GH-123. The label is the instance’s ref after the ref is set, and its ID before.
  • N interrupted and N paused give the number of interrupted and paused instances.
  • yielded means that the agent fired yield, so it can stop until your next prompt.

The row has these colours: smllm dim, the machine cyan, the state bold, the instance magenta, the notes dim, and yielded yellow. Colour is on by default, because a status line command never runs in a terminal that it could check. To turn colour off, set NO_COLOR=1 or pass --color never.

Options

The command takes these options:

smllm statusline [--session KEY] [--json] [--color auto|always|never]
  • --session KEY shows the session with the key KEY instead of reading stdin.
  • --json prints the fields described in the JSON fields instead of the row.
  • --color takes always, never, or auto. auto means always, unless NO_COLOR is set.

The JSON fields

smllm statusline --json prints one object, or {} when there is nothing to show. Every key is always present, and its value is null when it doesn’t apply.

FieldTypeMeaning
sessionstringThe smllm session key (sm-nqvfvw)
idleboolTrue when the session holds no instance
machinestring | nullThe state machine of the held instance
statestring | nullThe held instance’s current state
visitnumber | nullThe number of times the state has been entered
yieldedboolTrue when the agent fired yield after your last prompt
instanceobject | nullThe held instance
interruptedobject | nullThe instance that unmatched put aside
pausednumberThe number of paused instances of the configured state machines

instance and interrupted are each an object {machine, kind, id, ref, label, status}:

  • kind is what the machine calls an instance (meta.instance.kind).
  • ref is null until it is set.
  • label is the ref, or the ID when there is no ref.
  • status is active, interrupted, paused, or completed.

A later release can add fields. Before version 1.0, a field is renamed or removed only in a minor release.

The sample in this section comes from a project with this config and this machine:

.smllm/config.toml

[machines]
files = ["dev.smllm.yaml"]

.smllm/dev.smllm.yaml

id: dev
description: Work on an issue, then review the change.
initial: WORK
meta:
  smllm: 1
  instance:
    kind: issue
    ref: { param: issueId, pattern: "^GH-[0-9]+$", description: "The issue's id, such as GH-123." }
  events:
    done:
      description: Fire after the change is made.
    changes:
      description: Fire when the review asks for changes.
    approved:
      description: Fire when the review approves the change.
states:
  WORK:
    meta: { entryPoint: true }
    entry: { type: prompt, params: { text: "Make the change, then fire done." } }
    on:
      done: REVIEW
  REVIEW:
    entry: { type: prompt, params: { text: "Review the change, then fire approved or changes." } }
    on:
      changes: WORK
      approved: DONE
  DONE:
    type: final

The session paused the issues GH-121 and GH-122. Then it entered GH-123, and the review sent the issue back to WORK twice, so the session is at its third visit to WORK, and smllm statusline --json prints this object for it:

{
  "session": "sm-nqvfvw",
  "idle": false,
  "machine": "dev",
  "state": "WORK",
  "visit": 3,
  "yielded": false,
  "instance": { "machine": "dev", "kind": "issue", "id": "i-n7d6tx", "ref": "GH-123", "label": "GH-123", "status": "active" },
  "interrupted": null,
  "paused": 2
}

sm-nqvfvw is the key of the session, and i-n7d6tx is the instance’s ID. Your key and your instance’s ID differ.

Other harnesses

The command needs only a session key. smllm statusline --session KEY works from any status bar, prompt, or tmux line that knows the key. Without --session, the command reads stdin as JSON with a session_id that the Claude Code hooks have bound. A host that uses @six5536/smllm-wasm gets the same fields from engine.status(key).

The rules of smllm validate

This page lists the rules that smllm validate checks. Each finding ends with the code of the rule that it breaks, such as (FMT-4), and this page says what each rule checks. smllm info rules prints the same page, so it always matches the smllm that you run.

CodeRuleWhat validate checks
FMT-1The machine fileThe file is YAML that parses, sets meta.smllm: 1, and has at least one state. Its keys are the known ones, each required key is present, and each value has the expected type. The machine’s ID and its state names have the allowed characters, and no other machine in the config has the same ID or one that differs only in case. The file uses only the part of XState v5 that smllm supports: flat atomic and final states, without nested or parallel states, delays, or context, and with an invoke of only src and input.
FMT-2States and transitionsA final state has no on or always, isn’t an entry point, and can be reached. A machine without a final state gets an info finding, because its instances never complete. At most one state is the fallback, and the fallback isn’t final. A state doesn’t handle a built-in event under on, a list of guarded transitions ends with one without a guard, and neither the ref param nor any other param has the name of a param of enter. An event with the ref param sets the ref. A state with an invoke handles an event of source: host, which carries the work’s result, and each invoke names its src.
FMT-3Actions and guardsEach action is a {type, params} object of a known type, with the params that its type needs. A prompt has exactly one of text and file, a file path is relative to the file that gives it, and setRef appears only where an event carries the ref param. Each guard is a {type, params} object of a known type, with valid params. A visits guard counts a state that exists, and its atLeast is more than 0.
FMT-4Event and instance declarationsThe ref param’s name, and each declared event and param name, contain only letters, digits, _, and -. An event’s params are an object of properties, each required param is in properties, and each enum and pattern is valid and allows at least one value. A state rewords only the events that it has a transition on, and only params that the event declares in meta.events. Each declared event is used by a state, and each ref pattern can match a ref. An event’s source is agent or host, and a machine with an event of source: host gets an info finding, because only a host can fire the event, so the machine needs a host to drive it.
FMT-10JSON paramsA json param takes no enum or pattern, a string param takes no schema, and a param that sets the ref is a string. A json param’s schema, and each file that a relative $ref in it names, is a map or a JSON file that smllm can read, declares draft-07, 2020-12, or no draft, and is valid against its draft. A schema without a draft is 2020-12, or the draft of the schema that refers to it. Each $ref to another file is a path relative to the schema that holds it, because smllm doesn’t fetch a schema from the network.
FMT-6Files that smllm readssmllm can read each file that the config names.
FMT-7Combining configsA config with errors doesn’t replace the machines of the configs before it, and validate says which machines it leaves out and which machine replaces which.
AGT-1Author textAuthor text, such as a prompt or an event’s description, contains none of the closing tags </smllm>, </instructions>, and </events> that fence smllm’s own text, because the agent couldn’t then tell smllm’s text from the author’s. A finding under this rule is a warning.
AGT-2Events listA built-in event changes only its description, never its params or its source.
AGT-5TransitionsThe initial state and each transition’s target exist, each state can be reached, and a transition that should re-enter its state names it as the target. A state with always has no on events, because smllm leaves it at once, and no chain of always transitions loops back to its start.
AGT-6Guards and actionsmeta.sharedActions lists only states that exist.
MCP-1Param typesEach param has type: string, the default, or type: json.
CLI-3Configs and saved instancessmllm can read and parse each config file, and each saved instance is in a state that still exists.
CLI-11Command guards and actionsA command’s run is a non-empty string, run by a shell, or a non-empty list, run without one. A list-form run doesn’t rely on $SMLLM_* variables, because no shell expands them. A command’s timeoutSecs is at least 1.
STORE-1The instance filessmllm can read each instance file and each ref marker of the config’s machines.

FMT-1: The machine file

A finding of FMT-1 means that the machine file isn’t YAML that smllm reads, or that one of its keys or values isn’t one that smllm knows. The rules page says what the rule checks.

Messages

<file>:<line>: error: <YAML error> (FMT-1)
<file>: error: missing key `<key>` (FMT-1)
<file>:<line>: error: <path>: missing key `<key>` (FMT-1)
<file>:<line>: error: <path>: unknown key `<key>` (expected one of: <keys>) (FMT-1)
<file>:<line>: error: <path>: expected <type>, found <type> (FMT-1)
<file>:<line>: error: <path>: expected <type>, found nothing (FMT-1)
<file>:<line>: error: meta.smllm: unsupported smllm format version <n>. Set meta.smllm: 1, the only version that this smllm reads (FMT-1)
<file>:<line>: error: states: a state machine needs at least one state (FMT-1)
<file>:<line>: error: id: id "<id>" must contain only letters, digits, _, and - (FMT-1)
<file>:<line>: error: states.<state>: state name "<state>" must not be empty or contain spaces (FMT-1)
<file>: error: two machines in this config have id <id> (FMT-1)
<file>: error: machine id <id> differs from <id> only in case, so they would share a state directory on macOS and Windows (FMT-1)
<file>:<line>: error: states.<state>.type: `type: <type>` is not supported. smllm supports only flat atomic and final states (FMT-1)
<file>:<line>: error: <path>: `<key>` is not supported. smllm supports only part of XState: flat atomic and final states, without delays or context, and an invoke of only src and input (FMT-1)

Cause

The first missing key form is a key of the whole file, such as id, and the second is a key of a part of it, such as smllm under meta. A finding of a wrong type ends with found nothing when the key has no value, such as a state written as TRIAGE: alone.

Each message has one of these causes:

  • The YAML doesn’t parse.
  • A required key is missing, such as id, or smllm under meta.
  • A key has a name that smllm doesn’t know, or a value of a type that smllm doesn’t expect, or no value.
  • meta.smllm isn’t 1.
  • states is empty.
  • The machine’s ID has a character other than a letter, a digit, _, and -.
  • A state’s name is empty or has a space.
  • Two machines of a config have the same ID, or IDs that differ only in case.
  • A state sets type to parallel, history, or compound, which smllm leaves out.
  • The file uses a key of XState that smllm leaves out: states or initial inside a state, for a nested state, after, context, output, tags, history, onDone, types, or version.

Fix

Correct the key or the value that the message names. Set meta.smllm to 1, give the machine at least one state, and give each machine of a config an ID of its own. Keep each state flat: a state that needs steps of its own becomes several states of the machine.

Example

This machine sets a format version that smllm doesn’t read:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 2
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports the version:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:4: error: meta.smllm: unsupported smllm format version 2. Set meta.smllm: 1, the only version that this smllm reads (FMT-1)
1 error, 0 warnings, 0 info
[exit 1]

The fixed machine sets version 1:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

FMT-2: States and transitions

A finding of FMT-2 means that a state or a transition breaks a rule of how the machine moves from state to state, or that a param takes a name that smllm keeps for itself. The rules page says what the rule checks.

Messages

<file>:<line>: error: states.<state>.type: `<type>` is not one of: atomic, final (FMT-2)
<file>:<line>: error: states.<state>: a final state cannot have on or always (FMT-2)
<file>:<line>: warning: states.<state>: a final state that is an entry point completes the instance as soon as it is entered. Remove meta.entryPoint, or make the state non-final (FMT-2)
<file>:<line>: warning: states.<state>: state <state> is unreachable. Add a transition to it, or set meta.entryPoint: true (FMT-2)
<file>:<line>: info: states: no final state: instances of this machine never complete. If instances should complete, add a state with type: final (FMT-2)
<file>:<line>: error: states.<state>: the fallback state cannot be final (FMT-2)
<file>:<line>: error: states.<state>: only one state can be the fallback, and <state> already is (FMT-2)
<file>:<line>: error: states.<state>.on.<event>: <event> is a built-in event, so a state cannot handle it under on (FMT-2)
<file>:<line>: error: <path>: the last transition has a guard, so nothing happens when every guard fails. End the list with a transition without a guard, such as one back to this state (FMT-2)
<file>:<line>: error: meta.instance.ref.param: ref param <param> has the name of one of enter's own params. Choose another name, such as issueId (FMT-2)
<file>:<line>: warning: meta.events.<event>.params.properties.<param>: param <param> has the name of one of enter's own params (FMT-2)
<file>:<line>: warning: meta.events.<event>: <event> has a param with the ref param's name <param>, but never sets the ref. Rename the param, or add a setRef action (FMT-2)
<file>:<line>: error: states.<state>.invoke: the state invokes the host's work, but handles no event of source: host to carry its result. Declare the result as an event of source: host in meta.events, and handle it in the state's on (FMT-2)
<file>:<line>: error: states.<state>.invoke: a final state invokes no work, because the instance completes when it enters the state. Remove the invoke, or make the state atomic (FMT-2)
<file>:<line>: error: <path>.src: src is empty. Give src the name of the host's work (FMT-2)

Cause

smllm validate lists a warning only with --warnings, and an info finding only with --info.

Each message has its own cause:

  • A state’s type is a value other than atomic and final.
  • A final state has on or always. A final state completes the instance, so no transition leaves it.
  • A final state sets meta.entryPoint: true, so an agent who enters it completes the instance at once.
  • No transition leads to a final state, and the state isn’t an entry point.
  • The machine has no final state, so its instances never complete. This finding is only information, because a machine can run for ever on purpose.
  • A final state sets meta.fallback: true.
  • More than one state sets meta.fallback: true.
  • A state handles a built-in event, such as yield or pause, under on. smllm handles the built-in events enter, resume, pause, unmatched, yield, and listPaused itself.
  • The last transition of a list has a guard. When every guard fails, the event matches no transition.
  • meta.instance.ref.param is stateMachine or state, the names of the params of enter.
  • An event declares a param named stateMachine or state.
  • An event declares a param with the name of the ref param, but no transition on the event has a setRef action, so the param never sets the ref.
  • A state has an invoke, but handles no event of source: host under on, so the host can’t fire the result of the work.
  • A final state has an invoke.
  • An invoke has an empty src.

Fix

Make the change that the message names:

  • Set type to atomic or final, or leave it out for an atomic state.
  • Remove on and always from the final state, or remove type: final.
  • Remove meta.entryPoint from the final state, or make the state atomic.
  • Add a transition to the final state, or remove the state.
  • Add a state with type: final if the instances complete.
  • Move meta.fallback to an atomic state.
  • Keep meta.fallback: true on one state only.
  • Rename the event, or remove it from on.
  • End the list with a transition that has no guard, such as one back to the same state.
  • Give the ref param another name, such as issueId.
  • Rename the param.
  • Rename the param, or add a setRef action to a transition on the event.
  • Declare an event with source: host for the work’s result, and handle it in the state’s on.
  • Remove the invoke from the final state, or make the state atomic.
  • Give src the name of the work.

Example

This machine runs the tests before it completes, but the only transition on fixed has a guard:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed:
        - target: DONE
          guard:
            type: command
            params:
              run: cargo test
  DONE:
    type: final

smllm validate reports the guard:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:11: error: states.FIX.on.fixed: the last transition has a guard, so nothing happens when every guard fails. End the list with a transition without a guard, such as one back to this state (FMT-2)
1 error, 0 warnings, 0 info
[exit 1]

The fixed machine stays in FIX when the tests fail:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed:
        - target: DONE
          guard:
            type: command
            params:
              run: cargo test
        - target: FIX
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

FMT-3: Actions and guards

A finding of FMT-3 means that an action or a guard of the machine isn’t one that smllm knows, or that its params are wrong. The rules page says what the rule checks.

Messages

<file>:<line>: error: <path>: unknown action `<name>`. Write an action as {type, params}. Only setRef can be a bare name (FMT-3)
<file>:<line>: error: <path>.type: unknown action type `<type>` (expected one of: prompt, command, setRef) (FMT-3)
<file>:<line>: error: <path>: a prompt needs exactly one of: text, file (FMT-3)
<file>:<line>: error: <path>: prompt file <file>: <error>. The path is relative to the file that gives it (FMT-3)
<file>:<line>: error: <path>: setRef runs only in a transition's actions under `on`. Entry, exit, and always have no event to read the ref param from (FMT-3)
<file>:<line>: error: <path>: a guard is a map {type, params}, not a name. Guard types: command { run }, visits { state, atLeast } (FMT-3)
<file>:<line>: error: <path>.type: unknown guard type `<type>` (expected one of: command, visits) (FMT-3)
<file>:<line>: error: <path>: visits counts unknown state <state> (FMT-3)
<file>:<line>: warning: <path>: visits atLeast 0 is always true (FMT-3)

Cause

smllm validate lists a warning only with --warnings.

Each message has its own cause:

  • An action is a bare name other than setRef.
  • An action’s type is a value other than prompt, command, and setRef.
  • A prompt’s params give both text and file, or neither.
  • smllm can’t read the file that a prompt names. smllm reads the path from the folder of the file that gives it, which is the machine file, or the config for [idle] on-enter.
  • A setRef action is under entry, exit, or always. Only a transition under on has an event that carries the ref param.
  • A guard is a name, not a map.
  • A guard’s type is a value other than command and visits.
  • A visits guard counts a state that the machine doesn’t have.
  • A visits guard has atLeast: 0, which every instance passes.

Fix

Make the change that the message names:

  • Write the action as a map with type and params.
  • Set type to prompt, command, or setRef.
  • Give the prompt exactly one of text and file.
  • Correct the path, or create the file. The path is relative to the folder of the file that gives it: the machine file, or the config for [idle] on-enter.
  • Move setRef to the actions of a transition under on.
  • Write the guard as a map with type and params.
  • Set type to command or visits.
  • Name a state of the machine in state.
  • Set atLeast to 1 or more, or remove the guard.

Example

This machine gives a prompt file with a path that starts at the project, not at the machine file:

.smllm/prompts/triage.md

Reproduce the bug, then fire `reproduced`.

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    entry:
      type: prompt
      params:
        file: .smllm/prompts/triage.md
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports the prompt file:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:7: error: states.TRIAGE.entry: prompt file .smllm/prompts/triage.md: no such file. The path is relative to the file that gives it (FMT-3)
1 error, 0 warnings, 0 info
[exit 1]

The fixed machine gives the path from the folder of the machine file:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    entry:
      type: prompt
      params:
        file: prompts/triage.md
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

FMT-4: Event and instance declarations

A finding of FMT-4 means that a declaration under meta.events or meta.instance, or a state’s rewording of a param, has a bad name, a bad pattern, or a part that nothing uses. The rules page says what the rule checks.

Messages

<file>:<line>: error: meta.instance.ref.param: ref param "<param>" must contain only letters, digits, _, and - (FMT-4)
<file>:<line>: error: <path>: event name "<event>" must contain only letters, digits, _, and - (FMT-4)
<file>:<line>: error: meta.events.<event>.params.properties.<param>: param name "<param>" must contain only letters, digits, _, and - (FMT-4)
<file>:<line>: error: meta.events.<event>.params: params type must be object, not <type> (FMT-4)
<file>:<line>: error: meta.events.<event>.params.required: required param <param> is not in properties (FMT-4)
<file>:<line>: error: meta.events.<event>.params.properties.<param>: enum is empty, so it allows no value (FMT-4)
<file>:<line>: error: <path>.pattern: invalid pattern "<pattern>": <reason> (FMT-4)
<file>:<line>: warning: meta.events.<event>.params.properties.<param>: enum value "<value>" does not match the pattern, so the param can never take it (FMT-4)
<file>:<line>: warning: meta.events.<event>.params.properties.<param>: <event> sets the ref, so meta.instance.ref.pattern <pattern> applies instead of this pattern. Remove this pattern, or change meta.instance.ref.pattern (FMT-4)
<file>:<line>: warning: states.<state>.meta.paramDescriptions.<event>: <state> has no transition on <event>, so smllm never shows these descriptions (FMT-4)
<file>:<line>: error: states.<state>.meta.paramDescriptions.<event>.<param>: <event> declares no param <param>. Declare params in meta.events, because paramDescriptions only rewords their prompts (FMT-4)
<file>:<line>: warning: meta.events.<event>: event <event> is declared but no state uses it (FMT-4)
<file>:<line>: error: meta.events.<event>.source: `<value>` is not one of: agent, host (FMT-4)
<file>:<line>: info: meta.events: only the host fires the events <events>, so the machine needs a host to drive it. A harness can't start an instance of it, and a host that embeds the engine fires these events (FMT-4)

Cause

smllm validate lists a warning only with --warnings, and an info finding only with --info.

Each message has its own cause:

  • The ref param’s name has a character other than a letter, a digit, _, and -.
  • An event’s name, under meta.events or under a state’s on, has such a character.
  • A param’s name has such a character.
  • An event’s params has a type other than object.
  • required names a param that properties doesn’t declare.
  • A param’s enum is an empty list.
  • A pattern isn’t a valid regular expression.
  • A value of a param’s enum doesn’t match the param’s pattern.
  • An event that sets the ref gives the ref param a pattern of its own, and meta.instance.ref.pattern gives another. The pattern of meta.instance.ref applies.
  • A state’s meta.paramDescriptions rewords the params of an event that the state has no transition on.
  • A state’s meta.paramDescriptions rewords a param that the event doesn’t declare.
  • meta.events declares an event that no state has a transition on.
  • An event’s source is a value other than agent and host.
  • An event has source: host. This finding is only information: only a host program can fire the event, so the machine needs a host to drive it.

Fix

Make the change that the message names:

  • Rename the ref param with letters, digits, _, and -.
  • Rename the event in the same way.
  • Rename the param in the same way.
  • Set the type of params to object.
  • Correct the name in required, or declare the param under properties.
  • Add a value to enum, or remove enum.
  • Correct the pattern.
  • Correct the value or the pattern.
  • Remove the param’s pattern, or change meta.instance.ref.pattern.
  • Add a transition on the event to the state, or remove the event from meta.paramDescriptions.
  • Declare the param under the event in meta.events, or remove it from meta.paramDescriptions.
  • Add a transition on the event to a state, or remove the event.
  • Set source to agent or host.
  • Nothing needs to change if a host program drives the machine. If the agent should fire the event, set source to agent, or remove source.

Example

This machine requires a param issue, but the param under properties is issueId:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  events:
    reproduced:
      params:
        type: object
        required: [issue]
        properties:
          issueId:
            type: string
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports the required param:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:9: error: meta.events.reproduced.params.required: required param issue is not in properties (FMT-4)
1 error, 0 warnings, 0 info
[exit 1]

The fixed machine requires issueId:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  events:
    reproduced:
      params:
        type: object
        required: [issueId]
        properties:
          issueId:
            type: string
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

FMT-10: JSON params

A finding of FMT-10 means that a param with type: json has a constraint that only a string param takes, or that smllm can’t read or use the param’s JSON Schema. The rules page says what the rule checks.

Messages

<file>:<line>: error: <path>.schema: param <param> is a string, so it takes no schema. Set type: json for a param that takes a JSON value with a schema (FMT-10)
<file>:<line>: error: <path>.enum: param <param> takes a JSON value, so it takes no enum. Give the constraint in the param's schema (FMT-10)
<file>:<line>: error: <path>.pattern: param <param> takes a JSON value, so it takes no pattern. Give the constraint in the param's schema (FMT-10)
<file>:<line>: error: <path>: <event> sets the ref, so its param <param> must be a string. Remove type: json, or give the ref another param (FMT-10)
<file>:<line>: error: <path>.schema: the schema file <schema file> does not exist (FMT-10)
<file>:<line>: error: <path>.schema: <schema> can't be read: <error> (FMT-10)
<file>:<line>: error: <path>.schema: <schema> is not JSON: <error> (FMT-10)
<file>:<line>: error: <path>.schema: <schema> declares the draft <uri>, but smllm reads only draft-07 and 2020-12. Set $schema to http://json-schema.org/draft-07/schema# or https://json-schema.org/draft/2020-12/schema (FMT-10)
<file>:<line>: error: <path>.schema: <schema> has a $schema of <value>, but $schema must be a string (FMT-10)
<file>:<line>: error: <path>.schema: <schema> is not a valid <draft> schema: <error> (FMT-10)
<file>:<line>: error: <path>.schema: ref <ref> in <schema> names a file that does not exist (FMT-10)
<file>:<line>: error: <path>.schema: ref <ref> in <schema> resolves to no bundled file. smllm doesn't fetch a schema from the network: write the ref as a path relative to the schema that holds it (FMT-10)

Cause

In each message, <path> is the param, such as meta.events.planned.params.properties.plan. <schema> is schema and the name of a schema file, or the inline schema for a schema written in the machine file.

Each message has its own cause:

  • A string param has a schema.
  • A JSON param has an enum.
  • A JSON param has a pattern.
  • An event that sets the ref gives the ref param type: json. A ref is a string.
  • The schema file that schema names doesn’t exist. smllm reads the path from the folder of the machine file.
  • smllm can’t read the schema file, for example because the path names a folder.
  • The schema file isn’t JSON.
  • The schema’s $schema declares a draft other than draft-07 and 2020-12.
  • The schema’s $schema isn’t a string.
  • The schema isn’t valid against its draft. A schema without a $schema is 2020-12, or the draft of the schema that refers to it.
  • A $ref names a file that doesn’t exist. smllm reads the path from the folder of the schema that holds the $ref.
  • A $ref is a URL that names no schema file of the param. smllm doesn’t fetch a schema from the network.

Fix

Make the change that the message names:

  • Set type: json on the param, or remove schema.
  • Remove enum, and give the values in the schema.
  • Remove pattern, and give the pattern in the schema.
  • Remove type: json from the ref param, or set the ref from another param.
  • Correct the path, relative to the folder of the machine file, or create the file.
  • Correct the path, so that it names a file that smllm can read.
  • Correct the JSON.
  • Set $schema to http://json-schema.org/draft-07/schema# or https://json-schema.org/draft/2020-12/schema, or remove it.
  • Write $schema as a string.
  • Correct the schema at the place that the message names.
  • Correct the path of the $ref, or create the file.
  • Save the schema as a file next to the schema that refers to it, and write the $ref as a relative path.

Example

This machine’s schema file declares draft-04:

.smllm/schemas/plan.json

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "type": "object",
  "required": ["steps"],
  "properties": {
    "steps": { "type": "array", "items": { "type": "string" } }
  }
}

.smllm/plan.smllm.yaml

id: plan
initial: PLAN
meta:
  smllm: 1
  events:
    planned:
      params:
        type: object
        required: [plan]
        properties:
          plan:
            type: json
            schema: schemas/plan.json
states:
  PLAN:
    on:
      planned: DONE
  DONE:
    type: final

smllm validate reports the draft:

$ smllm validate .smllm/plan.smllm.yaml
.smllm/plan.smllm.yaml:13: error: meta.events.planned.params.properties.plan.schema: schema schemas/plan.json declares the draft http://json-schema.org/draft-04/schema#, but smllm reads only draft-07 and 2020-12. Set $schema to http://json-schema.org/draft-07/schema# or https://json-schema.org/draft/2020-12/schema (FMT-10)
1 error, 0 warnings, 0 info
[exit 1]

The fixed schema file declares draft-07:

.smllm/schemas/plan.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["steps"],
  "properties": {
    "steps": { "type": "array", "items": { "type": "string" } }
  }
}

smllm validate reports no finding:

$ smllm validate .smllm/plan.smllm.yaml
0 errors, 0 warnings, 0 info

FMT-6: Files that smllm reads

A finding of FMT-6 means that smllm can’t read a machine file that the config or the command line names. The rules page says what the rule checks.

Messages

<file>: error: can't read: no such file (FMT-6)
<file>: error: can't read: <reason> (FMT-6)

Cause

The file doesn’t exist, or smllm can’t read it. A path in the config’s files is relative to the folder of the config, so a path that starts at the project names a file that doesn’t exist. smllm can’t read a path that names a folder, or a file that the user isn’t allowed to read.

Fix

Correct the path in the config’s files, or in the command, so that it names the machine file. Write a path in files from the folder of the config. Where the message names another error, correct the path so that it names a file, or let your user read the file.

Example

This config names a machine file that doesn’t exist, because the machine’s file is fix.smllm.yaml:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

.smllm/config.toml

[machines]
files = ["fixes.smllm.yaml"]

smllm validate reports the file that it can’t read:

$ smllm validate
.smllm/fixes.smllm.yaml: error: can't read: no such file (FMT-6)
1 error, 0 warnings, 0 info
[exit 1]

The fixed config names the machine’s file:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

smllm validate reports no finding:

$ smllm validate
0 errors, 0 warnings, 0 info

FMT-7: Combining configs

A finding of FMT-7 means that smllm combined the user config, ~/.config/smllm/config.toml, with the project config, and that a machine of the project replaces a machine of the user config, or might. The rules page says what the rule checks.

Messages

<file>: info: machine <id> here replaces the one in <file> (FMT-7)
<file>: warning: smllm does not use machine <id> in <file> while this file, which replaces it, has errors (FMT-7)
<file>: warning: smllm doesn't load the machine <id> from earlier configs while this file has errors, because this file might replace it (FMT-7)

Cause

smllm validate lists a warning only with --warnings, and an info finding only with --info.

smllm reads the user config first, then the project config. When both configs give a machine with the same ID, the project’s machine replaces the user’s. Each message has its own cause:

  • A machine of the project config has the ID of a machine of the user config, and replaces it. This finding is only information.
  • A machine file of the project config has errors, and its id is the ID of a machine of the user config. smllm uses neither machine until the file is fixed, because the user’s machine isn’t the one that the project asks for.
  • The project config itself has errors, so smllm can’t tell which machines it gives. smllm uses none of the user config’s machines until the config is fixed.

Fix

Fix the errors that smllm validate reports in the project’s machine file or config. smllm then uses the project’s machine in place of the user’s. To use the user’s machine in the project, remove the project’s machine from the project config, or give the project’s machine another ID.

Example

smllm prints the full path of a file in the user config, and this page shortens your home folder to ~. The user config gives a machine fix:

~/.config/smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

~/.config/smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

The project gives its own machine fix, with a review step, but misspells final:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: REVIEW
  REVIEW:
    on:
      approved: DONE
  DONE:
    type: fnal

smllm validate --warnings reports the error, and that smllm doesn’t use the user’s machine in its place:

$ smllm validate --warnings
.smllm/fix.smllm.yaml: warning: smllm does not use machine fix in ~/.config/smllm/fix.smllm.yaml while this file, which replaces it, has errors (FMT-7)
.smllm/fix.smllm.yaml:13: error: states.DONE.type: `fnal` is not one of: atomic, final (FMT-2)
1 error, 1 warning, 0 info
[exit 1]

The fixed machine spells final:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: REVIEW
  REVIEW:
    on:
      approved: DONE
  DONE:
    type: final

A run that names the machine file checks the file alone, without the configs, and reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

smllm validate --info combines the configs, reports no error, and says that the project’s machine replaces the user’s:

$ smllm validate --info
.smllm/fix.smllm.yaml: info: machine fix here replaces the one in ~/.config/smllm/fix.smllm.yaml (FMT-7)
0 errors, 0 warnings, 1 info

AGT-1: Author text

A finding of AGT-1 means that text of the machine, such as a prompt or an event’s description, contains one of the closing tags </smllm>, </instructions>, and </events>. smllm fences the text that it gives the agent with these tags, so a tag in your text hides from the agent where smllm’s text ends. The finding is a warning. The rules page says what the rule checks.

Messages

<file>:<line>: warning: <path>: text contains `<tag>`, which smllm uses to fence agent text. Reword it so the agent can tell smllm's text from yours (AGT-1)

Cause

A prompt, an event’s description, a transition’s description, or another text that smllm passes to the agent contains </smllm>, </instructions>, or </events>. The usual cause is a prompt that quotes smllm’s own text. smllm validate lists the warning only with --warnings.

Fix

Reword the text without the tag. To refer to smllm’s text, name it in words, such as “the instructions”.

Example

The prompt of TRIAGE quotes the tag that ends the instructions:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug. Fire reproduced when you reach </instructions>." } }
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate --warnings reports the tag:

$ smllm validate --warnings .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:7: warning: states.TRIAGE.entry: text contains `</instructions>`, which smllm uses to fence agent text. Reword it so the agent can tell smllm's text from yours (AGT-1)
0 errors, 1 warning, 0 info

The fixed prompt says what to do in words:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    entry: { type: prompt, params: { text: "Write a test that reproduces the bug, then fire reproduced." } }
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate --warnings .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

AGT-2: Events list

A finding of AGT-2 means that the machine changes a built-in event, such as yield or pause, in a way that smllm doesn’t allow. A machine can reword the description of a built-in event, but smllm sets its params and who fires it. The rules page says what the rule checks.

Messages

<file>:<line>: error: meta.events.<event>: built-in event <event> can change only its description, not its params (AGT-2)
<file>:<line>: error: meta.events.<event>: built-in event <event> can change only its description, not its source (AGT-2)

Cause

An entry of meta.events has the name of a built-in event, and sets params or source.

Fix

Remove params and source from the entry, and keep its description. Where the agent needs an event with other params, declare an event of your own, with a name of its own.

Example

This machine gives the built-in event yield a param:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  events:
    yield:
      description: Stop and wait for the bug's reporter to answer.
      params:
        type: object
        properties:
          question: { type: string, description: What you asked the reporter. }
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports the params:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:6: error: meta.events.yield: built-in event yield can change only its description, not its params (AGT-2)
1 error, 0 warnings, 0 info
[exit 1]

The fixed machine rewords only the description of yield:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  events:
    yield:
      description: Stop and wait for the bug's reporter to answer.
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

AGT-5: Transitions

A finding of AGT-5 means that a transition of the machine leads nowhere, that a state can’t be reached, or that a state with always can’t settle. The rules page says what the rule checks.

Messages

<file>:<line>: error: initial: initial state <state> does not exist (AGT-5)
<file>:<line>: error: <path>: target state <state> does not exist (AGT-5)
<file>:<line>: error: states.<state>.always: always transitions loop: <states>. smllm leaves a state with always at once, so the loop might never stop. Break it with an event (AGT-5)
<file>:<line>: warning: states.<state>: state <state> is unreachable. Add a transition to it, or set meta.entryPoint: true (AGT-5)
<file>:<line>: warning: <path>: reenter has no effect without a target, because a transition without one never re-enters. To re-enter it, add target: <state> (AGT-5)
<file>:<line>: warning: states.<state>: smllm leaves a state with always at once, so it never offers the state's on events (AGT-5)

Cause

Each message has its own cause, and smllm validate lists the last three, the warnings, only with --warnings:

  • initial names a state that the machine doesn’t have.
  • A transition’s target names a state that the machine doesn’t have. A misspelt or renamed state is the usual cause.
  • States with always transitions lead back to the state where they started, so smllm would pass through them forever.
  • No transition leads to a state, and the state isn’t an entry point, so no instance can ever be in it.
  • A transition sets reenter: true but has no target. A transition without a target leaves the instance where it is, and never re-enters the state.
  • A state has both always and on. smllm leaves the state at once, so the agent never gets the chance to fire its events.

Fix

Fix each message in the order of the causes:

  • Set initial to the name of a state.
  • Correct the target to the name of a state, or add the state.
  • Give one of the states of the loop an event that the agent fires, in place of its always transition.
  • Add a transition to the state, or mark the state with meta: { entryPoint: true } if the agent enters it directly. Delete a state that nothing uses.
  • Set target to the state itself, so that the transition re-enters it and runs its exit and entry actions again.
  • Move the events of on to a state that the agent waits in, or remove the always transitions.

Example

The event reproduced of this machine leads to FIXING, but the state is called FIX:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: FIXING
  FIX:
    on:
      fixed: DONE
  DONE:
    type: final

smllm validate --warnings reports the missing state. It also warns that nothing leads to FIX, and so to DONE:

$ smllm validate --warnings .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:8: error: states.TRIAGE.on.reproduced: target state FIXING does not exist (AGT-5)
.smllm/fix.smllm.yaml:9: warning: states.FIX: state FIX is unreachable. Add a transition to it, or set meta.entryPoint: true (AGT-5)
.smllm/fix.smllm.yaml:12: warning: states.DONE: state DONE is unreachable. Add a transition to it, or set meta.entryPoint: true (FMT-2)
1 error, 2 warnings, 0 info
[exit 1]

The warning for DONE ends with (FMT-2), not (AGT-5), because DONE is a final state. A final state that no instance can reach breaks the rule for final states, so smllm reports it under FMT-2.

The fixed machine leads to FIX:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate --warnings .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

AGT-6: Guards and actions

A finding of AGT-6 means that an entry of meta.sharedActions names a state that the machine doesn’t have, or a position other than before and after. The rules page says what the rule checks.

Messages

<file>:<line>: error: meta.sharedActions[<n>]: sharedActions lists unknown state <state> (AGT-6)
<file>:<line>: error: meta.sharedActions[<n>].position: `<value>` is not one of: before, after (AGT-6)

Cause

Each message has its own cause:

  • The states list of a shared action names a state that the machine doesn’t have. A misspelt or renamed state is the usual cause.
  • The position of a shared action is neither before nor after.

Fix

Fix each message in the order of the causes:

  • Correct the name to the name of a state, or remove it from the list.
  • Set position to before, to run the shared actions before the state’s own actions, or to after, to run them after.

Example

The shared action of this machine still names FIXING, the old name of the state FIX:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  sharedActions:
    - states: [TRIAGE, FIXING]
      position: after
      entry: { type: prompt, params: { text: "Record what you try in the bug's notes." } }
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed: DONE
  DONE:
    type: final

smllm validate reports the unknown state:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:6: error: meta.sharedActions[0]: sharedActions lists unknown state FIXING (AGT-6)
1 error, 0 warnings, 0 info
[exit 1]

The fixed shared action names FIX:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  sharedActions:
    - states: [TRIAGE, FIX]
      position: after
      entry: { type: prompt, params: { text: "Record what you try in the bug's notes." } }
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

MCP-1: Param types

A finding of MCP-1 means that a param of an event has a type that smllm doesn’t take. A param is a string, the default, or a JSON value, with type: json. The rules page says what the rule checks.

Messages

<file>:<line>: error: <path>: param <param> has type <type>, but a param is a string or JSON. Use type: string, with enum or pattern to limit its values, or type: json for a JSON value (MCP-1)

Cause

A param of meta.events.<EVENT>.params.properties sets type to a value other than string and json, such as number, integer, or boolean.

Fix

Choose the type by what the param holds:

  • For a value that the agent gives as text, such as a number or a name, set type: string, or remove type. To limit its values, add pattern or enum.
  • For a structured value, set type: json, and add schema to check it.

Example

The param bugId of this machine is a number:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
      params:
        type: object
        properties:
          bugId: { type: number, description: The number of the bug. }
        required: [bugId]
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports the type:

$ smllm validate .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:11: error: meta.events.reproduced.params.properties.bugId: param bugId has type number, but a param is a string or JSON. Use type: string, with enum or pattern to limit its values, or type: json for a JSON value (MCP-1)
1 error, 0 warnings, 0 info
[exit 1]

The fixed param is a string of digits:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
  events:
    reproduced:
      description: Fire after a test reproduces the bug.
      params:
        type: object
        properties:
          bugId: { type: string, pattern: "^[0-9]+$", description: The number of the bug. }
        required: [bugId]
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final

smllm validate reports no finding:

$ smllm validate .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

CLI-3: Configs and saved instances

A finding of CLI-3 means that a saved instance is in a state that its machine no longer has, or that smllm can’t read a config file. The rules page says what the rule checks.

Messages

<file>: warning: saved instance <instance> is in state <state>, which no longer exists. To repair it, fire enter with its ref and a state (CLI-3)
<file>: error: can't read: <reason> (CLI-3)
<file>:<line>: error: <TOML error> (CLI-3)

Cause

Each message has its own cause, and smllm validate lists the first, a warning, only with --warnings:

  • smllm saved an instance in a state, and an edit of the machine file then renamed or removed that state. The message names the machine file.
  • smllm can’t read a config file, such as a file named with --config that doesn’t exist.
  • A config file isn’t TOML that smllm reads, or it has a key or a value that smllm doesn’t know.

Fix

Fix each message in the order of the causes:

  • Enter the instance again with the event enter, and give its ref and the state to continue in, as state. The state doesn’t have to be an entry point. Where you renamed the state by mistake, rename it back instead.
  • Correct the path of the config file, or make the file readable.
  • Correct the line that the message names.

Example

This machine has the states TRIAGE, FIX, and DONE:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed: DONE
  DONE:
    type: final

An agent enters the machine and fires reproduced, so the instance is in FIX, then puts the instance aside:

$ smllm fire --session sm-nqvfvw enter --param stateMachine=fix
<smllm>
session sm-nqvfvw · fix › TRIAGE · instance i-5z4x7n
Arrived by: enter (new instance)
Params: stateMachine = "fix"
</smllm>
$ smllm fire --session sm-nqvfvw reproduced
<smllm>
session sm-nqvfvw · fix › FIX · instance i-5z4x7n
Arrived by: reproduced from TRIAGE
</smllm>
$ smllm fire --session sm-nqvfvw pause
<smllm>
session sm-nqvfvw · idle
Paused instance i-5z4x7n at FIX.
State machines:
- fix
    instance id param ref
    starts at: TRIAGE
Paused:
- instance i-5z4x7n (fix) at FIX
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required, one of: fix): The state machine to enter.
    ref (optional): The id param that the list of state machines names for the machine: an existing id or ref continues that instance, and a new ref starts one with it. Omit it to start a new instance.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>

The machine then renames FIX to REPAIR:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: REPAIR
  REPAIR:
    on:
      fixed: DONE
  DONE:
    type: final

smllm validate --warnings reports the saved instance:

$ smllm validate --warnings
.smllm/fix.smllm.yaml: warning: saved instance i-5z4x7n is in state FIX, which no longer exists. To repair it, fire enter with its ref and a state (CLI-3)
0 errors, 1 warning, 0 info

Entering the instance with state=REPAIR moves it to the new state:

$ smllm fire --session sm-nqvfvw enter --param stateMachine=fix --param ref=i-5z4x7n --param state=REPAIR
<smllm>
session sm-nqvfvw · fix › REPAIR · instance i-5z4x7n
Arrived by: enter (saved state FIX no longer exists)
Params: stateMachine = "fix", ref = "i-5z4x7n", state = "REPAIR"
</smllm>

smllm validate reports no finding:

$ smllm validate --warnings
0 errors, 0 warnings, 0 info

CLI-11: Command guards and actions

A finding of CLI-11 means that a guard or an action of type: command can’t run as written. The rules page says what the rule checks.

Messages

<file>:<line>: error: <path>.params.run: run must be a string (shell) or a list of strings (no shell) (CLI-11)
<file>:<line>: error: <path>: command run is empty (CLI-11)
<file>:<line>: error: <path>: command run is an empty list (CLI-11)
<file>:<line>: error: <path>: timeoutSecs must be at least 1 (CLI-11)
<file>:<line>: warning: <path>: a list-form run gets no shell, so nothing expands $SMLLM_*. Use the string form, or read the environment in a script (CLI-11)

Cause

Each message has its own cause, and smllm validate lists the last, a warning, only with --warnings:

  • run is neither a string nor a list of strings, such as a number or a list that holds a number.
  • run is an empty string, or a string of spaces.
  • run is an empty list.
  • timeoutSecs is 0.
  • run is a list, and one of its items contains $SMLLM_. smllm runs a list without a shell, so the program gets the text $SMLLM_REF itself, not the value of the variable.

Fix

Fix each message in the order of the causes:

  • Write run as a string, which a shell runs, or as a list of strings: the program, then its arguments.
  • Give run the command to run.
  • Give run at least the program to run.
  • Set timeoutSecs to 1 or more, or remove it for the default of 60 seconds.
  • Write run as a string, so that the shell expands the variables, or run a script that reads them from its environment. On Windows, cmd writes a variable as %SMLLM_REF%.

Example

The command of DONE passes the ref of the instance in a list, where no shell expands $SMLLM_REF:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final
    entry: { type: command, params: { run: [git, commit, -am, "Fix $SMLLM_REF"] } }

smllm validate --warnings reports the list:

$ smllm validate --warnings .smllm/fix.smllm.yaml
.smllm/fix.smllm.yaml:11: warning: states.DONE.entry: a list-form run gets no shell, so nothing expands $SMLLM_*. Use the string form, or read the environment in a script (CLI-11)
0 errors, 1 warning, 0 info

The fixed command is a string, which a shell runs:

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: DONE
  DONE:
    type: final
    entry: { type: command, params: { run: 'git commit -am "Fix $SMLLM_REF"' } }

smllm validate reports no finding:

$ smllm validate --warnings .smllm/fix.smllm.yaml
0 errors, 0 warnings, 0 info

STORE-1: The instance files

A finding of STORE-1 means that smllm can’t read a file in which it saves the instances of a machine. smllm skips such a file everywhere else, so smllm validate is where you learn of it. The findings are warnings. The rules page says what the rule checks.

Messages

<file>: warning: can't read the instance file of <machine>: <reason>. smllm skips it, so fix or delete the file (STORE-1)
<file>: warning: can't read the ref marker of <machine>: <reason>. smllm finds these refs more slowly without the marker, and rewrites the marker when it next sets one of them. Don't delete the marker, because smllm reads a missing marker as no such ref (STORE-1)

Cause

smllm keeps each instance of a machine in a JSON file in the machine’s state folder, which is .smllm/state/<MACHINE> by default. The ref markers, in the folder’s refs folder, map the ref of each instance to its ID. smllm validate lists the warnings only with --warnings. Each message has its own cause:

  • An instance file isn’t JSON, or isn’t an instance that smllm reads. An edit by hand that went wrong is the usual cause, or a file from a version of smllm that saves instances in another form.
  • A ref marker isn’t JSON, or smllm can’t read it.

Fix

Fix each message in the order of the causes:

  • Correct the file, or delete it. Deleting it deletes the instance.
  • Correct the marker, but don’t delete it. smllm rewrites the marker the next time it sets a ref that the marker holds.

Example

An edit by hand cut this paused instance of the machine fix short:

.smllm/config.toml

[machines]
files = ["fix.smllm.yaml"]

.smllm/fix.smllm.yaml

id: fix
initial: TRIAGE
meta:
  smllm: 1
states:
  TRIAGE:
    on:
      reproduced: FIX
  FIX:
    on:
      fixed: DONE
  DONE:
    type: final

.smllm/state/fix/paused/i-5z4x7n.json

{
  "id": "i-5z4x7n",
  "machine": "fix",
  "ref": null,
  "state": "FIX",
  "status": "paused",
  "holder": null,
  "version": 3,
  "visits": {
    "FIX": 1,
    "TRIAGE": 1
  },

smllm validate --warnings reports the file:

$ smllm validate --warnings
.smllm/state/fix/paused/i-5z4x7n.json: warning: can't read the instance file of fix: it isn't valid JSON: EOF while parsing a value at line 13 column 0. smllm skips it, so fix or delete the file (STORE-1)
0 errors, 1 warning, 0 info

The fixed file is whole again:

.smllm/state/fix/paused/i-5z4x7n.json

{
  "id": "i-5z4x7n",
  "machine": "fix",
  "ref": null,
  "state": "FIX",
  "status": "paused",
  "holder": null,
  "version": 3,
  "visits": {
    "FIX": 1,
    "TRIAGE": 1
  },
  "resumeState": null,
  "created": 1793928806977,
  "updated": 1793928816175
}

smllm validate reports no finding:

$ smllm validate --warnings
0 errors, 0 warnings, 0 info

Why a state machine

An agent that follows a long prompt can skip a step, and nothing tells it, or you, that it did. smllm gives the agent one step at a time from a state machine, and when the agent tries to stop before it has fired one of the state’s events, smllm shows it those events instead.

One step at a time

A prompt gives the agent the whole workflow at once, and the agent decides which part applies. A state machine gives the agent only the instructions of its current state. When the agent fires an event, smllm moves it to the next state and shows it that state’s instructions, so the agent never holds a step that it isn’t on.

Each state lists the events that it accepts, and smllm refuses any other. An agent in TRIAGE can’t fire fixed, because TRIAGE offers only reproduced, so the agent can’t skip from the report to the end.

A stop that asks for a decision

An agent ends its turn when it decides that the work is done. In a state that isn’t final, the harness’s stop hook asks smllm, and smllm refuses the first stop and shows the agent the events of its state: an event of the machine, yield to wait for you, or pause to put the work aside. A stop then becomes a decision that the session records, not a step that the agent forgot. If the agent tries to stop again without firing an event, smllm lets it stop, so that an agent that can’t go on doesn’t loop, and the session’s audit shows the stop.

Checks that the agent doesn’t run

A transition can have a guard. A command guard runs a command, such as the project’s tests, and the transition happens only when the command succeeds. Otherwise smllm takes the next transition of the event, which can keep the agent in its state. The agent can say that the tests pass, but the machine moves on only when they do.

A machine that you can check

A machine is a file in the project, reviewed like the code. smllm validate checks it before an agent runs it: each target state exists, each state can be reached, and each event that the machine declares is used by a state. A prompt can’t be checked in this way, because its steps are prose.

The machine is a strict subset of an XState v5 config, so the tools of XState can draw it, and smllm graph draws it too.

See also

Sessions and instances

What is the difference between a session and an instance, and why does smllm keep the two apart? A session is one conversation of the agent, and an instance is one piece of work, such as an issue, that moves through a state machine. smllm keeps them apart so that the work outlasts the conversation: a later conversation continues the issue in the state where the last one left it.

A session is a conversation

When a conversation of the harness starts, the session start hook gives it an smllm session with a key, such as sm-nqvfvw. smllm records which conversation of the harness the key belongs to, so every later hook of the same conversation finds the same session. A conversation that the harness resumes or compacts keeps its session, and the hook shows the agent where the session is.

Pi, OpenCode, and Kilo Code take text only with a prompt, so their agent gets the session with the first prompt. GitHub Copilot and the agents integration give the agent no key: the agent fires enter without one, and smllm then makes the session.

The key appears in the header of every block, and the agent passes it on every call of the smllm tool. Only the main agent calls the tool, and it never passes the key to a subagent, because the session belongs to the main agent’s conversation.

A session records its harness, its working folder, its configs, the instance that it holds, and whether the agent yielded. It holds at most one instance at a time. A session that holds no instance is in idle, where the agent sees the machines that it can enter. smllm keeps the sessions in your user’s state folder, with an audit of every call that drove each session. On Linux and macOS the folder is ~/.local/state/smllm/ unless you set $XDG_STATE_HOME, and on Windows it is %APPDATA%\smllm\.

An instance is a piece of work

An instance is one run of a state machine over one piece of work. enter starts it, with a generated ID such as i-k0xzjt, in the machine’s initial state or in an entry point. Each event moves it, and a final state completes it.

An instance has a status: active while a session holds it, paused or interrupted after the agent puts it aside, and completed after it reaches a final state. It counts the visits to each state, and its history records each event, with the session that fired it, the states before and after, the params, and the results of the guards.

smllm keeps the instances in a state/ folder beside the config of their machine, which is .smllm/state/ for the project’s config, and git ignores the folder. The instances belong to the config, and the sessions belong to you. smllm instance list lists the instances, and smllm instance show shows one with its history.

A ref names the work

A ref is the work’s own name outside smllm, such as the issue GH-12. The machine names the param that carries the ref in meta.instance.ref, and it can give the ref a pattern. The ref is set once, by an enter that names a new ref, or by a setRef action of a transition. No two instances of a machine have the same ref.

After the ref is set, smllm shows the instance by its ref instead of its ID: in the header, in the idle list, and in the status line. The agent and you know the issue by its number, so enter with the number finds the instance in any session, and smllm instance show finds it by the number too.

Work that outlasts a session

A conversation ends, but its instance stays in its state, on disk. A new conversation gets a new session, which starts in idle. The agent continues the work by firing enter with the ref or the ID, and smllm returns the instance to its saved state, with the state’s instructions.

The idle list names the paused instances, so the agent sees the work that it paused in each new session. An instance that another session still holds isn’t in that list, and the agent continues it with its ref. A completed instance stays on disk too, and enter with an entry point as its state reopens it, with its visits and its history.

Work put aside

The agent puts an instance aside in two ways:

  • pause sets the instance aside to continue later, and returns the session to idle. The idle list shows the 10 most recently updated paused instances, and listPaused lists them all. enter with the ref continues one.
  • unmatched handles a request that fits none of the state’s events. When the machine has a fallback state, the instance moves there and stays active, and resume returns it to the state that it left. Otherwise the instance becomes interrupted, the session goes to idle, and resume from idle returns to it.

A session has one interrupted instance at most. When unmatched interrupts a second instance, the first one becomes paused, so that no work is lost.

Several sessions

You can run several conversations at once, in one project or in several. Each has its own session and its own key, and each holds its own instance.

Two sessions never hold the same instance. When the agent of one session fires enter for an instance that another session holds, the instance moves to the new session, and the new session’s block notes Took over from session KEY. The next event or stop of the old session returns it to idle, with the error that the instance moved to the new session.

Each write of an instance carries a version, and smllm refuses a write that another session made stale. When two sessions change one instance at the same moment, the second change is refused, and its session returns to idle. Neither session overwrites the other’s work.

smllm session list lists the sessions, with the instance that each holds.

See also

What happens when the agent stops

What does smllm do when the agent tries to end its turn? In a state that isn’t final, smllm blocks the stop and shows the agent the events of its state, so that the agent fires one that says what happens to the work: it goes on, waits for you, or puts the work aside. smllm lets the stop through in idle, after yield, and when the agent tries to stop a second time in a row without firing an event.

The stop hook

The harness runs its stop hook each time the agent tries to end its turn. The hook asks smllm about the agent’s session, and smllm answers in one of two ways. It lets the stop through, and the turn ends. Or it blocks the stop with a <smllm> block, and the harness gives the block to the agent and keeps the agent going.

The block of a blocked stop is the header and the events list of the state, without the state’s instructions. The agent’s text shows one.

A blocked stop asks for a decision

An agent ends its turn when its model stops calling tools. That point often comes too early: the agent leaves out a step, or it stops to report what it did before the work is finished. smllm blocks the stop because the machine’s state, not the agent’s text, records whether the work is finished.

The block names the events that the agent can fire. They are the state’s own events, which move the work on, and the built-in events yield, pause, and unmatched. Each one is a decision about the work, and smllm records each in the instance’s history and in the session’s audit. A stop is then a choice that you can read later, and not a step that the agent skipped without a trace.

Yield and pause

Two of the built-in events let the agent stop:

  • yield keeps the instance in its state, and lets the agent stop until your next prompt. The agent fires it to ask you a question, or to wait for something that only you can do. Its optional note says what the agent waits for. The status line shows yielded, so that you see that the agent waits for you. Your next prompt, or the next transition, ends the yield, and smllm then blocks a stop again.
  • pause puts the instance aside and returns the session to idle, where the agent stops freely. The agent fires it when the work has to wait longer, for example for a review. Any later session continues the work with enter, as Sessions and instances explains.

unmatched also returns the session to idle when the machine has no fallback state, so that the agent can handle a request that fits none of the events.

Idle stops freely

A session in idle holds no instance, so it has no work to finish, and smllm lets every stop through. A session also reaches idle after its instance enters a final state. The final state’s instructions, such as a request for a summary, are the last text that the agent gets for the work, and the agent then stops when it likes.

smllm also lets the agent stop when the machine of the session’s instance is no longer in the config, for example while its file has an error. The instance keeps its saved state, and the agent goes on after the file is fixed.

A second stop goes through

When the agent tries to stop again straight after a blocked stop, without firing an event, smllm lets the stop through. The harness tells the hook that the agent is going on only because of the earlier block, and the session records that no event was fired since. An agent that can’t go on, for example because it is stuck, would otherwise loop between the stop and the block without end.

smllm records this stop as runaway in the session’s audit, which smllm session show KEY --audit prints, and writes the events list to standard error. The instance stays in its state. Your next prompt clears the record of the block, so that the next stop is blocked again.

Harnesses that can’t block a stop

Some harnesses can’t block a stop, as Harnesses lists, and each lets every stop through for its own reason:

  • GitHub Copilot runs the hooks, but its session start hook can’t give the agent a session, so smllm binds none. The stop hook finds no session, and allows the stop without a word.
  • The agents integration installs no hooks, so no hook runs at a stop.
  • In opencode run, smllm blocks the stop as it does in OpenCode’s TUI, but the run ends with the agent’s turn, so the agent never reads the events list.

The machine still holds the agent to its states and its events. The agent learns to fire yield before it stops only from the instructions block that smllm harness install writes.

See also

Agent, host, and human steps

Who moves a state machine from state to state? The agent fires most events, but not every step of the work is the agent’s to take. A machine can run commands that the agent can’t skip, leave an event to the program that hosts smllm, and wait for a person through yield.

Steps that the agent takes

An event belongs to the agent unless the machine says otherwise: its source in meta.events is agent, the default. The agent fires it with the smllm tool, or with smllm fire in a harness without the tool. smllm accepts the event only when the state offers it and its params are valid, and the session’s audit records the call with the source agent.

You can fire the same events yourself with smllm fire from a shell, as Your first state machine does. smllm treats your call as the agent’s, because smllm fire makes the same call as the smllm tool.

Steps that smllm runs

A guard or an action of the type command runs a command, such as the project’s tests. smllm runs it as part of the transition, so the agent can’t skip it, and the agent can’t change its result.

  • A command guard chooses the transition. When the agent fires the event, smllm takes the first transition whose guard passes, and a guard passes when its command exits with 0. The agent can say that the tests pass, but the machine moves on only when they do. The block that the agent gets shows each guard’s result.
  • A command action runs when the instance enters or leaves a state, or takes a transition. An action never stops a transition. A failed action appears in the block as Action failed, and smllm skips the commands after it in the same list.
  • A state with always transitions is left as soon as the instance enters it, by the first transition whose guard passes. A check can then run with no event at all.

Each command gets the session, the machine, the state, the event, the instance, its ref, and the event’s params in its environment, in variables such as SMLLM_REF and SMLLM_PARAM_SIZE. Rule CLI-11 says what smllm validate checks in a command.

Steps that the host takes

A host is a program that embeds smllm’s engine, such as a web application that runs @six5536/smllm-wasm. A machine marks an event with source: host when only that program can decide it, for example from the result of its own checks. The host fires the event with fireAsHost.

The agent never sees a host event: the events list leaves it out, and smllm refuses it when the agent fires it. Under a harness such as Claude Code, no program fires host events, so an instance of the machine would wait for one that never comes. smllm therefore leaves such a machine out of the idle list under a harness, refuses to start an instance of it there, and smllm validate gives the machine an info finding under rule FMT-4.

A state can also name the host’s work with XState’s invoke, such as invoke: { src: runChecks, input: { strict: true } }. smllm runs nothing: the host’s view of the state lists each invoke, and the host runs the work that src names and fires its result as a host event. A host that runs several machines then finds its code by the name of the work, not by the names of the states. A state with an invoke must handle an event of source: host, which rule FMT-2 checks, so that the work’s result always has a way back into the machine.

Steps that a person takes

A person doesn’t fire an event through the tool. Instead, the agent waits for the person with yield, and the person answers with their next prompt. The agent then fires the event that the answer calls for.

A machine models a decision of a person, such as an approval, as a state of its own. The state’s instructions ask the agent to show the work, fire yield, and fire approved after the person approves. The decision is the person’s, and the event is the agent’s. The status line shows yielded while the agent waits, so that you see that the next step is yours. What happens when the agent stops explains how yield lets the agent stop.

Machines that mix them

One machine can give each step to the one who should take it. In this machine, the agent plans, a person approves the plan, the tests decide whether the change is done, and the host reviews it:

.smllm/config.toml

[machines]
files = ["change.smllm.yaml"]

.smllm/change.smllm.yaml

id: change
description: Make a change that the user approves and the host checks.
initial: PLAN
meta:
  smllm: 1
  events:
    planned: { description: Fire after you write the plan. }
    approved: { description: Fire after the user approves the plan. }
    done: { description: Fire after you make the change. }
    checked: { description: "The host fires this after the change passes its review.", source: host }
states:
  PLAN:
    entry: { type: prompt, params: { text: "Write a plan for the change, then fire planned." } }
    on:
      planned: APPROVE
  APPROVE:
    entry: { type: prompt, params: { text: "Show the plan to the user, then fire yield and wait. Fire approved after the user approves the plan." } }
    on:
      approved: WORK
  WORK:
    entry: { type: prompt, params: { text: "Make the change, then fire done." } }
    on:
      done:
        - { target: REVIEW, guard: { type: command, params: { run: "cargo test" } } }
        - { target: WORK, reenter: true }
  REVIEW:
    entry: { type: prompt, params: { text: "Wait while the host reviews the change." } }
    on:
      checked: DONE
  DONE:
    type: final

smllm validate says that the machine needs a host:

$ smllm validate --info
.smllm/change.smllm.yaml:6: info: meta.events: only the host fires the events checked, so the machine needs a host to drive it. A harness can't start an instance of it, and a host that embeds the engine fires these events (FMT-4)
0 errors, 0 warnings, 1 info

Under a harness, and in smllm fire, smllm refuses to start it:

$ smllm fire --session sm-nqvfvw enter --param stateMachine=change
<smllm>
session sm-nqvfvw · idle
error: state machine change needs a host to drive it: only the host fires its events checked
No state machines are configured.
Fire one event: smllm({ session: "sm-nqvfvw", event, params })
<events>
- enter: Start a new instance of a state machine, or continue one.
    stateMachine (required): The state machine to enter.
    state (optional): An entry point to enter instead, or any state for an instance whose saved state no longer exists. Omit it to enter a new instance at the initial state, or an existing one at its saved state.
</events>
</smllm>
[exit 1]

Without REVIEW and its host event, with done leading straight to DONE, the same machine runs under any harness, and the agent, the tests, and the person share the work.

See also