@eventmodelers/cli 1.0.79 → 1.0.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +54 -3
  2. package/RELEASE_NOTES.md +3 -1
  3. package/cli.js +22 -8
  4. package/lib/modeling-local-ai.js +19 -4
  5. package/package.json +1 -1
  6. package/shared/skills/connect/SKILL.md +6 -4
  7. package/shared/skills/learn-eventmodelers-api/SKILL.md +11 -10
  8. package/shared/skills/request-feedback/SKILL.md +0 -2
  9. package/shared/skills/update-slice-status/SKILL.md +0 -2
  10. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +2 -2
  11. package/stacks/modeling-kit/templates/.claude/skills/attributes/references/api-fallback.md +1 -3
  12. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +0 -2
  13. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/references/api-fallback.md +0 -2
  14. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +0 -4
  15. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/api-fallback.md +0 -4
  16. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-designing-automation-chains/SKILL.md +0 -1
  17. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-designing-automation-chains/references/api-fallback.md +0 -1
  18. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +0 -2
  19. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/references/api-fallback.md +0 -2
  20. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +0 -1
  21. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/references/api-fallback.md +0 -2
  22. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +0 -1
  23. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/api-fallback.md +4 -4
  24. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +0 -2
  25. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/references/api-fallback.md +0 -2
  26. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +1 -3
  27. package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +1 -3
  28. package/stacks/modeling-kit/templates/.claude/skills/html-screen/SKILL.md +0 -2
  29. package/stacks/modeling-kit/templates/.claude/skills/html-screen/references/api-fallback.md +0 -1
  30. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +3 -5
  31. package/stacks/modeling-kit/templates/.claude/skills/place-element/references/api-fallback.md +4 -8
  32. package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +0 -1
  33. package/stacks/modeling-kit/templates/.claude/skills/storyboard/references/api-fallback.md +0 -1
  34. package/stacks/modeling-kit/templates/.claude/skills/storyboard-screen/SKILL.md +0 -1
  35. package/stacks/modeling-kit/templates/.claude/skills/storyboard-screen/references/api-fallback.md +0 -1
  36. package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +0 -4
  37. package/stacks/modeling-kit/templates/.claude/skills/timeline/references/api-fallback.md +1 -7
  38. package/stacks/modeling-kit/templates/kit/AGENTS.md +2 -1
  39. package/stacks/modeling-kit/templates/kit/CLAUDE.md +1 -1
package/README.md CHANGED
@@ -335,7 +335,20 @@ SGLang all serve. Everything above the transport is identical, which is why anyt
335
335
  OpenAI-compatible works by pointing `LOCAL_AI_URL` at it. The `LOCAL_AI_*` vars are the same
336
336
  ones a build kit's local runner reads (`LOCAL_AI_TARGET`, `LOCAL_AI_URL`, `LOCAL_AI_API`,
337
337
  `LOCAL_AI_MODEL`, `LOCAL_AI_API_KEY`, `LOCAL_AI_NUM_CTX`), or set them once as `localAi` in
338
- `.eventmodelers/config.json`.
338
+ `.eventmodelers/config.json` — env wins over the file, the file over the preset:
339
+
340
+ ```json
341
+ {
342
+ "boardId": "...",
343
+ "token": "...",
344
+ "localAi": {
345
+ "target": "ollama",
346
+ "url": "http://localhost:11434",
347
+ "model": "qwen3.5:9b",
348
+ "numCtx": 49152
349
+ }
350
+ }
351
+ ```
339
352
 
340
353
  **What you get and what you don't.** The loop around the turn is unchanged — the prompt queue,
341
354
  the standalone board-change lane with all its damping, the idle review, the alive-ping, and the
@@ -349,9 +362,9 @@ than read from `.agent-modeling-kit/CLAUDE.md`. `--max-agents` has nothing to ca
349
362
 
350
363
  Give it room: the platform's MCP tool schemas alone run to ~16k tokens, so Ollama's default
351
364
  `num_ctx` of 4096 would silently truncate the tool block and leave the model inventing tool
352
- names. The runner raises it to 32768 by default (`LOCAL_AI_NUM_CTX`) and warns when the schemas
365
+ names. The runner raises it to 49152 by default (`LOCAL_AI_NUM_CTX`) and warns when the schemas
353
366
  still fill more than 60% of it. On an OpenAI-compatible server the context is fixed at launch
354
- instead, so start it accordingly (vLLM: `--max-model-len 32768`, llama.cpp: `-c 32768`) —
367
+ instead, so start it accordingly (vLLM: `--max-model-len 49152`, llama.cpp: `-c 49152`) —
355
368
  overflow there surfaces as an HTTP 400, which the runner reports with that advice attached.
356
369
  A turn is capped at 24 tool iterations, which ends a model that has lost the plot without
357
370
  ending the session.
@@ -361,6 +374,44 @@ different thing, and `anthropicBaseUrl` below is that path — but note that it
361
374
  speaking Anthropic's own `/v1/messages`, which Ollama does not serve. Pointing it straight at
362
375
  `localhost:11434` gets you a 404; a translating proxy has to sit in between.
363
376
 
377
+ ### Running headless, in a container
378
+
379
+ `run` normally confirms which board it is about to drive and where that board's credentials
380
+ come from. Those questions are worth one keystroke at a terminal and fatal anywhere else, so
381
+ `--non-interactive` turns them off: the board and credentials that resolve from flags,
382
+ `EVENTMODELERS_*` env vars and the config files are taken as final, and an incomplete set fails
383
+ the run with the reason instead of being interviewed for.
384
+
385
+ A missing TTY already implied this, which covers CI and most process supervisors — but not a
386
+ container started with `-it`, or a loop started from a terminal: stdin is a TTY nobody is
387
+ watching, and the run stops on a question forever. Pass the flag rather than relying on how
388
+ stdin happens to be wired.
389
+
390
+ ```bash
391
+ docker run --rm \
392
+ -e EVENTMODELERS_TOKEN=<token> \
393
+ -e EVENTMODELERS_ORGANIZATION_ID=<uuid> \
394
+ -e EVENTMODELERS_BOARD_ID=<uuid> \
395
+ -e LOCAL_AI_URL=http://host.docker.internal:11434 \
396
+ node:20 npx -y @eventmodelers/cli run --standalone --local-ai --non-interactive
397
+ ```
398
+
399
+ Two things a container needs that a laptop already had:
400
+
401
+ - **A writable `HOME`.** With no kit in the working directory the agent installs one under
402
+ `~/.eventmodelers/kit` and keeps per-board credentials in `~/.eventmodelers/boards/` (`0600`,
403
+ in a `0700` directory). Run as a user whose home exists and is writable, or mount a volume
404
+ there to keep the kit across restarts instead of reinstalling it on every boot.
405
+ - **A reachable model server.** `localhost` inside the container is the container, not the host
406
+ — point `LOCAL_AI_URL` at `host.docker.internal` (Docker Desktop), the host's LAN address, or
407
+ the service name if Ollama/vLLM is a sibling container.
408
+
409
+ `EVENTMODELERS_TOKEN` now also answers the credentials question on its own, the way
410
+ `EVENTMODELERS_BOARD_ID` always answered the board question — an env var that wins over every
411
+ config file anyway makes asking where the credentials come from moot. So a fully env-driven run
412
+ is silent even without the flag; pass it anyway, so a half-set environment fails loudly instead
413
+ of waiting for an answer.
414
+
364
415
  ### Installing skills globally
365
416
 
366
417
  By default, skills are copied into the project's own `.claude/skills/`. Pass `--global` to `init` or `init-modeling` to install them into `~/.claude/skills/` instead — available in every project without re-running the installer each time:
package/RELEASE_NOTES.md CHANGED
@@ -1,7 +1,9 @@
1
1
  ## Unreleased
2
2
 
3
3
  ### Features
4
- - `run --modeling`/`run --standalone` accept `--local-ai`, so the modeling agent can be driven by a local or self-hosted model instead of Claude. It was previously rejected as a build-kit-only flag, which conflated two different things: `--bash`/`--exec` select a *queue* the modeling loop has no equivalent of, while `--local-ai` selects a *model*. Both wire dialects are supported — `--local-ai` (bare) or `--local-ai ollama` for Ollama's native `POST /api/chat`, and `--local-ai vllm`/`lmstudio`/`llamacpp` for the OpenAI-compatible `POST /v1/chat/completions` that vLLM, LM Studio, llama.cpp-server, TGI and SGLang serve; anything else OpenAI-compatible works via `LOCAL_AI_URL`. The loop itself is untouched: the prompt queue, the standalone board-change lane and its damping, the idle review and the platform's full MCP tool set all behave as before, because everything Claude-specific already sat behind a single `runTurn(text)` seam. What a local model cannot bring along is the part that is not a wire format — the skills (`/place-element`, `/timeline`, `eventmodeling-*`) and the subagent fan-out are Claude Code features — so a self-directed turn does the most valuable piece of work itself, inline, and the board rules it needs come from the new runner's system prompt (`lib/modeling-local-ai.js`) instead of `.agent-modeling-kit/CLAUDE.md`. `--max-agents` is ignored in this mode. Ollama's `num_ctx` is raised to 32768 by default (the MCP tool schemas alone are ~16k tokens, well past Ollama's 4096 default, which would silently truncate the tool block), with a warning when the schemas still fill >60% of it and a turn cap of 24 tool iterations.
4
+ - `run --modeling`/`run --standalone` accept `--local-ai`, so the modeling agent can be driven by a local or self-hosted model instead of Claude. It was previously rejected as a build-kit-only flag, which conflated two different things: `--bash`/`--exec` select a *queue* the modeling loop has no equivalent of, while `--local-ai` selects a *model*. Both wire dialects are supported — `--local-ai` (bare) or `--local-ai ollama` for Ollama's native `POST /api/chat`, and `--local-ai vllm`/`lmstudio`/`llamacpp` for the OpenAI-compatible `POST /v1/chat/completions` that vLLM, LM Studio, llama.cpp-server, TGI and SGLang serve; anything else OpenAI-compatible works via `LOCAL_AI_URL`. The loop itself is untouched: the prompt queue, the standalone board-change lane and its damping, the idle review and the platform's full MCP tool set all behave as before, because everything Claude-specific already sat behind a single `runTurn(text)` seam. What a local model cannot bring along is the part that is not a wire format — the skills (`/place-element`, `/timeline`, `eventmodeling-*`) and the subagent fan-out are Claude Code features — so a self-directed turn does the most valuable piece of work itself, inline, and the board rules it needs come from the new runner's system prompt (`lib/modeling-local-ai.js`) instead of `.agent-modeling-kit/CLAUDE.md`. `--max-agents` is ignored in this mode. Ollama's `num_ctx` is raised to 49152 by default (the MCP tool schemas alone are ~16k tokens, well past Ollama's 4096 default, which would silently truncate the tool block), with a warning when the schemas still fill >60% of it and a turn cap of 24 tool iterations.
5
+
6
+ - `run --non-interactive` never asks anything: the board and credentials that resolve from flags, `EVENTMODELERS_*` env vars and the config files are final, and an incomplete set fails the run with the reason instead of being interviewed for. A missing TTY already implied this, which covered CI and most supervisors, but not a container started with `-it` or a loop started from a terminal — stdin is a TTY nobody is watching, and the run stopped on the board confirmation or the credentials question forever. `EVENTMODELERS_TOKEN` also now answers the credentials question on its own, the way `EVENTMODELERS_BOARD_ID` always answered the board question: an env var that outranks every config file makes asking where the credentials come from moot. The README gains a container recipe, including the two things a container needs that a laptop already had — a writable HOME for the global kit and per-board credentials, and a `LOCAL_AI_URL` that isn't `localhost`.
5
7
 
6
8
  ### Docs
7
9
  - The `anthropicBaseUrl` option no longer implies Ollama works behind it. That route keeps Claude Code and swaps only the endpoint, so the server has to speak Anthropic's own `/v1/messages`; Ollama does not, and pointing it at `localhost:11434` returns a 404. For Ollama, `--local-ai` is the supported route, and the README now says which of the two to reach for.
package/cli.js CHANGED
@@ -1568,10 +1568,22 @@ async function fetchDefaultBoardId(baseUrl, token) {
1568
1568
  // .eventmodelers/config.json up the tree beat ~/.eventmodelers/config.json. So
1569
1569
  // `run --standalone --board-id <uuid>` is enough for a board used before, and any run can
1570
1570
  // be pointed somewhere else entirely with --token/--organization-id.
1571
- async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print) {
1571
+ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print, nonInteractive = false) {
1572
1572
  const walked = loadEffectiveConfig(cwd, null, explicitConfigPath).config;
1573
1573
  const explicit = Object.fromEntries(Object.entries(flags ?? {}).filter(([, v]) => v));
1574
1574
 
1575
+ // Whether there is anyone to ask, decided once. A TTY was the only signal before, which is
1576
+ // right for a supervisor or CI (no stdin, so nothing to ask) but wrong for a loop started
1577
+ // from a terminal: stdin is a TTY nobody is watching, and the run stops on a question.
1578
+ // --non-interactive says so explicitly; --print never asks either.
1579
+ const interactive = !print && !nonInteractive && process.stdin.isTTY;
1580
+
1581
+ // An EVENTMODELERS_TOKEN set for this run answers the credentials question below as
1582
+ // squarely as --token does — applyEnvOverrides makes it win over every file anyway, so
1583
+ // asking where this board's credentials come from could not change the outcome. The board
1584
+ // question has always treated its env var this way; this is the same rule for the token.
1585
+ const credentialsNamed = !!(explicit.token || process.env.EVENTMODELERS_TOKEN);
1586
+
1575
1587
  // Which board comes first — everything else is stored per board, so there is nothing to
1576
1588
  // look up until we know which board this run is for.
1577
1589
  let boardId = explicit.boardId || process.env.EVENTMODELERS_BOARD_ID || walked.boardId || null;
@@ -1583,7 +1595,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1583
1595
  // non-interactive stdin such as CI or a process supervisor), where the resolved value
1584
1596
  // stands on its own exactly as before.
1585
1597
  let boardChosen = !!(explicit.boardId || process.env.EVENTMODELERS_BOARD_ID);
1586
- if (!boardChosen && !print && process.stdin.isTTY) {
1598
+ if (!boardChosen && interactive) {
1587
1599
  const answer = await prompt(boardId ? `\n Board ID [${boardId}]: ` : '\n Board ID: ');
1588
1600
  if (answer) {
1589
1601
  boardId = answer;
@@ -1613,7 +1625,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1613
1625
  // implied — explicit credentials on the command line — or when there is no one to ask:
1614
1626
  // --print, or a non-interactive stdin such as CI or a supervisor that would otherwise
1615
1627
  // hang here forever (those keep using whatever is on file, silently).
1616
- if (!print && !explicit.token && process.stdin.isTTY) {
1628
+ if (interactive && !credentialsNamed) {
1617
1629
  const hasAccountWide = !!(walked.token && walked.organizationId);
1618
1630
 
1619
1631
  // What "keep" would keep. A pointer entry is deliberately not offered: it says the
@@ -1678,9 +1690,10 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1678
1690
  : { ...applyEnvOverrides({ ...walked, ...stored }), ...explicit };
1679
1691
  if (boardId) config.boardId = boardId;
1680
1692
 
1681
- if (!config.token || !config.organizationId) {
1693
+ if ((!config.token || !config.organizationId) && interactive) {
1682
1694
  // Nothing anywhere — ask once, and save it account-wide rather than into this
1683
- // directory, so every later run from anywhere is silent.
1695
+ // directory, so every later run from anywhere is silent. Headless, there is nobody to
1696
+ // interview, so this is skipped and the check below fails the run with the reason.
1684
1697
  console.log('🔐 No Eventmodelers credentials found — configuring them once, account-wide.\n');
1685
1698
  config = await configureCredentials({
1686
1699
  config,
@@ -3107,6 +3120,7 @@ credentialFlags(program
3107
3120
  .option('--exec [command]', 'Hand each prompt to an external agent command instead of the default Claude runner, via ralph-exec.js (build-kit stacks only) — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto" or "opencode run". The prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent.')
3108
3121
  .option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
3109
3122
  .option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
3123
+ .option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--global).')
3110
3124
  .option('--standalone', 'Let the modeling agent work the board in the background, on its own initiative: on top of direct prompts it subscribes to the board\'s change channel (like the build agents do) and, whenever the board goes quiet after an edit — or has simply been idle for a while — it takes a turn nobody asked for. Changed nodes are a notification, not the task: it judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, specs for a new command or read model, a missing attribute along a chain, a screen, a question comment). Filling that detail in while the human keeps modeling is the point — it does not wait for the board to be finished. Implies --modeling.')
3111
3125
  .option('--max-agents <n>', 'Cap how many subagents a self-directed --standalone turn may dispatch at once, to bound what an unattended agent can spend per turn. The agent merges work that shares a slice or chain first, then takes the most valuable pieces up to this many and leaves the rest for a later turn. 1 makes it do the single most valuable piece itself, without spawning anything. Default 5. Ignored without --standalone — prompt turns are one piece of work by definition.', '5')
3112
3126
  .option('--exclusive', 'Work only the prompts addressed to this agent\'s id — the board\'s "preferred agent" (the star in the prompts panel) — and hand every untargeted prompt straight back to the queue for another agent to take. Without it an agent also works everything nobody addressed to anyone, which is what you want for a single agent and exactly what you do not want for a dedicated one (a board with a general agent plus a specialist, or an agent a supervisor drives by id). Pair it with --id so the same agent is addressable across restarts — --global/--standalone otherwise mint a fresh id per run, and prompts addressed to the previous run\'s id are never claimed. Leaves --standalone alone: a self-directed turn is nobody\'s prompt, so an exclusive standalone agent still works the board on its own initiative.')
@@ -3201,7 +3215,7 @@ credentialFlags(program
3201
3215
  ...(opts.credentials ? parseCredentialsArg(opts.credentials) : {}),
3202
3216
  ...Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v)),
3203
3217
  };
3204
- const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print);
3218
+ const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print, !!opts.nonInteractive);
3205
3219
  projectDir = await ensureGlobalKit(config.baseUrl);
3206
3220
  kitDir = join(projectDir, MODELING_KIT.kitDirName);
3207
3221
  overrides = config;
@@ -3581,11 +3595,11 @@ program
3581
3595
  ...agentHeaders(cfg),
3582
3596
  },
3583
3597
  body: JSON.stringify([{
3598
+ // The event id is the caller's and required; boardId/timestamp are the server's —
3599
+ // it derives both from the request and ignores anything sent here.
3584
3600
  id: randomUUID(),
3585
3601
  eventType: 'node:changed',
3586
3602
  nodeId: slice.id,
3587
- boardId: cfg.boardId,
3588
- timestamp: Date.now(),
3589
3603
  changedAttributes: ['sliceStatus'],
3590
3604
  meta: { sliceStatus: newStatus },
3591
3605
  }]),
@@ -47,7 +47,7 @@ const PRESETS = {
47
47
  };
48
48
 
49
49
  const DEFAULT_MODEL = 'qwen3.5:9b';
50
- const DEFAULT_NUM_CTX = 32768;
50
+ const DEFAULT_NUM_CTX = 49152;
51
51
  // A modeling turn is read-then-write (get_nodes, then a placement or a field change),
52
52
  // so it needs more round trips than the build kit's 12 — but a local model that has
53
53
  // lost the plot loops on one tool forever, and this is what ends that turn instead of
@@ -77,7 +77,22 @@ export function resolveLocalAiTarget({ target, localAi = {} } = {}) {
77
77
  throw new Error(`Unknown local-AI dialect "${dialect}" — one of: ${Object.keys(DIALECTS).join(', ')}`);
78
78
  }
79
79
 
80
- const rawCtx = process.env.LOCAL_AI_NUM_CTX || localAi.numCtx;
80
+ // Configured the same way every other knob here is: env wins over config.json's `localAi`,
81
+ // and the default stands when neither says otherwise. Validated rather than coerced, because
82
+ // Number('32k') is NaN, which JSON.stringify turns into `num_ctx: null` — a request Ollama
83
+ // accepts and silently answers with its own 4096 default, which is the exact failure the
84
+ // default below exists to prevent.
85
+ const rawCtx = process.env.LOCAL_AI_NUM_CTX ?? localAi.numCtx;
86
+ let numCtx = DEFAULT_NUM_CTX;
87
+ if (rawCtx !== undefined && rawCtx !== null && String(rawCtx).trim() !== '') {
88
+ numCtx = Number(rawCtx);
89
+ if (!Number.isInteger(numCtx) || numCtx <= 0) {
90
+ throw new Error(
91
+ `Invalid context size "${rawCtx}" (LOCAL_AI_NUM_CTX or localAi.numCtx) — a positive whole number of tokens, e.g. ${DEFAULT_NUM_CTX}`,
92
+ );
93
+ }
94
+ }
95
+
81
96
  return {
82
97
  url,
83
98
  dialect,
@@ -87,7 +102,7 @@ export function resolveLocalAiTarget({ target, localAi = {} } = {}) {
87
102
  // num_ctx is per-request in Ollama and its default (4096) is far below what the
88
103
  // MCP tool schemas alone need; on an OpenAI-compatible server the context is fixed
89
104
  // at launch, so there is nothing to send and overflow surfaces as an HTTP 400.
90
- numCtx: dialect === 'ollama' ? (rawCtx ? Number(rawCtx) : DEFAULT_NUM_CTX) : null,
105
+ numCtx: dialect === 'ollama' ? numCtx : null,
91
106
  };
92
107
  }
93
108
 
@@ -200,7 +215,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
200
215
  throw new Error(
201
216
  `${t.dialect} HTTP 400 — the request exceeds the server's context window. The MCP tool schemas alone ` +
202
217
  `are ~${approxTokens(tools)} tokens; restart the server with a larger context ` +
203
- `(vLLM: --max-model-len 32768, llama.cpp: -c 32768).\n${text.slice(0, 300)}`,
218
+ `(vLLM: --max-model-len 49152, llama.cpp: -c 49152).\n${text.slice(0, 300)}`,
204
219
  );
205
220
  }
206
221
  throw new Error(`${t.dialect} HTTP ${res.status}: ${text.slice(0, 300)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.79",
3
+ "version": "1.0.81",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -283,15 +283,17 @@ The same discipline applies to writes: `submit_node_events` takes `events[]`, so
283
283
 
284
284
  Where per-node calls genuinely can't be avoided, issue them together in one message so they run concurrently rather than in sequence.
285
285
 
286
- ### Ids and timestamps
286
+ ### Ids
287
287
 
288
- Elements you create carry client-side ids, and every `node:created` event carries a timestamp. Mint them **once per turn, in a single call**, and take from that pool as you assemble the event array:
288
+ Elements you create carry client-side ids one `nodeId` per new node — and **every event carries its own `id`**, a fresh uuid per event, never the node's. Mint them **once per turn, in a single call**, and take from that pool as you assemble the event array:
289
289
 
290
290
  ```bash
291
- for i in $(seq 5); do uuidgen; done; echo $(( $(date +%s) * 1000 ))
291
+ for i in $(seq 8); do uuidgen; done
292
292
  ```
293
293
 
294
- Nothing in that depends on anything you're about to read, so splitting it across three shells is three round trips bought for nothing. Never reach for GNU-only `date` specifiers (`%N`, `%3N`) here: BSD/macOS `date` prints them literally instead of failing, so the malformed timestamp survives until something downstream rejects it.
294
+ Nothing in that depends on anything you're about to read, so splitting it across several shells is round trips bought for nothing.
295
+
296
+ The event `id` is **required** — an event without one is rejected as an invalid shape. It is what keys the returned `hashes` map, and what makes a resubmit idempotent: the same event sent twice under the same id writes one board event, not two. An event's `boardId` and `timestamp`, by contrast, are **not** yours to send — the server takes the board from the call and stamps the time itself, and ignores both if you send them.
295
297
 
296
298
  ---
297
299
 
@@ -20,12 +20,12 @@ Server name: `eventmodelers`. Every tool takes `boardId` explicitly; none need `
20
20
  | Tool | Args | Purpose | REST equivalent |
21
21
  |---|---|---|---|
22
22
  | `list_boards` | — | List boards for the org | §1 `GET /api/boards` (org-scoped) |
23
- | `get_nodes` | `boardId`, `type?`, `name?`, `chapterId?`, `nodeIds?`, `projection?` (`"line"`) | List nodes, optionally by type and/or a partial case-insensitive title match. `chapterId` scopes to one timeline — prefer this over an unscoped board-wide call whenever the step is working within one chapter (the common case); `nodeIds` fetches a known, scattered subset in one call (e.g. re-verifying exactly the nodes just touched by a batch write) instead of a full `type` refetch; `projection: "line"` maps each match to `{id, type, title}` only | §3 `GET .../nodes` |
24
- | `get_node` | `boardId`, `nodeId`, `projection?` (`"cells"` \| `"edges"`) | Get one node. `projection: "cells"` (CHAPTER nodes only) returns just `{rows, columns, cells}` instead of the full `timelineData` — use whenever only the grid/occupancy is needed, not the whole chapter; `projection: "edges"` returns just that node's inbound/outbound connections instead of `findNodeById`'s full record. Both are opt-in — omitting `projection` is the unchanged full response | §3 `GET .../nodes/:nodeId` |
23
+ | `get_nodes` | `boardId`, `type?`, `name?`, `chapterId?`, `nodeIds?`, `projection?` (`"line"`) | List nodes, optionally by type and/or a partial case-insensitive title match. `chapterId` scopes to one timeline — prefer this over an unscoped board-wide call whenever the step is working within one chapter (the common case); `nodeIds` fetches a known, scattered subset in one call (e.g. re-verifying exactly the nodes just touched by a batch write) instead of a full `type` refetch; `projection: "line"` maps each match to `{id, type, title}` only. Every node carries `cellName`, its cell address in the chapter it is placed in (absent when unplaced) — read the cell names the cell-addressed tools need from here, never derive or invent one | §3 `GET .../nodes` |
24
+ | `get_node` | `boardId`, `nodeId`, `projection?` (`"cells"` \| `"edges"`) | Get one node — the full response carries `cellName`, its cell address in the chapter it is placed in. `projection: "cells"` (CHAPTER nodes only) returns just `{rows, columns, cells}` instead of the full `timelineData` — use whenever only the grid/occupancy is needed, not the whole chapter; `projection: "edges"` returns just that node's inbound/outbound connections instead of `findNodeById`'s full record. Both are opt-in — omitting `projection` is the unchanged full response | §3 `GET .../nodes/:nodeId` |
25
25
  | `get_node_comments` | `boardId`, `nodeId` | List comments on a node | §1 `GET .../nodes/:nodeId/comments` |
26
26
  | `get_board_events` | `boardId` | All board events, in sequence | §1 `GET .../events` |
27
27
  | `search_board_events` | `boardId`, `name` | Search events by node name | §1 `GET .../events/search` |
28
- | `submit_node_events` | `boardId`, `events[]`, `autoConnect?`, `compact?` | Create/update nodes (raw `NodeChangeEvent`/edge events). Every event property is described on the tool's own `events[]` schema — read that rather than this skill when all you need is the event shape. `autoConnect: false` places freshly-created nodes without wiring them to their own/previous-column neighbors (avoids a stray nearest-left edge); `compact: true` returns `{persisted: <count>}` instead of the per-node hash map | §3 `POST .../nodes/events` |
28
+ | `submit_node_events` | `boardId`, `events[]`, `autoConnect?`, `compact?` | Create/update nodes (raw `NodeChangeEvent`/edge events). Every event property is described on the tool's own `events[]` schema — read that rather than this skill when all you need is the event shape. An event needs its own `id` (required — a fresh uuid per event, never the node id) and its `eventType`, plus the keys of its kind (`nodeId`/`meta`/`node`, or `edgeId`/`source`/`target`). The `id` keys the returned `hashes` map and makes a resubmit idempotent — the same event sent twice under one id writes a single board event. Don't send `boardId` or `timestamp` per event — the server takes the board from the call and stamps the time itself, and ignores both if sent. `autoConnect: false` places freshly-created nodes without wiring them to their own/previous-column neighbors (avoids a stray nearest-left edge); `compact: true` returns `{persisted: <count>}` instead of the per-node hash map | §3 `POST .../nodes/events` |
29
29
  | `delete_node` | `boardId`, `nodeId` | Delete a node. Deleting a chapter (timeline) cascades — every node placed in one of its cells, plus any node parented to it (e.g. SLICE_BORDER), is deleted too, along with all their edges | (via `node:deleted` event, §3) |
30
30
  | `create_drawing` / `create_drawings` | `boardId`, `kind`, `x`, `y`, `width`, `height`, ... (plural: `drawings[]`) | Freehand canvas annotation (path/rect/text/sticky) — never placed in a cell. Use the plural form whenever an annotation is more than one stroke (a loop plus its arrows and label is one annotation, not three calls) | — (REST `POST .../drawing/draw` accepts a single drawing or an array) |
31
31
  | `find_nodes_in_drawing` | `boardId`, `drawingId` | Nodes fully contained inside a drawing's bounding box | — (no REST equivalent; MCP-only) |
@@ -48,7 +48,7 @@ Server name: `eventmodelers`. Every tool takes `boardId` explicitly; none need `
48
48
  | `update_slice_status` | `boardId`, `newStatus`, plus exactly one of `sliceId` / `sliceTitle` / `columnId` | Change a SLICE_BORDER's `sliceStatus`. With a title or column id there is no need to call `list_slices` first; an ambiguous title comes back with its candidates. A slice being created takes its status from `create_slice`/`create_slice_definition` instead | — (via `node:changed` event, §3) |
49
49
  | `get_slice_data` | `boardId`, `contextName?`, `contextId?`, `sliceId?` | Full element graph for slices in a context | §8 `GET /slicedata` |
50
50
  | `get_spec_info` | `boardId`, `timelineId`, `elementTypes?` | EVENT/COMMAND/READMODEL nodes valid in GWT steps. Pass `elementTypes` (subset of `EVENT`/`COMMAND`/`READMODEL`) to avoid pulling the full element list when only one or two types are needed — filtered server-side, not just after a full fetch | §6 `GET .../spec-info` |
51
- | `get_board_outline` | `boardId`, `chapterId` | One chapter's structure, compact: per-column node lists (`{id, type, title, lane}`) + a flat edge list, no HTML pages / field bodies / meta. The cheap "what is where and how is it wired" read — prefer over `get_nodes` (no projection) for orientation checks | — (MCP-only convenience) |
51
+ | `get_board_outline` | `boardId`, `chapterId` | One chapter's structure, compact: per-column node lists (`{id, type, title, lane, cellName}`) + a flat edge list, no HTML pages / field bodies / meta. `cellName` is each node's real cell address (column letter + row number) — the one read that gives you every address in a chapter at once. The cheap "what is where and how is it wired" read — prefer over `get_nodes` (no projection) for orientation checks | — (MCP-only convenience) |
52
52
  | `get_connected_nodes` | `boardId`, `nodeId`, `chapterId?`, `direction?` (`inbound`/`outbound`/`both`), `depth?`, `types?`, `includeFields?` | Neighbours of **one** node — what feeds it and what it feeds. Answers from a single anchor, unlike `get_attribute_chain` (which needs both ends of the chain as cell names up front). `depth` follows a whole chain; `types` filters the result only, never the traversal. Each neighbour carries `via`: `"edge"` for a real connection, `"layout"` when the node has none in that direction and the neighbour was inferred from the grid using auto-connect's own window (own column + adjacent one, forward-only pairs). Real edges always win. The `layout` fallback is what makes hand-built/imported chapters — which routinely carry **zero** edges — readable instead of falsely empty | — (MCP-only convenience) |
53
53
  | `validate_model` | `boardId`, `chapterId`, `checks?[]` | Server-side Event Modeling structural checklist over one chapter — compact `findings` only. Checks: unplaced nodes, backward arrows (with the todo-list `EVENT→READMODEL` exception), zero/multi-issuer commands, sourceless read models, two-screens-in-a-column, missing scenarios. Replaces the manual per-type `get_nodes` + `get_node projection=edges` validation pass | — (MCP-only convenience) |
54
54
  | `add_scenario` | `boardId`, `timelineId`, `columnId`, `scenarios[]`, `compact?` | Append GWT scenario(s) to a column's spec node — created automatically, and a scenario `id` is generated when omitted. A given/when/then step may be addressed by `{title, type}` instead of a node id, resolved against that timeline, so no `get_spec_info` call is needed first (an ambiguous title is reported with its candidates). `compact: true` returns `{specNodeId, added, scenarioCount, isNewNode}` instead of echoing every scenario back | §6 `POST .../scenarios` |
@@ -61,7 +61,7 @@ Server name: `eventmodelers`. Every tool takes `boardId` explicitly; none need `
61
61
  | `create_screen` | `boardId`, `contentType` (`'image'\|'sketch'\|'html'`), `nodeId?`, `chapterId`, `cellId?`/`cellName?`, plus content fields (`imageBase64`/`mimeType`, `elements[]`, or `pages[]`/`backgroundColor`), `title?`, `description?`, `fields?`, `autoConnect?` | Create + place a new screen node (SCREEN or HTML_SCREEN) atomically, in one call. `title` names the node (`meta.title`) in the same call — no follow-up `node:changed` just to label the screen; `create_screens` takes it per entry. Batch form `create_screens` takes `screens[]` (HTML only) + `autoConnect?`. `autoConnect: false` places without wiring to timeline neighbors | §4 `POST .../images/:id/sketch` + `image-nodes` |
62
62
  | `render_screen` | `boardId`, `nodeId`, `elements[]?` (SCREEN) or `pages[]?`+`backgroundColor?` (HTML_SCREEN), `description?` | Update an existing screen's content — exactly one of `elements`/`pages` | §4 `POST .../images/:id/sketch` + `image-nodes` |
63
63
  | `add_field_examples` | `boardId`, `nodeId?`, `name?`, `cellName?`, `timelineId?` | Fill empty field examples using linked-node context | — (MCP-only convenience) |
64
- | `get_attribute_chain` | `boardId`, `timelineId`, `targetCellName`, `sourceCellName` | Resolve every node between two cells, ordered target→source | — (MCP-only convenience) |
64
+ | `get_attribute_chain` | `boardId`, `timelineId`, `targetCellName`, `sourceCellName` | Resolve every node between two cells, ordered target→source. Both cell names must come from a node's reported `cellName` (`get_board_outline`, `get_nodes`, `get_node`) — a guessed address that happens to exist silently resolves the wrong chain | — (MCP-only convenience) |
65
65
  | `verify_screen` | `boardId`, `nodeId` | Check a screen node exists and has rendered content — works for both SCREEN and HTML_SCREEN, dispatching on the node's actual type | — (MCP-only convenience) |
66
66
  | `get_image_snapshot_description` | `boardId`, `nodeId` | Load the `{elements:[...]}` sketch description from storage | — (reads what §4 sketch endpoints write) |
67
67
  | `validate_slice_data` | `sliceData` | Offline validation of a `SliceDataOutput` payload — no board access | — (MCP-only, pure function) |
@@ -337,8 +337,6 @@ Place a free-text markdown note in that lane the same way any other node is plac
337
337
  "id": "<event-uuid>",
338
338
  "eventType": "node:created",
339
339
  "nodeId": "<node-uuid>",
340
- "boardId": "<boardId>",
341
- "timestamp": 1234567890,
342
340
  "chapterId": "<chapterId>",
343
341
  "cellId": "<feedbackRowId>-<columnId>",
344
342
  "meta": {
@@ -370,13 +368,16 @@ A `node:deleted` event cascades: if the deleted node is a chapter (timeline), ev
370
368
 
371
369
  **Request body**: `NodeChangeEvent[]`
372
370
 
371
+ Every event carries its own `id` — **required**, a fresh uuid per event and never the node id. An event without one is rejected (`400`, invalid event shape). It keys the response's `hashes` map, and it is what makes a resubmit safe: the board-event insert ignores a conflicting id, so the same batch sent twice writes one history row per event rather than two.
372
+
373
+ Don't send `boardId` or `timestamp` per event: the board is the path's (the one this request was authorized for) and the timestamp is the server's. Both are ignored if sent — older payloads that include them still work.
374
+
373
375
  ```typescript
374
376
  interface NodeChangeEvent {
375
- id: string // uuid
377
+ id: string // required — fresh uuid per event, NOT the node id; keys the response's hashes map
376
378
  eventType: 'node:created' | 'node:changed' | 'node:deleted'
377
379
  nodeId: string
378
- boardId: string
379
- timestamp: number // unix ms
380
+ // boardId / timestamp: don't send them — server-derived, ignored if present
380
381
  userId?: string
381
382
  hash?: string // content hash
382
383
  changedAttributes?: string[] // dot-paths e.g. 'meta.title'
@@ -128,8 +128,6 @@ curl -s -X POST "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/events" \
128
128
  "id": "<new-random-uuid>",
129
129
  "eventType": "node:changed",
130
130
  "nodeId": "<SLICE_NODE_ID>",
131
- "boardId": "<BOARD_ID>",
132
- "timestamp": <Date.now()>,
133
131
  "changedAttributes": ["sliceStatus"],
134
132
  "meta": {
135
133
  "sliceStatus": "Blocked"
@@ -89,8 +89,6 @@ curl -s -X POST "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/events" \
89
89
  "id": "<new-random-uuid>",
90
90
  "eventType": "node:changed",
91
91
  "nodeId": "<SLICE_NODE_ID>",
92
- "boardId": "<BOARD_ID>",
93
- "timestamp": <Date.now()>,
94
92
  "changedAttributes": ["sliceStatus"],
95
93
  "meta": {
96
94
  "sliceStatus": "<newStatus>"
@@ -42,6 +42,8 @@ mcp__eventmodelers__get_attribute_chain {
42
42
 
43
43
  The result gives you the ordered chain directly — save it as the chain used in Step 4, and skip the manual walk in 2a–3c below. Continue with the fallback only if MCP isn't connected.
44
44
 
45
+ **Both cell names must be real.** When the task named a node (an id, a title, a comment on it) instead of a cell, read that node's `cellName` off `get_board_outline` (every node in the chapter, with its address), `get_nodes`, or `get_node` — all three report it. Never compute an address from where a node appeared in a list, and never hand `get_attribute_chain` an address you have not read: a cell that exists but holds a different node resolves a wrong chain without any error.
46
+
45
47
  ### Fallback (no MCP) — resolve both cells to nodes
46
48
 
47
49
  For each cell (target and source), resolve it to a node using the exact same cell-resolution steps as the `examples` skill's "2c — Cell name" section (fetch chapters, fetch the chapter fresh to decode the grid, decode the cell name into a `CELL_ID`, then always fetch the cell live — `get_nodes` has no `cellId` filter) — see there for the full mechanics, substituting `x-user-id: attributes-skill`.
@@ -149,8 +151,6 @@ mcp__eventmodelers__submit_node_events {
149
151
  "id": "<uuid>",
150
152
  "eventType": "node:changed",
151
153
  "nodeId": "<NODE_ID_1>",
152
- "boardId": "$BOARD_ID",
153
- "timestamp": <epoch-ms>,
154
154
  "changedAttributes": ["meta.fields"],
155
155
  "meta": { "fields": "<updated_fields_array_1>" }
156
156
  },
@@ -15,13 +15,11 @@ Build the payload with Python to avoid JSON escaping issues, then POST it:
15
15
 
16
16
  ```bash
17
17
  python3 - <<EOF > /tmp/attributes_payload.json
18
- import json, time, uuid
18
+ import json, uuid
19
19
  payload = [{
20
20
  "id": str(uuid.uuid4()),
21
21
  "eventType": "node:changed",
22
22
  "nodeId": "<NODE_ID>",
23
- "boardId": "<BOARD_ID>",
24
- "timestamp": int(time.time() * 1000),
25
23
  "changedAttributes": ["meta.fields"],
26
24
  "meta": {
27
25
  "fields": <updated_fields_as_python_list>
@@ -232,8 +232,6 @@ mcp__eventmodelers__submit_node_events {
232
232
  "id": "<uuid>",
233
233
  "eventType": "node:changed",
234
234
  "nodeId": "<CHAPTER_ID>",
235
- "boardId": "<BOARD_ID>",
236
- "timestamp": <NOW_MS>,
237
235
  "changedAttributes": ["meta.title"],
238
236
  "meta": { "type": "CHAPTER", "title": "<flow name>" },
239
237
  "node": { "id": "<CHAPTER_ID>", "data": {} }
@@ -22,8 +22,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
22
22
  "id": "<uuid>",
23
23
  "eventType": "node:changed",
24
24
  "nodeId": "<CHAPTER_ID>",
25
- "boardId": "<BOARD_ID>",
26
- "timestamp": <NOW_MS>,
27
25
  "changedAttributes": ["meta.title"],
28
26
  "meta": { "type": "CHAPTER", "title": "<flow name>" },
29
27
  "node": { "id": "<CHAPTER_ID>", "data": {} }
@@ -107,8 +107,6 @@ mcp__eventmodelers__submit_node_events {
107
107
  "id": "<uuid>",
108
108
  "eventType": "node:changed",
109
109
  "nodeId": "<chapterId>",
110
- "boardId": "<BOARD_ID>",
111
- "timestamp": 1234567890,
112
110
  "meta": {"type": "CHAPTER", "title": "Reservation & Lending"}
113
111
  }]
114
112
  }
@@ -200,8 +198,6 @@ mcp__eventmodelers__submit_node_events {
200
198
  "id": "<event-uuid>",
201
199
  "eventType": "node:created",
202
200
  "nodeId": "<node-uuid>",
203
- "boardId": "<BOARD_ID>",
204
- "timestamp": 1234567890,
205
201
  "chapterId": "<chapterId>",
206
202
  "cellId": "<swimlaneRowId>-<columnId>",
207
203
  "meta": {
@@ -35,8 +35,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
35
35
  "id": "<uuid>",
36
36
  "eventType": "node:changed",
37
37
  "nodeId": "<chapterId>",
38
- "boardId": "<boardId>",
39
- "timestamp": 1234567890,
40
38
  "meta": {"type": "CHAPTER", "title": "Reservation & Lending"}
41
39
  }]'
42
40
  ```
@@ -78,8 +76,6 @@ Same body via `POST .../nodes/events`:
78
76
  "id": "<event-uuid>",
79
77
  "eventType": "node:created",
80
78
  "nodeId": "<node-uuid>",
81
- "boardId": "<boardId>",
82
- "timestamp": 1234567890,
83
79
  "chapterId": "<chapterId>",
84
80
  "cellId": "<swimlaneRowId>-<columnId>",
85
81
  "meta": {
@@ -95,7 +95,6 @@ mcp__eventmodelers__submit_node_events {
95
95
  "boardId": "<BOARD_ID>",
96
96
  "events": [{
97
97
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<returned-node-id>",
98
- "boardId": "<BOARD_ID>", "timestamp": 1234567890,
99
98
  "meta": {"type": "READMODEL", "title": "NotificationsToSend", "fields": [...], "listElement": true}
100
99
  }]
101
100
  }
@@ -14,7 +14,6 @@ Only needed when MCP is not connected. Every call below has an MCP equivalent in
14
14
  -H "Content-Type: application/json" \
15
15
  -d '[{
16
16
  "id":"<event-uuid>","eventType":"node:created","nodeId":"<node-uuid>",
17
- "boardId":"<BOARD_ID>","timestamp":1234567890,
18
17
  "chapterId":"<CHAPTER_ID>","cellId":"<interactionRowId>-<columnId>",
19
18
  "meta":{"type":"READMODEL","title":"NotificationsToSend","fields":[...]}
20
19
  }]'
@@ -160,8 +160,6 @@ mcp__eventmodelers__submit_node_events {
160
160
  "id": "<event-uuid>",
161
161
  "eventType": "node:changed",
162
162
  "nodeId": "<returned-node-id>",
163
- "boardId": "<BOARD_ID>",
164
- "timestamp": 1234567890,
165
163
  "meta": {
166
164
  "type": "COMMAND",
167
165
  "title": "ReserveBike",
@@ -34,8 +34,6 @@ curl -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
34
34
  "id": "<event-uuid>",
35
35
  "eventType": "node:created",
36
36
  "nodeId": "<node-uuid>",
37
- "boardId": "<boardId>",
38
- "timestamp": 1234567890,
39
37
  "chapterId": "<chapterId>",
40
38
  "cellId": "<interactionRowId>-<columnId>",
41
39
  "meta": {
@@ -267,7 +267,6 @@ mcp__eventmodelers__submit_node_events {
267
267
  "boardId": "<BOARD_ID>",
268
268
  "events": [{
269
269
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<returned-node-id>",
270
- "boardId": "<BOARD_ID>", "timestamp": 1234567890,
271
270
  "meta": {"type": "READMODEL", "title": "ActiveReservationView", "fields": [...]}
272
271
  }]
273
272
  }
@@ -26,8 +26,6 @@ Only needed when MCP is not connected. Every call below has an MCP equivalent in
26
26
  "id": "<event-uuid>",
27
27
  "eventType": "node:created",
28
28
  "nodeId": "<node-uuid>",
29
- "boardId": "<BOARD_ID>",
30
- "timestamp": 1234567890,
31
29
  "chapterId": "<CHAPTER_ID>",
32
30
  "cellId": "<interactionRowId>-<columnId>",
33
31
  "meta": {"type": "READMODEL", "title": "ActiveReservationView", "fields": [...]}
@@ -431,7 +431,6 @@ Not delegated to a separate skill — performed directly by this orchestrating s
431
431
  "boardId": "$BOARD_ID",
432
432
  "events": [{
433
433
  "id": "<event-uuid>", "eventType": "node:created", "nodeId": "<node-uuid>",
434
- "boardId": "$BOARD_ID", "timestamp": 1234567890,
435
434
  "chapterId": "$CHAPTER_ID", "cellId": "<feedbackLaneId>-<firstColumnId>",
436
435
  "meta": { "type": "MARKDOWN", "title": "Modeling Reasoning — <Chapter Name>", "description": "<full markdown body>" }
437
436
  }]
@@ -17,8 +17,8 @@ done
17
17
  curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
18
18
  -H "x-token: $TOKEN" -H "x-user-id: orchestrator" \
19
19
  -H "Content-Type: application/json" \
20
- -d '[{"id":"<uuid>","eventType":"node:changed","nodeId":"<nodeId>","boardId":"<BOARD_ID>",
21
- "timestamp":1234567890,"chapterId":"<chapterId>","cellId":"<rowId>-<colId>",
20
+ -d '[{"id":"<event-uuid>","eventType":"node:changed","nodeId":"<nodeId>",
21
+ "chapterId":"<chapterId>","cellId":"<rowId>-<colId>",
22
22
  "meta":{"type":"<TYPE>","title":"<title>"}}]'
23
23
  ```
24
24
 
@@ -45,7 +45,7 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/timelines/$CHAPTER_I
45
45
  curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
46
46
  -H "x-token: $TOKEN" -H "x-user-id: orchestrator" \
47
47
  -H "Content-Type: application/json" \
48
- -d '[{"id":"<event-uuid>","eventType":"node:created","nodeId":"<node-uuid>","boardId":"<BOARD_ID>",
49
- "timestamp":1234567890,"chapterId":"<CHAPTER_ID>","cellId":"<feedbackLaneId>-<firstColumnId>",
48
+ -d '[{"id":"<event-uuid>","eventType":"node:created","nodeId":"<node-uuid>",
49
+ "chapterId":"<CHAPTER_ID>","cellId":"<feedbackLaneId>-<firstColumnId>",
50
50
  "meta":{"type":"MARKDOWN","title":"Modeling Reasoning — <Chapter Name>","description":"<full markdown body>"}}]'
51
51
  ```
@@ -256,8 +256,6 @@ mcp__eventmodelers__submit_node_events {
256
256
  "id": "<event-uuid>",
257
257
  "eventType": "node:created",
258
258
  "nodeId": "<node-uuid>",
259
- "boardId": "<BOARD_ID>",
260
- "timestamp": 1234567890,
261
259
  "chapterId": "<CHAPTER_ID>",
262
260
  "cellId": "<actorRowId>-<columnId>",
263
261
  "meta": {"type": "SCREEN", "title": "<Screen Title>", "fields": [...]}
@@ -52,8 +52,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
52
52
  "id": "<event-uuid>",
53
53
  "eventType": "node:created",
54
54
  "nodeId": "<node-uuid>",
55
- "boardId": "<BOARD_ID>",
56
- "timestamp": 1234567890,
57
55
  "chapterId": "<CHAPTER_ID>",
58
56
  "cellId": "<actorRowId>-<columnId>",
59
57
  "meta": {"type": "SCREEN", "title": "<Screen Title>", "fields": [...]}
@@ -43,7 +43,7 @@ A run that opens each element with its own `get_node` to "read existing examples
43
43
 
44
44
  - `target` is a UUID → pass `nodeId`
45
45
  - `target` is a name → pass `name`
46
- - `target` is a cell name (e.g. `B3`) → pass `cellName` + `timelineId` (the chapter id — if multiple chapters exist on the board, resolve which one first using 2c-fallback's chapter lookup, or `mcp__eventmodelers__get_nodes { "boardId": "$BOARD_ID", "type": "CHAPTER" }`, and ask the user if ambiguous)
46
+ - `target` is a cell name (e.g. `B3`) → pass `cellName` + `timelineId` (the chapter id — if multiple chapters exist on the board, resolve which one first using 2c-fallback's chapter lookup, or `mcp__eventmodelers__get_nodes { "boardId": "$BOARD_ID", "type": "CHAPTER" }`, and ask the user if ambiguous). Only pass a cell name you were given or read back as a node's `cellName` (`get_board_outline`/`get_nodes`/`get_node` all report it) — if all you have is a node id or title, pass that instead rather than constructing an address.
47
47
 
48
48
  ```
49
49
  mcp__eventmodelers__add_field_examples { "boardId": "$BOARD_ID", "nodeId": "<target, if a UUID>" }
@@ -105,8 +105,6 @@ mcp__eventmodelers__submit_node_events {
105
105
  "id": "<uuid>",
106
106
  "eventType": "node:changed",
107
107
  "nodeId": "<TARGET_NODE.id>",
108
- "boardId": "$BOARD_ID",
109
- "timestamp": <epoch-ms>,
110
108
  "changedAttributes": ["meta.fields"],
111
109
  "meta": { "fields": "<updated-fields-array>" }
112
110
  }]
@@ -94,13 +94,11 @@ Build the payload with Python to avoid shell JSON-escaping issues, then POST it:
94
94
 
95
95
  ```bash
96
96
  python3 - <<EOF > /tmp/examples_payload.json
97
- import json, time, uuid
97
+ import json, uuid
98
98
  payload = [{
99
99
  "id": str(uuid.uuid4()),
100
100
  "eventType": "node:changed",
101
101
  "nodeId": "<TARGET_NODE.id>",
102
- "boardId": "<BOARD_ID>",
103
- "timestamp": int(time.time() * 1000),
104
102
  "changedAttributes": ["meta.fields"],
105
103
  "meta": {
106
104
  "fields": <updated-fields-array as Python list>
@@ -97,7 +97,6 @@ mcp__eventmodelers__submit_node_events {
97
97
  "boardId": "<BOARD_ID>",
98
98
  "events": [{
99
99
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<NODE_ID>",
100
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
101
100
  "meta": {
102
101
  "type": "HTML_SCREEN",
103
102
  "title": "<Screen Title>",
@@ -174,7 +173,6 @@ mcp__eventmodelers__submit_node_events {
174
173
  "boardId": "<BOARD_ID>",
175
174
  "events": [{
176
175
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<NODE_ID>",
177
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
178
176
  "changedAttributes": ["meta.fields"],
179
177
  "meta": { "type": "HTML_SCREEN", "fields": [
180
178
  {"name": "status", "type": "String", "example": "confirmed", "mapping": "ActiveReservationView.status", "cardinality": "Single"}
@@ -39,7 +39,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
39
39
  -H "Content-Type: application/json" \
40
40
  -d '[{
41
41
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<NODE_ID>",
42
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
43
42
  "changedAttributes": ["meta.fields"],
44
43
  "meta": { "type": "HTML_SCREEN", "fields": [
45
44
  {"name": "status", "type": "String", "example": "confirmed", "mapping": "ActiveReservationView.status", "cardinality": "Single"}
@@ -321,10 +321,9 @@ This step applies to `SCREEN` (view/output conflict case), `AUTOMATION`, `SCENAR
321
321
  mcp__eventmodelers__submit_node_events {
322
322
  "boardId": "<BOARD_ID>",
323
323
  "events": [{
324
+ "id": "<event-uuid>",
324
325
  "eventType": "node:created",
325
326
  "nodeId": "<node-uuid>",
326
- "boardId": "<BOARD_ID>",
327
- "timestamp": <Date.now()>,
328
327
  "chapterId": "<TIMELINE_ID>",
329
328
  "cellId": "<CELL_ID>",
330
329
  "meta": { "type": "<ELEMENT_TYPE>", "title": "<title>" },
@@ -343,10 +342,9 @@ mcp__eventmodelers__submit_node_events {
343
342
  mcp__eventmodelers__submit_node_events {
344
343
  "boardId": "<BOARD_ID>",
345
344
  "events": [{
345
+ "id": "<event-uuid>",
346
346
  "eventType": "node:created",
347
347
  "nodeId": "<node-uuid>",
348
- "boardId": "<BOARD_ID>",
349
- "timestamp": <Date.now()>,
350
348
  "chapterId": "<TIMELINE_ID>",
351
349
  "cellName": "<CELL_NAME>",
352
350
  "meta": { "type": "<ELEMENT_TYPE>", "title": "<title>" },
@@ -357,7 +355,7 @@ mcp__eventmodelers__submit_node_events {
357
355
 
358
356
  **Fallback (no MCP):** see `references/api-fallback.md` — "Step 7b — Create any other node type (fast path, `cellName`)".
359
357
 
360
- Response: `{ "hashes": { "<event-uuid>": "<hash>" } }`
358
+ Response: `{ "hashes": { "<event-id>": "<hash>" } }` — keyed by the `id` you sent on each event, which is how you match a hash back to the event that produced it.
361
359
 
362
360
  > **`node:created` with `cellId`/`cellName` IS the placement** — do NOT also call the `drop` endpoint afterwards. The `drop` endpoint adds a second cell reference without removing the first, causing the node to appear in two columns simultaneously. Use `node:created` with `cellId` or `cellName` for all initial placements.
363
361
 
@@ -124,10 +124,9 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
124
124
  -H "x-user-id: agent" \
125
125
  -H "Content-Type: application/json" \
126
126
  -d '[{
127
+ "id": "<event-uuid>",
127
128
  "eventType": "node:created",
128
129
  "nodeId": "<node-uuid>",
129
- "boardId": "<BOARD_ID>",
130
- "timestamp": <Date.now()>,
131
130
  "chapterId": "<TIMELINE_ID>",
132
131
  "cellId": "<CELL_ID>",
133
132
  "meta": {
@@ -148,10 +147,9 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
148
147
  -H "x-user-id: agent" \
149
148
  -H "Content-Type: application/json" \
150
149
  -d '[{
150
+ "id": "<event-uuid>",
151
151
  "eventType": "node:created",
152
152
  "nodeId": "<node-uuid>",
153
- "boardId": "<BOARD_ID>",
154
- "timestamp": <Date.now()>,
155
153
  "chapterId": "<TIMELINE_ID>",
156
154
  "cellName": "<CELL_NAME>",
157
155
  "meta": {
@@ -162,7 +160,7 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
162
160
  }]'
163
161
  ```
164
162
 
165
- Response: `{ "hashes": { "<event-uuid>": "<hash>" } }`
163
+ Response: `{ "hashes": { "<event-id>": "<hash>" } }` — keyed by the `id` you sent on each event, which is how you match a hash back to the event that produced it.
166
164
 
167
165
  ## Full worked example — place an EVENT via curl, start to finish
168
166
 
@@ -188,8 +186,6 @@ curl -s -X POST "http://localhost:3000/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/
188
186
  "id": "<event-uuid>",
189
187
  "eventType": "node:created",
190
188
  "nodeId": "<node-uuid>",
191
- "boardId": "<BOARD_ID>",
192
- "timestamp": 1714900000000,
193
189
  "chapterId": "<TIMELINE_ID>",
194
190
  "cellId": "<CELL_ID>",
195
191
  "meta": { "type": "EVENT", "title": "Order Placed" },
@@ -197,4 +193,4 @@ curl -s -X POST "http://localhost:3000/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/
197
193
  }]'
198
194
  ```
199
195
 
200
- Replace `<TIMELINE_ID>`, `<BOARD_ID>`, `<CELL_ID>`, `<event-uuid>`, and `<node-uuid>` with real UUIDs. Use `Date.now()` or a current unix-ms timestamp for `timestamp`.
196
+ Replace `<TIMELINE_ID>`, `<BOARD_ID>`, `<CELL_ID>`, `<event-uuid>` and `<node-uuid>` with real values you mint two uuids here: the node's and the event's own `id`, which is required. `boardId`/`timestamp` are omitted on purpose: the server derives both from the request and ignores them if sent.
@@ -224,7 +224,6 @@ mcp__eventmodelers__submit_node_events {
224
224
  "boardId": "<BOARD_ID>",
225
225
  "events": [{
226
226
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<SCREEN_NODE_ID>",
227
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
228
227
  "changedAttributes": ["meta.fields"],
229
228
  "meta": { "type": "HTML_SCREEN", "fields": [ /* planned fields */ ] }
230
229
  }]
@@ -67,7 +67,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
67
67
  -H "Content-Type: application/json" \
68
68
  -d '[{
69
69
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<SCREEN_NODE_ID>",
70
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
71
70
  "changedAttributes": ["meta.fields"],
72
71
  "meta": { "type": "HTML_SCREEN", "fields": [ /* planned fields */ ] }
73
72
  }]'
@@ -122,7 +122,6 @@ mcp__eventmodelers__submit_node_events {
122
122
  "boardId": "<BOARD_ID>",
123
123
  "events": [{
124
124
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<NODE_ID>",
125
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
126
125
  "changedAttributes": ["meta.fields"],
127
126
  "meta": { "type": "SCREEN", "fields": [
128
127
  {"name": "status", "type": "String", "example": "confirmed", "mapping": "ActiveReservationView.status", "cardinality": "Single"}
@@ -33,7 +33,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
33
33
  -H "Content-Type: application/json" \
34
34
  -d '[{
35
35
  "id": "<event-uuid>", "eventType": "node:changed", "nodeId": "<NODE_ID>",
36
- "boardId": "<BOARD_ID>", "timestamp": <NOW_MS>,
37
36
  "changedAttributes": ["meta.fields"],
38
37
  "meta": { "type": "SCREEN", "fields": [
39
38
  {"name": "status", "type": "String", "example": "confirmed", "mapping": "ActiveReservationView.status", "cardinality": "Single"}
@@ -246,8 +246,6 @@ mcp__eventmodelers__submit_node_events {
246
246
  "id": "<event-uuid>",
247
247
  "eventType": "node:created",
248
248
  "nodeId": "<node-uuid>",
249
- "boardId": "<BOARD_ID>",
250
- "timestamp": <Date.now()>,
251
249
  "chapterId": "<CHAPTER_ID>",
252
250
  "cellId": "<CELL_ID>",
253
251
  "meta": { "type": "EVENT", "title": "<EventName>" },
@@ -302,8 +300,6 @@ mcp__eventmodelers__submit_node_events {
302
300
  "id": "<new-uuid>",
303
301
  "eventType": "node:changed",
304
302
  "nodeId": "<eventNodeId>",
305
- "boardId": "<BOARD_ID>",
306
- "timestamp": <Date.now()>,
307
303
  "changedAttributes": ["meta.title"],
308
304
  "meta": { "type": "EVENT", "title": "<NewTitle>" },
309
305
  "node": { "id": "<eventNodeId>", "data": {} }
@@ -48,8 +48,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
48
48
  "id": "<event-uuid>",
49
49
  "eventType": "node:created",
50
50
  "nodeId": "<node-uuid>",
51
- "boardId": "<BOARD_ID>",
52
- "timestamp": <Date.now()>,
53
51
  "chapterId": "<CHAPTER_ID>",
54
52
  "cellId": "<CELL_ID>",
55
53
  "meta": { "type": "EVENT", "title": "<EventName>" },
@@ -67,8 +65,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
67
65
  "id": "<new-uuid>",
68
66
  "eventType": "node:changed",
69
67
  "nodeId": "<eventNodeId>",
70
- "boardId": "<BOARD_ID>",
71
- "timestamp": <Date.now()>,
72
68
  "changedAttributes": ["meta.title"],
73
69
  "meta": { "type": "EVENT", "title": "<NewTitle>" },
74
70
  "node": { "id": "<eventNodeId>", "data": {} }
@@ -84,8 +80,6 @@ curl -s -X POST "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/events" \
84
80
  -d '[{
85
81
  "id": "<new-uuid>",
86
82
  "eventType": "node:deleted",
87
- "nodeId": "<eventNodeId>",
88
- "boardId": "<BOARD_ID>",
89
- "timestamp": <Date.now()>
83
+ "nodeId": "<eventNodeId>"
90
84
  }]'
91
85
  ```
@@ -14,9 +14,10 @@ ones in a compressed, reusable form; only add if not already covered here.
14
14
  - `/update-slice-status` rejects moving a slice into a status it's already in — this is a concurrency guard so two agents can't both claim the same slice. Treat this as `ALREADY_IN_STATUS`, not a task failure: drop the prompt, move on to the next task, and do not retry the same update.
15
15
  - macOS/BSD `date` silently ignores GNU-only format specifiers like `%N`/`%3N` (sub-second precision) instead of erroring — it prints the literal characters, producing a malformed timestamp that only fails downstream. Don't shell out to `date` for sub-second precision; use `$(( $(date +%s) * 1000 ))` for whole-second-in-ms, or a runtime call (`Date.now()`, `process.hrtime()`) instead.
16
16
  - Before retrying a failed shell command a second time, diagnose why it failed (e.g. a GNU/BSD flag mismatch) rather than re-running it unchanged — repeating the same command produces the same failure and just burns retries.
17
+ - A cell address (`B2`, `AE6`) is always read, never computed: `get_board_outline`, `get_nodes` and `get_node` each report a node's `cellName`. An address inferred from a column letter plus a node's place in a list is a guess, and a guess that happens to name an occupied cell is accepted silently by every cell-addressed tool.
17
18
  - Orientation and working set are two different reads, in that order: `get_board_outline` (or `get_nodes` with `projection: "line"`) answers *where is the work*, and only then does one full-`meta` `get_nodes`, scoped by `chapterId`/`nodeIds`, cover the nodes actually being touched. Opening with an unscoped full-`meta` read pulls every field body and rendered HTML page on the board to answer the cheapest question of the turn.
18
19
  - Both reads are once per turn. A chapter's outline and grid don't move unless something writes to them, so re-read only after a structural write. Three `get_board_outline` calls in one turn means the first two were thrown away.
19
- - Mint ids and timestamps once per turn, in one shell: `for i in $(seq <n>); do uuidgen; done; echo $(( $(date +%s) * 1000 ))`. Nothing in it depends on anything being read, so splitting it across three calls buys three round trips for nothing.
20
+ - Mint ids once per turn, in one shell: `for i in $(seq <n>); do uuidgen; done` one per new node, plus one per event, since every event needs its own fresh `id` (required; not the node id). Nothing in it depends on anything being read, so splitting it across several calls buys round trips for nothing. Never mint a per-event `boardId` or `timestamp` — both are server-derived and ignored if sent.
20
21
  - A subagent is a fresh session: it gets `token=`/`org=`/`baseUrl=`/`board=` inline as already-resolved values (which satisfies `connect` at its Step 0 — tell it not to invoke `/connect`) plus the board state already read for it, inline. Handed bare node ids instead, it has exactly one way to recover the rest — re-reading the whole board, once per agent.
21
22
  - Dispatch executor agents with `model:` set to the session header's `subagent_model` (default `sonnet`). The judging happened before the dispatch, on this session's model; what's left is execution against a written brief. Keep an agent on the session model only where its piece re-derives modeling structure (a chain's shape, a slice boundary).
22
23
  - Only slices in status `Created` may be modified. `get_nodes` returns `sliceStatus` per node and `get_board_outline` per column, so the board read at `/connect` Step 5 already answers it — absent means the element is in no slice (writable), not locked. Never spend a `list_slices`/`get_slice_data` call just to check whether you may write.
@@ -96,7 +96,7 @@ stale. Then a single full-`meta` `get_nodes`, scoped by
96
96
  `chapterId` or `nodeIds`, covering the nodes you concluded you will touch. Both tiers are once per turn: keep what
97
97
  came back and answer later questions from it instead of re-fetching a chapter you already hold. `/connect` Step 5
98
98
  carries the full discipline — the two tiers, the one-call `submit_node_events` rule for writes, and the per-turn
99
- pool for the ids and timestamps a `node:created` needs. Whatever you hand a subagent comes out of that same read,
99
+ pool for the ids a `node:created` needs (one for the node, one for the event itself). Whatever you hand a subagent comes out of that same read,
100
100
  never out of a second one it pays for itself (step 2).
101
101
 
102
102
  **Every prompt gets exactly two `/update-prompt-status` calls per turn — never zero, never one.** `IN_PROGRESS` before you start the work (step 4), `DONE` after you finish it (step 6). This holds even for a prompt that turns out to be trivial or a no-op — the board UI has no other way to know the agent picked it up and finished it.