stateloom-live-loop · git:20260930.051133f · 2026-09-30 · sha256 038aa75546a72753

stateloom-live-loop git:20260930.051133fB

Immutable. This exact content never changes and is served at /api/v1/blob/038aa75546a72753.

---
name: stateloom-live-loop
description: Run the stateloom real-time design bridge so an agent and a human edit the same state-machine board at once. Use when starting the smcraft bridge hub, connecting the MCP server or smcx CLI to a live web canvas, running smcx watch or smcx presence, understanding rooms keyed by absolute document path, persist-then-emit durability, external-edit detection and mtime dedup, or diagnosing "○ no disk", "bridge unreachable", a canvas that will not animate, or a dropped emit warning.
---

# The live design loop

One `.smdf.json` file, several surfaces, all editing at once. The agent adds a state through
MCP; the human sees it bloom on the canvas half a second later. The human drags a box; the
agent's next `get_definition` has it.

```
        MCP (agent)          smcx (terminal)         web canvas (:4598)
             │                     │                        │
   writeDef()│           writeDef()│              PUT /api/file
             ▼                     ▼                        ▼
        ┌────────────────────────────────────────────────────────┐
        │        STATELOOM_PROJECT_FILE  (absolute path)         │
        │                  THE DURABLE TRUTH                     │
        └────────────────────────────────────────────────────────┘
             │ then, stamped with the new mtime, each emits
             └─────────────► hub :4599 ◄──────────────┘
                       sequencer · presence · differ
                       NEVER writes to disk
```

Two invariants explain nearly everything:

1. **Persist first, emit second.** Every mutating surface writes the file, *then* tells the
   hub. If the hub is down the change still lands — you lose the animation, never the work.
2. **The hub never writes disk.** It sequences, broadcasts, tracks presence, and watches the
   file for edits nobody announced. Kill it and the file-based loop still works.

---

## Step 1 — Start the hub

```bash
export STATELOOM_PROJECT_FILE="$(realpath -m ./statemachine.smdf.json)"
export STATELOOM_BRIDGE_PORT=4599
export STATELOOM_BRIDGE_HOST=127.0.0.1
export STATELOOM_BRIDGE_URL="http://127.0.0.1:4599"

smcraft-bridge                                  # env-driven, no flags
# or
smcx serve --port 4599 --host 127.0.0.1 --file "$STATELOOM_PROJECT_FILE"
```

**Verify:**

```bash
curl -sS "http://127.0.0.1:4599/socket.io/?EIO=4&transport=polling"
# → 0{"sid":"…","upgrades":["websocket"],"pingInterval":25000,…}
```

One hub is enough for many machines. Do not start a second one on another port because you
opened a second document — see rooms, below.

---

## Step 2 — Rooms are keyed by absolute document path

The hub serves **one room per `docId`**, and `docId` is the normalized absolute path of the
project file. Two peers meet only if their paths resolve to the same string.

This is why relative paths break the loop: `./statemachine.smdf.json` from the MCP process's
directory and `./statemachine.smdf.json` from the web server's directory are two different
rooms *and* two different files, with no error anywhere.

It is also what makes an agent able to move:

```
set_project_file { "path": "/srv/…/episode-103/diagrams/film-preprod.smdf.json" }
```

The MCP disconnects from the old room and joins the room for the new path. The reply tells you
both halves:

```
Active document: /srv/…/diagrams/film-preprod.smdf.json
Previous: /home/me/statemachine.smdf.json
no file yet — create_state_machine or load_definition will write it
bridge re-joining room '/srv/…/diagrams/film-preprod.smdf.json'
```

**Caveat, verified:** the web canvas binds its document per *server process* — `/api/file`
resolves the web app's own `STATELOOM_PROJECT_FILE`. Re-pointing the agent does **not**
re-point the browser. To move the canvas, restart the web server with the new env.

---

## Step 3 — Connect the peers

### The agent (MCP)

Register with `STATELOOM_BRIDGE_URL` set. Confirm from inside the session:

```
get_project_file
→ Active document: /abs/path.smdf.json
  machine 'OrderWorkflow' (6 states, 3 events)
  bridge: connected → http://127.0.0.1:4599
```

`bridge: not initialized` or `bridge not configured (STATELOOM_BRIDGE_URL unset)` means the
agent is editing offline. Its writes still persist; nobody watching will see them arrive.

### The terminal (`smcx`)

Every mutating command persists then emits, and says which happened:

```bash
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" add-state Green
```

```
+ state "Green" under "Root"
  · emitted def:patch → http://127.0.0.1:4599
```

Other endings, all non-fatal:

| Line | Means |
|---|---|
| `· bridge not set — persisted to <path> (disk only)` | No `--bridge` and no `STATELOOM_BRIDGE_URL` |
| `· bridge unreachable at <url> — persisted to disk only (…)` | Hub down or wrong port |

### The canvas (web, :4598)

The browser asks the server it was loaded from, `GET /api/config`, and gets back that
process's own `STATELOOM_BRIDGE_URL`. So a **published** canvas (`@miadi/stateloom-web`,
`stateloom-web --bridge …`) is re-pointed by restarting it with a different value — no
rebuild. Only a copy built from `web/` inlines `NEXT_PUBLIC_STATELOOM_BRIDGE_URL` at build
time, and that one does need rebuilding.

The value must be reachable **from the browser**, not from the server. Behind the container
gateway it is `/` — same origin — and then it is right by construction.

---

## Step 4 — Watch the loop live

### Terminal mirror

```bash
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" watch --as ascii
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" watch --as mermaid
```

`watch` joins the room as role `cli`, seeds itself from the join snapshot, then clears and
reprints on every change until Ctrl-C. It also tracks `runtime.enter` / `runtime.exit` events,
so a running machine's live state shows in the terminal.

Requires a bridge URL — without one it exits 1 with
`watch: no bridge URL — set --bridge or STATELOOM_BRIDGE_URL`.

### Who is in the room

```bash
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" presence
```

```
presence @ /workspace/…/statemachine.smdf.json — 3 peer(s):
  agent    mcp-agent
  web      designer
  cli      2HEUqusPJP4TFRQPAABN
```

Roles are `agent`, `cli`, `web`, `runtime`. Name a peer with `--name`:

```bash
smcx --name "mia-terminal" … presence
```

The MCP server presents as `mcp-agent` unless `STATELOOM_AGENT_NAME` says otherwise.

**Use `presence` as your first diagnostic.** It answers "are we in the same room?" in one line,
which is the question behind most live-loop confusion.

---

## Step 5 — Prove the whole loop in one command

With the hub up, the canvas open, and the agent registered:

```bash
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" add-state Proof
```

Expect, within a second: the node appears on the browser canvas with an enter animation; a
running `smcx watch` reprints with `Proof` in the tree; the agent's next `list_states` includes
it. Then clean up:

```bash
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" remove-state Proof
```

If the file changes but nothing animates, the hub link is broken, not the loop — the work is
safe, the sync is not.

---

## How the hub keeps one file honest

- **Sequencing.** The hub assigns a monotonic `seq` per room. A client that receives an
  out-of-order event asks for a full resync rather than drifting.
- **Self-write dedup.** Every emit carries the `mtime` of the file the sender just wrote. The
  hub keeps a small ring of recent `{mtime, hash}` pairs. When its own file watcher fires for
  a change already in the ring, it stays quiet — your write does not echo back at you.
- **External-edit detection.** When the watcher sees a change that is *not* in the ring —
  someone edited the JSON in an editor, or a standalone MCP wrote the same path — the hub
  diffs its in-memory definition against the file and broadcasts the difference as a granular
  patch. If the file is unparseable or the diff throws, it broadcasts the whole definition
  instead. Either way the surfaces converge.
- **Concurrent writers.** Disk is last-write-wins. The sequence gives ordering, not merging.
  Two peers editing the same state at the same second is still a race — narrate what you are
  about to change when a human is on the other end.

---

## Diagnosing `○ no disk`

The web toolbar shows one of four badges:

| Badge | Meaning |
|---|---|
| `⌁ synced` | The canvas and the file agree |
| `↻ remote changed` | The file moved under the canvas; it is re-hydrating |
| `⚠ disk error` | The bound path exists but could not be read or written |
| `○ no disk` | No file is bound — the canvas is editing thin air |

`○ no disk` is almost always **one relative path, two working directories**. The MCP server
and the web app each resolve `STATELOOM_PROJECT_FILE` against their own cwd; started from
different directories with a relative value, they bind different files and never meet.

Work it in this order:

```bash
# 1. What does the shell think?
echo "$STATELOOM_PROJECT_FILE"          # must start with /

# 2. Does the file exist and parse?
jq . "$STATELOOM_PROJECT_FILE" > /dev/null && echo "parses"

# 3. What does the agent think?  (MCP tool)
get_project_file                        # compare the path, character for character

# 4. What does the web process think?
#    Restart it with an explicit absolute value:
STATELOOM_PROJECT_FILE=/abs/path.smdf.json PORT=4598 npm run start

# 5. Are they in one room?
smcx --bridge "$STATELOOM_BRIDGE_URL" --doc "$STATELOOM_PROJECT_FILE" presence
```

The fix is always the same: **an absolute `STATELOOM_PROJECT_FILE` everywhere.** In a
repository checkout, `scripts/live-loop.sh` computes the absolute paths once, exports both the
`STATELOOM_*` and legacy `SMCRAFT_*` twins, and writes them to `.env.smcraft-live` so you can
read exactly what each process was handed.

---

## Other symptoms

| Symptom | Cause | Fix |
|---|---|---|
| MCP logs `dropped patch emit — bridge status=…; disk write succeeded, live canvas did not update. (warned once)` | The bridge client is not connected | Start the hub; restart the MCP so it re-joins |
| MCP logs `STATELOOM_BRIDGE_URL is not set — edits persist to disk but are NOT broadcast` | Registered without bridge env | Re-register with `--env STATELOOM_BRIDGE_URL=…` |
| `presence` shows only your CLI | Other peers are in a different room (different path) or not connected | Compare `get_project_file` against `echo $STATELOOM_PROJECT_FILE` |
| Canvas animates for the CLI but not the agent | Two different documents | `set_project_file` to the canvas's path |
| Hand-edited JSON does not reach the canvas | The hub is not watching that path | The watcher lives per room — make sure a peer has joined that `docId` |
| Everything works, nothing animates | The browser was handed no bridge URL, or one only the server can reach | `curl $WEB/api/config`. Empty → restart the canvas with `STATELOOM_BRIDGE_URL` set. A host the browser cannot resolve → use an address the browser can, or put the loom behind the container gateway where it is `/` |
| Duplicate `agent mcp-agent` rows in `presence` | Several MCP processes joined over the session | Harmless; they clear as processes exit |

---

## Working well beside a human

- Mutate in small steps. `add_state` × 5 is five animations the human can follow;
  `load_definition` of the same five is one silent swap.
- Say what you are about to do before you do it. The human is reading the board, not the log.
- Before a large change, `get_definition` — you then hold the exact JSON to restore.
- Never re-point the document mid-conversation without saying so. `set_project_file` moves the
  agent's room silently as far as the canvas is concerned.
- A live session is a relation, not a resource. If the human is mid-thought on a state, leave
  it alone.