@eventmodelers/cli 1.0.68 → 1.0.70

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
@@ -210,8 +211,10 @@ starting in one command. Resolution order for a run is `--credentials` and the i
210
211
  `--token`/`--organization-id`/`--board-id`/`--base-url` flags, then `EVENTMODELERS_*` env vars,
211
212
  then `~/.eventmodelers/boards/<board>.json`, then the usual `.eventmodelers/config.json` walk,
212
213
  and finally the account's default board. Whatever a run resolves is saved back to the
213
- per-board file (`0600`, in a `0700` directory) along with a stable agent id for the board's
214
- alive-ping. One machine can therefore drive several boards, across several accounts, at once.
214
+ per-board file (`0600`, in a `0700` directory). The agent id is *not* stored there a standalone
215
+ run mints a fresh one each time, so two ad-hoc agents on one board stay two agents (see
216
+ [Naming an agent](#naming-an-agent)). One machine can therefore drive several boards, across
217
+ several accounts, at once.
215
218
  The global kit itself holds no credentials at all — the token reaches `claude` through the
216
219
  spawned process's environment.
217
220
 
@@ -395,8 +398,24 @@ npx @eventmodelers/cli init-config # interactive, writes to
395
398
  npx @eventmodelers/cli init-config --board-id <uuid> # non-interactive, just overrides one field
396
399
  npx @eventmodelers/cli init-config --credentials "token=...,boardId=...,organizationId=...,baseUrl=..." # configure ONE board (~/.eventmodelers/boards/<board>.json), no prompts
397
400
  npx @eventmodelers/cli init-config --credentials - # same, read from stdin (keeps the token out of shell history)
401
+ npx @eventmodelers/cli init-config --name ci-builder # name the agent this config's runs identify as
398
402
  ```
399
403
 
404
+ ### Naming an agent
405
+
406
+ Every agent identifies itself to the platform with an `agentId`, and the board's live-agent view shows that bare uuid. A **project install** mints one on first use and reuses it on every restart (`agentIds` in the project root's `.eventmodelers/config.json`). A **standalone/global run** mints a fresh one per run instead: nothing stops two ad-hoc agents running for one board, and since the heartbeat is keyed on `(token, agentId, agentType)`, a shared id would make the second agent replace the first — one agent visible however many are running, and their board writes indistinguishable.
407
+
408
+ `--name` gives an agent a readable label instead of the uuid — accepted by `init`, `re-init`, and `init-config`, saved into `config.json` as `agentName`, and sent with every heartbeat from then on:
409
+
410
+ ```bash
411
+ npx @eventmodelers/cli init --stack node --name ci-builder # persisted for every later run of this kit
412
+ npx @eventmodelers/cli init-config --name martins-laptop # same, without re-installing
413
+ npx @eventmodelers/cli run --name one-off-check # override for a single run, nothing written
414
+ npx @eventmodelers/cli run --id <id> # pin ONE identity across restarts
415
+ ```
416
+
417
+ `run --id` is what you want when an agent has to keep the same identity every time it starts: a supervisor that already knows the id, a second agent of the same type in one *project* (which would otherwise share the project's single minted id), or an agent a board has starred as its **preferred agent** — that star addresses prompts to one id, so an agent whose id changes per run loses it on restart. `run --id`/`run --name` are per-run only: nothing is written to disk.
418
+
400
419
  ### Env vars and `--config` (scripted/CI installs)
401
420
 
402
421
  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 +429,7 @@ Every config field can be set via an `EVENTMODELERS_*` env var instead of the in
410
429
  | `EVENTMODELERS_ANTHROPIC_BASE_URL` | `anthropicBaseUrl` |
411
430
  | `EVENTMODELERS_MODEL` | `model` |
412
431
  | `EVENTMODELERS_SUBAGENT_MODEL` | `subagentModel` |
432
+ | `EVENTMODELERS_AGENT_NAME` | `agentName` |
413
433
 
414
434
  ```bash
415
435
  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)) {
@@ -1377,7 +1390,7 @@ function ensureEnvToken(targetDir, token) {
1377
1390
  const GLOBAL_DIR = join(homedir(), '.eventmodelers');
1378
1391
  const GLOBAL_KIT_DIR = join(GLOBAL_DIR, 'kit');
1379
1392
 
1380
- // One file per board: `{token, organizationId, boardId, baseUrl, agentId}`. Credentials
1393
+ // One file per board: `{token, organizationId, boardId, baseUrl}`. Credentials
1381
1394
  // ARE per board — a token is scoped to the org that owns it — so one machine can drive
1382
1395
  // several boards across several accounts at once, each with its own. Written 0600 in a
1383
1396
  // 0700 dir: unlike a project's .eventmodelers/config.json, there is no .gitignore standing
@@ -1395,16 +1408,21 @@ 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 is stored, the agent id is not: the name is a label for whoever runs here (and
1412
+ // this file is the only config a `run --standalone` from an arbitrary directory reads, so
1413
+ // dropping it would lose `init-config --name` on the very next run), while the id is minted
1414
+ // per run now — see resolveModelingCredentials for why.
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, ...agentName }
1402
1420
  : {
1403
1421
  token: config.token,
1404
1422
  organizationId: config.organizationId,
1405
1423
  boardId: config.boardId,
1406
1424
  baseUrl: config.baseUrl,
1407
- agentId: config.agentId,
1425
+ ...agentName,
1408
1426
  };
1409
1427
  writeFileSync(path, JSON.stringify(body, null, 2), { mode: 0o600 });
1410
1428
  }
@@ -1586,12 +1604,18 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1586
1604
  process.exit(1);
1587
1605
  }
1588
1606
 
1589
- // Distinguishes this agent from any other pinging the same board, and has to stay stable
1590
- // across runs or the platform sees a brand-new agent on every restart. Per board, since
1591
- // that is the identity the alive-ping is scoped to.
1592
- config.agentId = stored.agentId || readJsonSafe(boardCredentialsPath(config.boardId)).agentId || randomUUID();
1607
+ // A fresh identity for every standalone run, deliberately not persisted. A standalone agent
1608
+ // is started ad hoc from wherever, and nothing stops two of them running for the same board
1609
+ // with one id stored per board they upserted the same alive row (the heartbeat is keyed on
1610
+ // token + agent_id + agent_type), so the second agent replaced the first instead of joining
1611
+ // it: the board showed one agent however many were running, and their writes were
1612
+ // indistinguishable. A per-run uuid costs the identity its continuity across restarts (a
1613
+ // restarted agent is a new row, and the old one lingers until its 45s window lapses) — pass
1614
+ // `run --id <uuid>` when an agent needs to keep one identity, which is also what makes
1615
+ // "preferred agent" on the board stick to it.
1616
+ config.agentId = randomUUID();
1593
1617
  writeBoardCredentials(stored.useGlobal
1594
- ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId }
1618
+ ? { boardId: config.boardId, useGlobal: true, agentName: config.agentName }
1595
1619
  : config);
1596
1620
 
1597
1621
  return config;
@@ -1648,7 +1672,7 @@ async function ensureGlobalKit(baseUrl) {
1648
1672
  // question, sketch a screen. Without the flag that channel is still subscribed on
1649
1673
  // the same connection and every event on it is dropped, so the two modes differ by
1650
1674
  // 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) {
1675
+ async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}) {
1652
1676
  const configLibPath = join(kitDir, 'lib', 'config.js');
1653
1677
  if (!existsSync(configLibPath)) {
1654
1678
  console.error(`❌ ${relative(process.cwd(), configLibPath)} not found — --modeling needs a kit installed via \`init --modeling\`.`);
@@ -1670,12 +1694,18 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1670
1694
  // ~/.eventmodelers/boards/<board>.json — one dir driving several boards must not have
1671
1695
  // them all upsert one shared alive row. A project install keeps its id in the project
1672
1696
  // root config, namespaced by agent type, as it always has.
1673
- if (!overrides) local.agentId = ensureAgentId(kitDir, 'MODELING');
1697
+ // `run --id` overrides that for this run only — ensureAgentId is skipped rather than
1698
+ // overwritten, so the project's own stable id stays on disk and the next run without the
1699
+ // flag is the same agent the platform saw before.
1700
+ if (!overrides) local.agentId = identity.agentId || ensureAgentId(kitDir, 'MODELING');
1674
1701
  if (!local.token || !local.organizationId) {
1675
1702
  console.error('❌ --modeling needs platform credentials in .eventmodelers/config.json (token + organizationId) — run `/connect` once or paste your config first.');
1676
1703
  process.exit(1);
1677
1704
  }
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
1705
+ // The identity flags go on last: the global install's `overrides` carry the agent id
1706
+ // resolveModelingCredentials just minted for this run, which would otherwise win back over
1707
+ // an explicit --id.
1708
+ 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
1709
  if (!cfg.boardId) {
1680
1710
  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
1711
  process.exit(1);
@@ -1733,6 +1763,9 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1733
1763
  ...process.env,
1734
1764
  ...(cfg.anthropicBaseUrl ? { ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl } : {}),
1735
1765
  EVENTMODELERS_TOKEN: cfg.token,
1766
+ // What the connect skill puts in `.mcp.json`'s x-agent-id header and every curl-fallback
1767
+ // call, so the board work this agent does on the platform is attributed to this agent.
1768
+ ...(cfg.agentId ? { EVENTMODELERS_AGENT_ID: cfg.agentId } : {}),
1736
1769
  };
1737
1770
 
1738
1771
  let proc = null;
@@ -1889,6 +1922,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1889
1922
  }
1890
1923
 
1891
1924
  spawnProcess();
1925
+ log(`agent: ${cfg.agentName ? `${cfg.agentName} (${cfg.agentId})` : cfg.agentId}`);
1892
1926
  log(
1893
1927
  standalone
1894
1928
  ? `standalone: ON — reacting to direct prompts AND to board changes on its own initiative (max ${maxAgents} subagent(s) per self-directed turn)`
@@ -1898,7 +1932,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1898
1932
 
1899
1933
  async function getRealtimeToken() {
1900
1934
  const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, {
1901
- headers: { 'x-token': cfg.token },
1935
+ headers: { 'x-token': cfg.token, ...agentHeaders(cfg) },
1902
1936
  });
1903
1937
  if (!res.ok) throw new Error(`realtime-token: HTTP ${res.status}`);
1904
1938
  return (await res.json()).token;
@@ -1906,7 +1940,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1906
1940
 
1907
1941
  async function fetchNextPrompt(jwtToken) {
1908
1942
  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}` },
1943
+ headers: { 'x-token': cfg.token, Authorization: `Bearer ${jwtToken}`, ...agentHeaders(cfg) },
1910
1944
  });
1911
1945
  if (res.status === 404) return null;
1912
1946
  if (!res.ok) throw new Error(`prompts/next: HTTP ${res.status}`);
@@ -2279,7 +2313,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
2279
2313
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
2280
2314
  method: 'POST',
2281
2315
  headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
2282
- body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: 'MODELING', agent_id: cfg.agentId }),
2316
+ body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: 'MODELING', agent_id: cfg.agentId, ...(cfg.agentName ? { agent_name: cfg.agentName } : {}) }),
2283
2317
  signal: AbortSignal.timeout(10_000),
2284
2318
  });
2285
2319
  if (!res.ok) log(`ping failed: ${res.status} ${await res.text().catch(() => '')}`);
@@ -2344,6 +2378,23 @@ function credentialOverridesFromOpts(opts) {
2344
2378
  return { token: opts.token, boardId: opts.boardId, organizationId: opts.organizationId, baseUrl: opts.baseUrl };
2345
2379
  }
2346
2380
 
2381
+ // `--name` is not a credential — it's the display name of the agent a kit runs, sent with
2382
+ // every heartbeat (POST /api/agent-alive's agent_name) so the board can show which agent is
2383
+ // live instead of a bare uuid. It belongs beside the credentials in config.json rather than
2384
+ // on every command line, so `init`/`re-init`/`init-config` persist it as `agentName` and
2385
+ // every later run of that kit's agent picks it up from the config walk (both runtimes merge
2386
+ // unknown config fields through verbatim). `run --name` is the per-run override on top, and
2387
+ // writes nothing.
2388
+ const AGENT_NAME_OPTION = [
2389
+ '--name <name>',
2390
+ '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`.',
2391
+ ];
2392
+
2393
+ function identityOverridesFromOpts(opts) {
2394
+ const agentName = typeof opts.name === 'string' ? opts.name.trim() : undefined;
2395
+ return agentName ? { agentName } : {};
2396
+ }
2397
+
2347
2398
  credentialFlags(program
2348
2399
  .command('init')
2349
2400
  .alias('install')
@@ -2358,7 +2409,8 @@ credentialFlags(program
2358
2409
  .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.')
2359
2410
  .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.')
2360
2411
  .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
2361
- .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
2412
+ .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
2413
+ .option(...AGENT_NAME_OPTION))
2362
2414
  .action(async (opts, command) => {
2363
2415
  const globalOpts = command.optsWithGlobals();
2364
2416
 
@@ -2376,7 +2428,7 @@ credentialFlags(program
2376
2428
  print: globalOpts.print,
2377
2429
  global: opts.global,
2378
2430
  force: opts.force,
2379
- credentialOverrides: credentialOverridesFromOpts(opts),
2431
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2380
2432
  });
2381
2433
  return;
2382
2434
  }
@@ -2387,7 +2439,7 @@ credentialFlags(program
2387
2439
  print: globalOpts.print,
2388
2440
  global: opts.global,
2389
2441
  force: opts.force,
2390
- credentialOverrides: credentialOverridesFromOpts(opts),
2442
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2391
2443
  });
2392
2444
  return;
2393
2445
  }
@@ -2406,7 +2458,7 @@ credentialFlags(program
2406
2458
  print: globalOpts.print,
2407
2459
  global: opts.global,
2408
2460
  force: opts.force,
2409
- credentialOverrides: credentialOverridesFromOpts(opts),
2461
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2410
2462
  target: opts.target,
2411
2463
  });
2412
2464
  // Deliberately NOT under .bridge-kit/.eventmodelers/ — that whole name is
@@ -2444,7 +2496,7 @@ credentialFlags(program
2444
2496
  print: globalOpts.print,
2445
2497
  global: opts.global,
2446
2498
  force: opts.force,
2447
- credentialOverrides: credentialOverridesFromOpts(opts),
2499
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2448
2500
  templatesSource: join(clonedDir, 'templates'),
2449
2501
  hooks: opts.hooks,
2450
2502
  });
@@ -2457,7 +2509,7 @@ credentialFlags(program
2457
2509
  print: globalOpts.print,
2458
2510
  global: opts.global,
2459
2511
  force: opts.force,
2460
- credentialOverrides: credentialOverridesFromOpts(opts),
2512
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2461
2513
  hooks: opts.hooks,
2462
2514
  });
2463
2515
  });
@@ -2474,7 +2526,8 @@ credentialFlags(program
2474
2526
  .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`)
2475
2527
  .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.')
2476
2528
  .option('--global', 'Re-install skills into ~/.claude/skills/ instead of the project — defaults to however they were originally installed')
2477
- .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
2529
+ .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
2530
+ .option(...AGENT_NAME_OPTION))
2478
2531
  .action(async (opts, command) => {
2479
2532
  const globalOpts = command.optsWithGlobals();
2480
2533
  const targetDir = process.cwd();
@@ -2512,7 +2565,7 @@ credentialFlags(program
2512
2565
  print: globalOpts.print,
2513
2566
  global: opts.global !== undefined ? opts.global : !!manifest.global,
2514
2567
  force: opts.force,
2515
- credentialOverrides: credentialOverridesFromOpts(opts),
2568
+ credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
2516
2569
  skipRootScaffold: true,
2517
2570
  hooks: opts.hooks,
2518
2571
  });
@@ -2642,10 +2695,11 @@ credentialFlags(program
2642
2695
  .command('init-config')
2643
2696
  .description('Configure credentials only — writes .eventmodelers/config.json in the current directory, or ~/.eventmodelers/config.json with --global')
2644
2697
  .option('--global', 'Write account-wide defaults (organizationId + token only) to ~/.eventmodelers/config.json instead of the project')
2645
- .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.'))
2698
+ .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.')
2699
+ .option(...AGENT_NAME_OPTION))
2646
2700
  .action(async (opts, command) => {
2647
2701
  const globalOpts = command.optsWithGlobals();
2648
- const overrides = credentialOverridesFromOpts(opts);
2702
+ const overrides = { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) };
2649
2703
 
2650
2704
  // A blob naming a board configures that board's own file rather than a project or
2651
2705
  // account-wide config: the per-board store is keyed by board id, and the blob is
@@ -2662,9 +2716,6 @@ credentialFlags(program
2662
2716
  process.exit(1);
2663
2717
  }
2664
2718
  if (!parsed.baseUrl) parsed.baseUrl = DEFAULT_BASE_URL;
2665
- // Preserved across re-configuration: the platform keys a board's alive-ping on it, so
2666
- // regenerating it would present a long-running agent as a brand-new one.
2667
- parsed.agentId = readJsonSafe(boardCredentialsPath(parsed.boardId)).agentId || randomUUID();
2668
2719
  writeBoardCredentials(parsed);
2669
2720
  console.log('\n ✓ Saved credentials for board ' + parsed.boardId + ' to ' + boardCredentialsPath(parsed.boardId));
2670
2721
  console.log('\n Start the agent from anywhere with:\n');
@@ -2688,6 +2739,11 @@ credentialFlags(program
2688
2739
  if (pasted.token) base.token = pasted.token;
2689
2740
  if (overrides.organizationId) base.organizationId = overrides.organizationId;
2690
2741
  if (overrides.token) base.token = overrides.token;
2742
+ // Carried through the same narrowing: this branch rebuilds the file from scratch, so
2743
+ // an already-configured agentName has to be read back in or a later `init-config
2744
+ // --global` (e.g. rotating the token) would silently drop it.
2745
+ if (existing.agentName) base.agentName = existing.agentName;
2746
+ if (overrides.agentName) base.agentName = overrides.agentName;
2691
2747
 
2692
2748
  const configured = await configureCredentials({
2693
2749
  config: base,
@@ -2708,7 +2764,9 @@ credentialFlags(program
2708
2764
  // configureCredentials' generic paste/manual flow may have picked up
2709
2765
  // boardId/baseUrl too (e.g. from a pasted JSON blob) — strip them back out
2710
2766
  // before the final write, since --global only ever persists identity.
2711
- writeFileSync(configPath, JSON.stringify({ organizationId: configured.organizationId, token: configured.token }, null, 2));
2767
+ // --name is the one non-credential that belongs here: it names the agent, not the
2768
+ // project, so an account-wide default is as portable as the org/token beside it.
2769
+ writeFileSync(configPath, JSON.stringify({ organizationId: configured.organizationId, token: configured.token, ...(configured.agentName ? { agentName: configured.agentName } : {}) }, null, 2));
2712
2770
  console.log(`\n ✓ Saved account-wide defaults to ${configPath}`);
2713
2771
  } else {
2714
2772
  const targetDir = process.cwd();
@@ -2724,7 +2782,11 @@ credentialFlags(program
2724
2782
  boardIdOptional: true,
2725
2783
  overrides,
2726
2784
  print: globalOpts.print,
2727
- force: true,
2785
+ // Same reasoning as the --global branch above: a bare `init-config` means "re-ask
2786
+ // me", but an invocation that already carries its answers on the command line —
2787
+ // credentials, or just a --name to record — must not stop to prompt, or every
2788
+ // non-interactive caller hangs (a closed stdin crashes outright).
2789
+ force: !Object.values(overrides).some(Boolean),
2728
2790
  });
2729
2791
 
2730
2792
  // Keep `.mcp.json` in sync — this command can change `baseUrl` (e.g.
@@ -2747,6 +2809,8 @@ credentialFlags(program
2747
2809
  .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.')
2748
2810
  .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)')
2749
2811
  .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.')
2812
+ .option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. A project install otherwise mints one id per project and reuses it on every restart; --global/--standalone mints a fresh one per run, since two ad-hoc agents for one board must not share a row (the heartbeat is keyed on token + agent_id + agent_type, so the second would replace the first). Pass this when an agent has to keep ONE identity across restarts — a supervisor that already knows the id, or a board where it is the starred "preferred agent". Per-run only: nothing is written to disk.')
2813
+ .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.')
2750
2814
  .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.'))
2751
2815
  .action(async (opts, command) => {
2752
2816
  const globalOpts = command.optsWithGlobals();
@@ -2758,6 +2822,23 @@ credentialFlags(program
2758
2822
  if (command.getOptionValueSource('maxAgents') === 'cli' && !opts.standalone) {
2759
2823
  console.log('ℹ️ --max-agents only applies to --standalone turns; ignoring it here.');
2760
2824
  }
2825
+ // --id/--name are what the platform will see for this run, so a blank one is a
2826
+ // mistake worth failing on rather than silently falling back to the stored identity.
2827
+ const identity = {
2828
+ agentId: opts.id === undefined ? null : String(opts.id).trim(),
2829
+ agentName: opts.name === undefined ? null : String(opts.name).trim(),
2830
+ };
2831
+ for (const [flag, value] of [['--id', identity.agentId], ['--name', identity.agentName]]) {
2832
+ if (value === '') {
2833
+ console.error(`❌ ${flag} needs a non-empty value.`);
2834
+ process.exit(1);
2835
+ }
2836
+ }
2837
+ // ralph.sh has no realtime agent and never pings /api/agent-alive, so there is no
2838
+ // identity for either flag to override there.
2839
+ if ((identity.agentId || identity.agentName) && opts.bash) {
2840
+ console.log('ℹ️ --id/--name only apply to agents that ping the platform; the --bash loop does not, so they are ignored here.');
2841
+ }
2761
2842
  // Both kit dirs can be installed side by side (e.g. running a build-kit and a
2762
2843
  // modeling-kit agent from the same project). findInstalledKitDir only ever
2763
2844
  // returns its first fixed-order match, which would silently prefer one stack
@@ -2821,7 +2902,7 @@ credentialFlags(program
2821
2902
  const shown = relative(cwd, kitDir);
2822
2903
  await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (warm Claude process) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
2823
2904
  try {
2824
- await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides, maxAgents);
2905
+ await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides, maxAgents, identity);
2825
2906
  } catch (err) {
2826
2907
  console.error('[modeling] Fatal:', err);
2827
2908
  process.exit(1);
@@ -2866,7 +2947,9 @@ credentialFlags(program
2866
2947
  // their own separate output paths with no stream-json parsing to gate. RALPH_LOCAL is
2867
2948
  // read by all three runners (ralph.js's startRalph, and ralph.sh directly) to force the
2868
2949
  // local-only branch even when .eventmodelers/config.json has valid credentials.
2869
- execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '' } });
2950
+ // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read in ralph.js's startRalph, so
2951
+ // they reach both node runners but not ralph.sh, which has no heartbeat to identify.
2952
+ 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 ?? '' } });
2870
2953
  } catch (err) {
2871
2954
  process.exit(err.status || 1);
2872
2955
  }
@@ -3180,6 +3263,7 @@ program
3180
3263
  'x-token': cfg.token,
3181
3264
  'x-board-id': cfg.boardId,
3182
3265
  'x-user-id': 'cli-set-slice-status',
3266
+ ...agentHeaders(cfg),
3183
3267
  },
3184
3268
  body: JSON.stringify([{
3185
3269
  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.68",
3
+ "version": "1.0.70",
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 });
@@ -367,7 +376,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
367
376
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
368
377
  method: 'POST',
369
378
  headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
370
- 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 } : {}) }),
371
380
  signal: AbortSignal.timeout(10_000),
372
381
  });
373
382
  if (!res.ok) {
@@ -489,7 +498,7 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
489
498
  try {
490
499
  await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
491
500
  method: 'POST',
492
- 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) },
493
502
  body: JSON.stringify([{
494
503
  id: randomUUID(),
495
504
  eventType: 'node:changed',
@@ -583,10 +592,17 @@ export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
583
592
 
584
593
  export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
585
594
  const local = loadLocalConfig(kitDir);
586
- 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;
587
602
 
588
603
  console.log(`Ralph — kit: ${kitDir}`);
589
604
  console.log(` project: ${projectDir}`);
605
+ console.log(` agent: ${local.agentName ? `${local.agentName} (${local.agentId})` : local.agentId}`);
590
606
 
591
607
  // localOnly (set via `eventmodelers run --local`) forces this branch even when
592
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
 
@@ -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',