Turn a stateloom / smcraft .smdf.json state machine into runnable Python or TypeScript. Use when generating code with smcg or the generate_code MCP tool, validating a definition against V001-V014 before generating, running smcg --validate-only, wiring the stateloom runtime that generated code imports, running or testing a generated state machine, or diagnosing "Error parsing …: 'settings'" and lightweight fallback output.
Installs into .claude/skills of the current project.
Are you the author of Stateloom Codegen?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/jgwill-stateloom-codegen)
---
name: stateloom-codegen
description: Turn a stateloom / smcraft .smdf.json state machine into runnable Python or TypeScript. Use when generating code with smcg or the generate_code MCP tool, validating a definition against V001-V014 before generating, running smcg --validate-only, wiring the stateloom runtime that generated code imports, running or testing a generated state machine, or diagnosing "Error parsing …: 'settings'" and lightweight fallback output.
---
# SMDF → validated → code
```
machine.smdf.json
│
▼
Parser ──────────► EnrichedModel
│ state_map · event_map · timer_map
│ feeders_map · parent_map
│ all_states · leaf_states · composite_states
▼
Validator V001–V014
│ (clean)
▼
Generator ─────────► <machine>_fsm.py (Python)
│ <machine>_fsm.ts (TypeScript)
▼
your code imports the stateloom runtime and drives it
```
The runtime — `Context`, `ContextAsync`, `State`, `StateKind`, `TransitionHelper`,
`ObserverNull` — **ships as a dependency**. Generated code imports it; it is never inlined.
Install `miadi-stateloom-engine` (PyPI) or `@miadi/stateloom-engine` (npm) alongside the generated file.
---
## Step 1 — Validate before you generate
Generation on an invalid definition wastes a cycle. Validate first.
```bash
smcg /abs/path/machine.smdf.json --validate-only
```
Clean:
```
Validation passed.
```
Not clean — the rule ID tells you what to fix (full table in the `stateloom-design` skill's
`smdf-reference.md`):
```
Validation errors in /abs/path/machine.smdf.json:
[V005] Transition references undefined event: OrderApprove (Pending)
[V006] Transition references undefined state: Complete (Active)
2 error(s) found
```
Exit code is non-zero when any rule fires, so it gates a build:
```bash
smcg "$DEF" --validate-only || { echo "definition is not ready"; exit 1; }
```
The MCP `validate_definition` tool is a **different, smaller** check with overlapping rule
IDs. Use it while designing; use `smcg --validate-only` before generating.
---
## Step 2 — Unwrap, if the file came from the tools
Every writer in the live loop — the MCP server, `smcx`, the web designer — persists the
**wrapped** form:
```json
{ "stateMachine": { "settings": …, "events": …, "state": … } }
```
`smcg` does not accept that wrapper. Handing it a wrapped file fails with:
```
Error parsing /abs/path/machine.smdf.json: 'settings'
```
This is a `smcg` (Python) constraint. The TypeScript engine's `parseFile` / `parseJson` unwrap
`{ "stateMachine": … }` themselves in current `@miadi/stateloom-engine` — but unwrapping first works on every
version of both engines, so make it the habit.
Unwrap first:
```bash
jq '.stateMachine // .StateMachine // .' /abs/path/machine.smdf.json > /tmp/machine.bare.smdf.json
# without jq
python3 -c "import json,sys; d=json.load(open(sys.argv[1])); json.dump(d.get('stateMachine', d.get('StateMachine', d)), open(sys.argv[2],'w'), indent=2)" \
/abs/path/machine.smdf.json /tmp/machine.bare.smdf.json
```
Files written by hand (and the repository's `examples/*.smdf.json`) are already bare — check
with `head -3` before converting.
---
## Step 3 — Generate Python
```bash
pip install miadi-stateloom-engine # runtime + parser + generator + the smcg CLI
smcg /tmp/machine.bare.smdf.json -o ./generated/ -l python -v
```
```
Validation passed: 6 states, 3 events
Generated: ./generated/traffic_light_fsm.py
States: 6
Events: 3
Feeders: 1
```
| Option | Meaning |
|---|---|
| `-o, --output <dir>` | Output directory (default: cwd). Created if missing. |
| `-l, --language <lang>` | Target. **`python` is the only accepted value.** |
| `-n, --name <name>` | Override `settings.name` for this run |
| `--validate-only` | Validate and stop |
| `-v, --verbose` | Print state / event / feeder counts |
**Output filename is snake_case:** `settings.name` `TrafficLight` → `traffic_light_fsm.py`.
Newer builds of the CLI also accept `smcg generate <input> …` as an explicit subcommand; the
bare positional form shown above works on both.
---
## Step 4 — Run the generated Python
The generated module gives you a context class named `<Name>Context`, one class per **leaf**
state, an `IntEnum` of every state, and a feeder per event source.
```python
from traffic_light_fsm import TrafficLightContext
ctx = TrafficLightContext()
ctx.enter_initial_state() # required — nothing is entered until you call it
print(ctx.state_current.name) # → Red
ctx.on_timer_elapsed() # one method per event, snake_cased from the event id
print(ctx.state_current.name) # → Green
```
Event methods on the context forward to whatever leaf is current:
```python
def on_timer_elapsed(self) -> None:
if self.state_current:
self.state_current.on_timer_elapsed(self)
```
Events carrying parameters take them positionally, typed as declared in the SMDF:
`ctx.on_pedestrian_call("north-crossing")`.
### Three generator behaviours to know before you rely on the output
All three verified by running the generator.
1. **An internal transition with no actions emits an empty handler — invalid Python.**
A transition with no `nextState` and no `actions` produces
`def on_x(self, context) -> None:` with nothing under it, and importing the module raises
`IndentationError: expected an indented block after function definition`.
Give every internal transition at least one action:
```json
{ "event": "ARTEFACT_TYPED", "actions": [ { "code": "on_artefact_typed_noop()" } ] }
```
Diagnose it in one line before you import:
```bash
python3 -m py_compile ./generated/*_fsm.py && echo "compiles"
```
2. **Handlers are emitted on leaf states only.** A composite is emitted as a plain
`State(kind=StateKind.COMPOSITE)`, so a transition declared on a composite is **not**
dispatched to its children at runtime, even though it is correct in the model and draws in
every diagram. To make a shared exit executable, declare it on each leaf as well.
3. **Parallel regions are not generated.** `StateDef.parallel` is modelled and validated
(V010, V011) but the generator emits no region contexts. Check the output before depending
on it.
### Where your own logic goes
The generated file is regenerated wholesale — its header says *Do not edit manually*, and it
means it. Put behaviour in:
- `condition` expressions in the SMDF — emitted verbatim as guards
- `actions` on transitions, and `onEntry` / `onExit` blocks — each `{ "code": "…" }` is emitted
as **`context.<code>`**, so write `"initialize()"`, never `"self.initialize()"`
- an **observer**: the runtime calls entry / exit hooks, so a custom observer sees every
transition without touching generated code
- a subclass of the generated context, in your own file — this is where the methods your
actions call actually live:
```python
from traffic_light_fsm import TrafficLightContext
class MyLight(TrafficLightContext):
def open_ledger(self): # called by { "code": "open_ledger()" }
...
ctx = MyLight()
ctx.enter_initial_state()
```
---
## Step 5 — TypeScript
The TypeScript generator exists in the npm `@miadi/stateloom-engine` package but is **not wired to any CLI**.
`smcg -l typescript` is rejected by argparse — `python` is the only choice.
Generate programmatically:
```bash
npm install @miadi/stateloom-engine
```
```js
// generate-ts.mjs
import { parseFile, enrich, validate } from "@miadi/stateloom-engine/parser";
import { TypeScriptCodeGenerator } from "@miadi/stateloom-engine/codegen";
import { writeFileSync } from "node:fs";
const def = parseFile("/tmp/machine.bare.smdf.json");
const model = enrich(def);
const errors = validate(model);
if (errors.length) {
for (const e of errors) console.error(`[${e.ruleId}] ${e.message}`);
process.exit(1);
}
writeFileSync("./generated/traffic_light_fsm.ts", new TypeScriptCodeGenerator(model).generate());
```
```bash
node generate-ts.mjs && head -20 ./generated/traffic_light_fsm.ts
```
**Fix the import line.** The generated TypeScript imports its runtime relatively:
```ts
} from "./runtime.js";
```
That is right only if the file sits next to the runtime. In your own project, rewrite it:
```bash
sed -i 's|} from "./runtime.js";|} from "@miadi/stateloom-engine/runtime";|' ./generated/traffic_light_fsm.ts
```
Then use it the same way as Python, camelCased:
```ts
import { TrafficLightContext } from "./generated/traffic_light_fsm.js";
const ctx = new TrafficLightContext();
ctx.enterInitialState();
ctx.onTimerElapsed();
```
The npm `@miadi/stateloom-engine` package also ships a `Machine` interpreter (`@miadi/stateloom-engine/machine`) that runs an
SMDF definition directly, with no code generation step — reach for it when you want to
*execute* a definition rather than compile it.
---
## Generating through MCP
```
generate_code { "language": "python" }
generate_code { "language": "typescript" }
```
The tool validates first with the MCP's own rules and refuses on errors:
```
Fix validation errors first:
[V003] State 'Pending' references unknown event 'OrderApprove'
```
When clean, it unwraps the definition into a temp file, calls `smcg`, and returns the code as
text.
### Know which generator produced your code — verified behaviour
`generate_code` silently falls back to a lightweight inline generator whenever the `smcg`
subprocess does not hand back the file it expected. Two situations trigger it, and both are
common:
- **`language: "typescript"`** — `smcg` rejects `-l typescript`, so TypeScript from this tool
is *always* the fallback.
- **A `settings.name` that is not already snake_case** — `smcg` writes
`interactive_production_fsm.py` while the tool looks for `InteractiveProduction_fsm.py`, does
not find it, and falls back.
Tell them apart by the first line:
| First line | Generator | What you get |
|---|---|---|
| `"""Generated State Machine: <Name>"""` | Full `smcg` | `IntEnum`, entry/exit chains, guards, timers, feeder classes, `enter_initial_state` |
| `"""Generated by SMCraft"""` | Lightweight fallback | States and transitions only — no feeders, no entry/exit, no initial-state entry |
**For production code, run `smcg` on the file yourself** (Steps 2–3). Use `generate_code` to
sketch and to read structure inside a conversation.
If you want the MCP path to produce full output, name the machine in snake_case
(`settings.name: "order_workflow"`) so the expected and actual filenames coincide.
---
## End-to-end, one pass
```bash
DEF=/abs/path/machine.smdf.json
OUT=./generated
jq '.stateMachine // .StateMachine // .' "$DEF" > /tmp/bare.smdf.json
smcg /tmp/bare.smdf.json --validate-only || exit 1
smcg /tmp/bare.smdf.json -o "$OUT" -l python -v
# catches the empty-handler defect before it becomes a confusing traceback
python3 -m py_compile "$OUT"/*_fsm.py || { echo "generated code does not compile — see behaviour 1"; exit 1; }
python3 - <<'PY'
import glob, importlib.util, sys
path = glob.glob("./generated/*_fsm.py")[0]
spec = importlib.util.spec_from_file_location("fsm", path)
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
ctx = next(getattr(mod, n)() for n in dir(mod) if n.endswith("Context"))
ctx.enter_initial_state()
print("initial state:", ctx.state_current.name)
PY
```
Passing means the definition parses, validates, generates, imports and enters its initial
state — the whole pipeline proven in one run.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `Error parsing …: 'settings'` | Wrapped `{ "stateMachine": … }` handed to `smcg` | Unwrap (Step 2) |
| `IndentationError: expected an indented block after function definition` | An internal transition with no `actions` produced an empty handler | Add an action to every `nextState`-less transition (behaviour 1) |
| `AttributeError: '<Name>Context' object has no attribute 'x'` | An action's `code` is emitted as `context.x` and nothing implements `x` | Implement it on a subclass of the generated context |
| `smcg: error: argument -l/--language: invalid choice: 'typescript'` | `smcg` only targets Python | Generate TypeScript programmatically (Step 5) |
| `ModuleNotFoundError: No module named 'stateloom'` | Runtime not installed where the generated file runs | `pip install miadi-stateloom-engine` |
| `Cannot find module './runtime.js'` | Generated TS still has the relative import | Rewrite to `@miadi/stateloom-engine/runtime` |
| Output is far smaller than expected | The lightweight fallback ran | Check the first line; run `smcg` directly |
| Machine does not react to an event | The transition is on a composite, not a leaf | Declare it on each leaf too |
| `[V0xx]` errors you do not recognize | Canonical rules, not the MCP subset | See `smdf-reference.md` in `stateloom-design` |
| Generated file name is not what you expected | `smcg` snake_cases `settings.name` | `TrafficLight` → `traffic_light_fsm.py`; override with `-n` |