@eventmodelers/cli 1.0.67 → 1.0.69

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.
package/README.md CHANGED
@@ -119,6 +119,7 @@ npx @eventmodelers/cli run --local # skip platform config/crede
119
119
  npx @eventmodelers/cli run --modeling # modeling-kit: warm Claude process driven by the board's prompt queue
120
120
  npx @eventmodelers/cli run --standalone # same, plus acting on board changes unprompted — and needs no install at all
121
121
  npx @eventmodelers/cli run --standalone --board-id <uuid> # …from any directory, against any board (see Power users)
122
+ npx @eventmodelers/cli run --id <id> --name <label> # override this run's agent identity (id + display name); `init --name` persists a name instead
122
123
  npx @eventmodelers/cli fetch --context <name> # pull full slice detail for one context on the board into <kit-dir>/.slices/
123
124
  npx @eventmodelers/cli fetch --context <name> --slice-id <id> # same, then print just that slice
124
125
  npx @eventmodelers/cli fetch --context <name> --slice-title <title> # same, then print just the slice matching this title
@@ -395,8 +396,22 @@ npx @eventmodelers/cli init-config # interactive, writes to
395
396
  npx @eventmodelers/cli init-config --board-id <uuid> # non-interactive, just overrides one field
396
397
  npx @eventmodelers/cli init-config --credentials "token=...,boardId=...,organizationId=...,baseUrl=..." # configure ONE board (~/.eventmodelers/boards/<board>.json), no prompts
397
398
  npx @eventmodelers/cli init-config --credentials - # same, read from stdin (keeps the token out of shell history)
399
+ npx @eventmodelers/cli init-config --name ci-builder # name the agent this config's runs identify as
398
400
  ```
399
401
 
402
+ ### Naming an agent
403
+
404
+ Every agent identifies itself to the platform with a stable `agentId` it mints on first use (per project, or per board for `run --standalone`), and the board's live-agent view shows that bare uuid. `--name` gives it a readable one instead — accepted by `init`, `re-init`, and `init-config`, saved into `config.json` as `agentName`, and sent with every heartbeat from then on:
405
+
406
+ ```bash
407
+ npx @eventmodelers/cli init --stack node --name ci-builder # persisted for every later run of this kit
408
+ npx @eventmodelers/cli init-config --name martins-laptop # same, without re-installing
409
+ npx @eventmodelers/cli run --name one-off-check # override for a single run, nothing written
410
+ npx @eventmodelers/cli run --id <id> # …and a second agent of the same type, side by side
411
+ ```
412
+
413
+ The heartbeat is keyed on `(token, agentId, agentType)`, so two agents of the same type in one project need distinct ids to both show up — that's what `run --id` is for. `run --id`/`run --name` are per-run only: the persisted `agentName` (and the minted id) stay untouched, so the next plain `run` is the same agent the platform already knows.
414
+
400
415
  ### Env vars and `--config` (scripted/CI installs)
401
416
 
402
417
  Every config field can be set via an `EVENTMODELERS_*` env var instead of the interactive prompts — these always win over whatever's in `config.json`, so a fully env-driven install never prompts for credentials or Claude execution settings:
@@ -410,6 +425,7 @@ Every config field can be set via an `EVENTMODELERS_*` env var instead of the in
410
425
  | `EVENTMODELERS_ANTHROPIC_BASE_URL` | `anthropicBaseUrl` |
411
426
  | `EVENTMODELERS_MODEL` | `model` |
412
427
  | `EVENTMODELERS_SUBAGENT_MODEL` | `subagentModel` |
428
+ | `EVENTMODELERS_AGENT_NAME` | `agentName` |
413
429
 
414
430
  ```bash
415
431
  EVENTMODELERS_ORGANIZATION_ID=... EVENTMODELERS_BOARD_ID=... EVENTMODELERS_TOKEN=... \
package/cli.js CHANGED
@@ -312,6 +312,7 @@ const ENV_CONFIG_MAP = {
312
312
  EVENTMODELERS_ANTHROPIC_BASE_URL: 'anthropicBaseUrl',
313
313
  EVENTMODELERS_MODEL: 'model',
314
314
  EVENTMODELERS_SUBAGENT_MODEL: 'subagentModel',
315
+ EVENTMODELERS_AGENT_NAME: 'agentName',
315
316
  };
316
317
 
317
318
  function applyEnvOverrides(config) {
@@ -586,6 +587,17 @@ function readJsonSafe(path) {
586
587
  }
587
588
  }
588
589
 
590
+ // The `x-agent-id` header every platform call carries when this process knows its own id.
591
+ // The heartbeat says an agent is alive; this says which of the calls arriving are its — the
592
+ // platform stamps it on the board_events a write produces (so the board can show "alice moved
593
+ // this" rather than one anonymous robot), and matches it when claiming a prompt the user
594
+ // addressed to one preferred agent. Optional everywhere: an id-less caller behaves exactly as
595
+ // callers did before it existed.
596
+ function agentHeaders(cfg) {
597
+ const agentId = cfg?.agentId || process.env.EVENTMODELERS_AGENT_ID || '';
598
+ return agentId ? { 'x-agent-id': agentId } : {};
599
+ }
600
+
589
601
  // Distinguishes this agent process from any other agent pinging the same
590
602
  // token/board — e.g. a build-kit and a modeling-kit install in the same project
591
603
  // share one root config.json, and without a per-agent id both would upsert the
@@ -1089,8 +1101,9 @@ async function installStack(stackKey, stackCfg, options = {}) {
1089
1101
  // Extracted from installStack so `init-config` can reuse the exact same
1090
1102
  // paste/manual/instructions/skip flow without also scaffolding a stack.
1091
1103
  // `overrides` are values passed directly on the command line (--token, --board-id,
1092
- // --organization-id, --base-url) — the most explicit source available, so they
1093
- // win over both the config file and env vars before we even check what's missing.
1104
+ // --organization-id, --base-url, plus --name as `agentName`) — the most explicit source
1105
+ // available, so they win over both the config file and env vars before we even check
1106
+ // what's missing. Any field is written through verbatim; only requiredFields gate the prompt.
1094
1107
  async function configureCredentials({ config, configPath, targetDir, requiredFields, boardIdOptional, overrides = {}, print, skipGitignore = false, force = false }) {
1095
1108
  config = { ...config };
1096
1109
  for (const [field, value] of Object.entries(overrides)) {
@@ -1395,16 +1408,22 @@ function boardCredentialsPath(boardId) {
1395
1408
  function writeBoardCredentials(config) {
1396
1409
  const path = boardCredentialsPath(config.boardId);
1397
1410
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1411
+ // agentName rides along with agentId in both credential-bearing shapes: it is per-board
1412
+ // identity, same as the id, and this file is the only config a `run --standalone` from an
1413
+ // arbitrary directory ever reads — drop it here and `init-config --name` would be lost on
1414
+ // the very next run.
1415
+ const agentName = config.agentName ? { agentName: config.agentName } : {};
1398
1416
  const body = config.useBoard
1399
1417
  ? { boardId: config.boardId, useBoard: config.useBoard }
1400
1418
  : config.useGlobal
1401
- ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId }
1419
+ ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId, ...agentName }
1402
1420
  : {
1403
1421
  token: config.token,
1404
1422
  organizationId: config.organizationId,
1405
1423
  boardId: config.boardId,
1406
1424
  baseUrl: config.baseUrl,
1407
1425
  agentId: config.agentId,
1426
+ ...agentName,
1408
1427
  };
1409
1428
  writeFileSync(path, JSON.stringify(body, null, 2), { mode: 0o600 });
1410
1429
  }
@@ -1591,7 +1610,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1591
1610
  // that is the identity the alive-ping is scoped to.
1592
1611
  config.agentId = stored.agentId || readJsonSafe(boardCredentialsPath(config.boardId)).agentId || randomUUID();
1593
1612
  writeBoardCredentials(stored.useGlobal
1594
- ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId }
1613
+ ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId, agentName: config.agentName }
1595
1614
  : config);
1596
1615
 
1597
1616
  return config;
@@ -1648,7 +1667,7 @@ async function ensureGlobalKit(baseUrl) {
1648
1667
  // question, sketch a screen. Without the flag that channel is still subscribed on
1649
1668
  // the same connection and every event on it is dropped, so the two modes differ by
1650
1669
  // one filter rather than by a whole second realtime stack.
1651
- async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS) {
1670
+ async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}) {
1652
1671
  const configLibPath = join(kitDir, 'lib', 'config.js');
1653
1672
  if (!existsSync(configLibPath)) {
1654
1673
  console.error(`❌ ${relative(process.cwd(), configLibPath)} not found — --modeling needs a kit installed via \`init --modeling\`.`);
@@ -1670,12 +1689,18 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1670
1689
  // ~/.eventmodelers/boards/<board>.json — one dir driving several boards must not have
1671
1690
  // them all upsert one shared alive row. A project install keeps its id in the project
1672
1691
  // root config, namespaced by agent type, as it always has.
1673
- if (!overrides) local.agentId = ensureAgentId(kitDir, 'MODELING');
1692
+ // `run --id` overrides that for this run only — ensureAgentId is skipped rather than
1693
+ // overwritten, so the project's own stable id stays on disk and the next run without the
1694
+ // flag is the same agent the platform saw before.
1695
+ if (!overrides) local.agentId = identity.agentId || ensureAgentId(kitDir, 'MODELING');
1674
1696
  if (!local.token || !local.organizationId) {
1675
1697
  console.error('❌ --modeling needs platform credentials in .eventmodelers/config.json (token + organizationId) — run `/connect` once or paste your config first.');
1676
1698
  process.exit(1);
1677
1699
  }
1678
- const cfg = { ...(await fetchPlatformConfig(local)), ...(overrides ?? {}) }; // adds realtimeProvider + its provider-specific fields (supabaseUrl/supabaseAnonKey or pocketbaseUrl), + boardId if the config has a default one
1700
+ // The identity flags go on last: the global install's `overrides` carry the board's own
1701
+ // stored agentId (already persisted by resolveModelingCredentials), which would otherwise
1702
+ // win back over an explicit --id.
1703
+ const cfg = { ...(await fetchPlatformConfig(local)), ...(overrides ?? {}), ...(identity.agentId ? { agentId: identity.agentId } : {}), ...(identity.agentName ? { agentName: identity.agentName } : {}) }; // adds realtimeProvider + its provider-specific fields (supabaseUrl/supabaseAnonKey or pocketbaseUrl), + boardId if the config has a default one
1679
1704
  if (!cfg.boardId) {
1680
1705
  console.error('❌ --modeling needs a boardId — a modeling agent always runs for exactly one board. Run `/connect board=<uuid>` once, or add boardId to .eventmodelers/config.json.');
1681
1706
  process.exit(1);
@@ -1733,6 +1758,9 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1733
1758
  ...process.env,
1734
1759
  ...(cfg.anthropicBaseUrl ? { ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl } : {}),
1735
1760
  EVENTMODELERS_TOKEN: cfg.token,
1761
+ // What the connect skill puts in `.mcp.json`'s x-agent-id header and every curl-fallback
1762
+ // call, so the board work this agent does on the platform is attributed to this agent.
1763
+ ...(cfg.agentId ? { EVENTMODELERS_AGENT_ID: cfg.agentId } : {}),
1736
1764
  };
1737
1765
 
1738
1766
  let proc = null;
@@ -1898,7 +1926,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1898
1926
 
1899
1927
  async function getRealtimeToken() {
1900
1928
  const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, {
1901
- headers: { 'x-token': cfg.token },
1929
+ headers: { 'x-token': cfg.token, ...agentHeaders(cfg) },
1902
1930
  });
1903
1931
  if (!res.ok) throw new Error(`realtime-token: HTTP ${res.status}`);
1904
1932
  return (await res.json()).token;
@@ -1906,7 +1934,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1906
1934
 
1907
1935
  async function fetchNextPrompt(jwtToken) {
1908
1936
  const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/next?board_id=${encodeURIComponent(cfg.boardId)}`, {
1909
- headers: { 'x-token': cfg.token, Authorization: `Bearer ${jwtToken}` },
1937
+ headers: { 'x-token': cfg.token, Authorization: `Bearer ${jwtToken}`, ...agentHeaders(cfg) },
1910
1938
  });
1911
1939
  if (res.status === 404) return null;
1912
1940
  if (!res.ok) throw new Error(`prompts/next: HTTP ${res.status}`);
@@ -2212,15 +2240,32 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
2212
2240
  }
2213
2241
  }
2214
2242
 
2243
+ // A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}. Anything
2244
+ // that doesn't name this agent is ignored — a broadcast reaches every agent on the board, and
2245
+ // the older signal (the bare string "Exit") took all of them down at once. That string form is
2246
+ // gone for good, not just unhandled: Supabase's broadcast API rejects a non-object payload with
2247
+ // 422, so it never actually arrived here.
2248
+ const exitIfAddressed = (payload) => {
2249
+ if (payload?.type !== 'kill' || payload?.id !== cfg.agentId) return;
2250
+ log(`received kill (instruction: ${payload.instruction ?? 'exit'}) — shutting down`);
2251
+ process.exit(0);
2252
+ };
2253
+
2254
+ // The kill signal is broadcast on the BOARD channel (see the platform's agent-kill /
2255
+ // backoffice/killagent slices), not on the org channel this agent uses for prompts — without
2256
+ // this second subscription a modeling agent can only be stopped with a kill(1).
2257
+ realtime.subscribe(
2258
+ `board:${cfg.boardId}-slicechanged`,
2259
+ {message: exitIfAddressed},
2260
+ (status) => log(`channel "board:${cfg.boardId}-slicechanged": ${status}`),
2261
+ ).catch((err) => {
2262
+ log(`board channel subscribe failed, remote kill won't reach this agent: ${err.message}`);
2263
+ });
2264
+
2215
2265
  realtime.subscribe(
2216
2266
  channelName,
2217
2267
  {
2218
- message: (payload) => {
2219
- if (payload === 'Exit') {
2220
- log('received "Exit" — shutting down');
2221
- process.exit(0);
2222
- }
2223
- },
2268
+ message: exitIfAddressed,
2224
2269
  'prompt:created': () => {
2225
2270
  drain().catch((err) => log(`drain error: ${err.message}`));
2226
2271
  },
@@ -2262,7 +2307,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
2262
2307
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
2263
2308
  method: 'POST',
2264
2309
  headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
2265
- body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: 'MODELING', agent_id: cfg.agentId }),
2310
+ body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: 'MODELING', agent_id: cfg.agentId, ...(cfg.agentName ? { agent_name: cfg.agentName } : {}) }),
2266
2311
  signal: AbortSignal.timeout(10_000),
2267
2312
  });
2268
2313
  if (!res.ok) log(`ping failed: ${res.status} ${await res.text().catch(() => '')}`);
@@ -2327,6 +2372,23 @@ function credentialOverridesFromOpts(opts) {
2327
2372
  return { token: opts.token, boardId: opts.boardId, organizationId: opts.organizationId, baseUrl: opts.baseUrl };
2328
2373
  }
2329
2374
 
2375
+ // `--name` is not a credential — it's the display name of the agent a kit runs, sent with
2376
+ // every heartbeat (POST /api/agent-alive's agent_name) so the board can show which agent is
2377
+ // live instead of a bare uuid. It belongs beside the credentials in config.json rather than
2378
+ // on every command line, so `init`/`re-init`/`init-config` persist it as `agentName` and
2379
+ // every later run of that kit's agent picks it up from the config walk (both runtimes merge
2380
+ // unknown config fields through verbatim). `run --name` is the per-run override on top, and
2381
+ // writes nothing.
2382
+ const AGENT_NAME_OPTION = [
2383
+ '--name <name>',
2384
+ 'Human-readable name for the agent this kit runs (e.g. "ci-builder", "martins-laptop"), saved to config.json as `agentName` and sent with every heartbeat so the board shows a name instead of a bare uuid. Also settable via EVENTMODELERS_AGENT_NAME, and overridable for a single run with `run --name`.',
2385
+ ];
2386
+
2387
+ function identityOverridesFromOpts(opts) {
2388
+ const agentName = typeof opts.name === 'string' ? opts.name.trim() : undefined;
2389
+ return agentName ? { agentName } : {};
2390
+ }
2391
+
2330
2392
  credentialFlags(program
2331
2393
  .command('init')
2332
2394
  .alias('install')
@@ -2341,7 +2403,8 @@ credentialFlags(program
2341
2403
  .option('--build-kit', 'Install a blank build-kit scaffold (.build-kit/ + .claude/skills/build-*/SKILL.md placeholders, all TODO-marked) for a stack not built into this CLI yet — no fixed backend. Mutually exclusive with --stack/--modeling/--bridge.')
2342
2404
  .option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit, running .build-kit/lib/check-commit-scope.cjs) and wire it up via `git config core.hooksPath .githooks` — only meaningful with --stack (build-kit stacks). Off by default.')
2343
2405
  .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
2344
- .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
2406
+ .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
2407
+ .option(...AGENT_NAME_OPTION))
2345
2408
  .action(async (opts, command) => {
2346
2409
  const globalOpts = command.optsWithGlobals();
2347
2410
 
@@ -2359,7 +2422,7 @@ credentialFlags(program
2359
2422
  print: globalOpts.print,
2360
2423
  global: opts.global,
2361
2424
  force: opts.force,
2362
- credentialOverrides: credentialOverridesFromOpts(opts),
2425
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2363
2426
  });
2364
2427
  return;
2365
2428
  }
@@ -2370,7 +2433,7 @@ credentialFlags(program
2370
2433
  print: globalOpts.print,
2371
2434
  global: opts.global,
2372
2435
  force: opts.force,
2373
- credentialOverrides: credentialOverridesFromOpts(opts),
2436
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2374
2437
  });
2375
2438
  return;
2376
2439
  }
@@ -2389,7 +2452,7 @@ credentialFlags(program
2389
2452
  print: globalOpts.print,
2390
2453
  global: opts.global,
2391
2454
  force: opts.force,
2392
- credentialOverrides: credentialOverridesFromOpts(opts),
2455
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2393
2456
  target: opts.target,
2394
2457
  });
2395
2458
  // Deliberately NOT under .bridge-kit/.eventmodelers/ — that whole name is
@@ -2427,7 +2490,7 @@ credentialFlags(program
2427
2490
  print: globalOpts.print,
2428
2491
  global: opts.global,
2429
2492
  force: opts.force,
2430
- credentialOverrides: credentialOverridesFromOpts(opts),
2493
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2431
2494
  templatesSource: join(clonedDir, 'templates'),
2432
2495
  hooks: opts.hooks,
2433
2496
  });
@@ -2440,7 +2503,7 @@ credentialFlags(program
2440
2503
  print: globalOpts.print,
2441
2504
  global: opts.global,
2442
2505
  force: opts.force,
2443
- credentialOverrides: credentialOverridesFromOpts(opts),
2506
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2444
2507
  hooks: opts.hooks,
2445
2508
  });
2446
2509
  });
@@ -2457,7 +2520,8 @@ credentialFlags(program
2457
2520
  .option('--stack <name>', `Override which stack to refresh from (${Object.keys(REINITIABLE_STACKS).join(', ')}) instead of the one recorded in install-manifest.json — use this when the manifest is missing/stale, or to switch a .build-kit install to a different stack`)
2458
2521
  .option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit) and wire it up via `git config core.hooksPath .githooks` — same as `init --hooks`, for turning it on after the fact without a full re-scaffold. Off by default.')
2459
2522
  .option('--global', 'Re-install skills into ~/.claude/skills/ instead of the project — defaults to however they were originally installed')
2460
- .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
2523
+ .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
2524
+ .option(...AGENT_NAME_OPTION))
2461
2525
  .action(async (opts, command) => {
2462
2526
  const globalOpts = command.optsWithGlobals();
2463
2527
  const targetDir = process.cwd();
@@ -2495,7 +2559,7 @@ credentialFlags(program
2495
2559
  print: globalOpts.print,
2496
2560
  global: opts.global !== undefined ? opts.global : !!manifest.global,
2497
2561
  force: opts.force,
2498
- credentialOverrides: credentialOverridesFromOpts(opts),
2562
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2499
2563
  skipRootScaffold: true,
2500
2564
  hooks: opts.hooks,
2501
2565
  });
@@ -2625,10 +2689,11 @@ credentialFlags(program
2625
2689
  .command('init-config')
2626
2690
  .description('Configure credentials only — writes .eventmodelers/config.json in the current directory, or ~/.eventmodelers/config.json with --global')
2627
2691
  .option('--global', 'Write account-wide defaults (organizationId + token only) to ~/.eventmodelers/config.json instead of the project')
2628
- .option('--credentials <values>', 'Credentials as the comma-separated blob from app.eventmodelers.ai/account (token=...,boardId=...,organizationId=...,baseUrl=...), the equivalent JSON, or - to read either from stdin. When the blob names a board it configures THAT board (~/.eventmodelers/boards/<board>.json), which is all a later run --standalone --board-id <uuid> then needs.'))
2692
+ .option('--credentials <values>', 'Credentials as the comma-separated blob from app.eventmodelers.ai/account (token=...,boardId=...,organizationId=...,baseUrl=...), the equivalent JSON, or - to read either from stdin. When the blob names a board it configures THAT board (~/.eventmodelers/boards/<board>.json), which is all a later run --standalone --board-id <uuid> then needs.')
2693
+ .option(...AGENT_NAME_OPTION))
2629
2694
  .action(async (opts, command) => {
2630
2695
  const globalOpts = command.optsWithGlobals();
2631
- const overrides = credentialOverridesFromOpts(opts);
2696
+ const overrides = { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) };
2632
2697
 
2633
2698
  // A blob naming a board configures that board's own file rather than a project or
2634
2699
  // account-wide config: the per-board store is keyed by board id, and the blob is
@@ -2671,6 +2736,11 @@ credentialFlags(program
2671
2736
  if (pasted.token) base.token = pasted.token;
2672
2737
  if (overrides.organizationId) base.organizationId = overrides.organizationId;
2673
2738
  if (overrides.token) base.token = overrides.token;
2739
+ // Carried through the same narrowing: this branch rebuilds the file from scratch, so
2740
+ // an already-configured agentName has to be read back in or a later `init-config
2741
+ // --global` (e.g. rotating the token) would silently drop it.
2742
+ if (existing.agentName) base.agentName = existing.agentName;
2743
+ if (overrides.agentName) base.agentName = overrides.agentName;
2674
2744
 
2675
2745
  const configured = await configureCredentials({
2676
2746
  config: base,
@@ -2691,7 +2761,9 @@ credentialFlags(program
2691
2761
  // configureCredentials' generic paste/manual flow may have picked up
2692
2762
  // boardId/baseUrl too (e.g. from a pasted JSON blob) — strip them back out
2693
2763
  // before the final write, since --global only ever persists identity.
2694
- writeFileSync(configPath, JSON.stringify({ organizationId: configured.organizationId, token: configured.token }, null, 2));
2764
+ // --name is the one non-credential that belongs here: it names the agent, not the
2765
+ // project, so an account-wide default is as portable as the org/token beside it.
2766
+ writeFileSync(configPath, JSON.stringify({ organizationId: configured.organizationId, token: configured.token, ...(configured.agentName ? { agentName: configured.agentName } : {}) }, null, 2));
2695
2767
  console.log(`\n ✓ Saved account-wide defaults to ${configPath}`);
2696
2768
  } else {
2697
2769
  const targetDir = process.cwd();
@@ -2707,7 +2779,11 @@ credentialFlags(program
2707
2779
  boardIdOptional: true,
2708
2780
  overrides,
2709
2781
  print: globalOpts.print,
2710
- force: true,
2782
+ // Same reasoning as the --global branch above: a bare `init-config` means "re-ask
2783
+ // me", but an invocation that already carries its answers on the command line —
2784
+ // credentials, or just a --name to record — must not stop to prompt, or every
2785
+ // non-interactive caller hangs (a closed stdin crashes outright).
2786
+ force: !Object.values(overrides).some(Boolean),
2711
2787
  });
2712
2788
 
2713
2789
  // Keep `.mcp.json` in sync — this command can change `baseUrl` (e.g.
@@ -2730,6 +2806,8 @@ credentialFlags(program
2730
2806
  .option('--global', 'Run the modeling agent from the global install (~/.eventmodelers/kit), initializing it on first use, and ignore any kit in this directory. This is also what --modeling/--standalone fall back to on their own when nothing is installed here — pass it explicitly to prefer the global install over a local one. Credentials come from the flags below, EVENTMODELERS_* env vars, or ~/.eventmodelers/boards/<board>.json, so nothing is written into the current directory.')
2731
2807
  .option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
2732
2808
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
2809
+ .option('--id <id>', 'Override the agent id this run identifies itself with on the platform, instead of the stable one minted once per project (per board for --global/--standalone) and reused on every restart. The heartbeat is keyed on (token, agent_id, agent_type), so this is what lets a second agent of the same type run side by side without the two overwriting each other\'s row — or pins one to an id a supervisor already knows. Per-run only: nothing is written to disk, so the next run without the flag is the original agent again.')
2810
+ .option('--name <name>', 'A human-readable name for this agent, sent with every heartbeat so the board shows which agent is live rather than a bare uuid (e.g. "ci-builder", "martins-laptop"). Per-run only, like --id: the persistent name is `agentName` in config.json (set via `init --name` / `init-config --name`), and this overrides it for one run without writing anything.')
2733
2811
  .option('--credentials <values>', 'Credentials as the comma-separated blob from app.eventmodelers.ai/account (token=...,boardId=...,organizationId=...,baseUrl=...), the equivalent JSON, or - to read either from stdin. Saved to ~/.eventmodelers/boards/<board>.json, so it is only needed once per board, and passing it skips the first-run question. The individual flags below override single fields of it.'))
2734
2812
  .action(async (opts, command) => {
2735
2813
  const globalOpts = command.optsWithGlobals();
@@ -2741,6 +2819,23 @@ credentialFlags(program
2741
2819
  if (command.getOptionValueSource('maxAgents') === 'cli' && !opts.standalone) {
2742
2820
  console.log('ℹ️ --max-agents only applies to --standalone turns; ignoring it here.');
2743
2821
  }
2822
+ // --id/--name are what the platform will see for this run, so a blank one is a
2823
+ // mistake worth failing on rather than silently falling back to the stored identity.
2824
+ const identity = {
2825
+ agentId: opts.id === undefined ? null : String(opts.id).trim(),
2826
+ agentName: opts.name === undefined ? null : String(opts.name).trim(),
2827
+ };
2828
+ for (const [flag, value] of [['--id', identity.agentId], ['--name', identity.agentName]]) {
2829
+ if (value === '') {
2830
+ console.error(`❌ ${flag} needs a non-empty value.`);
2831
+ process.exit(1);
2832
+ }
2833
+ }
2834
+ // ralph.sh has no realtime agent and never pings /api/agent-alive, so there is no
2835
+ // identity for either flag to override there.
2836
+ if ((identity.agentId || identity.agentName) && opts.bash) {
2837
+ console.log('ℹ️ --id/--name only apply to agents that ping the platform; the --bash loop does not, so they are ignored here.');
2838
+ }
2744
2839
  // Both kit dirs can be installed side by side (e.g. running a build-kit and a
2745
2840
  // modeling-kit agent from the same project). findInstalledKitDir only ever
2746
2841
  // returns its first fixed-order match, which would silently prefer one stack
@@ -2804,7 +2899,7 @@ credentialFlags(program
2804
2899
  const shown = relative(cwd, kitDir);
2805
2900
  await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (warm Claude process) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
2806
2901
  try {
2807
- await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides, maxAgents);
2902
+ await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides, maxAgents, identity);
2808
2903
  } catch (err) {
2809
2904
  console.error('[modeling] Fatal:', err);
2810
2905
  process.exit(1);
@@ -2849,7 +2944,9 @@ credentialFlags(program
2849
2944
  // their own separate output paths with no stream-json parsing to gate. RALPH_LOCAL is
2850
2945
  // read by all three runners (ralph.js's startRalph, and ralph.sh directly) to force the
2851
2946
  // local-only branch even when .eventmodelers/config.json has valid credentials.
2852
- execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '' } });
2947
+ // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read in ralph.js's startRalph, so
2948
+ // they reach both node runners but not ralph.sh, which has no heartbeat to identify.
2949
+ execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '' } });
2853
2950
  } catch (err) {
2854
2951
  process.exit(err.status || 1);
2855
2952
  }
@@ -3163,6 +3260,7 @@ program
3163
3260
  'x-token': cfg.token,
3164
3261
  'x-board-id': cfg.boardId,
3165
3262
  'x-user-id': 'cli-set-slice-status',
3263
+ ...agentHeaders(cfg),
3166
3264
  },
3167
3265
  body: JSON.stringify([{
3168
3266
  id: randomUUID(),
@@ -48,8 +48,12 @@ function slugify(text) {
48
48
  async function fetchFullSliceData(cfg, contextName) {
49
49
  const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
50
50
  const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?contextName=${encodeURIComponent(contextName)}`;
51
+ const agentId = cfg.agentId || process.env.EVENTMODELERS_AGENT_ID || '';
51
52
  const res = await fetch(url, {
52
- headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'spec-kitty-adapter' },
53
+ headers: {
54
+ 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'spec-kitty-adapter',
55
+ ...(agentId ? { 'x-agent-id': agentId } : {}),
56
+ },
53
57
  });
54
58
  if (!res.ok) throw new Error(`slicedata fetch failed for context "${contextName}": HTTP ${res.status}`);
55
59
  return res.json();
package/lib/fetch.js CHANGED
@@ -72,7 +72,13 @@ function sliceFolderName(title) {
72
72
  // { cwd, kitDir, cfg: { token, organizationId, boardId, baseUrl }, opts: { context, sliceId?, sliceTitle? } }
73
73
  export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
74
74
  const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
75
- const headers = { 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'cli-fetch' };
75
+ // x-agent-id when we know it (see cli.js's agentHeaders) — a read doesn't write anything, but
76
+ // sending it everywhere keeps one rule instead of a per-call judgment about which calls count.
77
+ const agentId = cfg.agentId || process.env.EVENTMODELERS_AGENT_ID || '';
78
+ const headers = {
79
+ 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'cli-fetch',
80
+ ...(agentId ? { 'x-agent-id': agentId } : {}),
81
+ };
76
82
 
77
83
  // assertBoardAccess (the guard every one of these routes runs behind) only ever
78
84
  // answers 401/403 for credential/board-access problems — a 404 here always means
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.67",
3
+ "version": "1.0.69",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -148,9 +148,18 @@ function ensureAgentId(kitDir, agentType) {
148
148
  return agentId;
149
149
  }
150
150
 
151
+ // `x-agent-id` on every platform call this loop makes, when it knows its own agent id (see
152
+ // ensureAgentId above / RALPH_AGENT_ID). The heartbeat says this agent is alive; the header says
153
+ // which calls are its, so its board writes are attributed to it and a prompt the user addressed
154
+ // to one preferred agent is only ever claimed by that agent.
155
+ function agentHeaders(cfg) {
156
+ const agentId = cfg?.agentId || process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID || '';
157
+ return agentId ? { 'x-agent-id': agentId } : {};
158
+ }
159
+
151
160
  async function fetchPlatformConfig(local) {
152
161
  const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
153
- headers: { 'x-token': local.token },
162
+ headers: { 'x-token': local.token, ...agentHeaders(local) },
154
163
  });
155
164
  return { ...local, ...remote };
156
165
  }
@@ -160,7 +169,7 @@ async function fetchPlatformConfig(local) {
160
169
  async function getRealtimeToken(cfg) {
161
170
  const { token } = await fetchJSON(
162
171
  `${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`,
163
- { headers: { 'x-token': cfg.token } },
172
+ { headers: { 'x-token': cfg.token, ...agentHeaders(cfg) } },
164
173
  );
165
174
  return token;
166
175
  }
@@ -172,7 +181,7 @@ function slugify(str) {
172
181
  async function fetchAndPersistSlices(cfg, kitDir) {
173
182
  const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
174
183
  const { slices } = await fetchJSON(url, {
175
- headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId },
184
+ headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId, ...agentHeaders(cfg) },
176
185
  });
177
186
  const slicesDir = join(kitDir, '.slices');
178
187
  mkdirSync(slicesDir, { recursive: true });
@@ -316,11 +325,15 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
316
325
  realtime.subscribe(
317
326
  channelName,
318
327
  {
328
+ // A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
329
+ // Anything that doesn't name this agent is ignored — a broadcast reaches every agent on
330
+ // the board, and the older signal (the bare string "Exit") took all of them down at once.
331
+ // That string form is gone for good, not just unhandled: Supabase's broadcast API rejects
332
+ // a non-object payload with 422, so it never actually arrived here.
319
333
  message: (payload) => {
320
- if (payload === 'Exit') {
321
- console.log(`[agent] ${ts()} Received "Exit" — shutting down`);
322
- process.exit(0);
323
- }
334
+ if (payload?.type !== 'kill' || payload?.id !== cfg.agentId) return;
335
+ console.log(`[agent] ${ts()} Received kill (instruction: ${payload.instruction ?? 'exit'}) — shutting down`);
336
+ process.exit(0);
324
337
  },
325
338
  'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
326
339
  },
@@ -363,7 +376,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
363
376
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
364
377
  method: 'POST',
365
378
  headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
366
- body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: agentType, agent_id: cfg.agentId }),
379
+ body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: agentType, agent_id: cfg.agentId, ...(cfg.agentName ? { agent_name: cfg.agentName } : {}) }),
367
380
  signal: AbortSignal.timeout(10_000),
368
381
  });
369
382
  if (!res.ok) {
@@ -485,7 +498,7 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
485
498
  try {
486
499
  await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
487
500
  method: 'POST',
488
- headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'ralph-loop' },
501
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'ralph-loop', ...agentHeaders(cfg) },
489
502
  body: JSON.stringify([{
490
503
  id: randomUUID(),
491
504
  eventType: 'node:changed',
@@ -579,10 +592,17 @@ export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
579
592
 
580
593
  export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
581
594
  const local = loadLocalConfig(kitDir);
582
- local.agentId = ensureAgentId(kitDir, agentType);
595
+ // RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`, passed down as env
596
+ // (see cli.js's run dispatcher): a per-run identity override so a second agent of the same
597
+ // type can run side by side without the two overwriting each other's heartbeat row, and so
598
+ // the board can show a name instead of a bare uuid. An override skips ensureAgentId rather
599
+ // than overwriting it — the project's stable id stays on disk for the next plain run.
600
+ local.agentId = process.env.RALPH_AGENT_ID || ensureAgentId(kitDir, agentType);
601
+ if (process.env.RALPH_AGENT_NAME) local.agentName = process.env.RALPH_AGENT_NAME;
583
602
 
584
603
  console.log(`Ralph — kit: ${kitDir}`);
585
604
  console.log(` project: ${projectDir}`);
605
+ console.log(` agent: ${local.agentName ? `${local.agentName} (${local.agentId})` : local.agentId}`);
586
606
 
587
607
  // localOnly (set via `eventmodelers run --local`) forces this branch even when
588
608
  // credentials are present — it skips fetchPlatformConfig's network call to
@@ -31,6 +31,9 @@ const claudeEnv = {
31
31
  ...process.env,
32
32
  ...(cfg.anthropicBaseUrl ? { ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl } : {}),
33
33
  ...(cfg.token ? { EVENTMODELERS_TOKEN: cfg.token } : {}),
34
+ // Lets the skills this agent runs send x-agent-id on their own calls (connect puts it in
35
+ // `.mcp.json` and in every curl fallback), so their board writes are attributed to this agent.
36
+ ...(cfg.agentId ? { EVENTMODELERS_AGENT_ID: cfg.agentId } : {}),
34
37
  };
35
38
 
36
39
  // Collapses whitespace/newlines to a single line and truncates past `max` chars — a long
@@ -9,7 +9,7 @@ description: Resolve eventmodelers connection config (token, boardId, baseUrl) f
9
9
 
10
10
  **This should happen once per session, not once per skill.** If `TOKEN`/`BOARD_ID`/`ORG_ID`/`BASE_URL` are already resolved and verified from earlier in the current session — including earlier in the *same turn*, e.g. one skill internally invoking a second skill (`add-next-slice` → `html-screen`) — every subsequent "invoke `connect`" instruction is satisfied immediately by reusing those values. Do not re-run Steps 0–4 below. Only re-run this skill from scratch when a value actually needs to change: a fresh `401`/`403`/access-denied response from some other call, a different `board_id` on this turn, or a new inline param that overrides what's already resolved.
11
11
 
12
- **Subagents are a fresh session — hand them the resolved values.** When you spawn a subagent to do board work, put the already-resolved credentials inline in its prompt (`token=… board=… org=… baseUrl=…`). Its `connect` then satisfies everything at Step 0 and skips Steps 1–4 entirely: no config-file walk, no MCP re-registration, no verify call. Spawning three subagents without passing them down means paying the whole resolve-and-verify round three more times for values you already have.
12
+ **Subagents are a fresh session — hand them the resolved values.** When you spawn a subagent to do board work, put the already-resolved credentials inline in its prompt (`token=… board=… org=… baseUrl=…`, plus `agent=…` when you have an `AGENT_ID` — a subagent's writes are still this agent's writes, and it cannot read your environment). Its `connect` then satisfies everything at Step 0 and skips Steps 1–4 entirely: no config-file walk, no MCP re-registration, no verify call. Spawning three subagents without passing them down means paying the whole resolve-and-verify round three more times for values you already have.
13
13
 
14
14
  This skill also registers the **eventmodelers MCP server** for the project (Step 3.5) so other skills can call MCP tools (`mcp__eventmodelers__*`) instead of raw curl. MCP is the preferred transport; curl remains a fallback for hosts without MCP support, or for the one or two endpoints (documented in `learn-eventmodelers-api`) the MCP server doesn't expose.
15
15
 
@@ -25,17 +25,24 @@ After running, the following variables are available for the rest of the session
25
25
  | `BOARD_ID` | `x-board-id` | Target board UUID |
26
26
  | `ORG_ID` | — | Organization UUID (used in all board-scoped URLs) |
27
27
  | `BASE_URL` | — | Base URL, e.g. `http://localhost:3000` |
28
+ | `AGENT_ID` | `x-agent-id` | This agent process's own id, when running as one (Step 0.5). Optional — skip the header when there is no value. |
28
29
 
29
30
  Every curl-fallback call in every skill must include these headers:
30
31
  ```
31
32
  x-token: <TOKEN>
32
33
  x-board-id: <BOARD_ID>
33
34
  x-user-id: <skill-name> ← set by each skill individually
35
+ x-agent-id: <AGENT_ID> ← only when AGENT_ID resolved; omit the line entirely otherwise
34
36
  ```
35
37
 
38
+ `x-agent-id` is what makes the board say *which* agent did something: the platform stamps it on
39
+ every change the call writes, so the canvas shows this agent by name (rather than one anonymous
40
+ robot standing in for every agent at once), and a prompt a user addressed to one preferred agent
41
+ is only handed to the agent that claims it with that id.
42
+
36
43
  All board-scoped URLs follow the pattern: `<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/...`
37
44
 
38
- When calling MCP tools instead, no `x-*` headers are needed — the MCP server resolves `ORG_ID` from `TOKEN` itself and every tool takes `boardId` as an explicit argument. See `learn-eventmodelers-api` for the full tool catalog.
45
+ When calling MCP tools instead, no `x-*` headers are needed per call — the MCP server resolves `ORG_ID` from `TOKEN` itself and every tool takes `boardId` as an explicit argument. (`x-agent-id` rides along with the registered server config from Step 3.5, so MCP writes are attributed too.) See `learn-eventmodelers-api` for the full tool catalog.
39
46
 
40
47
  ---
41
48
 
@@ -49,13 +56,38 @@ Before reading the config file, scan the prompt/arguments that invoked this skil
49
56
  | `token=<uuid>` | `token=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
50
57
  | `org=<uuid>` | `org=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
51
58
  | `baseUrl=<url>` | `baseUrl=http://localhost:3000` |
59
+ | `agent=<uuid>` | `agent=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (the agent id a parent agent hands a subagent, so the subagent's board writes are attributed to the same agent) |
60
+
61
+ If an inline `board=<uuid>` is found, use it as `BOARD_ID` — **it takes priority over the config file**. Same for `token`, `org`, `baseUrl`, and `agent` (as `AGENT_ID`, which then makes Step 0.5 a no-op). Record which values came from inline params so they are not overwritten in Step 3.
52
62
 
53
- If an inline `board=<uuid>` is found, use it as `BOARD_ID`**it takes priority over the config file**. Same for `token`, `org`, and `baseUrl`. Record which values came from inline params so they are not overwritten in Step 3.
63
+ (`agent=<uuid>` is not one of the four required values — it is optional, and its absence never makes this skill ask anything.)
54
64
 
55
65
  **All four inline means this skill is already finished — stop here.** `token=`, `board=`, `org=` and `baseUrl=` arriving together is the shape a parent agent hands a subagent, and it resolves every required value in this one step. Do not walk the config file (Step 1), do not ask anything (Step 2), do not persist (Step 3), and do not make the verify call (Step 4): the parent resolved these values against this board and verified them there, so a subagent verifying them again learns nothing it wasn't just told and pays a round trip for it. Step 3.5 is a no-op too whenever `.mcp.json` already carries an `eventmodelers` entry — read the file, don't rewrite it, and don't re-register a server the session is already connected to. Print `Connected — board <BOARD_ID>` and return to the skill that invoked you.
56
66
 
57
67
  ---
58
68
 
69
+ ## Step 0.5 — Resolve `AGENT_ID` (agents only)
70
+
71
+ Optional, and only ever relevant when this session *is* an agent's session (`eventmodelers run`
72
+ spawned it). Resolve in this order and stop at the first hit:
73
+
74
+ 1. `$EVENTMODELERS_AGENT_ID` — the CLI exports it for the agent it runs. This is the normal case.
75
+ 2. `agentIds.MODELING` (or `agentIds.BUILD` for a build kit) in the project root's
76
+ `.eventmodelers/config.json` — the id the CLI minted once for this project and reuses on every
77
+ restart.
78
+
79
+ If neither exists, there is no `AGENT_ID`: this is a human's session, not an agent's. Leave it
80
+ unset and omit the `x-agent-id` header everywhere. Never invent one, and never reuse another
81
+ agent's id — the id is what the board attributes work to.
82
+
83
+ ```bash
84
+ agent_id="${EVENTMODELERS_AGENT_ID:-}"
85
+ [ -z "$agent_id" ] && [ -n "$config_file" ] && agent_id="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("agentIds",{}).get("MODELING",""))' "$config_file" 2>/dev/null)"
86
+ echo "${agent_id:-<none>}"
87
+ ```
88
+
89
+ ---
90
+
59
91
  ## Step 1 — Read config file
60
92
 
61
93
  Search for `.eventmodelers/config.json` starting from the current working directory and walking up through all parent directories. This is the same file every kit installed in this project reads and writes, so credentials only need to be entered once per project:
@@ -164,17 +196,25 @@ The eventmodelers backend exposes the same board capabilities as an MCP server a
164
196
  "eventmodelers": {
165
197
  "type": "http",
166
198
  "url": "<BASE_URL>/mcp",
167
- "headers": { "x-token": "${EVENTMODELERS_TOKEN}" }
199
+ "headers": { "x-token": "${EVENTMODELERS_TOKEN}", "x-agent-id": "${EVENTMODELERS_AGENT_ID}" }
168
200
  }
169
201
  }
170
202
  }
171
203
  ```
204
+ Keep the `x-agent-id` line in even when this session has no `AGENT_ID`: an unset variable
205
+ reaches the server as the literal `${EVENTMODELERS_AGENT_ID}` text, which it ignores (it only
206
+ accepts a uuid), so the entry is the same for a human session and an agent's. Writing one
207
+ `.mcp.json` for both is what keeps a second agent from inheriting the first one's id.
172
208
  3. Read the existing `.claude/settings.local.json` if present and merge in — never clobber `permissions`/`enabledMcpjsonServers` or anything else already there. Ensure it has `EVENTMODELERS_TOKEN` set under `env` (create the file with just this key if it doesn't exist yet):
173
209
  ```json
174
210
  {
175
211
  "env": { "EVENTMODELERS_TOKEN": "<TOKEN>" }
176
212
  }
177
213
  ```
214
+ Do **not** write `EVENTMODELERS_AGENT_ID` into this file. It is per running agent process, not
215
+ per project: the CLI exports it for the agent it spawns, and a value frozen into a shared
216
+ settings file would hand every session — including a second agent and the user's own — the
217
+ same agent identity.
178
218
  A plain `.env` file does **not** work here — Claude Code never sources one, so a `${EVENTMODELERS_TOKEN}` placeholder in `.mcp.json` would be left unexpanded (sent as the literal `${EVENTMODELERS_TOKEN}` text), which fails auth and pushes the client into an OAuth flow the eventmodelers server can't satisfy for this client. `.claude/settings.local.json`'s `env` block — alongside the inherited shell environment — is the only thing Claude Code actually resolves `.mcp.json` placeholders against.
179
219
  4. Ensure `.claude/settings.local.json` is listed in `.gitignore` (same check-then-append pattern as Step 3 uses for `.eventmodelers/config.json`) — it now holds the same secret and must never be committed. (Gitignored by Claude Code's own convention already, but don't rely on that silently.)
180
220
 
@@ -90,6 +90,7 @@ Server name: `eventmodelers`. Every tool takes `boardId` explicitly; none need `
90
90
  |---|---|---|
91
91
  | `Authorization` | Some routes | Supabase JWT bearer token |
92
92
  | `x-user-id` | Node operations | User identifier |
93
+ | `x-agent-id` | Optional | The agent process making the call — the uuid it also sends as `agent_id` on its heartbeat (`$EVENTMODELERS_AGENT_ID`, resolved by the `connect` skill). Stored on the `board_events` rows a write produces and broadcast with them, so the board shows *which* agent touched an element instead of one anonymous agent for all of them, and it decides who may claim a prompt addressed to a preferred agent. Send it on every call; a value that isn't uuid-shaped is ignored. |
93
94
  | `x-causation-id` | Optional | Event causation tracing |
94
95
  | `x-correlation-id` | Optional | Correlation tracing |
95
96
 
@@ -324,6 +325,8 @@ The node's content lives in `meta.description` (a plain string of markdown sourc
324
325
 
325
326
  All node endpoints require header: `x-user-id`
326
327
 
328
+ Also send `x-agent-id` (`$EVENTMODELERS_AGENT_ID`) on every write — that is what attributes the change to this agent by name on the board. See **Authentication & Headers** above.
329
+
327
330
  ### POST `/api/org/:orgId/boards/:boardId/nodes/events`
328
331
  Submit node change events.
329
332
 
@@ -855,6 +858,8 @@ element was selected) plus the `focusArea`, and nothing else.
855
858
  ### GET `/api/org/:orgId/prompts/next`
856
859
  Claim the next pending (`ADDED`) prompt for a board — atomically flips it to `CLAIMED` and returns it. This is what a running modeling agent's warm loop polls. Auth: `x-token` **and** a Supabase JWT (`Authorization: Bearer`) together.
857
860
 
861
+ Send `x-agent-id` here too: a prompt the user addressed to one preferred agent (`prompts.agent_id`) is only ever handed to the agent claiming with that id, and a caller without the header claims untargeted prompts only. Addressing one is `POST /api/org/:orgId/prompts` with `agent_id: "<uuid>"` — the board's prompts panel does it when someone stars an agent.
862
+
858
863
  **Query params**: `board_id` (required)
859
864
  **Response**: `200` — the claimed row (now `status: "CLAIMED"`), including its parsed `context` and the `hidden` flag · `404` — no `ADDED` prompts available
860
865
 
@@ -902,16 +907,20 @@ Exchange an `x-token` for a short-lived Supabase-compatible JWT, used to subscri
902
907
  ### POST `/api/agent-alive`
903
908
  Record a heartbeat ping for a running modeling/build agent. Auth: Supabase JWT (`Authorization: Bearer`) — exchange the `x-token` for one first via `GET /api/org/:orgId/prompts/realtime-token` above; a raw `x-token` alone is not accepted here.
904
909
 
905
- **Body**: `{ token: string, board_id?: string, agent_type: 'MODELING' | 'BUILD', agent_id: string }`
910
+ **Body**: `{ token: string, board_id?: string, agent_type: 'MODELING' | 'BUILD', agent_id: string, agent_name?: string }`
906
911
  **Response**: `200` — `{ ok: true }`
907
912
  **Errors**: `400` `agent_id`/`agent_type` missing · `404` token not found
908
913
 
914
+ `agent_id` is the client's own stable id (the heartbeat is keyed on `token` + `agent_id` + `agent_type`); `agent_name` is an optional display name for it, so the board can show which agent is live instead of a bare uuid. Both are overridable per run via `eventmodelers run --id/--name`.
915
+
916
+ The same `agent_id` belongs in the `x-agent-id` header of every board call this agent makes: the heartbeat says the agent is alive, the header says which of the incoming calls are its. Without it a write is attributed to "some agent", every live agent shares one presence identity on the board, and prompts addressed to a preferred agent are never claimed.
917
+
909
918
  ---
910
919
 
911
920
  ### GET `/api/org/:orgId/boards/:boardId/agent-alive`
912
921
  Check whether an agent has pinged for a board within the last 45s. Auth: `x-token` (bot) or a Supabase JWT (`Authorization: Bearer`) — either works.
913
922
 
914
- **Response**: `200` — `{ alive: boolean, agentTypes: string[] }`
923
+ **Response**: `200` — `{ alive: boolean, agentTypes: string[], agents: { agentId: string, agentType: string, agentName: string | null }[] }` — one `agents` entry per live agent process, `agentTypes` the de-duplicated set of their types
915
924
 
916
925
  ---
917
926
 
@@ -181,11 +181,12 @@ Steps:
181
181
  6. **If you already said it, don't say it again.** Before posting a comment — or having a
182
182
  subagent post one — read the node's existing comments. An unresolved question already
183
183
  there means that contribution is on the board.
184
- 7. **Write no progress entry.** A self-directed turn is modeling, not tracked progress —
185
- nothing goes into `progress.txt` here (that file belongs to prompt turns, which answer to
186
- someone who asked). Still promote anything reusable to `.agent-modeling-kit/AGENTS.md`
187
- (same as step 9 of a prompt turn in `.agent-modeling-kit/CLAUDE.md`), including anything a
188
- subagent reported back.
184
+ 7. **Write nothing to disk.** A self-directed turn is modeling, not tracked progress — nothing
185
+ goes into `progress.txt`, and nothing into `.agent-modeling-kit/AGENTS.md` either. You only
186
+ ever get here in a `standalone=on` session, which is ad-hoc: its kit dir is shared across
187
+ every board and nobody reads it afterwards. The board is the only place anything is kept, so
188
+ anything reusable — including what a subagent reported back — goes there, as a comment on the
189
+ node it concerns.
189
190
  8. Reply `<promise>DONE</promise>`, naming what you dispatched and what each agent did, or —
190
191
  when step 2 turned up nothing worth doing — change nothing at all and reply
191
192
  `<promise>NOOP</promise>`. A NOOP is a perfectly good outcome, and the CLI widens the gap
@@ -12,6 +12,20 @@ for their independent, self-contained slice-implementation tasks). Each user mes
12
12
  receive already IS the one prompt to handle; there's nothing to read, pre-filter, or pick
13
13
  from.
14
14
 
15
+ ### `standalone=on` is ad-hoc — write nothing to disk
16
+
17
+ The session header carries `standalone=on` or `standalone=off`. `standalone=on` is an **ad-hoc**
18
+ session: it belongs to no project, its kit dir is a `~/.eventmodelers/kit` shared by every board,
19
+ and nobody goes looking in there afterwards. So in a `standalone=on` session the **board is the
20
+ only place anything is kept** — comments, elements, slice statuses, scenarios. Write no file at
21
+ all: no `progress.txt` (step 8), no `.agent-modeling-kit/AGENTS.md` (step 9), and nothing a skill's
22
+ own instructions suggest writing down either. Anything worth keeping goes on the board, as a
23
+ comment on the node it concerns. This overrides every "write it down" instruction elsewhere in this
24
+ file and in any skill.
25
+
26
+ With `standalone=off` the session belongs to one project and the kit dir is that project's, so
27
+ steps 8 and 9 apply as written.
28
+
15
29
  You are a long-lived process handling many turns in a row. **Read this file once**, on
16
30
  the first turn (the one whose message begins with `MODE=modeling`) — don't re-read it on
17
31
  every later turn just because a new prompt came in. The same applies to other one-time
@@ -122,8 +136,8 @@ mention it in the `DONE` comment, and leave it for a self-directed turn (or for
122
136
  - If it doesn't — the prompt is ambiguous enough that any guess risks doing the wrong thing — stop instead of guessing. Skip straight to step 6 and mark the prompt `DONE` with a comment explaining what's unclear and pointing to the comment you just posted. Never leave a prompt neither progressed nor closed.
123
137
  6. **Mark the prompt as finished** — invoke `/update-prompt-status` with this turn's `prompt_id`, `newStatus=DONE`, and a `comment` that summarizes what you actually did (e.g. "Added the OrderPlaced event and wired it to the read model"). Do this once, right after the work is done — not per skill call within the turn.
124
138
  7. If this turn has a `comment_id` field, invoke `/handle-comment` with `action=resolve`, `nodeId` from the resolved `NODE_ID` (step 3), `commentId` from `comment_id`.
125
- 8. Append a progress entry to `progress.txt` see the Progress Entry Format below. Fill in the `Learnings` line with anything reusable noticed this turn (pattern, gotcha, useful context), or "none".
126
- 9. If this turn's `Learnings` line was not "none", promote it to `.agent-modeling-kit/AGENTS.md` (create it if it doesn't exist) — only add it if it's not already there.
139
+ 8. **`standalone=off` only** — append a progress entry to `progress.txt`; see the Progress Entry Format below. Fill in the `Learnings` line with anything reusable noticed this turn (pattern, gotcha, useful context), or "none". In a `standalone=on` session, skip this: that session writes no files (see Mode), so note anything worth keeping as a board comment instead.
140
+ 9. **`standalone=off` only** — if this turn's `Learnings` line was not "none", promote it to `.agent-modeling-kit/AGENTS.md` (create it if it doesn't exist) — only add it if it's not already there.
127
141
  10. Reply `<promise>DONE</promise>` and wait for the next turn.
128
142
 
129
143
 
@@ -197,7 +211,8 @@ Read `.claude/skills/<skill-name>/SKILL.md` before executing — each skill has
197
211
 
198
212
  ## Progress Entry Format
199
213
 
200
- Prompt turns only a standalone board-change turn never writes one.
214
+ `standalone=off` prompt turns only. A `standalone=on` session writes no progress file at all (see
215
+ Mode), and a self-directed board-change turn never writes one in any session.
201
216
 
202
217
  APPEND to `progress.txt` (never replace):
203
218
  ```
@@ -89,7 +89,8 @@ function hasCredentials(cfg) {
89
89
 
90
90
  async function fetchPlatformConfig(local) {
91
91
  const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
92
- headers: { 'x-token': local.token },
92
+ // x-agent-id when this install knows its agent id — see the CLI's agentHeaders.
93
+ headers: { 'x-token': local.token, ...(local.agentId || process.env.EVENTMODELERS_AGENT_ID ? { 'x-agent-id': local.agentId || process.env.EVENTMODELERS_AGENT_ID } : {}) },
93
94
  });
94
95
  return { ...local, ...remote };
95
96
  }
@@ -153,9 +153,18 @@ function ensureAgentId(kitDir, agentType) {
153
153
  return agentId;
154
154
  }
155
155
 
156
+ // `x-agent-id` on every platform call this loop makes, when it knows its own agent id (see
157
+ // ensureAgentId above / RALPH_AGENT_ID). The heartbeat says this agent is alive; the header says
158
+ // which calls are its, so its board writes are attributed to it and a prompt the user addressed
159
+ // to one preferred agent is only ever claimed by that agent.
160
+ function agentHeaders(cfg) {
161
+ const agentId = cfg?.agentId || process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID || '';
162
+ return agentId ? { 'x-agent-id': agentId } : {};
163
+ }
164
+
156
165
  async function fetchPlatformConfig(local) {
157
166
  const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
158
- headers: { 'x-token': local.token },
167
+ headers: { 'x-token': local.token, ...agentHeaders(local) },
159
168
  });
160
169
  return { ...local, ...remote };
161
170
  }
@@ -169,7 +178,7 @@ function slugify(str) {
169
178
  async function fetchAndPersistSlices(cfg, kitDir) {
170
179
  const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
171
180
  const { slices } = await fetchJSON(url, {
172
- headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId },
181
+ headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId, ...agentHeaders(cfg) },
173
182
  });
174
183
  const slicesDir = join(kitDir, '.slices');
175
184
  mkdirSync(slicesDir, { recursive: true });
@@ -391,7 +400,7 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
391
400
  try {
392
401
  await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
393
402
  method: 'POST',
394
- headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'ralph-loop' },
403
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'ralph-loop', ...agentHeaders(cfg) },
395
404
  body: JSON.stringify([{
396
405
  id: randomUUID(),
397
406
  eventType: 'node:changed',