@cotal-ai/connector-opencode 0.77.1 → 0.78.0

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.
@@ -30964,9 +30964,10 @@ function reclaimStale(path, inspectedIno) {
30964
30964
  const sentinel = `${path}.reclaim`;
30965
30965
  const mine = JSON.stringify({ pid: process.pid, start: processStartToken(process.pid), nonce: randomBytes(8).toString("hex"), ts: Date.now() });
30966
30966
  if (!publishAtomic(sentinel, mine)) {
30967
- if (inspectLock(sentinel).state === "stale")
30967
+ const reclaimer = inspectLock(sentinel);
30968
+ if (reclaimer.state === "stale")
30968
30969
  throw new Error(`a lock reclaim was interrupted and its sentinel remains (${sentinel}) - if no cotal process is running, remove it and retry`);
30969
- return;
30970
+ return reclaimer.state === "active" ? reclaimer.owner : void 0;
30970
30971
  }
30971
30972
  try {
30972
30973
  const found = readOwner(path);
@@ -30988,9 +30989,7 @@ function reclaimStale(path, inspectedIno) {
30988
30989
  }
30989
30990
  }
30990
30991
  }
30991
- function acquireLock(path, opts = {}) {
30992
- const waitMs = opts.waitMs ?? 3e5;
30993
- const pollMs = opts.pollMs ?? 200;
30992
+ function newAttempt(path, opts) {
30994
30993
  mkdirSync(dirname(path), { recursive: true });
30995
30994
  const nonce3 = randomBytes(12).toString("hex");
30996
30995
  const owner = {
@@ -31000,13 +30999,15 @@ function acquireLock(path, opts = {}) {
31000
30999
  ts: Date.now(),
31001
31000
  ...opts.label ? { label: opts.label } : {}
31002
31001
  };
31003
- const content = `${JSON.stringify(owner)}
31004
- `;
31005
- const deadline = Date.now() + waitMs;
31006
- for (; ; ) {
31007
- if (publishAtomic(path, content)) {
31008
- let released = false;
31009
- return {
31002
+ return { path, nonce: nonce3, content: `${JSON.stringify(owner)}
31003
+ ` };
31004
+ }
31005
+ function tryAcquireOnce({ path, nonce: nonce3, content }) {
31006
+ if (publishAtomic(path, content)) {
31007
+ let released = false;
31008
+ return {
31009
+ kind: "acquired",
31010
+ held: {
31010
31011
  nonce: nonce3,
31011
31012
  release() {
31012
31013
  if (released)
@@ -31020,23 +31021,47 @@ function acquireLock(path, opts = {}) {
31020
31021
  }
31021
31022
  }
31022
31023
  }
31023
- };
31024
- }
31025
- const found = readOwner(path);
31026
- const inspection = inspectLock(path);
31027
- if (inspection.state === "absent")
31028
- continue;
31029
- if (inspection.state === "stale") {
31030
- reclaimStale(path, found?.ino);
31031
- sleepSync(Math.min(pollMs, 50));
31032
- continue;
31033
- }
31034
- if (Date.now() >= deadline) {
31035
- throw opts.onTimeout?.(inspection.owner) ?? new Error(`${opts.label ?? "a lock"} is held by a live process (pid ${inspection.owner.pid}) and did not release within ${Math.round(waitMs / 1e3)}s - retry, or if it is wedged, remove ${path}`);
31024
+ }
31025
+ };
31026
+ }
31027
+ const found = readOwner(path);
31028
+ const inspection = inspectLock(path);
31029
+ if (inspection.state === "absent")
31030
+ return { kind: "retry" };
31031
+ if (inspection.state === "stale") {
31032
+ const reclaimer = reclaimStale(path, found?.ino);
31033
+ return reclaimer ? { kind: "reclaiming", owner: reclaimer } : { kind: "retry" };
31034
+ }
31035
+ return { kind: "held", owner: inspection.owner };
31036
+ }
31037
+ function reclaimPollMs(pollMs) {
31038
+ return Math.min(pollMs, 50);
31039
+ }
31040
+ function acquireLock(path, opts = {}) {
31041
+ const waitMs = opts.waitMs ?? 3e5;
31042
+ const pollMs = opts.pollMs ?? 200;
31043
+ const attempt = newAttempt(path, opts);
31044
+ const deadline = Date.now() + waitMs;
31045
+ for (; ; ) {
31046
+ const result = tryAcquireOnce(attempt);
31047
+ switch (result.kind) {
31048
+ case "acquired":
31049
+ return result.held;
31050
+ case "retry":
31051
+ continue;
31052
+ case "reclaiming":
31053
+ sleepSync(reclaimPollMs(pollMs));
31054
+ continue;
31055
+ case "held":
31056
+ if (Date.now() >= deadline)
31057
+ throw lockTimeoutError(path, opts, result.owner, waitMs);
31058
+ sleepSync(pollMs);
31036
31059
  }
31037
- sleepSync(pollMs);
31038
31060
  }
31039
31061
  }
31062
+ function lockTimeoutError(path, opts, owner, waitMs) {
31063
+ return opts.onTimeout?.(owner) ?? new Error(`${opts.label ?? "a lock"} is held by a live process (pid ${owner.pid}) and did not release within ${Math.round(waitMs / 1e3)}s - retry, or if it is wedged, remove ${path}`);
31064
+ }
31040
31065
 
31041
31066
  // ../../packages/core/dist/local-consumer-custody.js
31042
31067
  import { createHash } from "node:crypto";
@@ -35656,8 +35681,10 @@ async function describeEndpoint(nc, space, endpoint, caller, opts = {}) {
35656
35681
  let retryTimer;
35657
35682
  let denialWatch;
35658
35683
  let onAbort;
35684
+ const startedAt = performance.now();
35685
+ const unanswered = (ms) => new EpEnvelopeError("deadline-exceeded", `no describe reply from ${endpoint}${opts.instanceId !== void 0 ? ` instance ${opts.instanceId}` : ""} within ${ms}ms on the ${rail} rail`, [{ kind: EP_UNANSWERED, endpoint, command: "describe", rail }]);
35659
35686
  const cancelled = new Promise((_, reject) => {
35660
- onAbort = () => reject(new EpEnvelopeError("unavailable", `describe(${endpoint}) observation cancelled`));
35687
+ onAbort = () => reject(opts.signal?.reason?.name === "TimeoutError" ? unanswered(Math.round(performance.now() - startedAt)) : new EpEnvelopeError("unavailable", `describe(${endpoint}) observation cancelled`));
35661
35688
  opts.signal?.addEventListener("abort", onAbort, { once: true });
35662
35689
  });
35663
35690
  try {
@@ -35694,7 +35721,7 @@ async function describeEndpoint(nc, space, endpoint, caller, opts = {}) {
35694
35721
  }, DESCRIBE_RETRY_MS);
35695
35722
  });
35696
35723
  const timeout = new Promise((_, reject) => {
35697
- timer = setTimeout(() => reject(new EpEnvelopeError("deadline-exceeded", `no describe reply from ${endpoint}${opts.instanceId !== void 0 ? ` instance ${opts.instanceId}` : ""} within ${deadlineMs}ms on the ${rail} rail`, [{ kind: EP_UNANSWERED, endpoint, command: "describe", rail }])), deadlineMs);
35724
+ timer = setTimeout(() => reject(unanswered(deadlineMs)), deadlineMs);
35698
35725
  });
35699
35726
  const { body: reply, responder } = await Promise.race([got, timeout, denialWatch.denied, cancelled]);
35700
35727
  if (reply.ok !== true) {
@@ -35895,9 +35922,10 @@ async function submitAndFollowGoal(nc, space, endpoint, caller, deadlineMs, subm
35895
35922
  };
35896
35923
  const phaseError = (code) => new EpEnvelopeError(code, submitted ? "the goal submission was interrupted while in flight; its outcome is unknown. It may have been accepted or executed; do NOT retry without inspecting 'ps'/'inspect' first (SPEC 13.6)" : "goal observation stopped or exceeded its deadline before submission; the manager request WAS NOT RUN", void 0, submitted ? "unknown" : "not-executed");
35897
35924
  let deadline = Date.now() + deadlineMs;
35898
- const bounded = async (operation, until, remaining = until - Date.now()) => {
35925
+ const bounded = async (operation, until) => {
35899
35926
  if (stopped || completed)
35900
35927
  throw phaseError("unavailable");
35928
+ const remaining = until - Date.now();
35901
35929
  if (remaining <= 0)
35902
35930
  throw phaseError("deadline-exceeded");
35903
35931
  let timer;
@@ -35978,11 +36006,19 @@ async function submitAndFollowGoal(nc, space, endpoint, caller, deadlineMs, subm
35978
36006
  if (stopped)
35979
36007
  throw phaseError("unavailable");
35980
36008
  if (opts?.prepare) {
35981
- const prepare = opts.prepare;
36009
+ let timer;
35982
36010
  try {
35983
- await bounded(() => prepare(work.signal), deadline, deadlineMs);
36011
+ await Promise.race([opts.prepare(work.signal, deadline), aborted2, new Promise((resolve2) => {
36012
+ timer = setTimeout(() => {
36013
+ work.abort(new DOMException("the goal follow's deadline passed", "TimeoutError"));
36014
+ setImmediate(resolve2);
36015
+ }, Math.min(deadline - Date.now(), 2147483647));
36016
+ })]);
36017
+ work.signal.throwIfAborted();
35984
36018
  } catch (err2) {
35985
- throw stopped ? phaseError("unavailable") : err2;
36019
+ throw stopped ? phaseError("unavailable") : work.signal.aborted && !unansweredRequest(err2) ? phaseError("deadline-exceeded") : err2;
36020
+ } finally {
36021
+ clearTimeout(timer);
35986
36022
  }
35987
36023
  }
35988
36024
  subscribe(opts?.currentNc?.() ?? nc);
@@ -36419,6 +36455,19 @@ function parseLivenessAnswer(body, expectPlane) {
36419
36455
  return instance === void 0 ? { plane, responder } : { plane, responder, instance };
36420
36456
  }
36421
36457
 
36458
+ // ../../packages/core/dist/deadline.js
36459
+ async function withDeadline(p, ms, onTimeout) {
36460
+ let timer;
36461
+ const timeout = new Promise((_, rej) => {
36462
+ timer = setTimeout(() => rej(onTimeout()), Math.max(1, ms));
36463
+ });
36464
+ try {
36465
+ return await Promise.race([p, timeout]);
36466
+ } finally {
36467
+ clearTimeout(timer);
36468
+ }
36469
+ }
36470
+
36422
36471
  // ../../packages/core/dist/artifact.js
36423
36472
  var ARTIFACT_PART_KIND = "artifact";
36424
36473
  function isArtifactPart(value) {
@@ -37270,21 +37319,6 @@ var CotalEndpoint = class _CotalEndpoint extends EventEmitter {
37270
37319
  this.credsTxn = next.then(() => void 0, () => void 0);
37271
37320
  return next;
37272
37321
  }
37273
- /** Race `p` against a `ms` deadline so a slow/hung SecretStore fetch (or preflight) cannot exceed
37274
- * the daemon transaction bound. The underlying promise is not cancellable, so callers also FENCE a
37275
- * late commit by re-checking the deadline before mutating {@link currentCreds}. */
37276
- static async withDeadline(p, ms, msg) {
37277
- let timer;
37278
- const timeout = new Promise((_, rej) => {
37279
- timer = setTimeout(() => rej(new Error(msg)), Math.max(1, ms));
37280
- });
37281
- try {
37282
- return await Promise.race([p, timeout]);
37283
- } finally {
37284
- if (timer)
37285
- clearTimeout(timer);
37286
- }
37287
- }
37288
37322
  /** The one PROVE-then-adopt transaction shared by the 75% timer and the explicit reload (run under
37289
37323
  * {@link runCredsTxn}). Fetch (deadline-bounded) → identity-pin → optional `expected` fingerprint →
37290
37324
  * PREFLIGHT the candidate on a disposable connection → fence a late commit against the deadline →
@@ -37296,7 +37330,7 @@ var CotalEndpoint = class _CotalEndpoint extends EventEmitter {
37296
37330
  const deadline = opts.deadline ?? Date.now() + _CotalEndpoint.RELOAD_DEADLINE_MS;
37297
37331
  if (Date.now() > deadline)
37298
37332
  throw new Error("reloadCreds: the request deadline elapsed while queued behind another credential transaction; nothing adopted");
37299
- const candidate2 = await _CotalEndpoint.withDeadline(this.credsSource(), deadline - Date.now(), "reloadCreds: the creds source did not return before the daemon deadline; nothing adopted");
37333
+ const candidate2 = await withDeadline(this.credsSource(), deadline - Date.now(), () => new Error("reloadCreds: the creds source did not return before the daemon deadline; nothing adopted"));
37300
37334
  const id = idFromCreds(candidate2);
37301
37335
  if (id !== this.connId)
37302
37336
  throw new Error(`creds source returned identity ${id}, expected ${this.connId} - renewal may not swap the connection's nkey`);
@@ -38301,13 +38335,13 @@ var CotalEndpoint = class _CotalEndpoint extends EventEmitter {
38301
38335
  throw new Error(this.notLiveMsg());
38302
38336
  const nc = this.nc;
38303
38337
  const caller = this.serviceCaller();
38304
- const resolve2 = async (signal = opts.signal) => {
38338
+ const resolve2 = async (signal = opts.signal, deadlineMs = opts.deadlineMs ?? 1e4) => {
38305
38339
  signal?.throwIfAborted();
38306
38340
  const cached2 = opts.instanceId === void 0 ? this.resolvedServices.get(endpoint) : void 0;
38307
38341
  if (cached2)
38308
38342
  return cached2;
38309
38343
  const svc = await resolveService(nc, this.space, endpoint, caller, {
38310
- deadlineMs: opts.deadlineMs ?? 1e4,
38344
+ deadlineMs,
38311
38345
  signal,
38312
38346
  ...opts.instanceId !== void 0 ? { instanceId: opts.instanceId } : {}
38313
38347
  });
@@ -38323,13 +38357,16 @@ var CotalEndpoint = class _CotalEndpoint extends EventEmitter {
38323
38357
  return Promise.resolve(unfollowable);
38324
38358
  return invokeCommand(nc, this.space, service2, command, args, { ...invokeOpts, signal });
38325
38359
  };
38326
- const resolveFirst = async (signal = opts.signal) => {
38360
+ const resolveFirst = async (signal = opts.signal, deadline = Date.now() + (opts.deadlineMs ?? 1e4)) => {
38327
38361
  try {
38328
- return await resolve2(signal);
38362
+ return await resolve2(signal, deadline - Date.now());
38329
38363
  } catch (e) {
38330
38364
  if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
38331
38365
  throw e;
38332
- return await resolve2(signal);
38366
+ const left = deadline - Date.now();
38367
+ if (left <= 0)
38368
+ throw e;
38369
+ return await resolve2(signal, left);
38333
38370
  }
38334
38371
  };
38335
38372
  const doInvoke = async (service2, signal = opts.signal) => {
@@ -38390,8 +38427,8 @@ var CotalEndpoint = class _CotalEndpoint extends EventEmitter {
38390
38427
  let service;
38391
38428
  return this.followServiceGoal(endpoint, (signal) => doInvoke(service, signal), opts.deadlineMs ?? 1e4, {
38392
38429
  signal: opts.signal,
38393
- prepare: async (signal) => {
38394
- service = await resolveFirst(signal);
38430
+ prepare: async (signal, deadline) => {
38431
+ service = await resolveFirst(signal, deadline);
38395
38432
  }
38396
38433
  });
38397
38434
  }
@@ -43380,7 +43417,7 @@ function configFromEnv(env = process.env) {
43380
43417
  token: material?.token || env.COTAL_TOKEN?.trim() || link?.token,
43381
43418
  user: link?.user,
43382
43419
  pass: link?.pass,
43383
- tls: env.COTAL_TLS?.trim() === "1" || link?.tls || false,
43420
+ tls: envFlag(env, "COTAL_TLS") || link?.tls || false,
43384
43421
  feedbackKey: env.COTAL_FEEDBACK_KEY?.trim() || void 0,
43385
43422
  feedbackUrl: env.COTAL_FEEDBACK_URL?.trim() || void 0
43386
43423
  };
@@ -43424,75 +43461,95 @@ function fmtChannel(channel) {
43424
43461
  async function invokeUserManager(config2, bearer, command, args, opts = {}) {
43425
43462
  opts.signal?.throwIfAborted();
43426
43463
  const user = config2.userAuth;
43427
- const { caller: triple, instanceId } = managerCallerBinding(bearer, {
43428
- space: config2.space,
43429
- owner: user.owner,
43430
- actor: user.actor,
43431
- lifecycleUid: config2.lifecycleUid,
43432
- instanceId: config2.managerInstanceId
43433
- });
43434
- const connectOpts = standaloneConnectOpts({ bearer, sentinelCreds: user.sentinelCreds, tls: config2.tls });
43435
- const nc = await dialerFor(config2.servers)({
43436
- servers: config2.servers,
43437
- ...connectOpts,
43438
- maxReconnectAttempts: 0,
43439
- timeout: Math.min(opts.deadlineMs ?? 1e4, 2e3)
43440
- });
43441
- let caller = triple;
43442
- try {
43443
- caller = await issuedUserCaller(nc, config2.space, String(connectOpts.name), triple);
43444
- } catch (e) {
43445
- if (!isPermissionDenied(e)) {
43446
- await nc.close();
43447
- throw e;
43448
- }
43449
- }
43464
+ const endpoint = BASELINE_LIFECYCLE_ENDPOINT;
43465
+ let nc;
43466
+ let caller;
43467
+ let instanceId;
43468
+ let service;
43450
43469
  const onAbort = () => {
43451
- void nc.close();
43470
+ void nc?.close();
43452
43471
  };
43453
- opts.signal?.addEventListener("abort", onAbort, { once: true });
43454
- try {
43455
- opts.signal?.throwIfAborted();
43456
- const endpoint = BASELINE_LIFECYCLE_ENDPOINT;
43457
- const resolve2 = () => resolveService(nc, config2.space, endpoint, caller, { instanceId, deadlineMs: opts.deadlineMs ?? 1e4, signal: opts.signal });
43458
- let service = await resolve2();
43472
+ const resolve2 = (signal = opts.signal, deadlineMs = opts.deadlineMs ?? 1e4) => resolveService(nc, config2.space, endpoint, caller, { instanceId, deadlineMs, signal });
43473
+ const connect7 = async (signal = opts.signal) => {
43474
+ const token2 = await bearer(signal);
43475
+ ({ caller, instanceId } = managerCallerBinding(token2, {
43476
+ space: config2.space,
43477
+ owner: user.owner,
43478
+ actor: user.actor,
43479
+ lifecycleUid: config2.lifecycleUid,
43480
+ instanceId: config2.managerInstanceId
43481
+ }));
43482
+ const connectOpts = standaloneConnectOpts({ bearer: token2, sentinelCreds: user.sentinelCreds, tls: config2.tls });
43483
+ nc = await dialerFor(config2.servers)({
43484
+ servers: config2.servers,
43485
+ ...connectOpts,
43486
+ maxReconnectAttempts: 0,
43487
+ timeout: Math.min(opts.deadlineMs ?? 1e4, 2e3)
43488
+ });
43489
+ if (signal?.aborted)
43490
+ onAbort();
43491
+ signal?.throwIfAborted();
43492
+ try {
43493
+ caller = await issuedUserCaller(nc, config2.space, String(connectOpts.name), caller);
43494
+ } catch (e) {
43495
+ if (!isPermissionDenied(e))
43496
+ throw e;
43497
+ }
43498
+ };
43499
+ const prepare = async (signal, deadline) => {
43500
+ await connect7(signal);
43501
+ const left = deadline - Date.now();
43502
+ if (left <= 0)
43503
+ throw new EpEnvelopeError("deadline-exceeded", "the deadline passed before the manager was resolved; the manager request WAS NOT RUN", void 0, "not-executed");
43504
+ service = await resolve2(signal, left);
43505
+ };
43506
+ const invoke = async (signal = opts.signal) => {
43459
43507
  const unfollowable = opts.follow ? goalFollowRefusal(service) : void 0;
43460
43508
  if (unfollowable)
43461
43509
  return unfollowable;
43462
- const invoke = async () => {
43463
- const result = await invokeCommand(nc, config2.space, service, command, args, opts);
43464
- if (result.reply.ok === false && replyRefusedBeforeEffect(result.reply.error)) {
43465
- try {
43466
- service = await resolve2();
43467
- const unfollowable2 = opts.follow ? goalFollowRefusal(service) : void 0;
43468
- if (unfollowable2)
43469
- throw new Error(unfollowable2.reply.error.message);
43470
- } catch (error51) {
43471
- return { ...result, reply: { ...result.reply, error: {
43472
- ...result.reply.error,
43473
- message: `${command} WAS NOT RUN; manager instance ${instanceId} refused before effects, and re-resolution failed: ${error51 instanceof Error ? error51.message : String(error51)}`
43474
- } } };
43475
- }
43476
- return invokeCommand(nc, config2.space, service, command, args, opts);
43510
+ const invokeOpts = { target: opts.target, deadlineMs: opts.deadlineMs, signal };
43511
+ const result = await invokeCommand(nc, config2.space, service, command, args, invokeOpts);
43512
+ if (result.reply.ok === false && replyRefusedBeforeEffect(result.reply.error)) {
43513
+ try {
43514
+ service = await resolve2(signal);
43515
+ const unfollowable2 = opts.follow ? goalFollowRefusal(service) : void 0;
43516
+ if (unfollowable2)
43517
+ throw new Error(unfollowable2.reply.error.message);
43518
+ } catch (error51) {
43519
+ return { ...result, reply: { ...result.reply, error: {
43520
+ ...result.reply.error,
43521
+ message: `${command} WAS NOT RUN; manager instance ${instanceId} refused before effects, and re-resolution failed: ${error51 instanceof Error ? error51.message : String(error51)}`
43522
+ } } };
43477
43523
  }
43478
- return result;
43479
- };
43524
+ return invokeCommand(nc, config2.space, service, command, args, invokeOpts);
43525
+ }
43526
+ return result;
43527
+ };
43528
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
43529
+ try {
43530
+ if (opts.follow)
43531
+ return await opts.follow(invoke, prepare);
43532
+ await connect7();
43533
+ service = await resolve2();
43480
43534
  return await invoke();
43481
43535
  } finally {
43482
43536
  opts.signal?.removeEventListener("abort", onAbort);
43483
- let timer;
43484
- try {
43485
- await Promise.race([
43486
- nc.drain().catch(() => nc.close()),
43487
- new Promise((resolve2) => {
43488
- timer = setTimeout(resolve2, 2e3);
43489
- timer.unref();
43490
- })
43491
- ]);
43492
- } finally {
43493
- if (timer !== void 0)
43494
- clearTimeout(timer);
43495
- await nc.close();
43537
+ const control = nc;
43538
+ if (control) {
43539
+ let timer;
43540
+ try {
43541
+ await Promise.race([
43542
+ control.drain().catch(() => control.close()),
43543
+ new Promise((resolve3) => {
43544
+ timer = setTimeout(resolve3, 2e3);
43545
+ timer.unref();
43546
+ })
43547
+ ]);
43548
+ } finally {
43549
+ if (timer !== void 0)
43550
+ clearTimeout(timer);
43551
+ await control.close();
43552
+ }
43496
43553
  }
43497
43554
  }
43498
43555
  }
@@ -45134,41 +45191,42 @@ ${list.join("\n")}`);
45134
45191
  const input = clean && Object.keys(clean).length ? clean : void 0;
45135
45192
  if (this.config.userAuth) {
45136
45193
  const managerInstanceId = opts.instanceId ?? this.config.managerInstanceId;
45137
- const { instanceId: _instanceId, ...invokeOpts } = opts;
45138
- const submit = async (signal) => {
45139
- const bearer = await execBearerCmd([
45140
- ...this.config.userAuth.bearerCmd,
45141
- "--manager-call",
45142
- ...managerInstanceId ? ["--manager-instance", managerInstanceId] : []
45143
- ], signal);
45144
- return invokeUserManager({ ...this.config, managerInstanceId }, bearer, command, input, { ...invokeOpts, signal });
45145
- };
45146
- r = opts.follow ? await this.ep.followServiceGoal(BASELINE_LIFECYCLE_ENDPOINT, submit, opts.deadlineMs, {
45147
- reconcile: async (goalId, attributed, context) => {
45148
- const instanceId = attributed.responder?.instanceId;
45149
- if (!instanceId)
45150
- throw new Error("accepted goal has no broker-attributed manager instance");
45151
- const bearer = await execBearerCmd([
45152
- ...this.config.userAuth.bearerCmd,
45153
- "--manager-call",
45154
- "--manager-instance",
45155
- instanceId
45156
- ], context.signal, Math.min(3e4, context.deadlineMs));
45157
- const config2 = {
45158
- ...this.config,
45159
- managerInstanceId: instanceId,
45160
- lifecycleUid: context.caller.uid,
45161
- userAuth: { ...this.config.userAuth, owner: context.caller.owner, actor: context.caller.actor }
45162
- };
45163
- const queryRes = await invokeUserManager(config2, bearer, "goal-result", { goalId }, {
45164
- signal: context.signal,
45165
- deadlineMs: Math.min(invokeOpts.deadlineMs ?? 1e4, context.deadlineMs)
45166
- });
45167
- if (queryRes.reply.ok !== true)
45168
- throw new Error(queryRes.reply.error?.message ?? "goal-result query refused");
45169
- return queryRes.reply.data;
45170
- }
45171
- }) : await submit();
45194
+ const { instanceId: _instanceId, follow, ...invokeOpts } = opts;
45195
+ const bearer = (signal) => execBearerCmd([
45196
+ ...this.config.userAuth.bearerCmd,
45197
+ "--manager-call",
45198
+ ...managerInstanceId ? ["--manager-instance", managerInstanceId] : []
45199
+ ], signal);
45200
+ r = await invokeUserManager({ ...this.config, managerInstanceId }, bearer, command, input, {
45201
+ ...invokeOpts,
45202
+ follow: follow ? (submit, prepare) => this.ep.followServiceGoal(BASELINE_LIFECYCLE_ENDPOINT, submit, opts.deadlineMs, {
45203
+ prepare,
45204
+ reconcile: async (goalId, attributed, context) => {
45205
+ const instanceId = attributed.responder?.instanceId;
45206
+ if (!instanceId)
45207
+ throw new Error("accepted goal has no broker-attributed manager instance");
45208
+ const bearer2 = (signal) => execBearerCmd([
45209
+ ...this.config.userAuth.bearerCmd,
45210
+ "--manager-call",
45211
+ "--manager-instance",
45212
+ instanceId
45213
+ ], signal, Math.min(3e4, context.deadlineMs));
45214
+ const config2 = {
45215
+ ...this.config,
45216
+ managerInstanceId: instanceId,
45217
+ lifecycleUid: context.caller.uid,
45218
+ userAuth: { ...this.config.userAuth, owner: context.caller.owner, actor: context.caller.actor }
45219
+ };
45220
+ const queryRes = await invokeUserManager(config2, bearer2, "goal-result", { goalId }, {
45221
+ signal: context.signal,
45222
+ deadlineMs: Math.min(invokeOpts.deadlineMs ?? 1e4, context.deadlineMs)
45223
+ });
45224
+ if (queryRes.reply.ok !== true)
45225
+ throw new Error(queryRes.reply.error?.message ?? "goal-result query refused");
45226
+ return queryRes.reply.data;
45227
+ }
45228
+ }) : void 0
45229
+ });
45172
45230
  } else {
45173
45231
  r = await this.ep.invokeService(BASELINE_LIFECYCLE_ENDPOINT, command, input, opts);
45174
45232
  }
@@ -45302,14 +45360,16 @@ ${list.join("\n")}`);
45302
45360
  this.pullTrouble = reason;
45303
45361
  this.log(`the run-turn pull is not answering (${reason}); retrying every ${TURN_POLL_MS}ms`);
45304
45362
  }
45305
- /** Format every not-yet-surfaced turn as an injectable context block, WITHOUT marking anything
45306
- * (undefined when none wait). Two-phase on purpose, like the inbox's format-then-verdict rail:
45307
- * marking at format time would let a lost frame auto-yield `done` for work the model never
45308
- * saw. A caller commits with {@link commitSurfacedTurns} only once the injection verifiably
45309
- * reached the host. The payload is opaque relay bytes; when it parses as JSON with a string
45310
- * `context`, that rendered context is what the model reads, else the raw payload is shown. */
45363
+ /** Format every not-yet-surfaced turn still inside its deadline as an injectable context block,
45364
+ * WITHOUT marking anything (undefined when none wait). Two-phase on purpose, like the inbox's
45365
+ * format-then-verdict rail: marking at format time would let a lost frame auto-yield `done` for
45366
+ * work the model never saw. A caller commits with {@link commitSurfacedTurns} only once the
45367
+ * injection verifiably reached the host. The payload is opaque relay bytes; when it parses as
45368
+ * JSON with a string `context`, that rendered context is what the model reads, else the raw
45369
+ * payload is shown. */
45311
45370
  peekPendingTurns() {
45312
- const waiting = [...this.activeTurns.values()].filter((t) => !t.surfaced).sort((a, b) => a.acceptedAt - b.acceptedAt);
45371
+ const now = Date.now();
45372
+ const waiting = [...this.activeTurns.values()].filter((t) => !t.surfaced && t.deadlineAt > now).sort((a, b) => a.acceptedAt - b.acceptedAt);
45313
45373
  if (!waiting.length)
45314
45374
  return void 0;
45315
45375
  const blocks = waiting.map((t) => {
@@ -62459,10 +62519,10 @@ function date4(params) {
62459
62519
  config(en_default());
62460
62520
 
62461
62521
  // ../connector-core/dist/docs-bundle.generated.js
62462
- var DOCS_VERSION = "0.77.1";
62522
+ var DOCS_VERSION = "0.78.0";
62463
62523
  function loadDocsBundle() {
62464
62524
  return {
62465
- "version": "0.77.1",
62525
+ "version": "0.78.0",
62466
62526
  "generatedFrom": "docs/*.md + SPEC.md + spec/cotal-lang.md + spec/cotal.schema.json",
62467
62527
  "pages": [
62468
62528
  {
@@ -62505,7 +62565,7 @@ function loadDocsBundle() {
62505
62565
  "title": "Identity",
62506
62566
  "kind": "Concept (informative)",
62507
62567
  "summary": "Who can do what on a mesh, and how it is enforced.",
62508
- "body": "# Identity\n\n> **Concept** (informative) \xB7 **For:** operators and implementers \xB7 **Normative:** [SPEC \xA72](../SPEC.md#2-identity), [\xA79](../SPEC.md#9-nats--jetstream-security-and-authorization), [\xA710](../SPEC.md#10-connection-and-onboarding), [Appendix B](../SPEC.md#appendix-b-profile-acls)\n\nWho can do what on a mesh, and how it is enforced. The design goal: the mesh is a **real\nboundary against untrusted peers in a shared space**; an agent can only speak as itself\nand only where its declared permissions allow, enforced by the broker, not by agent\ngoodwill. What that boundary does and does not protect is the\n[security model](security.md); the exact ACLs are\n[SPEC Appendix B](../SPEC.md#appendix-b-profile-acls).\n\n## On by default\n\n`cotal up` provisions a JWT-authed space; `cotal up --open` runs an unauthenticated dev\nmesh instead. Both bind loopback by default. `--host 0.0.0.0` widens the bind\nindependently, so \"network-reachable\" never silently means \"unauthenticated\". Open mode\nis for quick local experiments and sits outside every security claim\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)).\n\n## Shared identity\n\nAn agent's wire identity is a **principal**: an `owner.actor` pair, where the owner is\nthe account (a human, or an organization) the agent acts on behalf of, and the actor is\nthe agent's own handle under that owner ([SPEC \xA72](../SPEC.md#2-identity)). The same pair\nis the card id, the sender tokens in every subject it publishes, the presence key, and\nits durable-consumer names. On an open dev mesh the owner is the literal `local`; on a\nper-user-auth mesh it is a derived token (`u_` plus 26 characters, so no PII rides the\nwire). The connection still authenticates with an **nkey**, generated locally (the signer\nonly ever sees the public half), but the nkey is the transport credential, not the\nidentity: it scopes only the per-connection reply inbox.\n\n**The sender is encoded in the subject.** Every publish carries the sender's owner and\nactor in positions the broker's permissions pin to that connection, so an agent *cannot*\nemit as anyone else: not as another owner, and not as a sibling actor under its own\nowner. Receivers verify the payload's `from.id` against the subject sender and reject\nmismatches; sender authenticity is broker-enforced end to end\n([SPEC \xA73](../SPEC.md#3-subject-layout), [\xA75](../SPEC.md#5-envelopes)).\n\n**Account = space, user = agent.** A space is one NATS account, a server-enforced\nisolation boundary. An operator signs the account; an account **signing key** mints\nper-agent user JWTs.\n\n## Provisioner\n\nThe **provisioner** is whoever holds the account signing key. It mints profile-scoped\ncredentials and pre-creates the durables agents may only *bind* (their DM inbox, their\nrole's task queue). The manager hosts it today, but nothing is manager-special about it;\nprivilege attaches to the signer, and a space can run without a manager.\n`cotal mint <name> --profile <agent|observer|admin>` is the out-of-band path; spawn calls\nthe same library ([CLI](cli.md)). The out-of-band profiles carry no default TTL: pass\n`--expires-in <seconds>` (or `--expires-at`) for a bounded credential, which a\nstanding-renewal consumer requires; pass `--identity <creds>` to re-mint for the nkey a\nfile already carries, keeping the principal and its durables. Minting static creds is a\n**static-auth** surface: a\nper-user-auth space refuses it, because agents there join under a logged-in user, never\nvia a handed-out file (see *Per-user auth* below).\n\nAgent-profile minting resolves one mesh root for the persona ACL, account signer and default\ncredential storage. If the current folder holds trust for a different space or account, mint\nrefuses and names both roots. It never signs one root's persona policy with another root's authority.\n\n## Profiles\n\nEvery credential is a profile: an explicit allow-list built from the same\nsubject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The\nnormative shapes are [SPEC Appendix B](../SPEC.md#appendix-b-profile-acls); in brief:\n\n| Profile | Is |\n|---|---|\n| **agent** | The ordinary peer: publishes as itself to its declared channels, reads within its read ACL + its own DM/task inboxes. Its read-only presence and channel-registry watches create and inspect client-managed ordered consumers but cannot delete any consumer on those streams; the broker removes a finished watch's consumer five minutes after its last interest. |\n| **observer** | Read-only chat + presence; DMs invisible. What `cotal console` runs. Holds no consumer delete on any stream it reads, so it cannot remove another principal's watch or the delivery daemon's fan-out consumer; the broker removes its own finished consumers. |\n| **admin** | Elevated *read-only* god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (`cotal web`). |\n| operator-side | Narrow single-purpose creds for the machinery (supervising, provisioning, teardown, delivery); the reference implementation splits these so no one connection can read every DM *and* delete every stream ([security model](security.md)). |\n| **run-driver** | One workflow run and takeover attempt: its journal subject, replay durable and run-owned record writes. Store reads and effects go through the host. |\n| **run-mediator** | The trusted hosting process's separate connection for workflow effects and leader reads. It exposes journal-checked, run-bound operations and never hands its credential to the driver. |\n| **run-operator** | One served run read, or one half of an answer, minted per call: a read holds the records walk, one run's replay and the admission read; the answering half is minted for one checkpoint token and holds that pause's answer record and settle alone. |\n| **issuer** | One issuance window: the party holding the space signer mints it for a few minutes to stage and release a credential's evidence, retire the issuances a lifecycle terminal leaves behind, or resolve the evidence a request rides. |\n| **run-admitter** | One run's admission record or revocation marker, minted per run for a minute: two exact keys in the admission store and nothing else. |\n\n**An agent's channel scope is three verbs**: `subscribe` (reads at boot),\n`allowSubscribe` (read ACL), `allowPublish` (post ACL, default-deny), declared in its\n[agent file](agent-files.md) or [manifest](manifest.md), minted into its cred. One card\nwith the recipes: [Channels & permissions](channels-and-permissions.md).\n\n**DM confidentiality** holds against peers by construction: deliveries ride per-identity\ninbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an\nagent cannot create a consumer filtered to someone else's inbox\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization) items 1\u20135).\n\n## Issued authority\n\nA credential says who is calling. It does not, by itself, say what the caller was granted, and a\nhost that acts on a caller's behalf (a workflow run, [workflows](workflows.md)) needs that from\nthe issuer, not from a ledger that may have changed since. So a static agent credential is an\n**issuance** ([SPEC \xA713.15](../SPEC.md#1315-issued-authority)): before the material is handed\nout, the issuer records the credential's final permission ceiling, as evidence keyed by a fresh\n**generation**, in `cotal_issued_<space>`. That store is append-only at the broker and the\nevidence is read as the first message on its key, so nothing written later on the same key, by\nanyone, changes what a resolver sees. The credential's endpoint rows then ride a versioned\nrail, `cotal.<space>.ep.v1.\u2026`, with that generation pinned beside the caller triple, so the\nbroker binds every request to the ceiling the issuer accepted. The legacy `ep.` rail and the\n`ep.v1.` rail are disjoint subject spaces; a credential holds rows on one of them, and every\nendpoint serves both.\n\nA connected client learns its generation by reading one row in `cotal_accepted_<space>` under a\ntoken the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts\nwhat the file says. A renewal keeps the generation only while the ceiling is byte-identical to the\nevidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection.\nA static agent's evidence names its credential ledger family as the source it depends on, and the\nlifecycle terminal that retires that family retires its issuances with it.\n\nThe manager, `cotal spawn`, and the CLI's control-caller instruments all mint through an `issuer`\nsession. The deployer instrument does not: it is the one instrument with no default expiry, and an\nissuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own\nlifecycle uid. Only workflow `run-start` requires the binding today: a request for it on the legacy\nrail is refused with `permission-denied` and the detail `ai.cotal.ep.unbound-caller-authority`\nnaming the caller. Every other command serves both rails.\n\nA user-auth `manager-caller` view is issued at the auth callout for an interactive row, or for a\nmanaged seat whose current persona-derived scope contains `run` and whose direct parent's fresh\nsame-owner grant also contains `run`. An admin parent's containment bypass does not count. The\nsingle source is the child's own actor row and lifecycle, and evidence records the signed ceiling\nwithout adding capabilities. Same-nonce renewal requires the same triple and byte-identical ceiling.\nThe existing view policy and manager instance gate are unchanged, with no new request kind. A seat\nmissing either `run` input keeps ordinary legacy commands. Without signed `run`, the broker refuses\n`run-start`; with signed `run` but no eligible parent, the manager refuses with\n`ai.cotal.ep.unbound-caller-authority`. A failed issuance never retries on that rail.\n\n## Declared capabilities\n\nControl-plane power is a **declared capability**, not a default. An agent file carrying\n`capabilities: [spawn]` gets the privileged control subject minted into its cred: spawn,\nplus stop/despawn of its *own* children, plus persona definition. On a static or open mesh its own\nchildren include what it launches with `cotal spawn --detach` from its own shell, since that\ncommand runs as the seat. Without it, an agent can\nonly self-despawn and pull or yield the run turns addressed to it. `capabilities: [run]` mints\nthe manager's workflow-run commands (start, resume, answer, status, list) together with the spawn\nset, since a program the agent starts may spawn; the manager drives the run under a per-run\n`run-driver` credential of its own, never the caller's. The tool surface mirrors the grant:\n`cotal_spawn` / `cotal_persona` / `cotal_personas` are injected only for `spawn`, and `cotal_run`\nonly for `run` ([agent files](agent-files.md)). Destructive\noperator ops (history purge, cross-agent stop) live on a third tier no agent credential\nreaches. Persona redefinition separates content from policy; the write path takes only\n`model`/`persona`, so a peer cannot grant itself a capability by redefining a file.\n\n## Per-user authentication\n\n`cotal up --user-auth --idp <auth base URL>` (or manifest `broker.auth: \"user\"`) puts a\n**human identity plane** above the per-agent one: people sign in to an external IdP once,\nand every connect is authorized live against the operator's **actor ledger**. No creds\nfiles to hand out, and revoking a grant actually bites.\n\n**The flow.** Each person runs `cotal login --idp <url>` once per machine. The IdP URL, the JWKS\nURL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a\nloopback IP literal such as `127.0.0.1`, never on `localhost`, whose resolution would choose the\nkeys the mesh trusts. After that,\nany command works: cached IdP session \u2192 fresh IdP proof per connect (so IdP-side\nrevocation bites here too) \u2192 the configured exchange turns it into a short-lived Cotal bearer \u2192\nthe broker's **auth callout** checks the bearer and the ledger at connect time and mints\na scoped credential on the spot. Every bearer also names a **root credential** row in the\nspace's credential ledger, proved live at each connect, so revoking that one credential\nbites at the very next connect. The operator grants access with\n`cotal actor grant <actor> --sub <their id> --full` for the full envelope (all\nchannels; scope `spawn,role:default`, so it may spawn and may delegate the default role), or names\n`--scope`, `--allow-subscribe` and `--allow-publish` for a narrow row. A grant with any of the\nthree left off and no `--full` is refused.\nNo ledger row, no access; there is no allow-by-default.\n\n**Space catalogs.** A successful authenticated `GET <idp>/token` may advertise one catalog with:\n\n```http\nLink: <https://idp.example/spaces>; rel=\"https://cotal.ai/relations/space-catalog\"\n```\n\nThe target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may\nuse HTTP for local development. A missing, foreign-origin, or insecure link records that this account\nhas no catalog. The client never guesses a path.\n\nThe catalog request carries the opaque cached session as its bearer and returns a complete snapshot:\n\n```json\n{\n \"v\": 1,\n \"account\": { \"idpUrl\": \"https://idp.example/api/auth\", \"issuer\": \"https://idp.example\", \"sub\": \"user-id\" },\n \"spaces\": [\n { \"id\": \"space-id\", \"slug\": \"shared_project\", \"name\": \"Shared project\", \"kind\": \"hosted\", \"role\": \"owner\", \"registration\": {} }\n ]\n}\n```\n\nThe client checks every `registration` with the same `checkUserBundle` validator used by `cotal\nmeshes add`. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the\ncatalog's `ETag`; a transport error, non-success response, or invalid candidate leaves the prior\nsnapshot intact and reports the failure. Only a 401 response for the saved session recommends signing\nin again. Transport errors and server failures report that account's refresh error without discarding\nthe session. The registry is reconciled under the provider's catalog lock, and a snapshot whose\nreconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or\nnot modified.\n\nThe user-auth registration document may include one closed policy object:\n\n```json\n{ \"policy\": { \"events\": \"required\" } }\n```\n\nNo other key under `policy` and no other value for `policy.events` is accepted. The registry preserves\nthis field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is\nrefreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a\njoin, or a manager start, after that command's own local refusals; the returned space, broker,\ntransport, IdP, issuer, audience, and exchange pins must all match before only the policy is added.\nA failed expired refresh refuses that operation. Read-only commands such as `status` and `meshes`\nnever refresh. The five-second warm window makes no request.\n\nEvery registration is also bound to the proved account. Its IdP URL must match the account and its\nissuer must match the exact JWT `iss` pin. Its exchange, provisioning, and manager-authority\nendpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole\ncandidate snapshot.\n\nThe `slug` is the space identity resolved by `--space`, `use`, registry roots, and collisions. The\n`name` is a display label only.\n\nDiscovered registry entries are owned by the normalized IdP origin plus the proved `sub`. That key is\nstored as an opaque digest, so accounts on one machine never union their spaces and the registry does\nnot persist the subject. A manual or locally started record with the same name is never overwritten.\nLogout removes only the entries owned by the account whose session was revoked. Local teardown,\ncleanup, and liveness pruning do not remove discovered entries.\n\nAn account that previously advertised no catalog is checked again by explicit `cotal sync`. The\nordinary lazy path checks again after its five-second capability window, so an IdP can enable the\nLink for an existing login without making the person sign in again.\n\n**One auth service per space** hosts both halves: the NATS auth callout and the token\nexchange. Its default HTTP listener remains loopback-only and requires the per-start capability\nstored in the owner-only `auth-service.json` file. An operator may add a second listener with\n`cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example`.\nThat listener still binds `127.0.0.1`; put a reverse proxy in front of it and terminate TLS there.\nIn-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal\nand fork proxy-based deployments.\n\nThe loopback face also serves three host-only doors, all capability-gated and never on the public\nface. Two retire a lifecycle: `/interactive-lifecycle/retire` (used by `cotal actor grant/revoke`)\nand `/managed-lifecycle/retire`, which finishes a managed agent's terminal retirement after its\nremote manager is gone. The third decides one:\n`POST /manager-service-authority/verify-enrollment` answers whether a remote manager may have a\nmanaged agent enrolled or released under its authenticated owner. The body is\n`{ owner, request }` and nothing else. The caller's capability scope is read from this machine's\nledger, never taken from the body, so a host that forwarded a participant-supplied scope could not\ngrant itself `supervise`. The door reads the manager gate and checks the registration proof inside\nthe service process, so no signing material reaches the caller, and it returns\n`{ authorized: true, owner, actor, instanceId, serveEpoch }` or maps its refusal to 400, 401, 403,\n409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock\n`dispatchManagerAuthorityRequest` refuses both request kinds with `unimplemented` rather than\nanswering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted\nruntime create and status kinds. For those it reads the manager actor's ledger row itself and returns\n`{ authorized: true, owner, instanceId, actor, target }`.\n[embedding.md](embedding.md) documents the managed doors' contracts.\n\nThe public listener has a closed surface: `GET /health`, `GET /jwks`, `POST /exchange`,\n`POST /manager-service-authority`, and `GET /.well-known/cotal-mesh`; every other path is 404. It\ndoes **not** require the loopback\ncapability. That capability proves same-uid access to a 0600 local file and has no remote meaning;\non the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against\nthe pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must\nmatch a fresh managed-ledger row. The public face mints two elevated views, both still gated on\nledger scope `admin`: `channel-writer` (`cotal channels set/default`) and `channel-purger` (the\ndashboard's per-click channel delete). It also mints the narrowing `manager-caller` view for humans\nor managed agents. That view adds no capability and binds the bearer to one live registered manager\ninstance. God-view (`admin`), space-history `purger`, `deployer`, and `manager-service` stay\nloopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: `cotal web` still\nmints the read-only admin view at startup, so a remote dashboard that needs that god-view still\nfails even when a later delete would mint `channel-purger`.\n\nThe well-known response contains the IdP pins and the actual deny-all sentinel credential remote\nagents need before the bearer-driven auth callout. The pins ride a `userAuth` arm that names the\nauth provider, and that name is the same one the local arm registers under. A document naming a\ndifferent provider than the one serving it would register an entry nothing can resolve, so both read\none constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but\nconsumers must still take the bundle only from the intended HTTPS origin and must verify TLS.\n`--exchange-trusted-proxy` opts into peer attribution by the **last** `X-Forwarded-For` hop; use it\nonly when the listener is reachable solely through a proxy you control.\nWithout it, forwarded headers are ignored and the socket address is the peer key. Public failure\nbuckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at\nmost 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling\nmore than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid\ncredential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape\nmatters to the deployment.\n\n### Enrollment redeem\n\nA remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached\nIdP login. The enrollment is a secret-bearing URL. The client performs one request:\n\n```http\nGET <enrollment URL>\n```\n\nIt sends no `Authorization` header and no request body. The URL must be HTTPS, except for plain HTTP\nto a loopback IP literal. The client redeems only an enrollment URL that is already in canonical\nform and contains none of `\\ @ ? #`. That is checked on the raw string before parsing, so every\nrewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case\nfolding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal\nrather than a redeem of a URL the owner never minted. Redirects are refused. The client never\nretries because a successful claim deletes the server-side token row. The token expires five minutes\nafter mint.\n\nSuccess is `200` with this JSON object:\n\n```text\nspace\nbrokerAccess { kind, ... }\nowner\nactor\nlifecycleUid\nactorToken\nsentinelCreds\nauthServiceUrl\nidp { url, issuer, audience }\nsubscribe[]\nallowSubscribe[]\nallowPublish[]\n```\n\nThe grant arrays are informational; the broker row remains authoritative. A stock-dialable\ndeployment also includes `server`, `tlsRequired`, `userAuth`, and optional `policy`, forming the same user-bundle\nsuperset that `cotal meshes add --user-auth-file` accepts. That lets a bare seat register the mesh\nfrom the enrollment response before launch. For `brokerAccess.kind: \"direct\"`, the stock `server`\nmust equal `brokerAccess.url` byte for byte or the client refuses the bundle before registration. A\ntunnel kind carries no dial address, so its `brokerAccess` is not compared to the operator-asserted\nstock `server` face.\n\nUnknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They\nall return `404 {\"error\":\"unknown, expired, or already-used enrollment\"}`. The client reports only\n`enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one`. It does not\nguess which case occurred.\n\nAfter redeem, the seat stores only the normal remote user-mesh and agent material. The actor token\nis exchanged at `authServiceUrl` through the existing `agent-bearer --exchange-url` path. The\nenrollment URL is not logged, persisted, or forwarded into any child process, including the bearer\npreflight and harness.\n\nThe service starts with the broker, is torn down by `cotal down`, and holds the\ndata-account signing key for the callout (a running manager is the other standing holder, for\nthe creds it mints); the operator seed never enters it. It also owns the space's two authority\nstores (lifecycle records and the credential ledger), provisions them at boot, and refuses\nconnects it cannot credential-check against them; there is no fallback path. If it\ndies while the broker lives, re-running `cotal up` heals it, and a boot whose auth\nservice never became ready exits non-zero, so automation never reads a dead identity\nplane as success. Changing any public-listener flag requires `cotal down` followed by `cotal up`\nwith the new values; a refresh adopts an already-running auth service rather than silently replacing\nits listener policy. \"One per space\" is enforced, not assumed (SPEC \xA713.13): at boot the\nservice takes a broker-backed ownership claim, so a second same-space auth process refuses\nwith instructions instead of silently splitting the plane, and a crashed one's claim is\nreclaimed only once the broker confirms its connections are gone. That verdict is trusted only\non a standalone broker (a clustered one refuses the reclaim, since a partitioned member\ncould still hold them). If the claim's connections die mid-run, the service downs itself\nloudly instead of serving from a half-dead plane.\n\nCtrl-C on a foreground `up`, and a broker that exits under it, stop the service with the same stop\n`cotal down auth` uses. It holds the reservation `down` takes, so a concurrent `cotal down auth` is\nrefused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after\nSIGTERM.\n\n**Your agents are yours.** `cotal spawn` on a user mesh grants a managed actor under the\n*spawning operator's* owner and launches the agent with a bearer command instead of a\ncreds file. The agent exchanges its spawn-time secret for short bearers (five minutes or\nless) and refreshes ahead of each expiry. A locally provisioned foreground launch refuses an\nexisting actor rather than rotating another launch's secret. Its exit and failed-start cleanup\nrevoke only the grant matching the launch's token and lifecycle. Broker-footprint deletion follows\na successful owned revoke; credential files are removed only while they still hold this launch's\nmaterial. Cleanup reports failures without deleting a successor's state. A crashed launcher can\nleave a grant that needs operator recovery. Remote foreground launches remove their own local\nmaterial but leave revocation to the issuer. Managed despawn follows the retirement sequence below.\nManifest deploys (`up -f`) stamp the logged-in owner into the launch, so those agents are yours too.\n\n**Despawn tears the lifecycle down, then frees the name.** When you despawn an agent, the manager\ndrives the *full* teardown of that lifecycle: it shreds the local credential files, revokes the\nagent's standing mint authority (its ledger row, so a copied token can no longer mint a fresh\ncredential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks\nthe auth service to *retire* the lifecycle (settle in-flight work, evict the departed credentials,\nrecord it retired). The name is held *reserved pending retirement* until **all** of that completes,\nthe broker-footprint cleanup, the standing-authority revoke, **and** the lifecycle retirement, not the\nretirement alone, so a same-name respawn in the gap is refused with\na plain reason and a retry hint rather than quietly handing the alias to a new agent while\nthe old lifecycle's teardown is still running. Only once the broker footprint is gone, the standing\nauthority is revoked, and the retirement is confirmed does the name free, and `cotal spawn <same-name>`\ngives you a fresh agent cleanly. This is what makes reusing an agent's name safe: the old lifecycle is\nfully torn down before the new one takes the alias. A local file that cannot be removed does not\nstop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and\nthe name stays held. If the auth service is unreachable or the standing-authority revoke fails,\nthe despawn still stops the agent and *holds* the name. **A\nsame-name `cotal spawn` re-drives the whole teardown** and finishes it. Retrying the despawn has no\neffect because the agent is already stopped. The operator copy tells you to recover the stack\n(`cotal supervise`) rather than reusing the name over an unretired predecessor.\n\n**A crash mid-retirement resumes at the next boot.** The retirement's last two steps (recording the\nissuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash\nbetween them leaves the gate retired while the head is still `retiring`: an alias that can neither\nmint nor be replaced. The auth service's boot crash-resume decides what it owes across *both*\nobjects (the gate *and* the alias head), so the next boot finishes that tail from the durable\noperation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal\nlanded, or a successor already took the alias) is left skipped. A retry of the despawn converges on\nthe same recovery.\n\n**Delegation only narrows (the envelope rule).** A user's grant is their envelope:\neverything under their owner (their CLI, every agent they spawn, every agent those\nspawn) stays within its channel lists and its capability scope. Handing a role to a\nspawned agent needs the matching `role:<r>` capability in the spawner's scope. The whole\ndelegation chain is checked, not just the last link, and re-checked at every bearer\nexchange, so narrowing a user's grant reaches their agents within minutes, and revoking\nthe user revokes everything under them, grandchildren included. A spawn beyond the\nenvelope is refused with the exact widening re-grant to ask the operator for.\n\n**Control ops ride your own login**, gated by ledger scope. `spawn` covers launching,\n`ps`, and stop/attach of the agents under **your own owner**: the owner is the\nadministrative boundary of its own subtree, so you (and your agents) manage what you own\nwithout any extra grant. `admin` is the explicit opt-in for touching **other owners'**\nagents; it is never part of a default grant and never accepted from a manifest.\n\n**Elevated operator surfaces ride the same login** through a short-lived *view*: the\nexchange stamps a server-authored view claim into the bearer, and the callout mints that\nconnection as the matching non-agent profile instead of `agent`. `cotal web` and\n`cotal console` ask for the read-only admin view, `clean history` for the purger,\n`channels set/default` for the channel-writer (all gated on ledger scope `admin`);\n`up -f` deploys over the deployer view, gated on `spawn`, because deploying your own team\nis spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist\nonly on a signed-in human exchange. The `manager-caller` view is the one managed-exchange exception\nbecause it narrows the agent's existing manager command set to one server-selected instance and adds\nno capability. All views are authorized against the fresh ledger row at every connect and expire\nwith the bearer, so narrowing or revoking a grant bites within minutes here too. On the public\nexchange face only `channel-writer`, `channel-purger`, `manager-caller`, `session-caller`, and\n`transfer-writer` are served; `admin`, `purger`, `deployer`, and `manager-service` remain loopback-only.\n\nThe `session-caller` view is how `cotal attach` opens a seat's session on a user-auth mesh. It needs\nno ledger scope, because the session grant is the authority. The exchange takes the grant with the\nlogin proof and leader-reads the redeemed `session.<id>` row. It issues the bearer only when the row\nis active and unexpired, its signature equals the presented grant's, its holder is this owner and\nactor at this lifecycle, its endpoint and serving epoch match, and the serving manager's gate is open\nat that epoch. The callout repeats the same check at connect and mints the session's caller rails\nwith the grant's expiry instead of the bearer's.\n\nThe `transfer-writer` view is how `cotal spawn --resume <id> --detach --on <instance>` uploads a\nsession this host holds on a user-auth mesh. It needs scope `admin`, as the `transcript-receive`\ncall it serves does, and names one object: the target instance and the transcript's SHA-256. The\ncallout mints writes to that object of that instance's transfer bucket and nothing else. The bearer,\nand with it the broker connection, lives at most five minutes, the lifetime of the static\n`transfer-writer` credential, and never past the login proof it was exchanged for.\n\n### Remote manager authority\n\nA registered user remains an ordinary `agent` bearer by default. Running a detached manager\non a remote user-auth mesh needs the closed server-authored **`manager-service`** view, which\nis distinct from every general-purpose profile. The operator grants it only by adding\n`supervise` to that user's actor-ledger scope. `supervise` is deliberately distinct from\n`spawn` and `admin`: spawn controls your agents, admin permits the separate cross-owner\noperations, and neither grants persistent manager registration authority.\n\nOnly a signed-in human may request this view from the loopback/operator exchange. The public\nexchange and every managed-agent secret exchange refuse it. At exchange and each connection,\nthe auth service re-reads the actor row; revoking or removing `supervise` therefore denies the\nnext view exchange and connection. A grant must carry the whole requested row just like every\nother actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not\nonly `supervise`.\n\nThe service is one opaque manager instance for the user's derived owner and a fixed\nserver-selected manager actor. Its authority is limited to that instance's manager\nregistration, contracts, status, endpoint rails, gate and credential family; it cannot read or\nwrite another owner or instance. It never exposes a signer, static provisioner credential, owner\nsecret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the\npublic-nkey JWT material through the typed lifecycle-bound protocol: **prepare \u2192 activate \u2192\nrenew**, plus host-owned **evict-family-principal** and **reconcile-registration** maintenance\noperations and a one-shot **retire** phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation\ncoordinate; the host writes its credential ledger row and finalizes the gate before it releases\nusable material. The retire phase fresh-checks the current manager instance, server-derived serve\nprincipal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester\ncredential pinned to that target. The manager invokes the registered `auth` endpoint's\n`retire-lifecycle` command through the generic client, resolving the service and calling it with\nan exact target and the operation id derived from the target lifecycle UID. The endpoint\nrecomputes that id from the broker-pinned target before any durable access. A caller cannot\nsubstitute another valid operation identity for the same target, and retries plus auth-service\nboot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.\n\nRegistration maintenance stays on the host. One eviction request carries up to 256 holders, and the\nhost accepts it only when its single sealed scan of the caller instance's\n`epcred.manager.<instanceId>.*` family finds every one of them. Reconciliation may\ntarget a foreign manager slot holder in the same space, but it runs only after the delivery daemon\nproves the frozen gate's holder gone under a complete sweep. The participant receives neither an\nevictor credential nor authority over another instance's records or gate. A clean stop refreshes an\nunhealthy executor before deregistration. A restart verify-evicts its old family, and a manager\nblocked by a foreign governance slot whose holder's gate is still frozen at the slot's stamp asks\nthe host to reconcile that holder and retries the registration once.\n\nA remote manager can provision only descendants of the same derived owner, and the host\nvalidates that relation and the current manager grant for every provision. It cannot broaden the\nuser's envelope or provision a sibling owner's agent. Renewals are bounded. If login, the\n`supervise` grant, or the host manager authority service is unavailable, the manager reports a\ndegraded state and refuses new agents, restarts, or replacement credentials rather than\nsubstituting local/static authority. Existing live agents remain running only while their own\nvalid authority permits it; recovery requires the host service and a fresh successful renewal.\n\nThe manager-authority protocol is closed, so a host whose auth service predates a field the\nmanager sends refuses the whole request. The manager reports that refusal as version skew after the\nhost's reason: it names its own Cotal version and the refused field, and says it needs a host at\nthat version or later. It never drops the field to fit the older host. Upgrade the host first;\n[Upgrading](UPGRADING.md) promises no rolling upgrade between versions.\n\nA manager on remote authority mints from that authority alone; it consults the local root's\nrecords only to refuse a conflict, and only the supervised space's own trust records count as\none. A workspace that hosts an unrelated static space beside the participant sign-in is a\nnormal configuration.\n\n**User authentication has one path.** On a user-auth space, commands never fall back to\nstatic minting or credless connects: a missing login or a down auth service is one\nsentence naming the exact recovery, and static agent/observer/admin minting is refused\noutright. The refusal is deny-new: a static cred signed before the space flipped stays\nbroker-valid until the signing key is rotated ([security model](security.md)).\n\n## The IdP callout contract\n\nAny OIDC identity provider that issues **EdDSA/Ed25519** JWTs plugs in here directly; a provider that\nissues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or\nre-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The\nreference implementation ships **Better Auth** as a\ndev and test fixture only (it is a `devDependency` of `@cotal-ai/auth`; the only code that imports\nit is the `dev-idp.ts` harness and the smoke tests, never the runtime `src`). The one runtime\ncoupling to an IdP is the `idp.ts` bridge plus the `auth-provider` extension. The bridge core\n(`createIdpBridge`) is IdP-generic for **EdDSA** tokens (issuer, audience, JWKS as configuration).\nThe stock end-to-end flow around it, though, is **Better-Auth-shaped**: `cotalAuthProvider` pins\n`<base>/jwks` and issuer/audience to the IdP origin, and the login client speaks Better Auth's\ndevice-code endpoints (`/device/code`, `/device/token`, `/token`) with an opaque revocable session.\nSo a Better-Auth-shaped EdDSA IdP uses the stock flow directly; **any other production IdP is a\nhosted-composability gap, not a configuration change**. A host integrates it by building its own\nlogin and provider wiring on the low-level primitives (`createIdpBridge`, `createUserTokenIssuer`),\nnot by reusing the stock provider. Note that importing `@cotal-ai/auth` self-registers\n`cotalAuthProvider`, and `resolveAuthProvider()` throws when two providers are registered, so a host\non the registry-resolution path must not also register its own. Whatever the path, never loosen the\nissuer/audience/JWKS pins to force-fit an IdP.\n\nThe bridge (`createIdpBridge`) exchanges a verified IdP token for a Cotal bearer in three steps:\n\n1. **Bearer validation.** Verify the IdP's JWT offline against its **pinned JWKS**, with the token\n algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded\n key material (`jku`/`jwk`/`x5u`/`x5c`) is rejected, so the token can never influence key\n resolution. Issuer and audience are checked, and the minted Cotal bearer is capped to the\n upstream proof's remaining lifetime.\n2. **Owner derivation.** The opaque per-space owner derives deterministically from the JSON-array\n encoding of `[idp issuer, sub]`, namespaced by issuer so no issuer/sub pair can straddle a\n delimiter, and re-login re-lands the same person in the same lanes. The owner-token *format*\n (`u_` followed by 26 base32-lower characters) is normative\n ([SPEC section 2](../SPEC.md#2-identity)). At the contract level the *derivation* from an\n identity is a pluggable edge, but the reference `createIdpBridge` fixes it\n (`deriveOwnerForIdpSubject`) and takes no derivation callback, so what a host configures is the\n IdP, not the derivation. **The encoding is frozen:** changing it, or changing the IdP issuer\n string, re-keys every owner in the space, which is a migration on the order of rotating the space\n secret.\n3. **Actor authorization and mint.** The operator's ledger hook authorizes the `(owner, actor)` pair\n and is the only source of the bearer's `scope`/`parent`; the issuer then mints the Cotal bearer,\n re-asserting every claim shape.\n\nA host wires this with the IdP's own coordinates and nothing from `@cotal-ai/auth` changes:\n\n```ts\nimport { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from \"@cotal-ai/auth\";\nconst bridge = createIdpBridge({\n idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP\n space,\n spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime\n issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer\n authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant\n});\n```\n\n## Joining\n\nA single **join link** carries server, auth, and space\n([SPEC \xA710](../SPEC.md#10-connection-and-onboarding)):\n\n```\ncotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)\n```\n\nHumans: `cotal join --link \u2026`. Agents: `COTAL_LINK=\u2026 ` in the environment. The connector\nexpands it and auto-joins. Token/user-pass links are the open-mode path; the default\nauthed path threads a minted creds file, and the endpoint adopts the credential's identity\nas its card id. A seat the manager spawned reaches that file through its **launch\nmaterial** rather than through `COTAL_CREDS` in an environment every descendant process\ninherits (see [Configuration](config.md#launch-material)); a session you drive by hand\nstill sets `COTAL_CREDS` itself.\n\n## Honest limitations (v0)\n\n- **The signing key is hot** on the mint/manager box of a static-auth mesh; the \"real\n boundary\" holds given operator-controlled cred distribution. On a per-user-auth mesh\n the data-account signing key is held by the auth service (the callout stage) and by any\n running manager, which loads the trust bundle and self-mints its supervisor cred and\n renewals from it; a copied signing *seed* still stays valid for its identity until the\n signing key is rotated. Rotation remains the revocation lever for trust material.\n- **The two `$SYS` creds renew through rotation.** `membership-observer` and\n `connection-evictor` are signed by the system-account seed, which is never persisted, so no\n running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new\n system account (`cotal down` then `cotal up --rotate-sys`), which leaves the data account,\n every agent cred and the store untouched but does invalidate earlier full backups (they bind to\n the operator JWT and system account they were taken under, so re-run `cotal backup` after). Past that horizon the mesh keeps delivering, but the\n membership feed and live eviction stop; `cotal doctor auth` and the manager warn from the 75%\n point onward.\n- **Static agent creds are long-lived; the machinery's are not.** One-shot command creds\n expire in minutes and the standing daemon creds in 24h with the manager renewing them\n (`cotal doctor auth` is the one diagnosis and repair surface). But a static *agent*\n cred has no TTL yet: `cotal_despawn` cuts a session, not a credential, and a\n compromised agent that copied its creds can reconnect until the signing key is\n rotated. Per-user-auth spaces close this: bearers live minutes, `cotal actor revoke`\n denies the next exchange and the next connect and evicts the principal's live\n connections immediately.\n- **Not non-repudiation.** Authenticity is broker-enforced, not portable proof; it does\n not survive an untrusted relay. Signed envelopes are reserved\n ([SPEC \xA711](../SPEC.md#11-versioning-and-extensibility)).\n- **Chat metadata leaks in-space.** Content reads are ACL-bounded; stream metadata\n (channel names, per-subject counts) is not yet ([security model](security.md)).\n\n**Denials are loud, never silent.** A publish outside an ACL surfaces as a logged denial\n(\"denied, not absent\") on the endpoint's error path; an over-tight ACL never looks like a\nmissing peer ([run a mesh](run-a-mesh.md)).\n"
62568
+ "body": "# Identity\n\n> **Concept** (informative) \xB7 **For:** operators and implementers \xB7 **Normative:** [SPEC \xA72](../SPEC.md#2-identity), [\xA79](../SPEC.md#9-nats--jetstream-security-and-authorization), [\xA710](../SPEC.md#10-connection-and-onboarding), [Appendix B](../SPEC.md#appendix-b-profile-acls)\n\nWho can do what on a mesh, and how it is enforced. The design goal: the mesh is a **real\nboundary against untrusted peers in a shared space**; an agent can only speak as itself\nand only where its declared permissions allow, enforced by the broker, not by agent\ngoodwill. What that boundary does and does not protect is the\n[security model](security.md); the exact ACLs are\n[SPEC Appendix B](../SPEC.md#appendix-b-profile-acls).\n\n## On by default\n\n`cotal up` provisions a JWT-authed space; `cotal up --open` runs an unauthenticated dev\nmesh instead. Both bind loopback by default. `--host 0.0.0.0` widens the bind\nindependently, so \"network-reachable\" never silently means \"unauthenticated\". Open mode\nis for quick local experiments and sits outside every security claim\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)).\n\n## Shared identity\n\nAn agent's wire identity is a **principal**: an `owner.actor` pair, where the owner is\nthe account (a human, or an organization) the agent acts on behalf of, and the actor is\nthe agent's own handle under that owner ([SPEC \xA72](../SPEC.md#2-identity)). The same pair\nis the card id, the sender tokens in every subject it publishes, the presence key, and\nits durable-consumer names. On an open dev mesh the owner is the literal `local`; on a\nper-user-auth mesh it is a derived token (`u_` plus 26 characters, so no PII rides the\nwire). The connection still authenticates with an **nkey**, generated locally (the signer\nonly ever sees the public half), but the nkey is the transport credential, not the\nidentity: it scopes only the per-connection reply inbox.\n\n**The sender is encoded in the subject.** Every publish carries the sender's owner and\nactor in positions the broker's permissions pin to that connection, so an agent *cannot*\nemit as anyone else: not as another owner, and not as a sibling actor under its own\nowner. Receivers verify the payload's `from.id` against the subject sender and reject\nmismatches; sender authenticity is broker-enforced end to end\n([SPEC \xA73](../SPEC.md#3-subject-layout), [\xA75](../SPEC.md#5-envelopes)).\n\n**Account = space, user = agent.** A space is one NATS account, a server-enforced\nisolation boundary. An operator signs the account; an account **signing key** mints\nper-agent user JWTs.\n\n## Provisioner\n\nThe **provisioner** is whoever holds the account signing key. It mints profile-scoped\ncredentials and pre-creates the durables agents may only *bind* (their DM inbox, their\nrole's task queue). The manager hosts it today, but nothing is manager-special about it;\nprivilege attaches to the signer, and a space can run without a manager.\n`cotal mint <name> --profile <agent|observer|admin>` is the out-of-band path; spawn calls\nthe same library ([CLI](cli.md)). The out-of-band profiles carry no default TTL: pass\n`--expires-in <seconds>` (or `--expires-at`) for a bounded credential, which a\nstanding-renewal consumer requires; pass `--identity <creds>` to re-mint for the nkey a\nfile already carries, keeping the principal and its durables. Minting static creds is a\n**static-auth** surface: a\nper-user-auth space refuses it, because agents there join under a logged-in user, never\nvia a handed-out file (see *Per-user auth* below).\n\nAgent-profile minting resolves one mesh root for the persona ACL, account signer and default\ncredential storage. If the current folder holds trust for a different space or account, mint\nrefuses and names both roots. It never signs one root's persona policy with another root's authority.\n\n## Profiles\n\nEvery credential is a profile: an explicit allow-list built from the same\nsubject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The\nnormative shapes are [SPEC Appendix B](../SPEC.md#appendix-b-profile-acls); in brief:\n\n| Profile | Is |\n|---|---|\n| **agent** | The ordinary peer: publishes as itself to its declared channels, reads within its read ACL + its own DM/task inboxes. Its read-only presence and channel-registry watches create and inspect client-managed ordered consumers but cannot delete any consumer on those streams; the broker removes a finished watch's consumer five minutes after its last interest. |\n| **observer** | Read-only chat + presence; DMs invisible. What `cotal console` runs. Holds no consumer delete on any stream it reads, so it cannot remove another principal's watch or the delivery daemon's fan-out consumer; the broker removes its own finished consumers. |\n| **admin** | Elevated *read-only* god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (`cotal web`). |\n| operator-side | Narrow single-purpose creds for the machinery (supervising, provisioning, teardown, delivery); the reference implementation splits these so no one connection can read every DM *and* delete every stream ([security model](security.md)). |\n| **run-driver** | One workflow run and takeover attempt: its journal subject, replay durable and run-owned record writes. Store reads and effects go through the host. |\n| **run-mediator** | The trusted hosting process's separate connection for workflow effects and leader reads. It exposes journal-checked, run-bound operations and never hands its credential to the driver. |\n| **run-operator** | One served run read, or one half of an answer, minted per call: a read holds the records walk, one run's replay and the admission read; the answering half is minted for one checkpoint token and holds that pause's answer record and settle alone. |\n| **issuer** | One issuance window: the party holding the space signer mints it for a few minutes to stage and release a credential's evidence, retire the issuances a lifecycle terminal leaves behind, or resolve the evidence a request rides. |\n| **run-admitter** | One run's admission record or revocation marker, minted per run for a minute: two exact keys in the admission store and nothing else. |\n\n**An agent's channel scope is three verbs**: `subscribe` (reads at boot),\n`allowSubscribe` (read ACL), `allowPublish` (post ACL, default-deny), declared in its\n[agent file](agent-files.md) or [manifest](manifest.md), minted into its cred. One card\nwith the recipes: [Channels & permissions](channels-and-permissions.md).\n\n**DM confidentiality** holds against peers by construction: deliveries ride per-identity\ninbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an\nagent cannot create a consumer filtered to someone else's inbox\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization) items 1\u20135).\n\n## Issued authority\n\nA credential says who is calling. It does not, by itself, say what the caller was granted, and a\nhost that acts on a caller's behalf (a workflow run, [workflows](workflows.md)) needs that from\nthe issuer, not from a ledger that may have changed since. So a static agent credential is an\n**issuance** ([SPEC \xA713.15](../SPEC.md#1315-issued-authority)): before the material is handed\nout, the issuer records the credential's final permission ceiling, as evidence keyed by a fresh\n**generation**, in `cotal_issued_<space>`. That store is append-only at the broker and the\nevidence is read as the first message on its key, so nothing written later on the same key, by\nanyone, changes what a resolver sees. The credential's endpoint rows then ride a versioned\nrail, `cotal.<space>.ep.v1.\u2026`, with that generation pinned beside the caller triple, so the\nbroker binds every request to the ceiling the issuer accepted. The legacy `ep.` rail and the\n`ep.v1.` rail are disjoint subject spaces; a credential holds rows on one of them, and every\nendpoint serves both.\n\nA connected client learns its generation by reading one row in `cotal_accepted_<space>` under a\ntoken the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts\nwhat the file says. A renewal keeps the generation only while the ceiling is byte-identical to the\nevidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection.\nA static agent's evidence names its credential ledger family as the source it depends on, and the\nlifecycle terminal that retires that family retires its issuances with it.\n\nThe manager, `cotal spawn`, and the CLI's control-caller instruments all mint through an `issuer`\nsession. The deployer instrument does not: it is the one instrument with no default expiry, and an\nissuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own\nlifecycle uid. Only workflow `run-start` requires the binding today: a request for it on the legacy\nrail is refused with `permission-denied` and the detail `ai.cotal.ep.unbound-caller-authority`\nnaming the caller. Every other command serves both rails.\n\nA user-auth `manager-caller` view is issued at the auth callout for an interactive row, or for a\nmanaged seat whose current persona-derived scope contains `run` and whose direct parent's fresh\nsame-owner grant also contains `run`. An admin parent's containment bypass does not count. The\nsingle source is the child's own actor row and lifecycle, and evidence records the signed ceiling\nwithout adding capabilities. Same-nonce renewal requires the same triple and byte-identical ceiling.\nThe existing view policy and manager instance gate are unchanged, with no new request kind. A seat\nmissing either `run` input keeps ordinary legacy commands. Without signed `run`, the broker refuses\n`run-start`; with signed `run` but no eligible parent, the manager refuses with\n`ai.cotal.ep.unbound-caller-authority`. A failed issuance never retries on that rail.\n\n## Declared capabilities\n\nControl-plane power is a **declared capability**, not a default. An agent file carrying\n`capabilities: [spawn]` gets the privileged control subject minted into its cred: spawn,\nplus stop/despawn of its *own* children, plus persona definition. On a static or open mesh its own\nchildren include what it launches with `cotal spawn --detach` from its own shell, since that\ncommand runs as the seat. Without it, an agent can\nonly self-despawn and pull or yield the run turns addressed to it. `capabilities: [run]` mints\nthe manager's workflow-run commands (start, resume, answer, status, list) together with the spawn\nset, since a program the agent starts may spawn; the manager drives the run under a per-run\n`run-driver` credential of its own, never the caller's. The tool surface mirrors the grant:\n`cotal_spawn` / `cotal_persona` / `cotal_personas` are injected only for `spawn`, and `cotal_run`\nonly for `run` ([agent files](agent-files.md)). Destructive\noperator ops (history purge, cross-agent stop) live on a third tier no agent credential\nreaches. Persona redefinition separates content from policy; the write path takes only\n`model`/`persona`, so a peer cannot grant itself a capability by redefining a file.\n\n## Per-user authentication\n\n`cotal up --user-auth --idp <auth base URL>` (or manifest `broker.auth: \"user\"`) puts a\n**human identity plane** above the per-agent one: people sign in to an external IdP once,\nand every connect is authorized live against the operator's **actor ledger**. No creds\nfiles to hand out, and revoking a grant actually bites.\n\n**The flow.** Each person runs `cotal login --idp <url>` once per machine. The IdP URL, the JWKS\nURL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a\nloopback IP literal such as `127.0.0.1`, never on `localhost`, whose resolution would choose the\nkeys the mesh trusts. After that,\nany command works: cached IdP session \u2192 fresh IdP proof per connect (so IdP-side\nrevocation bites here too) \u2192 the configured exchange turns it into a short-lived Cotal bearer \u2192\nthe broker's **auth callout** checks the bearer and the ledger at connect time and mints\na scoped credential on the spot. Every bearer also names a **root credential** row in the\nspace's credential ledger, proved live at each connect, so revoking that one credential\nbites at the very next connect. The operator grants access with\n`cotal actor grant <actor> --sub <their id> --full` for the full envelope (all\nchannels; scope `spawn,role:default`, so it may spawn and may delegate the default role), or names\n`--scope`, `--allow-subscribe` and `--allow-publish` for a narrow row. A grant with any of the\nthree left off and no `--full` is refused.\nNo ledger row, no access; there is no allow-by-default.\n\n**Space catalogs.** A successful authenticated `GET <idp>/token` may advertise one catalog with:\n\n```http\nLink: <https://idp.example/spaces>; rel=\"https://cotal.ai/relations/space-catalog\"\n```\n\nThe target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may\nuse HTTP for local development. A missing, foreign-origin, or insecure link records that this account\nhas no catalog. The client never guesses a path.\n\nThe catalog request carries the opaque cached session as its bearer and returns a complete snapshot:\n\n```json\n{\n \"v\": 1,\n \"account\": { \"idpUrl\": \"https://idp.example/api/auth\", \"issuer\": \"https://idp.example\", \"sub\": \"user-id\" },\n \"spaces\": [\n { \"id\": \"space-id\", \"slug\": \"shared_project\", \"name\": \"Shared project\", \"kind\": \"hosted\", \"role\": \"owner\", \"registration\": {} }\n ]\n}\n```\n\nThe client checks every `registration` with the same `checkUserBundle` validator used by `cotal\nmeshes add`. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the\ncatalog's `ETag`; a transport error, non-success response, or invalid candidate leaves the prior\nsnapshot intact and reports the failure. Only a 401 response for the saved session recommends signing\nin again. Transport errors and server failures report that account's refresh error without discarding\nthe session. The registry is reconciled under the provider's catalog lock, and a snapshot whose\nreconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or\nnot modified.\n\nThe user-auth registration document may include one closed policy object:\n\n```json\n{ \"policy\": { \"events\": \"required\" } }\n```\n\nNo other key under `policy` and no other value for `policy.events` is accepted. The registry preserves\nthis field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is\nrefreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a\njoin, or a manager start, after that command's own local refusals; the returned space, broker,\ntransport, IdP, issuer, audience, and exchange pins must all match before only the policy is added.\nA failed expired refresh refuses that operation. Read-only commands such as `status` and `meshes`\nnever refresh. The five-second warm window makes no request.\n\nEvery registration is also bound to the proved account. Its IdP URL must match the account and its\nissuer must match the exact JWT `iss` pin. Its exchange, provisioning, and manager-authority\nendpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole\ncandidate snapshot.\n\nThe `slug` is the space identity resolved by `--space`, `use`, registry roots, and collisions. The\n`name` is a display label only.\n\nDiscovered registry entries are owned by the normalized IdP origin plus the proved `sub`. That key is\nstored as an opaque digest, so accounts on one machine never union their spaces and the registry does\nnot persist the subject. A manual or locally started record with the same name is never overwritten.\nLogout removes only the entries owned by the account whose session was revoked. Local teardown,\ncleanup, and liveness pruning do not remove discovered entries.\n\nAn account that previously advertised no catalog is checked again by explicit `cotal sync`. The\nordinary lazy path checks again after its five-second capability window, so an IdP can enable the\nLink for an existing login without making the person sign in again.\n\n**One auth service per space** hosts both halves: the NATS auth callout and the token\nexchange. Its default HTTP listener remains loopback-only and requires the per-start capability\nstored in the owner-only `auth-service.json` file. An operator may add a second listener with\n`cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example`.\nThat listener still binds `127.0.0.1`; put a reverse proxy in front of it and terminate TLS there.\nIn-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal\nand fork proxy-based deployments.\n\nThe loopback face also serves three host-only doors, all capability-gated and never on the public\nface. Two retire a lifecycle: `/interactive-lifecycle/retire` (used by `cotal actor grant/revoke`)\nand `/managed-lifecycle/retire`, which finishes a managed agent's terminal retirement after its\nremote manager is gone. The third decides one:\n`POST /manager-service-authority/verify-enrollment` answers whether a remote manager may have a\nmanaged agent enrolled or released under its authenticated owner. The body is\n`{ owner, request }` and nothing else. The caller's capability scope is read from this machine's\nledger, never taken from the body, so a host that forwarded a participant-supplied scope could not\ngrant itself `supervise`. The door reads the manager gate and checks the registration proof inside\nthe service process, so no signing material reaches the caller, and it returns\n`{ authorized: true, owner, actor, instanceId, serveEpoch }` or maps its refusal to 400, 401, 403,\n409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock\n`dispatchManagerAuthorityRequest` refuses both request kinds with `unimplemented` rather than\nanswering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted\nruntime create and status kinds. For those it reads the manager actor's ledger row itself and returns\n`{ authorized: true, owner, instanceId, actor, target }`.\n[embedding.md](embedding.md) documents the managed doors' contracts.\n\nThe public listener has a closed surface: `GET /health`, `GET /jwks`, `POST /exchange`,\n`POST /manager-service-authority`, and `GET /.well-known/cotal-mesh`; every other path is 404. It\ndoes **not** require the loopback\ncapability. That capability proves same-uid access to a 0600 local file and has no remote meaning;\non the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against\nthe pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must\nmatch a fresh managed-ledger row. The public face mints two elevated views, both still gated on\nledger scope `admin`: `channel-writer` (`cotal channels set/default`) and `channel-purger` (the\ndashboard's per-click channel delete). It also mints the narrowing `manager-caller` view for humans\nor managed agents. That view adds no capability and binds the bearer to one live registered manager\ninstance. God-view (`admin`), space-history `purger`, `deployer`, and `manager-service` stay\nloopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: `cotal web` still\nmints the read-only admin view at startup, so a remote dashboard that needs that god-view still\nfails even when a later delete would mint `channel-purger`.\n\nThe well-known response contains the IdP pins and the actual deny-all sentinel credential remote\nagents need before the bearer-driven auth callout. The pins ride a `userAuth` arm that names the\nauth provider, and that name is the same one the local arm registers under. A document naming a\ndifferent provider than the one serving it would register an entry nothing can resolve, so both read\none constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but\nconsumers must still take the bundle only from the intended HTTPS origin and must verify TLS.\n`--exchange-trusted-proxy` opts into peer attribution by the **last** `X-Forwarded-For` hop; use it\nonly when the listener is reachable solely through a proxy you control.\nWithout it, forwarded headers are ignored and the socket address is the peer key. Public failure\nbuckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at\nmost 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling\nmore than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid\ncredential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape\nmatters to the deployment. A refusal at `POST /manager-service-authority` counts against the same\nbudget as one at `/exchange` on that face, so after 30 refusals in a minute either route answers a\nfurther refusal with 429.\n\n### Enrollment redeem\n\nA remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached\nIdP login. The enrollment is a secret-bearing URL. The client performs one request:\n\n```http\nGET <enrollment URL>\n```\n\nIt sends no `Authorization` header and no request body. The URL must be HTTPS, except for plain HTTP\nto a loopback IP literal. The client redeems only an enrollment URL that is already in canonical\nform and contains none of `\\ @ ? #`. That is checked on the raw string before parsing, so every\nrewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case\nfolding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal\nrather than a redeem of a URL the owner never minted. Redirects are refused. The client never\nretries because a successful claim deletes the server-side token row. The token expires five minutes\nafter mint.\n\nSuccess is `200` with this JSON object:\n\n```text\nspace\nbrokerAccess { kind, ... }\nowner\nactor\nlifecycleUid\nactorToken\nsentinelCreds\nauthServiceUrl\nidp { url, issuer, audience }\nsubscribe[]\nallowSubscribe[]\nallowPublish[]\n```\n\nThe grant arrays are informational; the broker row remains authoritative. A stock-dialable\ndeployment also includes `server`, `tlsRequired`, `userAuth`, and optional `policy`, forming the same user-bundle\nsuperset that `cotal meshes add --user-auth-file` accepts. That lets a bare seat register the mesh\nfrom the enrollment response before launch. For `brokerAccess.kind: \"direct\"`, the stock `server`\nmust equal `brokerAccess.url` byte for byte or the client refuses the bundle before registration. A\ntunnel kind carries no dial address, so its `brokerAccess` is not compared to the operator-asserted\nstock `server` face.\n\nUnknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They\nall return `404 {\"error\":\"unknown, expired, or already-used enrollment\"}`. The client reports only\n`enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one`. It does not\nguess which case occurred.\n\nAfter redeem, the seat stores only the normal remote user-mesh and agent material. The actor token\nis exchanged at `authServiceUrl` through the existing `agent-bearer --exchange-url` path. The\nenrollment URL is not logged, persisted, or forwarded into any child process, including the bearer\npreflight and harness.\n\nThe service starts with the broker, is torn down by `cotal down`, and holds the\ndata-account signing key for the callout (a running manager is the other standing holder, for\nthe creds it mints); the operator seed never enters it. It also owns the space's two authority\nstores (lifecycle records and the credential ledger), provisions them at boot, and refuses\nconnects it cannot credential-check against them; there is no fallback path. If it\ndies while the broker lives, re-running `cotal up` heals it, and a boot whose auth\nservice never became ready exits non-zero, so automation never reads a dead identity\nplane as success. Changing any public-listener flag requires `cotal down` followed by `cotal up`\nwith the new values; a refresh adopts an already-running auth service rather than silently replacing\nits listener policy. \"One per space\" is enforced, not assumed (SPEC \xA713.13): at boot the\nservice takes a broker-backed ownership claim, so a second same-space auth process refuses\nwith instructions instead of silently splitting the plane, and a crashed one's claim is\nreclaimed only once the broker confirms its connections are gone. That verdict is trusted only\non a standalone broker (a clustered one refuses the reclaim, since a partitioned member\ncould still hold them). If the claim's connections die mid-run, the service downs itself\nloudly instead of serving from a half-dead plane.\n\nCtrl-C on a foreground `up`, and a broker that exits under it, stop the service with the same stop\n`cotal down auth` uses. It holds the reservation `down` takes, so a concurrent `cotal down auth` is\nrefused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after\nSIGTERM.\n\n**Your agents are yours.** `cotal spawn` on a user mesh grants a managed actor under the\n*spawning operator's* owner and launches the agent with a bearer command instead of a\ncreds file. The agent exchanges its spawn-time secret for short bearers (five minutes or\nless) and refreshes ahead of each expiry. A locally provisioned foreground launch refuses an\nexisting actor rather than rotating another launch's secret. Its exit and failed-start cleanup\nrevoke only the grant matching the launch's token and lifecycle. Broker-footprint deletion follows\na successful owned revoke; credential files are removed only while they still hold this launch's\nmaterial. Cleanup reports failures without deleting a successor's state. A crashed launcher can\nleave a grant that needs operator recovery. Remote foreground launches remove their own local\nmaterial but leave revocation to the issuer. Managed despawn follows the retirement sequence below.\nManifest deploys (`up -f`) stamp the logged-in owner into the launch, so those agents are yours too.\n\n**Despawn tears the lifecycle down, then frees the name.** When you despawn an agent, the manager\ndrives the *full* teardown of that lifecycle: it shreds the local credential files, revokes the\nagent's standing mint authority (its ledger row, so a copied token can no longer mint a fresh\ncredential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks\nthe auth service to *retire* the lifecycle (settle in-flight work, evict the departed credentials,\nrecord it retired). The name is held *reserved pending retirement* until **all** of that completes,\nthe broker-footprint cleanup, the standing-authority revoke, **and** the lifecycle retirement, not the\nretirement alone, so a same-name respawn in the gap is refused with\na plain reason and a retry hint rather than quietly handing the alias to a new agent while\nthe old lifecycle's teardown is still running. Only once the broker footprint is gone, the standing\nauthority is revoked, and the retirement is confirmed does the name free, and `cotal spawn <same-name>`\ngives you a fresh agent cleanly. This is what makes reusing an agent's name safe: the old lifecycle is\nfully torn down before the new one takes the alias. A local file that cannot be removed does not\nstop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and\nthe name stays held. If the auth service is unreachable or the standing-authority revoke fails,\nthe despawn still stops the agent and *holds* the name. **A\nsame-name `cotal spawn` re-drives the whole teardown** and finishes it. Retrying the despawn has no\neffect because the agent is already stopped. The operator copy tells you to recover the stack\n(`cotal supervise`) rather than reusing the name over an unretired predecessor.\n\n**A crash mid-retirement resumes at the next boot.** The retirement's last two steps (recording the\nissuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash\nbetween them leaves the gate retired while the head is still `retiring`: an alias that can neither\nmint nor be replaced. The auth service's boot crash-resume decides what it owes across *both*\nobjects (the gate *and* the alias head), so the next boot finishes that tail from the durable\noperation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal\nlanded, or a successor already took the alias) is left skipped. A retry of the despawn converges on\nthe same recovery.\n\n**Delegation only narrows (the envelope rule).** A user's grant is their envelope:\neverything under their owner (their CLI, every agent they spawn, every agent those\nspawn) stays within its channel lists and its capability scope. Handing a role to a\nspawned agent needs the matching `role:<r>` capability in the spawner's scope. The whole\ndelegation chain is checked, not just the last link, and re-checked at every bearer\nexchange, so narrowing a user's grant reaches their agents within minutes, and revoking\nthe user revokes everything under them, grandchildren included. A spawn beyond the\nenvelope is refused with the exact widening re-grant to ask the operator for.\n\n**Control ops ride your own login**, gated by ledger scope. `spawn` covers launching,\n`ps`, and stop/attach of the agents under **your own owner**: the owner is the\nadministrative boundary of its own subtree, so you (and your agents) manage what you own\nwithout any extra grant. `admin` is the explicit opt-in for touching **other owners'**\nagents; it is never part of a default grant and never accepted from a manifest.\n\n**Elevated operator surfaces ride the same login** through a short-lived *view*: the\nexchange stamps a server-authored view claim into the bearer, and the callout mints that\nconnection as the matching non-agent profile instead of `agent`. `cotal web` and\n`cotal console` ask for the read-only admin view, `clean history` for the purger,\n`channels set/default` for the channel-writer (all gated on ledger scope `admin`);\n`up -f` deploys over the deployer view, gated on `spawn`, because deploying your own team\nis spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist\nonly on a signed-in human exchange. The `manager-caller` view is the one managed-exchange exception\nbecause it narrows the agent's existing manager command set to one server-selected instance and adds\nno capability. All views are authorized against the fresh ledger row at every connect and expire\nwith the bearer, so narrowing or revoking a grant bites within minutes here too. On the public\nexchange face only `channel-writer`, `channel-purger`, `manager-caller`, `session-caller`, and\n`transfer-writer` are served; `admin`, `purger`, `deployer`, and `manager-service` remain loopback-only.\n\nThe `session-caller` view is how `cotal attach` opens a seat's session on a user-auth mesh. It needs\nno ledger scope, because the session grant is the authority. The exchange takes the grant with the\nlogin proof and leader-reads the redeemed `session.<id>` row. It issues the bearer only when the row\nis active and unexpired, its signature equals the presented grant's, its holder is this owner and\nactor at this lifecycle, its endpoint and serving epoch match, and the serving manager's gate is open\nat that epoch. The callout repeats the same check at connect and mints the session's caller rails\nwith the grant's expiry instead of the bearer's.\n\nThe `transfer-writer` view is how `cotal spawn --resume <id> --detach --on <instance>` uploads a\nsession this host holds on a user-auth mesh. It needs scope `admin`, as the `transcript-receive`\ncall it serves does, and names one object: the target instance and the transcript's SHA-256. The\ncallout mints writes to that object of that instance's transfer bucket and nothing else. The bearer,\nand with it the broker connection, lives at most five minutes, the lifetime of the static\n`transfer-writer` credential, and never past the login proof it was exchanged for.\n\n### Remote manager authority\n\nA registered user remains an ordinary `agent` bearer by default. Running a detached manager\non a remote user-auth mesh needs the closed server-authored **`manager-service`** view, which\nis distinct from every general-purpose profile. The operator grants it only by adding\n`supervise` to that user's actor-ledger scope. `supervise` is deliberately distinct from\n`spawn` and `admin`: spawn controls your agents, admin permits the separate cross-owner\noperations, and neither grants persistent manager registration authority.\n\nOnly a signed-in human may request this view from the loopback/operator exchange. The public\nexchange and every managed-agent secret exchange refuse it. At exchange and each connection,\nthe auth service re-reads the actor row; revoking or removing `supervise` therefore denies the\nnext view exchange and connection. A grant must carry the whole requested row just like every\nother actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not\nonly `supervise`.\n\nThe service is one opaque manager instance for the user's derived owner and a fixed\nserver-selected manager actor. Its authority is limited to that instance's manager\nregistration, contracts, status, endpoint rails, gate and credential family; it cannot read or\nwrite another owner or instance. It never exposes a signer, static provisioner credential, owner\nsecret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the\npublic-nkey JWT material through the typed lifecycle-bound protocol: **prepare \u2192 activate \u2192\nrenew**, plus host-owned **evict-family-principal** and **reconcile-registration** maintenance\noperations and a one-shot **retire** phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation\ncoordinate; the host writes its credential ledger row and finalizes the gate before it releases\nusable material. The retire phase fresh-checks the current manager instance, server-derived serve\nprincipal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester\ncredential pinned to that target. The manager invokes the registered `auth` endpoint's\n`retire-lifecycle` command through the generic client, resolving the service and calling it with\nan exact target and the operation id derived from the target lifecycle UID. The endpoint\nrecomputes that id from the broker-pinned target before any durable access. A caller cannot\nsubstitute another valid operation identity for the same target, and retries plus auth-service\nboot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.\n\nRegistration maintenance stays on the host. One eviction request carries up to 256 holders, and the\nhost accepts it only when its single sealed scan of the caller instance's\n`epcred.manager.<instanceId>.*` family finds every one of them. Reconciliation may\ntarget a foreign manager slot holder in the same space, but it runs only after the delivery daemon\nproves the frozen gate's holder gone under a complete sweep. The participant receives neither an\nevictor credential nor authority over another instance's records or gate. A clean stop refreshes an\nunhealthy executor before deregistration. A restart verify-evicts its old family, and a manager\nblocked by a foreign governance slot whose holder's gate is still frozen at the slot's stamp asks\nthe host to reconcile that holder and retries the registration once.\n\nA remote manager can provision only descendants of the same derived owner, and the host\nvalidates that relation and the current manager grant for every provision. It cannot broaden the\nuser's envelope or provision a sibling owner's agent. Renewals are bounded. If login, the\n`supervise` grant, or the host manager authority service is unavailable, the manager reports a\ndegraded state and refuses new agents, restarts, or replacement credentials rather than\nsubstituting local/static authority. Existing live agents remain running only while their own\nvalid authority permits it; recovery requires the host service and a fresh successful renewal.\n\nThe manager-authority protocol is closed, so a host whose auth service predates a field the\nmanager sends refuses the whole request. The manager reports that refusal as version skew after the\nhost's reason: it names its own Cotal version and the refused field, and says it needs a host at\nthat version or later. It never drops the field to fit the older host. Upgrade the host first;\n[Upgrading](UPGRADING.md) promises no rolling upgrade between versions.\n\nA manager on remote authority mints from that authority alone; it consults the local root's\nrecords only to refuse a conflict, and only the supervised space's own trust records count as\none. A workspace that hosts an unrelated static space beside the participant sign-in is a\nnormal configuration.\n\n**User authentication has one path.** On a user-auth space, commands never fall back to\nstatic minting or credless connects: a missing login or a down auth service is one\nsentence naming the exact recovery, and static agent/observer/admin minting is refused\noutright. The refusal is deny-new: a static cred signed before the space flipped stays\nbroker-valid until the signing key is rotated ([security model](security.md)).\n\n## The IdP callout contract\n\nAny OIDC identity provider that issues **EdDSA/Ed25519** JWTs plugs in here directly; a provider that\nissues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or\nre-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The\nreference implementation ships **Better Auth** as a\ndev and test fixture only (it is a `devDependency` of `@cotal-ai/auth`; the only code that imports\nit is the `dev-idp.ts` harness and the smoke tests, never the runtime `src`). The one runtime\ncoupling to an IdP is the `idp.ts` bridge plus the `auth-provider` extension. The bridge core\n(`createIdpBridge`) is IdP-generic for **EdDSA** tokens (issuer, audience, JWKS as configuration).\nThe stock end-to-end flow around it, though, is **Better-Auth-shaped**: `cotalAuthProvider` pins\n`<base>/jwks` and issuer/audience to the IdP origin, and the login client speaks Better Auth's\ndevice-code endpoints (`/device/code`, `/device/token`, `/token`) with an opaque revocable session.\nSo a Better-Auth-shaped EdDSA IdP uses the stock flow directly; **any other production IdP is a\nhosted-composability gap, not a configuration change**. A host integrates it by building its own\nlogin and provider wiring on the low-level primitives (`createIdpBridge`, `createUserTokenIssuer`),\nnot by reusing the stock provider. Note that importing `@cotal-ai/auth` self-registers\n`cotalAuthProvider`, and `resolveAuthProvider()` throws when two providers are registered, so a host\non the registry-resolution path must not also register its own. Whatever the path, never loosen the\nissuer/audience/JWKS pins to force-fit an IdP.\n\nThe bridge (`createIdpBridge`) exchanges a verified IdP token for a Cotal bearer in three steps:\n\n1. **Bearer validation.** Verify the IdP's JWT offline against its **pinned JWKS**, with the token\n algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded\n key material (`jku`/`jwk`/`x5u`/`x5c`) is rejected, so the token can never influence key\n resolution. Issuer and audience are checked, and the minted Cotal bearer is capped to the\n upstream proof's remaining lifetime.\n2. **Owner derivation.** The opaque per-space owner derives deterministically from the JSON-array\n encoding of `[idp issuer, sub]`, namespaced by issuer so no issuer/sub pair can straddle a\n delimiter, and re-login re-lands the same person in the same lanes. The owner-token *format*\n (`u_` followed by 26 base32-lower characters) is normative\n ([SPEC section 2](../SPEC.md#2-identity)). At the contract level the *derivation* from an\n identity is a pluggable edge, but the reference `createIdpBridge` fixes it\n (`deriveOwnerForIdpSubject`) and takes no derivation callback, so what a host configures is the\n IdP, not the derivation. **The encoding is frozen:** changing it, or changing the IdP issuer\n string, re-keys every owner in the space, which is a migration on the order of rotating the space\n secret.\n3. **Actor authorization and mint.** The operator's ledger hook authorizes the `(owner, actor)` pair\n and is the only source of the bearer's `scope`/`parent`; the issuer then mints the Cotal bearer,\n re-asserting every claim shape.\n\nA host wires this with the IdP's own coordinates and nothing from `@cotal-ai/auth` changes:\n\n```ts\nimport { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from \"@cotal-ai/auth\";\nconst bridge = createIdpBridge({\n idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP\n space,\n spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime\n issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer\n authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant\n});\n```\n\n## Joining\n\nA single **join link** carries server, auth, and space\n([SPEC \xA710](../SPEC.md#10-connection-and-onboarding)):\n\n```\ncotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)\n```\n\nHumans: `cotal join --link \u2026`. Agents: `COTAL_LINK=\u2026 ` in the environment. The connector\nexpands it and auto-joins. Token/user-pass links are the open-mode path; the default\nauthed path threads a minted creds file, and the endpoint adopts the credential's identity\nas its card id. A seat the manager spawned reaches that file through its **launch\nmaterial** rather than through `COTAL_CREDS` in an environment every descendant process\ninherits (see [Configuration](config.md#launch-material)); a session you drive by hand\nstill sets `COTAL_CREDS` itself.\n\n## Honest limitations (v0)\n\n- **The signing key is hot** on the mint/manager box of a static-auth mesh; the \"real\n boundary\" holds given operator-controlled cred distribution. On a per-user-auth mesh\n the data-account signing key is held by the auth service (the callout stage) and by any\n running manager, which loads the trust bundle and self-mints its supervisor cred and\n renewals from it; a copied signing *seed* still stays valid for its identity until the\n signing key is rotated. Rotation remains the revocation lever for trust material.\n- **The two `$SYS` creds renew through rotation.** `membership-observer` and\n `connection-evictor` are signed by the system-account seed, which is never persisted, so no\n running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new\n system account (`cotal down` then `cotal up --rotate-sys`), which leaves the data account,\n every agent cred and the store untouched but does invalidate earlier full backups (they bind to\n the operator JWT and system account they were taken under, so re-run `cotal backup` after). Past that horizon the mesh keeps delivering, but the\n membership feed and live eviction stop; `cotal doctor auth` and the manager warn from the 75%\n point onward.\n- **Static agent creds are long-lived; the machinery's are not.** One-shot command creds\n expire in minutes and the standing daemon creds in 24h with the manager renewing them\n (`cotal doctor auth` is the one diagnosis and repair surface). But a static *agent*\n cred has no TTL yet: `cotal_despawn` cuts a session, not a credential, and a\n compromised agent that copied its creds can reconnect until the signing key is\n rotated. Per-user-auth spaces close this: bearers live minutes, `cotal actor revoke`\n denies the next exchange and the next connect and evicts the principal's live\n connections immediately.\n- **Not non-repudiation.** Authenticity is broker-enforced, not portable proof; it does\n not survive an untrusted relay. Signed envelopes are reserved\n ([SPEC \xA711](../SPEC.md#11-versioning-and-extensibility)).\n- **Chat metadata leaks in-space.** Content reads are ACL-bounded; stream metadata\n (channel names, per-subject counts) is not yet ([security model](security.md)).\n\n**Denials are loud, never silent.** A publish outside an ACL surfaces as a logged denial\n(\"denied, not absent\") on the endpoint's error path; an over-tight ACL never looks like a\nmissing peer ([run a mesh](run-a-mesh.md)).\n"
62509
62569
  },
62510
62570
  {
62511
62571
  "slug": "agent-files",
@@ -62533,21 +62593,21 @@ function loadDocsBundle() {
62533
62593
  "title": "`cotal` CLI reference",
62534
62594
  "kind": "Reference: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract.",
62535
62595
  "summary": "cotal is the operator command line for the reference implementation: bring a mesh up, mint identities, launch agents, watch what they do, and tear it all down.",
62536
- "body": "# `cotal` CLI reference\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\n`cotal` is the operator command line for the reference implementation: bring a mesh up, mint\nidentities, launch agents, watch what they do, and tear it all down. It is a thin client over the\nwire contract: the normative subjects and schemas live in the [SPEC](../SPEC.md); this page is\nlookup material for the commands, not a walkthrough; if you are new, start with\n[Getting started](getting-started.md).\n\n## Running it\n\n```bash\nnpm install -g cotal-ai # puts `cotal` on your PATH (needs Node 22+)\ncotal --help # every command, grouped\ncotal --version # cotal-ai version + each installed extension's (also `cotal -v`)\ncotal <command> --help # one command's flags and usage\n```\n\n`npx cotal-ai <command>` runs it without a global install; in a dev clone, `pnpm cotal <command>`\nruns it through `tsx` with no build step. Bare `cotal` prints help. Every command generates its own\n`--help`, usage, and shell completion from its declared flags.\n\nAn undeclared flag is a usage error, and so is a flag given more than once unless it is\nrepeatable, as `--opt` and `down --session-store` are. The command prints the error and its help,\nexits 1, and does not run.\n\nCommand output, including error lines on stderr and the guided `setup` and `meshes add` prompts,\nis colored only when stdout is a terminal, so piped or redirected output is plain text. A non-empty\n`NO_COLOR` turns color off on a terminal too. `FORCE_COLOR` turns color on even when output is\npiped, unless it is `0` or `false`, and it takes precedence over `NO_COLOR`.\n\nCommands come from the surfaces the binary composes: the base mesh CLI, the manager\n(`supervise`), and the delivery daemon (`deliver`), plus any operator-installed extensions.\n`cotal ext add <npm-package>` installs any registry providers a package contributes: commands,\nruntimes, and local process lifecycle descriptors. The `web` dashboard and optional manager\nruntimes ship this way.\n\n## Commands\n\n| Area | Command | Purpose |\n|---|---|---|\n| Set up & lifecycle | [`setup`](#setup) | Guided, configure-only setup (installs, seeds personas; launches nothing) |\n| Set up & lifecycle | [`update`](#update) | Reconcile first-party extensions and check or opt into a coherent CLI upgrade |\n| Set up & lifecycle | [`up`](#up) | Start a local mesh (nats-server + JetStream), or boot a whole manifest with `-f` |\n| Set up & lifecycle | [`down`](#down) | Stop the whole stack, selected registered components, or a manifest deploy |\n| Set up & lifecycle | [`backup`](#backups) | Create an offline full-space or registry-only artifact from a preserved cut |\n| Set up & lifecycle | [`clean`](#clean) | Configurable cleanup: purge history (live), or wipe the local store / identity (stopped) |\n| Set up & lifecycle | [`meshes`](#mesh-registry) | List the running meshes on this machine |\n| Set up & lifecycle | [`sync`](#mesh-registry) | Refresh the signed-in account's advertised spaces |\n| Set up & lifecycle | [`use`](#mesh-registry) | Set the default mesh a bare `cotal spawn` joins |\n| Set up & lifecycle | [`status`](#mesh-registry) | Read-only diagnostics for setup, processes, and the selected mesh |\n| Agents & personas | [`spawn`](#spawn) | Launch an agent from a persona (foreground, or `--detach` via the manager) |\n| Agents & personas | [`models`](#models) | List connector model catalogs and variants from the manager |\n| Agents & personas | [`ps`](#managed-seats) | List managed agents and their mesh status |\n| Agents & personas | [`stop`](#managed-seats) | Ask the manager to stop a managed agent |\n| Agents & personas | [`attach`](#managed-seats) | Stream and drive a managed agent's terminal (pty runtime) |\n| Agents & personas | [`input`](#input) | Type one line into a managed agent's terminal without attaching |\n| Agents & personas | [`personas`](#personas) | List, show, edit, create, or remove local personas |\n| Agents & personas | [`supervise`](#supervise) | Run a manager daemon (the agent supervisor / control plane) |\n| Agents & personas | [`service`](#service) | Run the manager as a user service (survives logout and reboot) |\n| Agents & personas | [`runtimes`](#runtimes) | List the agent runtimes the manager can spawn through and whether each is reachable |\n| Agents & personas | [`seats`](#seats) | List the pty seat custodians an earlier Linux manager left, and drain the ones whose agent has exited |\n| Agents & personas | [`reconcile-gate`](#reconcile-gate) | Unfreeze an issuance gate left frozen by a crashed restart when the successor cannot boot-heal it (holder gone, complete CONNZ sweep) |\n| Messaging & watching | [`endpoints`](#endpoints) | List every endpoint in the live presence roster, including infrastructure |\n| Messaging & watching | [`describe` / `invoke`](#endpoint-control) | Resolve a v0.4 service's command surface off the wire; invoke one command by name |\n| Messaging & watching | [`send`](#send) | Send one message, then exit: DM a peer, post a channel, or ask a role |\n| Messaging & watching | [`channels`](#channels) | Inspect or set the channel registry |\n| Messaging & watching | [`history`](#history) | Clear retained message history |\n| Messaging & watching | [`console`](#console) | Live protocol view for a space (TUI, or `--plain` line stream) |\n| Messaging & watching | [`web`](#web) | Browser dashboard (installed as the `@cotal-ai/web` extension) |\n| Extensions & misc | [`linear`](#linear) | Serve the official Linear MCP server as an endpoint agents can call (installed as the `@cotal-ai/linear` extension) |\n| Auth & meshes | [`mint`](#mint) | Mint a creds file for a space (static auth mode) |\n| Auth & meshes | [`login`](#login) | Sign in to a per-user-auth mesh's IdP (once per machine) |\n| Auth & meshes | [`logout`](#login) | Revoke the IdP session and clear the cached login |\n| Auth & meshes | [`actor`](#actor) | Manage a user-auth space's actor ledger (grant / revoke / list) |\n| Auth & meshes | [`doctor`](#doctor) | Credential-health diagnosis and repair (`doctor auth`) |\n| Auth & meshes | [`join`](#join) | Join a space as your own presence (interactive) |\n| Manifest | [`topology`](#manifest-deploys) | Validate and view a mesh manifest's access graph (read-only) |\n| Extensions & misc | [`ext`](#ext) | Install / remove operator CLI extensions |\n| Extensions & misc | [`completion`](#completion) | Print or install shell completion |\n| Extensions & misc | [`feedback`](#feedback) | Send feedback to the Cotal developers |\n| Extensions & misc | [`deliver`](#server-daemons) | Run the server-side Plane-3 delivery daemon |\n| Workflow runs | [`run`](#run) | Operate durable workflow runs: start, resume, list, inspect, answer a checkpoint, check an edited program with migrate |\n| Extensions & misc | [`feedback-intake`](#server-daemons) | Run a self-hosted feedback intake server |\n\nThe manifest modes of `up`, `spawn`, and `down` (`-f <cotal.yaml>`) plus `topology` are covered\ntogether under [Manifest deploys](#manifest-deploys).\n\n## setup\n\n```bash\ncotal setup [--full] [--demo] [--yes] [--skills]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--full` | off | Redo the full guided flow (implies `--demo`) |\n| `--demo` | off | Also seed the guided expert team (`david`, `sven`, `me`) |\n| `--yes`, `-y` | off | Non-interactive accept-all (for agents / CI) |\n| `--skills` | off | Reconcile Cotal skills only through installed connector providers, plus `~/.agents/skills`. Refused with `--full` or `--demo`. |\n\nGuided setup is **configure-only**: it checks prerequisites, invokes installed connectors' declared setup providers, and\nseeds persona files, and it launches nothing (no mesh, no web, no manager). First run gets the\nnarrated flow; later runs print a status card. By default it seeds one `default` persona; the\n`david`/`sven`/`me` team is opt-in via `--demo`. `cotal status` points stale Claude skills and\nout-of-date `.agents` skills at `cotal setup --skills`, not unscoped `setup`. See [Getting started](getting-started.md) and, for\nmaintainers, [setup internals](setup-internals.md).\n\nWhen a mesh resolves, setup seeds that mesh's recorded `.cotal/agents` catalog, the same catalog a\nfollowing `cotal spawn` reads. It prints the absolute destination. On a fresh machine with no mesh it\nuses this folder and says why; when several meshes are available and none is selected, it refuses\nrather than choosing a catalog.\n\n## update\n\n```bash\ncotal update [--self] [--space <s>] [--server <url>] [--creds <path>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--self` | off | If a newer release exists, install that exact validated `cotal-ai` version globally and reconcile through the newly installed binary |\n| `--space`, `--server`, `--creds` | resolved mesh | Select the running manager whose continuity state is reported |\n\nWithout `--self`, `update` keeps the installed first-party surfaces coherent with the running\nbinary: it force-reconciles the four built-in connectors, then reinstalls other `@cotal-ai/*`\noperator extensions at the binary's exact version. Each extension runs in an isolated child, so one\nfailure cannot poison later replays. It then checks npm; a newer binary is an informational notice\nwith `cotal update --self` as the next command, not an automatic install.\n\nAfter disk reconciliation, `update` reads the selected running manager. A machine with no recorded\nmesh has no running manager to observe, so that read is skipped and the command completes. The same\nholds when every recorded mesh is down and none is selected. A remote user mesh, registered with\n`cotal meshes add --mode user`, is named and skipped: its manager runs under another install, so\nthere is no custody on this machine to preserve, and a `legacy` verdict still comes only from a\nmanager this machine read. A recorded mesh that is down is still a\nrefusal when the command selects it, with `--space` or by running inside its project, and so is a\nnamed space that is not running. With several meshes running and no `--space`, `--server` or\n`--creds`, the install is machine-wide, so every running manager is reported in turn, each under its\nspace name, before anything is written; a `legacy` verdict on any of them makes the whole run not a\nhot update. A selector flag still reports one manager. A manager without a\ncustody generation is reported as `legacy`: it cannot preserve its manager-owned PTYs, so the\ncommand says that this is not a hot update and prints `exact`, `fork`, `fresh`, or `drain-only`\nfor every seat. This report sends no stop, preservation-commit, or replacement command.\nIt does not preserve a running PTY on a legacy manager. The built-in pty runtime spawns\nin-process on every platform and reports `legacy`. On Linux it still adopts seats that an earlier\nmanager left under a detached custodian, but it starts no new custodian. An incompatible native\n`@lydell/node-pty` or ConPTY ABI break remains an explicit per-seat maintenance cut.\n\nWith `--self`, the selected running manager is reported before any global install. When a newer\nrelease exists, Cotal then installs the exact version it validated, resolves and verifies that\npackage in npm's global root, then launches that binary with the same `--space` / `--server` /\n`--creds` selection to reconcile connectors and first-party extensions to the new generation. An npx\nor dev-clone invocation therefore installs and continues through a separate global copy; it never\nclaims the already-running process changed. If the binary is current, `--self` performs the normal\nlocal reconcile without reinstalling it.\n\nThird-party extensions are listed with their installed version and recorded spec but are not\nauto-updated in v1. Floating third-party updates require `@cotal-ai/*` peer-range validation and are\na future follow-up. A failed connector/extension install, npm metadata check, or requested global\ninstall is reported and makes the command exit nonzero. Independent extension attempts continue so\nthe output includes every failure; an unavailable npm registry does not undo a completed local\nreconcile, but the command still exits nonzero because it could not establish that the install is\ncurrent.\n\n## up\n\n```bash\ncotal up [--detach] [--open] [--space <s>] [--server <url>] [--channels <path>] [--runtime <name>]\ncotal up --user-auth --idp <url> [--exchange-public-port <n> --exchange-public-url <https://\u2026> [--exchange-trusted-proxy]]\ncotal up --tls-cert <cert.pem> --tls-key <key.pem> # serve broker TLS (both, or neither)\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\ncotal up -f <cotal.yaml> [--dry-run] [--runtime <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--server <url>` | auto (free local port) | Listen URL override |\n| `--host <host>` | none | Bind host override for a **fresh** broker boot: an IP or hostname only, never a URL (that is `--server`) and never `host:port` (the port comes from `--server` or its default); a URL or port-bearing value is refused pointing at the right flag. With no `--server`, the broker URL is derived from it, so `--host <addr>` alone is enough to make a mesh reachable at that address; a `--host`/`--server` pair naming different addresses is refused. A wildcard bind (`0.0.0.0`, `::`) keeps a dialable loopback URL. Recorded on the mesh and reused by every later manager launch, so a repair or resume keeps remote [`attach`](#managed-seats) working. A live refresh (`\u2713 mesh already running`) does not rewrite `.cotal/auth/server.conf` or rebind nats; stop the broker, then re-run `up --host` |\n| `--space <s>` | the folder's name | Space name |\n| `--store-dir <dir>` | none | JetStream store directory (recorded; a repair up reuses it) |\n| `--max-file-store <bytes>` | nats-server's dynamic cap | JetStream file storage cap in bytes (a positive integer, no unit suffix). Without it nats-server sizes the store at start as three quarters of the free space on its filesystem. The cap is fixed at broker start: a running broker cannot change it (`cotal down` first), `down --preserve-state` keeps it for the resume, and a resume with a different value is refused. Not accepted with `-f` |\n| `--channels <path>` | `.cotal/channels.json` if present | Channel-registry seed file (JSON). An explicit path that is missing is an error |\n| `--restore <dir>` | none | Restore a completed offline backup before exposing the normal listener |\n| `--restore-only registry` | artifact selection | Restore only the registry component |\n| `--accept-missing-source` | off | Explicit disaster consent when the inode-bound preserved source is absent |\n| `--accept-stale-checkpoint` | off | Explicit consent to resume a seat whose checkpoint was captured outside its recorded recency horizon |\n| `--open` | off (auth) | Unauthenticated dev mesh: no JWT, no ACLs |\n| `--user-auth` | off | Per-user auth: people `cotal login`; connects are authorized against the actor ledger |\n| `--idp <url>` | none | With `--user-auth`: the IdP auth base URL to pin on first enable |\n| `--exchange-public-port <n>` | none | With `--user-auth`: add the public exchange face on this loopback port, for an HTTPS reverse proxy to forward to |\n| `--exchange-public-url <https://\u2026>` | none | With `--exchange-public-port`: advertise the reverse proxy's HTTPS URL in discovery |\n| `--exchange-trusted-proxy` | off | With `--exchange-public-port`: attribute public failure buckets to the last `X-Forwarded-For` hop. Enable only when the listener is reachable solely through a trusted proxy; otherwise the socket address is used |\n| `--detach` | off | Run in the background (stop with `cotal down`) |\n| `--tls-cert <path>` | none | PEM certificate to serve TLS with. Must be given together with `--tls-key`. Before starting the broker, Cotal checks readability, private-key mode, key/certificate match, the validity window, and host coverage. `nats-server` accepts an expired certificate and leaves the failure to clients, so Cotal performs these checks first. The decision is recorded; a later bare `cotal up` keeps serving TLS |\n| `--tls-key <path>` | none | PEM private key for `--tls-cert`. Refused if group- or other-readable (tighten to `600`) |\n| `--file <cotal.yaml>`, `-f` | none | Launch a whole mesh from a manifest |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--runtime <name>` | `pty` (or the manifest's, with `-f`) | Agent runtime for the mesh manager (`pty` built in; others are installed extensions, explicit-only). Resolved + probed before the broker starts; an uninstalled/unreachable runtime fails loud. With `-f`, overrides the manifest's runtime |\n| `--max-sessions <n>` | 64 | Live-session ceiling for the mesh manager. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. Recorded on the mesh and reused by every later manager launch, so a repair or resume does not silently drop back to 64. A running manager cannot change it: `cotal down` first, then `cotal up --max-sessions <n>` |\n| `--no-manager` | off | Broker-only boot: start the broker and, in auth mode, the delivery daemon, and no local manager. A refresh under the flag of a mesh whose manager is live refuses rather than keeping or stopping it (`cotal down manager` first). Cannot be combined with `--runtime`, `--max-sessions`, or an agent-declaring manifest |\n| `--rotate-sys` | off | Rotate the space's system account and re-mint its two `$SYS` creds. Needs a stopped mesh; refused with `--open` |\n\n`cotal up` boots a local nats-server with JetStream and, in auth mode (the default), JWT auth and\nper-agent ACLs; `--detach` records the mesh so `cotal spawn` from any directory can find it. With no\n`--server`, it auto-selects a free port if the default address is taken; an explicit `--server`\nstays fail-loud on collision. `--detach` also brings up the control plane (delivery daemon in auth\nmode, then the manager). `--no-manager` is the broker-only mode: it boots\nthe broker (and the delivery daemon in auth mode) and starts no manager, so there is no manager\npidfile to leave stale. A refresh under the flag of a mesh whose manager is live refuses rather\nthan keeping or stopping it: `cotal down manager` first. For a split topology with a manager, wait for `.cotal/manager.<spaceKey>.log` to contain `\u2713 manager up`, then `cotal down manager` on that\nhost and run [`supervise`](#supervise) against the remote broker; see\n[Run a mesh](run-a-mesh.md). `cotal up --detach` prints `\u2713 running in the background:` with\n`manager` listed (pidfile liveness, not a teardown boundary); with `--no-manager` the line lists\nonly what actually started. Ctrl-C on a foreground `up` stops the manager through the same stop as\nbare `cotal down` (see [`down`](#down)), then the rest of the stack, and reports managed agents\nunder the same rule: when the manager stop is refused, Ctrl-C prints the refusal with the reap route\nand leaves the stack running. The `-f` form is a\n[manifest deploy](#manifest-deploys).\n\nA repair `up` on a mesh whose broker died reopens the store its record names, and refuses a\ndifferent `--store-dir` rather than silently opening a second store.\n\nThe generated `.cotal/auth/server.conf` is written on a real broker boot and is not an\noperator-owned config. `--host` changes that file only when nats is actually started. A unit\nrestart that leaves an answering listener in place is a refresh, not a rebind.\n\nOn an existing mesh, `cotal up` reconciles the presence and lease bucket TTLs. It writes a reserved\ncanary and waits for the bucket to expire it before reporting success. If the broker accepts the\nstream update but the backing store does not persist or enforce it, `up` exits nonzero with a TTL\npersistence error instead of trusting the value returned by stream info. A refresh that restores a\nmissing manager says so with its pid (`\u2713 restored in the background: manager (pid N)`); a refresh\nthat finds everything already running prints only the `\u2713 mesh \"<space>\" already running` line. A\nfirst boot starts its manager without the restore line.\n\n\n`--user-auth --idp <url>` starts the space's auth service alongside the broker: the NATS\nauth callout plus its capability-gated local exchange, and optionally the closed public exchange\nface configured by the three `--exchange-*` flags above. The service is torn down with `cotal down`,\nand a re-run of `cotal up` heals a dead service on a running broker. `up` waits for the service to\nfinish binding: while the daemon it launched (or found running) stays alive, the wait extends past\nthe base 15s up to 60s; a daemon that exits is refused at once with \"exited before becoming ready\",\nand one alive past 60s is refused as \"alive and still starting\" (wedged), naming the pid record and\nthe service log. `--user-auth` and `--open`\ncontradict each other and are refused loudly; a running broker cannot change auth mode\nwithout a `cotal down` first. See [identity & auth](identity-and-auth.md).\n\n`--rotate-sys` renews the two `$SYS` credentials (`membership-observer`, `connection-evictor`).\nThey carry a 30-day expiry and nothing re-signs them in place, because the system-account seed is\nnever persisted, so they are renewed by issuing a **new system account** under the same broker\noperator and minting fresh creds against it. A plain re-`up` does **not** do this: it reuses the\nexisting trust record, and its `$SYS` creds along with it.\n\nThe rotation is safe to run on a real space, with one operational cost. The data account, the account\nsigning key, every agent credential minted from it, and the JetStream store are all untouched; what\ndies is the retired system account, and with it any out-of-band copy of the old `$SYS` creds, on every\nbroker that loads the rotated config. The cost is that **earlier full backups stop being restorable**\n(see below), so this is not a no-consequence operation. It needs the broker to restart on the rewritten\nconfig, so it runs as part of a boot:\n\n```bash\ncotal down\ncotal up --rotate-sys --detach # agents reconnect; nothing is re-provisioned\ncotal doctor auth # both $SYS creds healthy again, 30 days out\n```\n\nA rotation is a stopped, fresh boot, and anything that is not one refuses it, all for the same reason\n(the on-disk material and the broker it runs on must never end up on different generations):\n\n- a live mesh, because the running broker would keep serving the retired account;\n- an open mesh, whether that comes from `--open` or from `broker.auth: false` in a manifest, which\n has no system account at all;\n- `--restore`, because reinstating a trust root and superseding it in one command leaves no way to\n say which authority the mesh came up on;\n- an unfinished restore or resume attempt on this root, including one `cotal up` would recover on\n its own, because those paths can adopt a live listener and return without booting a broker;\n- a root that hosts more than one space, because the system account lives in the shared broker\n record and a rotation would retire every tenant's, while the root holds one `$SYS` cred pair\n pinned to one data account.\n\nTwo things to know before you run it:\n\n- **The retirement is config-load-bound.** Old `$SYS` creds are refused by any broker that loads the\n rotated config. A stale `nats-server` still running the *previous* config in memory would keep\n honouring them, so stop every broker for this root first. `--rotate-sys` refuses if this root's\n mesh is recorded as running, if anything unidentified is answering at the address it was given, or\n if the root's pid file names a live (or unreadable) process. Those are Cotal's own ownership\n records, not a scan of the process table: a `nats-server` you started by hand against this root's\n `server.conf` on some other port writes none of them and will not be seen. Do not run one.\n- **It invalidates earlier full backups.** A full artifact binds to the trust chain it was taken\n against, and that commitment covers the operator JWT and the system account. Every full backup\n taken before a rotation refuses to restore afterwards, so take a fresh `cotal backup` once the\n rotated mesh is up. `cotal up --restore` names this case when the data account still matches.\n\nThe commit is not atomic (a trust-record write plus two credential writes), so an interrupted\nrotation leaves the record ahead of the creds. That split is detected rather than silent: every\n`cotal up` on an auth mesh, and every `cotal doctor auth`, compares each `$SYS` cred's issuer against\nthe persisted record and names the retired account. `up` warns rather than refusing, because these\ncreds power the membership graph and live eviction, both of which degrade fail-soft; the mesh is not\nworth taking down over them. Re-running the rotation heals it, at the cost of one generation.\n\nWhile those creds are expired the mesh keeps delivering messages, but the\n[membership feed](delivery-daemon.md) and live connection eviction stay down; `cotal doctor auth`\nand the manager's log both name the credential and this repair.\n\n## down\n\n```bash\ncotal down\ncotal down --with-agents\ncotal down --preserve-state [--store-dir <dir>] [--session-store <dir> \u2026]\ncotal down manager [delivery auth web nats ...]\ncotal down web [--space <name>]\ncotal down -f <cotal.yaml> | --run <id> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--file <cotal.yaml>`, `-f` | none | Tear down this manifest's deploy |\n| `--run <id>` | none | Tear down one `spawn -f` run by id |\n| `--space <name>` | current mesh | With components: the mesh whose target-addressed components (e.g. `web`) to stop |\n| `--dry-run` | off | Print the manifest teardown or selected components, mutate nothing |\n| `--with-agents` | off | Bare whole stack only: also stop and deprovision every managed agent |\n| `--preserve-state` | off | Bare whole stack only: fence the manager, retain principals and durable state, stop and prove the stack down, then publish `ready` |\n| `--store-dir <dir>` | `.cotal/nats` | With `--preserve-state`: the actual store path (required for a custom store) |\n| `--session-store <dir>` | none | With `--preserve-state`: a harness transcript store directory to capture with every continuation-capable retained seat. Repeatable. No default and never inferred from a connector name; a path that does not exist or is not a directory is refused before anything stops |\n\nBare `cotal down` stops the whole local stack in dependency order and leaves managed agents running\nwhen their runtime lets them outlive the manager. Before signalling the manager it verifies the spare\ncapability of the exact recorded manager, which records what that manager's stop does with its\nseats, and it reports the agents left behind plus `cotal down --with-agents` as the explicit reap.\nWhen the manager had no managed agents, it prints no report.\nThe built-in pty runtime keeps each PTY inside the manager process, so those seats cannot outlive\nit: every manager stop stops and deprovisions them, and `down` reports them as stopped. Every manager\nstop the CLI makes runs this one path: `down`, Ctrl-C on a foreground `cotal up`, the teardown after\nthat `up`'s broker exits, the leftover-manager stop before `cotal up -f`, and the delivery cutover.\nEach holds the manager's stop reservation, so a second stop while one is in flight is refused,\nnames the process holding it, and leaves that stop's `--with-agents` policy in place. Each sends\n`SIGKILL` to a manager still running 15s after `SIGTERM`. Ctrl-C stops the manager first; when that\nstop is refused or the manager's exit cannot be confirmed, Ctrl-C signals nothing else, prints the\nrefusal with the reap route, and leaves the stack running; end it with `cotal down --with-agents`.\n`--with-agents` is a one-shot destructive policy bound to the exact verified manager process\nand the exact live `down` stop reservation; a stale, malformed, crashed, or different stop attempt\ncannot turn a later bare shutdown destructive. If a managed agent cannot be proven stopped within\nthe manager's stop timeout, the manager logs which one, still closes its broker connections and\nconsole listener, and exits with code 1. It does not release its pidfile, liveness lease or\nservice registration in that case, so no successor is handed authority while that agent may still\nrun; the lease lapses on its TTL. Positional component names stop\nonly those self-registered local processes; for example, `cotal down manager` leaves delivery and\nthe broker running, and `cotal down web` is available when the web extension is installed. A\ncomponent that starts target-resolved (the web dashboard) is stopped the same way: `cotal down web`\nresolves the mesh the same way as `cotal web` (registry current mesh first, `--space` to name one), so\nit works from any directory; the other components always stop under the folder you run it in. The\n`-f` / `--run` forms tear down a [manifest deploy](#manifest-deploys) without stopping the whole mesh\nand cannot be combined with component names. Stopping `nats` alone is refused while an unselected\nregistered daemon is still live; include those components or use bare `cotal down`.\n\nA pinned manager with no spare-capability record is not signalled by bare `cotal down` or `cotal\ndown manager`. A current manager always publishes the record, so a missing one means an older\nmanager: one that predates capability reporting, or one whose pty runtime reported that it cannot\ndetach its agents. Stop each managed agent explicitly, then run `cotal down --with-agents` from the\nmesh root to stop the whole stack. An older manager does not understand\nthe reap request, which is why the agents must already be stopped.\n\nBare `cotal down` inventories by pidfile. When this folder's registered broker answers and no\n`nats.pid` records it, the command does not say nothing is running. It names the space and the\nbroker address, says no pidfile records that process, says it will not stop a process it did not\nstart, and exits 1. Stop that broker with whatever started it (an init unit, a container, or the\nhand-run process). `cotal meshes rm <space>` only drops the registration. The probe runs whether or\nnot other owned components were running: they stop and clear their artifacts first, then the broker\nis named. A component stop and `--dry-run` stay pidfile-only and do not probe.\n\n`down` reads each process record once. A component that exits and removes its own record while\n`down` runs counts as having no record, so the stop goes on. Any other failed read is an error.\n\n**Teardown verifies pinned process identity before signalling.** PIDs are recycled by every OS,\nso a recorded pid alone is not a durable target identity. `up` and `cotal web` record each\nprocess's creation identity in a sibling `<pidfile>.identity` pin, which holds the pid and the\nprocess start reported by the OS. Every stop path, including `down` for the broker, web and\nextension components, and the manager, delivery and auth-service stops, applies the same rule. A pin\nthat names a different start means the pid was reused, so teardown refuses and preserves it. A torn\nor unreadable pin also refuses.\n\nThe pidfile and its pin are published by renames, and the pidfile rename is the commit point. Just\nbefore it, the pin holds two lines: the old process's and the new one's. A launcher that dies\nmid-publish therefore leaves the old record or the new one, each checked against its own pin line,\nnever a pidfile without its pin. An old record with no pin is legacy, so its line holds `-` in place\nof the token and it stays legacy until the commit. A CLI older than this change reads a two-line pin\nas torn and refuses.\n\nPublishes of one pidfile are serialized by a lock file beside it, `<pidfile>.publish.lock`, because\nthe launcher and the daemon it starts both publish the same record. The next publisher reclaims a\nlock left by a crashed one. When no start token can be read for the new process, its pin line holds\n`-` in place of the token, which reads as a legacy record, and the publish ends in the legacy shape:\na pidfile with no pin. Teardown, and a daemon removing its own record on exit, take the same lock and\nremove the record only while the pidfile still names the pid they stopped, so a stop that races a\npublish leaves the new record whole.\n\nThe web dashboard claims `web.pid` with an exclusive create, so a second dashboard for the same mesh\nis refused, and writes its pin right after the claim. A stop that runs between the two reads a\nlegacy record. A refused claim calls the record stale, and points at `cotal down web`, only when the\nfile is empty or its pid is proven gone. Content that is not a pid, or a pid whose liveness the\nkernel will not report, is refused as possibly fronting a running process, as `cotal down web`\nrefuses it.\n\nThe pidfile pid and the pin pid are two coordinates. Automatic cleanup follows **proven death of\nthe pidfile target** (ESRCH on that pid): a torn sibling pin does not wedge a dead pidfile pid.\nA torn pairing where the pin names another pid, while the pidfile pid is still live or not proven\ndead, still refuses. Inspect both pids with `ps`. Do not delete `<pidfile>.identity` to force a\nstop; that weakens target-identity protection. Once the pidfile process is dead, rerunning\nteardown clears the stale record automatically.\n\nThe first teardown after upgrading a running pre-pin stack has a narrower guarantee. A live record\nwith no identity pin is signalled after a loud warning that it predates identity pinning. Restarting\nthe component writes the pin, so later teardowns receive full match and mismatch protection. The\nsame warning applies on platforms where no stable start token is available. For a legacy manager,\nbare `cotal down` also warns that agent sparing cannot be verified before it signals. Because the\nCLI cannot establish which SIGTERM handler that already-running binary carries, it never presents\nthe pre-signal seat inventory as confirmed spared; a genuinely older destructive handler may still\nreap those agents. `--with-agents` publishes a one-shot reduced-guarantee handoff bound to the\nrecorded manager pid and the live `.stopping` reservation's inode, then signals unconditionally.\nThat handoff cannot be replayed by a later stop attempt. A pin that exists and does not match the\nlive process still refuses before signal.\n\n`--with-agents` performs the old destructive logical teardown: managed processes stop and their\ncredentials, ACL rows, and delivery footprints are deprovisioned. `--preserve-state` is a different\nmaintenance transition: it stops retained processes while suppressing leave/deprovision cleanup, persists the manager's\nsame-principal resume inventory, stops the entire stack without removing run/auth artifacts, and\npublishes a stable inode-bound cut only after every recorded process is proven stopped and the exact\nrecorded NATS endpoint is unreachable. A missing or stale broker pidfile never counts as stopped. The\nattempt is bound durably before the manager is fenced, the resume document and attempt-bound\n`cut-intent` are fsynced before manager commit, and the manager's commitment itself is journaled\n(`cut-committed`) before any process stops. A retry after a crash at any of those boundaries reuses\nthe exact recorded attempt and finishes the remaining stop and endpoint proofs idempotently, without\nneeding the (by then intentionally dead) manager. A partial cut never publishes `ready`. It cannot\nbe combined with component names, manifest teardown, or `--dry-run`.\n\n**Seat checkpoints.** After the stack is proven down, the cut writes one checkpoint per retained\nseat under `.cotal/maintenance/v1/checkpoints/<attempt>/<seat>/`, and prints the path, the\ncontinuity class and the generation for each. The path carries the preservation attempt because a\ncheckpoint is immutable once sealed: a shared directory would make the second cut in a root refuse\non the first cut's leftovers, and clearing it would destroy an artifact a rollback still needs. The\ncapture happens only at that point because anything earlier races a harness that is still writing\nits transcript and its working tree.\n\nEach checkpoint directory is created 0700, refuses a destination that already exists, and holds:\n\n- `repo.bundle`, the seat `cwd`'s reachable history, anchored on the base commit the record names\n by full object id;\n- `repo.index.diff` and `repo.worktree.diff`, the staging state as two diffs, base to index and\n index to worktree. Two rather than one because a single combined diff restores a mixed tree with\n the right bytes and the wrong index: a source reporting `MM README` would come back as ` M README`;\n- `repo.untracked.tar`, the untracked files in scope;\n- the harness session pointer, when the seat's connector declares one, and the transcript store\n files the operator named with `--session-store`. Each records where the destination puts it back\n as an anchor (the workspace root, the account home, or the seat's `cwd`) plus a relative path,\n because the destination's root and home are its own and the source host's absolute spelling would\n either miss them or write outside them;\n- `checkpoint.json`, written last, after every digest is computed over the bytes that landed.\n\nThe record carries the manager's resume entry unchanged as its first field, then the space, the seat\nname, the recovered `lifecycleUid`, the writer generation the cut was taken at, `capturedAt`, the\nrecency horizon, the applied profile revision, the seat's `git status --porcelain` as the cut read\nit, and the continuity class. Every captured file is\nlisted with its byte size and sha256, so an operator verifies the whole artifact with `sha256sum`\nand `git bundle verify`. No secret values, no operator keys and no source-host launch material\nenter it.\n\nThe generation is the highest one this root has claimed for the seat in `.cotal/auth`. A\ngeneration entry there that is a symlink, a directory or anything else that is not a regular file\nrefuses the cut, as every other auth record does.\n\nThe continuity class is what the connector declares, capped by what the checkpoint carries. A\nconnector declaring session continuation classifies as `exact`, but reopening a session takes both\nhalves, the pointer that names it and the store that holds its transcript. A checkpoint missing\neither one cannot reopen that session, so it is recorded as `fresh` when the connector declares a\nfresh start and `drain-only` otherwise. A pointer with no store is capped the same way as a cut\ncarrying neither, because it names a session whose bytes the artifact does not contain. A class is a promise the destination is entitled\nto act on, so it never describes bytes the artifact does not contain. The transcript store stays an\noperator input: this repository does not know where a harness keeps its transcript, so `exact`\nrequires `--session-store` to name one.\n\nThe recorded status is read under the same selection rule as the untracked set, so it describes the\nstate the captured bytes can reproduce. The destination re-reads it in the promoted tree and refuses\na difference.\n\nThe untracked selection rule is recorded in the record and is\n`git ls-files --others --exclude-standard -z, excluding .cotal/`. It honors `.gitignore`, so an\nignored file the seat needs does not travel and has to be moved separately. The `.cotal/` exclusion\nis a secrecy boundary rather than a size one: when a seat's `cwd` is also the mesh root, the control\ndirectory is untracked, and without the exclusion the broker trust material, the space account, the\nmanager instance identity's private seed and the seat's own credentials would land inside the\nartifact. A checkpoint carries credential references only; the destination resolves that material\nitself.\n\nA seat whose launch options could not be resolved is refused rather than checkpointed, with the\nmanager's own wording: `imperative launch options have no non-secret durable source (<keys>)`. The\nrefusal arrives at prepare time, so the cut stops before any child does.\n\nA delegated seat (SPEC \xA713.17) is refused at prepare time too, with\n`a delegated seat is not resumed by a later manager; stop it before preserving`. A manager stop\nafter a refused cut retires that seat through its retirement path.\n\n## clean\n\n```bash\ncotal clean <history|store|all> --force\ncotal clean restore-attempt --attempt <id> --force\ncotal clean restore-fallback --attempt <id> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | `history`: target mesh |\n| `--dms` | off | `history`: also clear DM history |\n| `--store-dir <dir>` | `.cotal/nats` | `store`/`all`: JetStream store directory |\n| `--force` | none | Required: destructive, no prompting |\n| `--attempt <id>` | none | `restore-attempt`: exact stale pre-commit attempt; `restore-fallback`: matching healthy committed restore |\n\nOne configurable cleanup verb; every target requires `--force`.\n\n- `history` purges the retained message backlog on the **running** broker (channels, plus DMs\n with `--dms`). The same operation as [`history clear`](#history), which stays as an alias.\n- `store` deletes the **stopped** mesh's JetStream store (`.cotal/nats`): streams, durable\n consumers, and messages. This is the reset for stale on-disk broker state, e.g. durables\n minted by an older, incompatible Cotal generation surviving a `down`/`up` cycle.\n- `all` is `store` plus the space identity (`.cotal/auth`), the local creds and markers tied to\n it, any crash residue a normal `down` would have swept (stale pidfiles, `run/`), and the mesh's\n registry entry; the next `cotal up` mints a fresh identity.\n\n`history` needs the mesh up; `store` and `all` refuse while any recorded mesh process is still\nalive or any same-root recorded broker endpoint remains reachable (run `cotal down` first). They\nalso refuse outright on a root that holds accounts for several spaces: the store and the broker\ntrust record are shared by every space on the broker, so both targets would take out all of them\nand no `--space` can narrow that. `down`, `backup` and `up --restore` refuse there for the same\nreason. `cotal status` lists the tenants on such a root. Personas\n(`.cotal/agents`) and logs are never touched. The mesh record now carries a custom\nstore location for `up`'s own repair, but `clean` still takes `--store-dir` itself; `clean` does\nnot read the record. Custom cleanup targets must contain either the Cotal store-generation marker or a\nreal `jetstream/` store directory; filesystem roots, project roots, and Cotal auth/maintenance trees\nare always refused.\n\n`store` and `all` also refuse every maintenance journal state. After a healthy committed restore,\n`restore-fallback` is the only supported way to remove the recorded unchanged old-store inode; it\nnever deletes the active target, requires both the exact attempt id and `--force`, and retires the\ncompleted restore journal so a later `down --preserve-state` can start a new backup cycle.\n\n## Backups\n\n```bash\ncotal down --preserve-state [--store-dir <dir>]\ncotal backup create <dir> [--only full|registry] [--store-dir <dir>]\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\n```\n\nBackup is offline-only. It requires the stable `ready` record from `down --preserve-state`, an exact\nstore match, no live recorded process, and an unreachable exact endpoint from the recorded cut.\nThat endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile\nis still refused. It claims the cut, reflink/copies the stopped source to a\nprivate attempt clone, and opens only that clone on a random loopback bootstrap broker with an\nindependent parent/deadline watchdog. It validates the canonical stream and pull-consumer inventory,\nwrites native snapshots with consumers excluded, and stores conservative contiguous ACK-floor\ncheckpoints separately. The presence bucket is memory-backed, so it does not survive the cut and\nthe clone may lack it. Every other stream must be present. The original store is never opened by\nthe backup broker, and the stack is not restarted implicitly. Artifact destinations must not overlap\nthe preserved source or maintenance\nattempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the\npreserved source, or the maintenance attempt tree.\n\nStopped client-managed KV ordered consumers are ephemeral read residue, not backup state. Backup\nignores only the pinned client's exact stopped shapes: ordinary last-value watchers and the\nwhole-bucket scanner that uses all-history delivery to collapse concurrent tombstones. A bound\nconsumer or any lookalike with a different filter, inbox, lifetime, or other config is still refused.\n\n`full` is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, and\nvalidated durable checkpoints. `registry` is the sole partial artifact. Presence, derived membership\nfeed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor\nledger files are excluded. `full` means every transferable message and registry stream, not every\nJetStream resource: endpoint submissions/facts/events/timers/workflow state, contract artifacts, and\nthe records/auth/session stores are nonportable control state. Restore recreates those streams empty\nwith their canonical configs before exposing the normal listener, so active endpoint runs,\nlifecycles, and sessions do not cross a backup. Artifacts are exclusively created `0700`;\nsnapshot/checkpoint files and\nthe manifest are `0600`; `manifest.json` is written last with exact sizes and SHA-256 values. The\ndirectory is trusted operator input: hashes detect corruption, not malicious rewriting.\n\nRestore validates and stages the exact allowlisted artifact bytes before moving or creating a store.\nIt requires the same space and existing trust state. The whole pre-commit window holds a journaled\nliveness claim (coordinator, watchdogs, brokers, absolute deadline): ordinary `up` and a repeated\n`up --restore` refuse while the claim is live, and a stale attempt is recovered only after the\ndeadline has elapsed and every recorded owner is proven dead. A retried `up --restore` handles this\nautomatically; an operator can also recover it explicitly with `cotal clean restore-attempt --attempt <id> --force`. Nothing\never rolls back a live attempt. A registry-only artifact restores as registry-only whether or not\n`--restore-only registry` is passed; omitted infrastructure is always created and the exact\npost-restore stream inventory is asserted before commit intent. Ordinary `up` from a preserved cut\nresumes only the exact recorded source store and runtime; a contradicting `--store-dir` or\n`--runtime` fails in preflight.\n\n**Admitting a seat checkpoint.** An ordinary `up` from a preserved cut admits that cut's seat\ncheckpoints before it journals the resume attempt and before any process starts, so a refusal costs\nnothing. Three gates run in order, each naming what it saw.\n\n1. *Integrity.* Every file the record names must be present, a regular non-symlink file, the\n recorded byte size and the recorded sha256, re-stat'd after the read so a file that moved is a\n refusal. Failure here consults no other gate.\n2. *Identity.* The recorded space must match, the recorded `lifecycleUid` must not belong to a live\n incarnation, and the profile revision must match this host's or be resumed under deliberately\n this host's. A differing revision is refused with both digests and the remedy, and there is no\n override: the checkpoint carries the recorded digest and not the config bytes, so nothing could\n run the seat under the recorded revision, and the manager re-digests the same file and refuses\n drift on its own. This gate has no blanket override, which is the only reason the next one may\n have one.\n3. *Recency.* `capturedAt` is compared to this host's clock against the horizon the record carries.\n Inside it, the seat resumes. Outside it, `up` refuses and prints the capture instant, the clock\n reading and the horizon; `--accept-stale-checkpoint` admits it anyway and the exercised consent\n is printed with the actual age. An unreadable `capturedAt` is refused with no override, because a\n freshness gate that fails open is not a gate.\n\nCustody transfers only after all three pass. The destination claims the recorded generation plus one\nby exclusive create, before it launches anything. A lost create means another destination is already\nclaiming that seat, and it refuses with `seat-writer-generation-create-lost` rather than adopting\nthe winner and becoming a second writer. The recorded `lifecycleUid` is reused and never minted, so\nthe resumed seat binds the same lifecycle-keyed durables.\n\nAdmission is reconciled against the inventory the resume is about to hand the manager, and that\nreconciliation finishes before the restore moves a single tree. A checkpoint whose recorded\n`lifecycleUid` is not the one the retained inventory carries describes a different incarnation of\nthat seat, and it refuses with both uids while every live working tree is still untouched and no\ngeneration is claimed. A retained\nseat with no admitted checkpoint refuses the resume by name: an absent checkpoint directory and an\nabsent record are indistinguishable from a seat that was never checkpointed, and a seat that starts\nwithout passing the gates has claimed no generation. `--accept-stale-checkpoint` is recorded in the\nresume journal with the seat, the capture instant, the admitted age and the horizon, so the consent\nsurvives the terminal it was typed into.\n\nThe whole admission is all or nothing. Coverage is settled first, then every gate runs over every\ncheckpoint, and only then is any generation claimed. A refusal at any point leaves every generation\nunclaimed, including a lost exclusive create during the claim itself: the claims that attempt made\nare removed before the refusal is raised, by the exact paths it wrote, so a generation another\ndestination holds is never touched. A claim is a create that can never be made again, so a refusal\nthat left one behind would consume the retry over the same checkpoint set.\n\n**Restoring a seat checkpoint.** Once every gate has passed over every checkpoint, and before a\nsingle generation is claimed, `up` puts each admitted seat's captured bytes back. A refusal here\ncosts nothing for the same reason a gate failure does: no claim has been made and nothing has\nstarted.\n\nA restore never moves or replaces the destination's own control directory. A checkpoint excludes\n`.cotal/` by design, so a seat whose `cwd` holds one, which is the layout an operator gets by\nrunning `up` and `spawn` in a single directory, is refused before anything is staged: promoting a\ntree that cannot contain `.cotal/` over that `cwd` would carry this host's live trust material and\nmaintenance state away with the superseded tree. The refusal names the control directory it found\nand the remedy, which is to give the seat a working tree that is not a workspace root.\n\nEach seat is staged beside its own `cwd`, in `<cwd>.incoming`:\n\n1. every recorded digest is verified again over the files as they are now;\n2. the bundle is cloned into `<cwd>.incoming`, which is refused when that path already exists;\n3. the recorded base commit is verified in the clone and checked out detached, so a bundle that does\n not contain it stops the resume instead of continuing against a different history;\n4. the index diff is applied with `--index` and the worktree diff without it, both `--binary\n --allow-empty`. That order is what puts staged content back in the index rather than only in the\n worktree, and `--allow-empty` is why a seat with a clean tree is still restorable;\n5. the untracked archive is extracted.\n\nEvery seat stages before any seat is promoted. Promotion moves an existing `cwd` aside to\n`<cwd>.superseded.<timestamp>` and renames the staging directory into place, then puts the session\npointer and store files where the destination's connector reads them, then re-reads\n`git status --porcelain` in the promoted tree and compares it to the status the checkpoint recorded.\nA restore that applied without error and produced a different index is a refusal, not a warning. The\ntwo renames are the only steps that touch the path the seat will use, so a failure anywhere leaves\nevery seat's live `cwd` as it was.\n\nThe rename itself claims the superseded name, and a taken name gets a numeric suffix. The timestamp\nhas one-second resolution, so two promotions of the same seat within one second compute the same\npath; a rename onto a name that already holds a tree fails on every platform, and that failure is\nread as taken. Nothing creates the name ahead of the move, because Windows refuses to rename onto an\nexisting directory at all. A superseded tree is the thing that rename exists to keep.\n\n`git` and `tar` run as child processes with argument arrays, never a shell string.\n\nA leftover `<cwd>.incoming` refuses the resume by name. A staging directory from a failed run is the\nonly record of what failed, so nothing removes one automatically: inspect it, remove it by hand, and\nresume. A pre-existing `cwd` is renamed rather than deleted, so a wrong checkpoint costs a rename\ninstead of a tree. When a promotion fails, the renames that attempt made are undone and the staging\ntree is left where it is, as the evidence for what did not verify.\n\nA session pointer whose recorded `sessionId` is not the one the retained inventory reopens is\nrefused before anything is cloned. A session file already present at its destination is judged by\ncontent: bytes equal to the recorded digest are already restored, and different bytes under the path\nthe connector is about to read are refused with both digests rather than clobbered.\n\n`up --restore <dir>` reaches the same admission and the same restore, after the store is restored\nand validated and before commit intent is journaled. A registry-only restore resumes no seat, so it\nadmits and restores nothing.\n\nOne limit is worth stating plainly. The writer generation is claimed by exclusive create inside one\nworkspace root, so it fences two resumes on the same host and does not fence two independent\ndestinations: copy a checkpoint to two roots and both claim the same successor. A real cross-host\nfence needs a coordinate neither root owns.\n\nAuthenticated restores validate the complete\nspace trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;\nfull restores commit to the validated operator, system-account, data-account, and active-signer root\nchain in addition to the static/user authority fingerprint. Because the system account is part of that\ncommitment, a [`cotal up --rotate-sys`](#up) makes every full artifact taken before it unrestorable\nagainst this root: take a fresh full backup after each rotation. The composed commitment is revalidated\nimmediately before store mutation and never includes secret seeds. Restore never creates fresh auth.\nSame-path restores atomically retain the old\nsource at the journaled fallback path; alternate targets retain it in place; a missing canonical\nsource needs explicit `--accept-missing-source`. Quarantine and target restores use current canonical\nconfigs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a\ncommit-intent immediately before the normal listener starts. Archive bytes never instantiate the real\ntarget: after quarantine validation, every stream is re-snapshotted from the validated quarantine\nstate into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back\nthe attempt-owned target; after it, ambiguity preserves both stores and records forward-repair\nrecourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.\n\nBootstrap brokers in every auth mode, including open, mount the store under a local account with\nrandom operation-specific logins only, each carrying the exact per-phase subject permission matrix;\nnormal static credentials and user-auth sentinel/bearer connections are rejected, and no auth\nservice or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,\nrestore initiation, exact upload id, validation, and each checkpoint recreation use separate exact\nauthorities. Every checkpoint carries the source stream's message/first/last sequence state and must\nmatch its snapshot record before mutation; core then derives and validates the only allowed start\npolicy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical `DeliverAll`\nWorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a\nstart-sequence policy there. Registry-only restore creates every omitted canonical stream and transient\nbucket on the isolated target before the normal listener is exposed. It deliberately does not resume\nretained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and\nstopped rather than being reprovisioned into a partial restore.\n\nAfter listener readiness, the manager starts attempt-bound, validates retained credentials/tokens\nwithout granting or reprovisioning, and resumes the exact persisted principals under cleanup\nsuppression. Registry-only restore uses the same flow with an empty agent set. On a user-auth mesh\nthese manager calls run as the logged-in operator's `cli` actor, the caller the preserve cut used, so\nthat actor needs a current `admin` grant. `commitResume` is an\nidempotent validation barrier only: success must be `awaitingFinalize` with an attempt-bound 64-hex\ncommit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that\nexact evidence as `manager-committed` (restore) or `resume-committed` (ordinary resume), then calls\ntoken-bound `finalizeResume`; only an `active` response for the exact token releases suppression. The\nCLI records the same token in finalization evidence before a restore becomes `active`, or before an\nordinary resume retires and consumes the marker. Re-entry from either committed state skips the prior\nidempotent activation/commit phases, retries finalization with the durable token, and finishes the\nworkspace transition. Failure before finalization preserves the committed state and cleanup\nsuppression; it is not rewritten through a degraded transition. Re-entry between any two earlier\nboundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or\nchanged per-agent dependency is a named fail-closed result; the journal becomes degraded and remains\navailable for forward repair. A retry from `resume-intent`,\n`resume-active`, or `resume-degraded` reuses the same attempt and inventory after the prior listener is\nproven stopped. A retained agent the lost manager already launched can still be running, for example\nin a tmux window, while the journal reads `resume-intent`. On a static mesh the replacement manager\ncloses that seat through the reference the lost manager recorded on the agent's slot, waits for the\nprincipal to leave presence, and launches it again. A live principal with no such record, or one that\nstays live after the seat is closed, is refused. Every normal restore listener has an unguessable\nattempt-bound NATS server name. The CLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity\nimmediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID\nrecord, endpoint, and target identity all match that proof; degraded restore repair then moves through\nthe guarded workspace transition only after manager commit. If an uncommitted bound owner is provably\ndead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the\nsame attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener\nor ambiguous owner is preserved and refused, never adopted by reachability alone. A reconstructed\ncommit/degraded attempt without either the exact bound proof or a durable dead-listener replacement\nrecord fails closed even when the recorded port is free. A later ordinary startup may pass an `active`\nrestore only when its details prove manager commit and its exact recorded listener is dead.\n\n## Mesh registry\n\n```bash\ncotal meshes [--json]\ncotal meshes add # guided, on a terminal\ncotal meshes add <space> --server <url> [--root <dir>] [--mode auth|open|user] [--tls] [--force]\ncotal meshes add <space> --mode user (--user-auth-file <bundle.json> | --from <https url>)\ncotal meshes rm <space> [<space> \u2026] [--force]\ncotal sync [--idp <auth base URL>]\ncotal use <space>\ncotal status [--space <s>] [--server <url>] [--components]\n```\n\n`meshes` lists the meshes this machine knows; a `*` marks the `current` default a bare\n`cotal spawn` joins. Entries learned from a signed-in account are marked `discovered`. Their\nregistration trust is stored under the account's private auth state, and the registry contains no\nsession token or sentinel credential bytes. Commands resolve the catalog `slug`; a different human\n`name` is rendered only as a label.\n\n`meshes --json` prints one JSON object per recorded mesh per line: `space`, `server`, `mode`,\n`root`, `default` (the `*`), and `origin` (`up`, `manual`, or `catalog` for a discovered entry). A\nlocal or hand-registered entry also carries `offline`. A discovered entry is never probed, so it has\nno `offline` field. `tlsRequired`, `events: \"required\"` and a discovered entry's `catalogName` appear\nonly when the record has them. An empty registry prints nothing and exits 0. The note about a default\nthat matches no record goes to stderr, so stdout carries only rows, on a first run too. The table is\npresentation and is not a stable parsing target. `meshes add` and `meshes rm` refuse `--json`.\n\nA registry record this build cannot use is refused by name, never rendered and never skipped. One\nthat does not parse, or is missing a field every consumer reads (`server`, `mode`, `root`, `ts`,\n`space`), makes every registry command exit 1 with the file's path and what is wrong with it.\nRemove the file or restore the record; nothing repairs or invents a field for you.\n\nAn IdP may advertise a same-origin space catalog during login. Cotal reads the complete snapshot and\nadds every valid registration without a separate `meshes add`. A snapshot younger than five seconds\nis used without a request. After that, commands that resolve a mesh target conditionally refresh the\nsaved catalogs. An operation targeting a discovered space refreshes only that space's account and\nrefuses if that account fails. Operations targeting local or manually registered meshes refresh every\naccount, print one warning for each failure, and continue. `cotal status` refreshes every account,\nnever refuses on a refresh failure, and lists each account as `fresh`, `updated`, `not-modified`,\n`no-catalog`, or `failed` with its error. `cotal sync` bypasses freshness and reports added, changed,\nremoved, unchanged, and name collisions. `--idp` limits it to one signed-in account. It never connects\nto a broker.\n\nThe registry is updated under the same lock that guards the catalog cache, so a command never lists\na discovered space set that another command is still writing. The cache records a fetched snapshot\nas not yet applied before the first registry write and as applied after the last. If a command dies\nor is stopped in between, the next command applies that snapshot again before it can use it, with\nno request inside the freshness window.\n\nThe shared dispatcher applies this preparation to every command that declares both `--space` and\n`--server` as mesh-target flags, including commands registered by other packages and commands that\ndeclare their own equivalent flag objects. Daemon and startup commands that use those names only as\nconfiguration explicitly opt out. Registry-local `meshes add` and `meshes rm` never refresh a catalog.\nWhile the registry holds a record this build cannot use, the preparation neither refreshes nor\napplies a catalog, so the command's own checks run first. A snapshot left unapplied is applied by the\nnext preparation after the record is restored or removed. A command that resolves its target through\nthe registry still refuses the record by name.\n\nRun on a terminal with the space or `--server` missing, **`meshes add` is guided**: it asks for the\none thing that cannot be derived (the broker URL), probes it, and tells you what answered - open or\nrequiring credentials. It then offers the spaces your `--root` already holds credentials for, states\nthe mode as a fact about that broker rather than asking, and shows the exact record before writing\nanything. A broker that does not answer, or a space name already registered, becomes a choice rather\nthan an error. Anything you pass on the command line is taken as given and not asked again. Without\na terminal - a script, an agent, CI - nothing prompts and the flag form's errors stand\n(`COTAL_NO_PROMPT=1` forces that too).\n\n`cotal up` and `cotal down` maintain their own records. `meshes add` registers a mesh they cannot\nspeak for: one running on another machine, a shared broker, a hosted space. `--root` is the folder\nwhose `.cotal/auth` holds that mesh's credentials and whose `.cotal/agents` holds its personas.\nThe default is the project you run it in. The registry stores that path, never a secret. `--mode`\ndefaults to `auth` when the root holds the space's account record and to `open` otherwise. The\nbroker is probed before anything is recorded, so a wrong address, or credentials that mesh will\nnot accept, fails here instead of at the first `spawn`; `--force` records without verifying (and\nreplaces an existing record).\n\nA hostname or public address is registrable only when the connection will **require TLS**. Pass\n`--tls`, or use a `tls://` URL. The scheme is recorded as enforced intent, so every later dial\nthrough the record demands the handshake (and `meshes add tls://\u2026` against a plaintext broker is\nrefused at registration). Without required TLS the fence admits loopback and private-overlay\nliterals only. RFC1918 addresses are refused in both modes because a cafe LAN is private but does not belong to you.\n\nA **user-auth** mesh registers from supplied pinned trust, never guessed: `--user-auth-file`\ntakes the bundle exported where the mesh runs; `--from` asks before it dials the address at all,\nthen fetches the `/.well-known/cotal-mesh` discovery document under that address (HTTPS only; a URL\nthat already ends in that path is fetched as given), displays the pins, and asks again before\nadopting them. Neither fetch follows redirects: a 302 can move a pinned fetch\nonto plaintext or onto another host, so it is refused rather than followed, and the pinned\nexchange must itself be an `https://` URL, except for an exchange on this machine, where plain\n`http://` is accepted for a loopback *literal* (`127.0.0.1`, `::1`, any spelling of them) but not\nfor `localhost`, which is a name rather than an address. Registration verifies that the exchange\nanswers `/health` and `/jwks` as the pinned issuer. It also verifies that the broker refuses a bare\nconnect; that auth-required refusal is the pass. The sentinel credentials land in a 0600 file under\nthe entry's root; the registry records only the path.\n\n`meshes rm` drops records. It never stops a mesh. For a mesh running on this machine `cotal down`\nis the right verb, and `rm` says so unless you pass `--force`. A hand-added record is removed by\n`meshes rm`, by an `add --force` replacement, or by a `cotal up` that actually starts the broker for that same space, server and root, which becomes that\nmesh and so takes the record over (a `cotal up` for that space anywhere else refuses instead).\nNothing that merely *infers* a record is stale from a dead broker touches it: an\nunreachable broker is listed `offline` and stays, whether `cotal up` or `cotal meshes add`\nwrote the record; a foreground `up` whose broker exits unexpectedly keeps its record the same way.\nA bare command does not treat that offline record as a running mesh;\nname it with `--space` to restart it. `cotal down` / `cotal clean all` still drop an `up` record for the project\nthey tear down; a hand-added one they leave alone even when it shares a root, because nothing\non this machine could write it back.\n\nA discovered entry belongs to the normalized IdP origin and proved subject that supplied it. Local\nteardown, cleanup, and liveness pruning do not remove it. A manual or locally started entry with the\nsame name wins and remains untouched; that discovered name is reported as a collision. Logging out\nremoves only the discovered entries owned by that account.\n\n`cotal meshes` and `cotal status` print `events: required` for a registration carrying\n`policy: { events: \"required\" }`. On that space, foreground spawn, detached spawn, manager starts,\nand interactive `join` cannot opt out or join without an event plane. `--no-events` is refused with\nthe space named. A connector without an event plane is refused with both the space and connector\nnamed. A session whose own grant omits `events.<owner>.<actor>` is refused before joining and the\nmessage names a full-row `actor grant` repair. A running seat whose event plane stops for good on\nthat space stops too. A seat the manager resumes from a preserved cut meets the same rules as a fresh\nspawn: a seat retained with `--no-events`, or on a connector without an event plane, is refused, and\na resumed seat carries the requirement in its launch material.\n\n`use <space>` sets that default; the selection applies from every directory,\nincluding inside another mesh's project. `status` is a read-only report: machine prerequisites\n(starting with the installed `cotal-ai` version), the installed extensions and their versions, this\nfolder's `.cotal/`, the recorded meshes, and a live snapshot of the selected mesh (roster, channels,\nmembership feed). Stale Claude skills and out-of-date `.agents` skills recommend `cotal setup --skills`,\nnot unscoped `cotal setup`. `status` takes `--space` / `--server` to pick the mesh to inspect; it starts\nnothing. The manager row asks the service endpoint once: a live process that does not answer is\n`not serving`, and a probe that could not be made leaves the row `running \xB7 service unchecked`.\nA process row whose PID record exists but cannot be read, or exists under both its current and a\npre-upgrade name, reads `pidfile unreadable` with the error, and the other rows still print. A live\nmanager whose delivery-aware marker cannot be read keeps its row and names the failure as\n`delivery-aware marker unreadable` with the error. The `Web process` row\nprints the address the selected mesh's dashboard recorded in `web.session` once it was listening,\nwhile the PID in its `web.pid` is alive. A `web.pid` or `web.session` that exists but cannot be read\nis named on the row with the error. Otherwise it reads `down`, or `not installed` without the web\nextension. Status and the `cotal setup` card count the web extension as installed only when a\npackage in the extensions manifest provides `web` and that package is on disk. A web package record\nthat cannot be read is named on the `Web extension` row and on the card's web row, after the\ndashboard's address when it is listening and beside an unreadable `web.pid` or `web.session`. The\n`Web process` row still prints, and reads `down` unless the dashboard is listening.\n\nIf a refresh fails, `status` may still show the kept catalog bytes for diagnosis. It labels them\nstale with the last successful snapshot timestamp and the refresh error. It never calls that state\nsynchronized or online. If a selected discovered space vanishes from a successful snapshot, the\nselection is cleared and the command reports that no default is selected.\n\nFor a user-auth mesh the selected-mesh section reports the login `status` works as: the signed-in\nsubject when this machine holds a cached session for the entry's pinned IdP, or the exact `cotal\nlogin --idp <url>` line when it does not, with no network round trip either way. A locally\nprovisioned space also shows the actor grant row; a discovered or registered remote entry reports\nthe grant as not checkable on this machine, because the ledger runs where the space was\nprovisioned. `--components` on a user-mode target probes as that same signed-in login (`ps`'s\ncredential), never a static mint; when the login cannot supply a credential, the row says why\ninstead of printing the broker's refusal of an unauthenticated probe.\n\nPersona rows name the catalog they describe. If this folder and the selected mesh use different\ncatalogs, status names both and marks which one spawn launches from. A green `default` means the file\npasses the same agent-file loader spawn uses; a present but invalid file is reported as invalid.\n\n`cotal status --components` adds a fail-loud per-component health pass. It reads **each\ncomponent's own control surface**, rather than treating a PID, a lease, or a successful probe of a\nsibling as proof that the component serves. It prints one of `serving`, `absent`, `not-serving`, or\n`refused` for each component and exits `0`, `1`, `2`, or `3` respectively (the highest observed\nstate wins):\n\n- **manager**: local PID record, its liveness-lease holder and PID, then the manager's own typed\n `status` service reachability from this host. Manager builds that do not report static\n reconciliation say `static reconciliation not reported by this manager build`; the line stays\n visible even when the manager is otherwise `serving`.\n- **delivery**: local PID record, its ready lease (`ready` is the daemon's own bound-control\n signal), and the latest `renewal.<spaceKey>.json` adoption verdict, the record of the space the\n command was asked about, keyed per space the way the pidfiles are. A re-signed credential and a\n broker-accepted adoption stay distinct facts. A root-only `renewal.json` left by an older build\n names no space and is never read as any space's verdict (`doctor auth` names it as a leftover).\n- **web**: local PID record, then the `/api/meta` response at the address the dashboard recorded in\n `web.session` once it was listening, which must name the same PID. The probe presents the\n readiness nonce recorded beside that address, the one credential the dashboard accepts on\n `/api/meta`. A live PID with no recorded address (the dashboard is still writing it, or an\n earlier build started it), or an unrecognizable process record, is `refused`, not a green\n default-port guess. A `web.session` that exists but cannot be read is also `refused`, and the row\n names the read error.\n- **broker**: the registered mesh URL dialed from this host with its recorded TLS requirement.\n\n`absent` means Cotal has no live local component record (or has a stale record); `not-serving`\nmeans the component record is live but its service/readiness surface did not answer or is not ready.\nThose are intentionally separate exit cases. A failed or unreadable probe is `refused`, never an\nabsent component or a clean zero. A PID record that exists but cannot be read, or exists under both\nits current and a pre-upgrade name, refuses only its own row. A record that its component removes\nwhile the pass runs reads as `absent`.\n\n## spawn\n\n```bash\ncotal spawn [<persona>] [--detach] [--name <n>] [--agent <a>] [--model <m>] [--variant <v>] [--prompt <text>] [--cwd <dir>]\ncotal spawn -f <cotal.yaml> [--dry-run]\n```\n\nFor a foreground spawn onto a remote user-auth mesh, a launcher may supply a one-time enrollment\ninstead of a cached human login. Prefer a private file:\n\n```bash\nCOTAL_ENROLLMENT_FILE=/run/secrets/cotal-enrollment \\\n cotal spawn --config ./seat.md --space main\n```\n\nThe file contains only the enrollment URL, ending with at most one line terminator, and must be\nmode `0600` on POSIX. An orchestrator that cannot mount a file may set `COTAL_ENROLLMENT_URL`\ninstead; that value is redeemed byte for byte, so a trailing newline in it is refused. Setting both\nis refused. Enrollment input\nrequires `--space` and applies only to a foreground persona spawn. If the mesh is not registered yet,\nthe enrollment response must carry the stock user-bundle fields and the command needs\n`--config <persona-file>` because there is no local remote-mesh persona catalog to read. The client\nredeems the URL once, registers the returned mesh material, exchanges the returned actor token at the\npinned auth service, and removes both enrollment variables before starting any child process.\n\nA cached login for the same IdP and an enrollment are conflicting proofs, so the command refuses\nrather than choosing one. An invalid enrollment never falls back to login provisioning. Unknown,\nexpired, revoked, and already-used enrollments all produce one response: ask the owner for a fresh\none. See [Enrollment redeem](identity-and-auth.md#enrollment-redeem) for the HTTP contract.\n\nA runtime that starts a managed seat outside the manager's filesystem hands the child a managed\nhandoff instead: one `0600` file named by `COTAL_MANAGED_HANDOFF_FILE`, carrying the lifecycle the\nmanager already enrolled. The runtime builds the command with `delegatedSeatCommand`:\n\n```bash\nCOTAL_MANAGED_HANDOFF_FILE=/run/seat/handoff.json \\\n cotal spawn --config ./seat.md --space main --name <actor> --agent claude \\\n --expect-owner <owner> --expect-lifecycle-uid <uid>\n```\n\nThe `cotal` entry reads the file, deletes it and drops the variable before it parses flags, prints\nhelp or loads extensions, so every outcome leaves no file. The variable is read under any letter\ncase; spellings that name different files are refused after every one of them was deleted. The\nspawn then refuses a malformed handoff, or one whose space, owner, actor or lifecycle UID differs\nfrom `--space`, `--expect-owner`, `--name` and `--expect-lifecycle-uid`, before any broker\nconnection or exchange request. Every refusal on this path names the field and never a value from\nthe handoff. The registration's server, exchange and enforcement checks, the local state this\nmachine keeps for the space (its mesh record, user-auth state and agent secret files), target\nresolution, the policy refresh, the broker preflight and the agent auth preflight quote the space,\nthe server, the exchange URL, the actor or a path named for one of them in their own diagnostics and\nin the filesystem errors under them. For a handoff each prints one fixed sentence that names the\nfield and the phase instead, whether its check fails or an error is thrown. When the agent auth\npreflight's rollback then fails to remove a secret or file, that sentence is followed by the names of\nthe cleanup steps that failed, without their errors. The event-plane policy\nrefusals name the handoff's space field. An actor outside `[A-Za-z0-9_]` and a space that cannot\nname local state, such as `..`, are refused as malformed before any plane. A handoff conflicts with\nthe enrollment variables, `--detach`, `-f` and `--creds`, and needs `--config <persona-file>`. From\nthere it runs the enrollment consumer above without redeeming anything. See\n[Delegated seats](embedding.md#delegated-seats-outside-the-managers-filesystem).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | resolved mesh | Target space |\n| `--server <url>` | registry entry | Broker URL override |\n| `--creds <path>` | none | Control-caller creds for an off-registry manager (`--detach` only) |\n| `--name <n>` | persona's `name:` | Presence-name override (does not choose the persona) |\n| `--config <persona-or-path>` | none | Persona catalog name or file path; wins over the positional |\n| `--agent <a>` | persona's `agent:`, else `COTAL_DEFAULT_AGENT`, else `claude` | Connector type (`claude`, `opencode`, `jcode`, `hermes`, and so on) |\n| `--role <r>` | persona's `role:` | Role override |\n| `--model <m>` | persona's `model:` | Model override |\n| `--variant <v>` | persona's `variant:` | Model variant override (connector-defined; e.g. OpenCode reasoning tiers) |\n| `--cwd <dir>` | this cwd | Working directory to root the agent at. Refused before launch when the directory does not exist on the serving manager's host. |\n| `--prompt <text>` | none | Initial prompt auto-submitted at start |\n| `--resume <id>` | none | Fork an existing session id into the mesh; only connectors that declare resume support accept it (see [the matrix](connectors.md)). The manager records the source session id, and `ps --wide` shows it. With `--detach --on <instance>`, a Claude session held on this host is carried to that instance first ([Resume a session](connect-claude.md#resume-a-session)); carrying one needs `--on` |\n| `--no-events` | event plane on where supported | Opt out of the session's structured event plane (`--events` only restates the default) |\n| `--share-tools <sel>` | none | Share named operator MCP servers with the agent |\n| `--subscribe <a,b>` | persona's | Channel read-set override |\n| `--allow-subscribe <a,b>` | = subscribe | Read-ACL override |\n| `--allow-publish <a,b>` | deny | Post-ACL override |\n| `--detach`, `-d` | off | Launch via the manager into a detached PTY (reattach with `cotal attach`) |\n| `--on <instance>` | class anycast | With `--detach` only: pin the launch to one manager instance id (the whole id, as `ps` prints it). Refused on a foreground spawn (no manager to pin), with `-f` (a manifest deploy launches through the manager class queue), and when empty |\n| `--file <cotal.yaml>`, `-f` | none | Deploy a manifest onto the running mesh |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--allow-stale <a,b>` | none | With `-f`: waive named stale agents (apply-only) |\n| `--runtime <name>` | manifest's | With `-f`: override the manifest's runtime |\n| `--expect-owner <u_\u2026>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the owner the handoff must carry |\n| `--expect-lifecycle-uid <uid>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the lifecycle UID the handoff must carry |\n\nEach session uses its connector's **event plane** by default: a stream of structured events\ndescribing what the agent did, rather than the prose it wrote, on a channel of its own. The channel is named after\nthe agent's principal, `events.<owner>.<actor>`, never after its display name, because two live\nagents are allowed to share a display name and would then share a stream. The launch grants publish\nrights on that channel alone, foreground and detached alike. On an open mesh, which issues no\ncredentials, the launch still allocates the agent an id, so the channel names a stable actor.\n`--no-events` is the explicit opt-out unless the selected registration says\n`policy: { events: \"required\" }`. Required policy makes the\nevent arm and grant mandatory, so `--no-events` and connectors without an event plane are refused.\n\nThe launch decision and the grant are separate on purpose. Holding publish rights on a channel is\nnot a request to publish to it, so writing an event channel into an agent file's `allowPublish`\ndoes not override `--no-events`.\n\nThe persona (`--config` > positional > `COTAL_DEFAULT_PERSONA` > `default`) is loaded from the\ntarget mesh's `.cotal/agents/` when it is a bare name. A reference that contains a path separator or\nends in `.md` is loaded from that file. A relative path resolves against the mesh root, except that\nan enrollment or a managed handoff resolves `--config` against the working directory. A missing\npersona is refused with the catalog directory or the file that was checked. The launch flags\noverride the file. On a user-auth mesh the\neffective name is also the agent's actor token, so it must match the token grammar (no `-`); the\nspawn is refused with that explanation before any request is sent. Foreground runs the agent\nattached to your terminal; `--detach` hands the launch to the running manager. Both modes get the\ndurable backstop on a mesh that runs the delivery daemon; `--live-only` skips it for a foreground\nspawn (messages posted while it is disconnected are then not replayed). A foreground exit retires\nthe agent's creds and broker footprint, like a manager despawn. The creds and the footprint are\nremoved as separate steps, so a creds file that cannot be deleted does not leave the broker durables\nbehind, and the failed steps are reported together. On a user-auth mesh the two arms\ndiffer: a spawn against a mesh this machine provisioned revokes the actor row on exit, while a\nremote spawn (an enrollment or the advertised provisioning endpoint) removes only this machine's\ncredential files; its grant stays until the mesh operator revokes it, and the launch line says\nwhich arm you are on. A spawn through the advertised provisioning endpoint against a record that\npins no exchange URL is refused before the grant is requested, so no credential lands on this\nmachine. A foreground `--share-tools` selection that names a server the config does not declare is\nrefused before anything is provisioned. A `--detach` spawn is an\n**action**: the manager accepts it and returns the allocated identity at once, then the launch\nfollows to a terminal outcome rather than blocking (see [the control surface](control-surface.md)).\nSee [Connect Claude Code](connect-claude.md) and [Agent files](agent-files.md); `-f` is a\n[manifest deploy](#manifest-deploys). (`cotal start` was merged into `cotal spawn --detach`.)\nA `--detach` spawn onto a manager from another Cotal release is refused before any request is sent\nwhen the manager's contract does not declare a field this CLI sends. The refusal names the field,\ncalls it version skew, and gives this CLI's version. A field you leave unset is not sent, so it\nnever causes that refusal.\n\nA manager has 50 seat slots, and each seat counts once. A slot is held by a managed seat (a row in\nthat manager's `cotal ps`, including a seat still joining), by a reserved launch the manager accepted\nbut has not started a process for, or by a cooling hold. A seat that ends within 10 seconds of\nstarting leaves its slot cooling until those 10 seconds pass, unless an operator stopped it. Such a\nseat holds only that cooling slot, even while its launch is still reporting the failure. A spawn\nrefused at the limit states that split and whether waiting can free a slot:\n\n```text\nat capacity (50 of 50 slots: 49 managed, 0 reserved, 1 cooling); waiting frees a cooling slot in 7s, or despawn one\n```\n\nA cooling slot frees at the stated time. A launch that has not settled frees its slot only if it\nfails, and a managed seat frees its slot only when it stops. The refusal counts a launch as pending\nonly while it holds a slot, so a launch whose seat already ended is not counted. The roster counts\npresence, which also includes peers no manager owns, so its total is a different number.\n\nRun from a managed seat's own shell on a static or open mesh, `cotal spawn --detach` launches as\nthat seat when it targets the seat's own space. Without `--space` it picks that target the way the\noperator path does, so a recorded mesh that is not running is skipped. The CLI reads the seat's\nlaunch identity (`COTAL_NAME`, `COTAL_ID`, `COTAL_LIFECYCLE_UID`, `COTAL_SPACE`, and on a static\nmesh the seat's own credential), so the manager records the seat as the spawner, the same as for\nthe seat's `cotal_spawn` tool. On a static mesh that credential also proves the seat's space, so a\nlaunch without `COTAL_SPACE` still runs as the seat, and a target space holding no credential for\nthe seat is refused. An open mesh acts as the seat only when `COTAL_SPACE` names its space. The\nseat can then stop the child with `cotal_despawn`, and the manager stops the child when the seat\nexits. On a static mesh a seat whose agent file lacks `capabilities: [spawn]` is refused, because\nits credential holds no spawn subject.\n`--on <instance>` keeps its pin: the seat's own credential has no instance route, so on a static\nmesh the CLI mints a one-shot `manager-caller` view for the seat, pinned to that instance and\ncarrying the spawn subject only when the seat's credential holds it. On an open mesh the call keeps\nthe TLS requirement the mesh records. `--creds`, `--server` with an unregistered `--space`, and a\nuser-auth mesh keep the operator path.\n\nForeground user-mode launches serialize their local actor material. A duplicate refuses\nwithout replacing the first launch's files. A new local-user foreground launch also refuses\nan existing grant before rotating it. Departure cleanup is conditional on the launch's token\nand lifecycle generation, so an earlier launch cannot revoke a successor's grant. Remote\nprovisioning remains the mesh operator's authority and can reissue material before the\nclient observes an existing same-host consumer.\n\n## models\n\n```bash\ncotal models [--agent <connector>] [--refresh]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--agent <connector>` | all registered connectors | Connector whose catalog to list |\n| `--refresh` | off | Ask the connector to refresh its provider cache |\n\nAsks the running manager for each connector's model catalog (model ids plus their variants)\nfor connectors that expose one. OpenCode and Codex query harness/provider surfaces; Jcode reads\nproviders that enable `model_catalog = true` in the operator Jcode `config.toml`. Jcode's listed\neffort tiers render as `variants (declared, not provider-verified)`, and launch can still refuse one.\nA connector without a catalog says so. A connector whose harness the manager did not find at boot\nreports the reason boot recorded, as a spawn does, so restart the manager after installing it. Pick\na result with `cotal spawn --model <id> --variant <v>`, where `<id>` is the model id as the catalog\nprinted it. OpenCode and Codex ids are the full\n`provider/model`; Jcode ids are bare (`opus-5`, not `cliproxy/opus-5`), because the provider is\nselected by the operator's Jcode config and a prefixed id is refused at launch with the bare form\nnamed.\n\n## endpoints\n\n```bash\ncotal endpoints [--space <s>] [--server <url>] [--creds <path>]\n```\n\nLists the mesh presence roster: agents, the manager, and any other protocol endpoint, with each\nendpoint's role, kind, status, and current activity. Unlike `ps`, this is a read-only presence view;\nit is not limited to child processes owned by the manager.\n\n## Endpoint control\n\n```bash\ncotal describe <endpoint> [--on <instance>] [--space <s>]\ncotal invoke <endpoint> <command> [--args '<json>'] [--space <s>]\ncotal invoke <endpoint> <command> --name <agent> [--admin] [--space <s>]\n```\n\nThe generic v0.4 service surface. `describe` resolves a registered endpoint's command set off the\nwire - the reserved `describe` command answers the registered contract digests, the schemas are\nfetched from the space's content-addressed contract store, recompiled, and verified against those\ndigests - and prints each command with its capability class and targeting shape. `--on <instance>`\npins `describe` to one manager instance's rail (the whole id, as `ps` prints it under its\n`manager <id>` headers), so an operator can read what that instance serves in a multi-manager space;\nunpinned, the class queue answers and the attribution line names whichever instance did. `invoke`\ncalls one command by name: `--args` is a JSON object validated against the fetched input schema\n*before*\npublish; a targeted command takes `--name <agent>` (resolved to the agent's current principal through\n`inspect`) or `--self`. `--admin` uses the admin instrument credential, whose cross-agent reach rides\nthe operator-only `any` authorization mode. Neither command has compile-time knowledge of any\nendpoint's schemas - this is the same trust chain every built-in control command now uses. Needs an\nauth mesh: the manager registers its service on both static and per-user meshes (a signed-in user\nrides their bearer; each visible or invoked command still requires its existing grant, and cross-agent\nreach needs the `admin` scope). An open mesh has no service registry.\n\n## Managed seats\n\n```bash\ncotal ps [--on <instance>] [--wide | --json] [--slots] [--space <s>]\ncotal stop --name <n> [--on <instance>] [--space <s>]\ncotal attach --name <n> [--on <instance>] [--no-reconnect] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | none | Managed agent to stop / attach (required) |\n| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on \"\"`, an unset shell variable) is refused, never treated as absent. A roster principal id (`local.\u2026`) is refused with a message naming the instance id `ps` prints |\n| `--wide` (`ps`) | off | After each seat's compact row, print extra operational facts the manager records: the provider the connector reported serving the model, `cwd`, `pid`, spawner, lifecycle uid, the owning manager's instance id and host, and for a `--resume` seat the session it forked (`forked from <id>`, with the source title and transcript SHA-256 once a Hermes or Jcode seat has recorded its fork; a carried Claude session prints `forked from <host>:<id>` with its title, SHA-256 and `carried <time>`, the time its bytes reached the manager). Model and requested variant stay in the identity row rather than printing twice. A fact the manager did not record (for example a runtime with no real process, or a connector that reported no provider) prints nothing, never a placeholder |\n| `--json` (`ps`) | off | Machine-readable: one JSON object per seat per line, copied unchanged from the manager row. Instance headers and errors go to stderr, so stdout contains only rows. Mutually exclusive with `--wide` |\n| `--slots` (`ps`) | off | List the durable static slot rows this manager owns instead of live seats, through the `slots` command. Mutually exclusive with `--wide`. A row that is not in the live roster still prints, with `live=false`; a retired row never prints |\n| `--no-reconnect` (`attach`) | off | End the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code |\n\nA raw `--creds` file is refused by `ps`, `stop`, `attach` and the other control commands, because\nthat route mints no endpoint-caller triple; the project folder, or `--space` against the registry\nentry, is the route that does.\n\nThe human `ps` row is presentation text and is not a stable parsing target. Scripts use `--json`,\nwhich is the machine-readable row contract.\n\n`--slots --wide` is refused: `--slots` lists durable static slot rows, `--wide` prints live seat facts, and the two answer different questions. Across a multi-manager scatter, `--slots` prints each manager's rows under its own instance header, the same way the plain `ps` scatter does.\n\nThese are operator clients over the running manager's control plane. The default row includes the\nconnector, model pin, optional requested variant, and runtime as operational descriptors for the\nmanaged row. They do not make a shared display name a unique protocol identity; use `--json` when\nunambiguous owner+actor attribution is required. An omitted variant means no override was requested;\nCotal does not invent an effective provider default it cannot observe. `ps` also prints two state\nfacts per managed agent, because they answer different questions: the process fact from the manager's\nown runtime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact\nfrom the roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has\nno presence row at all: a seat that has not joined yet, or one that never did). When the seat's\nconnector relays a harness-reported condition, the mesh fact carries its code and how long it has\nheld, so a seat whose turn died on a provider rate limit reads `waiting (rate_limit for 40m)` rather\nthan a bare `waiting`, and `--json` carries the whole `condition` object. When the connector reports\nthe seat's last work event (presence `activeAt`), the mesh fact ends with its age, such as\n`\xB7 active 3s ago`, and `--json` carries `activeAt`. A seat whose turn stopped advancing keeps\nheartbeating, so its presence row stays fresh and this age is what shows the stall. A seat can be\n`running` and `mesh offline` at once: the process is alive and its presence has lapsed. That row says\nhow long, as in `mesh offline for <age>` with an age such as `3.5h`, counted from the seat's last\npresence heartbeat, which `--json` carries as `offlineSince` (epoch ms). The age is read only from\nthe seat's own presence record, matched on its principal and lifecycle uid, so a same-named peer or\nan older lifecycle never dates it. The manager log names each managed seat that is offline on the\nmesh while its slot is held\n(`seat offline on the mesh: <name> - last heartbeat <time>; process <state>`), including one its\nwatch first sees offline after a reconnect, and each one that comes back\n(`seat back on the mesh: <name>`), so a watchdog that only checks process liveness has a line to\nact on. The manager does not reap or re-key such a seat. The mesh fact is only a verdict while the\nmanager's own presence watch is fresh: when that watch has been silent past the liveness window, or\nhas not replayed the bucket yet, every row prints `mesh unknown` with the reason instead (`--json`\ncarries it as `meshView: stale | unpopulated`), because `offline` and `not in roster` would then\ndescribe the manager's watch rather than the seat. The manager rebinds a watch that goes\nsilent under a live connection on its own, so `mesh unknown` normally clears within a liveness window.\nOn a user-auth mesh `ps` also renders each managed agent's last credential-refresh outcome, fail-closed.\n\n**Mode split (chosen up front, never try-scatter-then-degrade):**\n\n- **Static / open mesh.** Bare `ps` is a **class scatter**: it freezes the live manager class from\n the records registry, merges every registered instance's agents grouped and attributed per\n instance, and a non-answering instance is shown as `registered, no answer within the deadline`\n (never silently omitted). A refused list or a missing answer makes the census incomplete: rows\n from other instances remain visible, but `ps` prints an incomplete-census warning on stderr and\n exits non-zero, including with `--json`. Those rows are not a complete seat count. A contract\n mismatch prints one plain comparison of the requested and served input/output digest pairs and\n advises aligning manager versions. The no-answer label means only that the instance is registered\n and did not answer. It does not say the host is down, because a dead host never deregisters itself and a\n live one can be slow; if it is gone, deregister it.\n `--on <instance>` pins the read to one exact instance id instead. A wrong pin fails loud\n rather than falling through: a well-formed id that no live manager carries is reported as\n `manager instance <id> did not answer` (nothing else is asked), and a credential without that\n instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager\n that answers with a refusal is shown with its own cause; \"no manager reachable\" is said only when\n nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile),\n `ps` says the manager registry could not be read rather than pronouncing on the managers, which\n may all be up.\n\n**The verdict is scoped to the endpoint rail the request rode.** An issued caller rides the\nversioned `ep.v1` rail, a separate subject space from the legacy `ep` rail, and an endpoint serves\nboth (SPEC 13.15). A manager older than the versioned rail serves `ep` alone, so it can be running,\nregistered and answering while an issued caller's request reaches nobody. Silence on `ep.v1` is\nreported as `no manager answered on the <rail> rail` with `ep.v1` as the rail, and names both causes\nit is consistent with: no manager running, or one older than the rail. The CLI cannot tell them\napart, because the service registry records no package version, so check whether a manager is\nrunning and, if it is, its version. The same scoping applies to `cotal run`'s hosted verbs, which\ndrop the `--local` suggestion there, since `--local` drives the run from the calling process and\nnames the caller as its answerer.\n\n**`stop` and `attach` route by seat locality.** A seat can only be stopped or attached by the\nmanager actually running it, and the class queue does not know which one that is. So on a\nstatic/open mesh both verbs first ask every registered instance which one hosts the named seat, then\naddress that instance directly. This happens by default; you do not need `--on`.\n\n`--on <instance>` remains the override, for when you already know where the seat lives or the\nlookup itself is degraded. On a **user-auth mesh**, the exchange selects one authorized manager\nfor a short-lived `manager-caller` view. `--on` requests a specific instance; without it, selection\nmust be unique. Discovery and the command use that instance route. The caller gains no registry\nread or scatter permission. An absent, ambiguous or unauthorized selection refuses before sending\nthe command.\n\nA seat is reported as **not found** only when every reachable instance answered for itself. An\ninstance that stayed silent past the deadline, or that refused the read rather than answering, said\nnothing about which seats it hosts, so the seat may be running on it. That case reports that the\nlocation could not be established, names the instances that did not answer, and states outright\nthat it is not a report that the seat is gone. Read it as unknown and retry with\n`--on <instance>`; a retry loop that treats it as \"already gone\" stops looking for a seat that is\nstill running. A single manager cannot tell \"hosted elsewhere\" from \"does not exist\": it answers\n`not-found` for both, which is why the search asks all of them and why an incomplete search\nconcludes nothing.\n- **User-auth mesh.** `cotal ps` reports what **one** authorized manager knows about your agents\n (an instance-addressed read against its in-memory roster, owner-filtered). It does **not** report\n other manager instances or establish whether they are reachable. Completeness across a\n multi-manager user-auth space is not claimed.\n A manager that does not answer fails the command outright (exit non-zero), rather than printing\n an empty list that could be read as \"no agents\". Your ledger row needs the `admin` scope to\n reach `ps` at all; `spawn` alone is refused by the broker (the ep tier boundary).\n\n`attach` streams and drives an agent's terminal on the `pty` runtime; detach with the escape key\n(Ctrl-] by default; see [`COTAL_DETACH_KEY`](config.md)). The key is recognised as the legacy\ncontrol byte and as the kitty keyboard protocol and xterm modifyOtherKeys encodings of the same\npress, so a terminal with either protocol enabled detaches too. It does so over a one-use, holder-bound\nmesh session ([SPEC](../SPEC.md) \xA713.6): the manager replies with a signed session grant (never a\n`127.0.0.1` URL), the CLI redeems it once over the broker, and the browser console (`cotal console`)\ndrives the same session. `stop` and `attach` need a running manager to talk to. On a static mesh\nthey are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your\nowner) need only the `spawn` scope; another owner's agent needs `admin` on your ledger row\n([identity & auth](identity-and-auth.md)). Launch detached agents with [`spawn --detach`](#spawn).\n\n**`attach` reconnects when the link dies.** A session lives on a network link, and a laptop that\nsleeps, a VPN that drops or a wifi handover kills it. When that happens `attach` prints\n`[cotal: connection lost, reconnecting]` on stderr and starts asking the manager for a new session:\na fresh grant, a fresh per-session credential, a fresh connection, so every attempt re-runs the same\nauthorization the first attach did. On success it prints `[cotal: reconnected]`, the manager repaints\nthe seat's current screen the way it does for any attach, and you carry on in the same terminal.\nRetries wait 1s, 2s, 5s, 10s, then 30s, for as long as the seat exists. The detach key is read the\nwhole time the loop runs, the waits and the attempts alike, so a reconnect never traps you: press it\nwhile a session is being established and the attach ends there, and a session that lands behind the\npress is handed back to the manager rather than left holding a slot. Everything else you type while\nthere is no session is dropped rather than queued, so keystrokes aimed at a terminal that turned out\nto be frozen, Ctrl-C included, are not delivered to the agent by a reconnect you did not know had\nhappened. That starts before the first session, not at the first reconnect: at a terminal, `attach`\nreads and drops what you type while it is still resolving the mesh, so a key struck at a prompt that\nhas not come up yet does not reach the agent when it does.\nThe terminal is in raw mode for the whole reconnect, including when the link died before the first\nsession finished opening, so the detach key works there too instead of echoing as `^]`.\n\nA **pipe** carries script input. For example, `printf 'ls\\n' | cotal attach --name web` is\nbuffered until the session opens. Buffering continues across reconnects, so\n`tail -f log | cotal attach --name web` does not lose the part of its feed written while the link was\ndown. Only a terminal gets the reader; `--no-reconnect` keeps the old behaviour on both.\n\nIt stops on its own when reconnecting cannot help, and says why: a manager that refuses the attach\nexits non-zero with the manager's own message, and a reconnect that finds the seat no longer there\n(despawned, or its agent exited while the link was down) exits cleanly with `seat <name> is gone`.\nA local connect refusal that retrying cannot fix, such as a static-auth mesh whose seed is now\nmissing, also exits non-zero with the refusal's own sentence. A broker that is still unreachable\nkeeps the loop trying in silence.\nA refusal that could still pass, such as a manager at its session ceiling, is relayed in the\nmanager's own words while the loop keeps trying, once per refusal rather than once per attempt.\nPressing the detach key, or the agent's process exiting while you are attached, ends the attach as\nit always did. `--no-reconnect` turns all of this off and restores the single-session behaviour,\nwhich is what a script wants.\n\nEach reconnect also hands the abandoned session back to the manager, over the first link that can\ncarry the message, so an attach that flaps does not eat the manager's session slots one outage at a\ntime. If that message never gets a link, the attach says so when it ends. The live-session ceiling\ndefaults to 64 concurrent sessions (`--max-sessions`); the browser console opens one session per\npane, so a dashboard over a large mesh should size for agents \xD7 panes. Hitting the ceiling refuses\nbefore a credential is minted and names `--max-sessions`.\n\nWhich mesh `attach` resolves also decides **how it redeems the grant**. On a registered open mesh\nthere is no local seed. The CLI connects bare, the same way other control commands already do, and\nthe session rail is the caller rail that a real open-mode connection already reaches. Telling the\noperator to re-register the root is false: the registered root is already the contract. On a\nstatic-auth mesh the grant is still redeemed by minting a short-lived\nsession-scoped credential from the seed at the root the mesh resolved to, never from a `.cotal`\nfound by walking up from whichever directory you happen to be standing in. The difference is not\nhypothetical: `~/.cotal` exists on every install because the mesh registry lives there, so a command\nrun anywhere under your home directory but outside a project used to mint from your home\ndirectory's trust and present it to a broker that trusts a different chain, which surfaced as a\nbare authorization failure that named nothing. A directory that does hold another chain for the\nsame space is now reported on the way past, and not obeyed:\n\n```text\n! this directory resolves to /Users/you, whose .cotal/auth holds a DIFFERENT trust chain for space \"team\".\n attach used /Users/you/projects/app, the root this mesh resolved to. The other one is not being used, and is worth a look.\n```\n\nWhen a **static-auth** mesh holds no seed at the resolved root, `attach` refuses and names what it\nresolved, the broker and the root, instead of describing a directory it did not use and instead of\ntaking the open-mode path. An authenticated registry entry with a missing seed is still\nauthenticated. On a USER-AUTH mesh `attach` reads no seed. It sends your login and the session grant to\nthe auth service, which issues a `session-caller` bearer only if your owner and actor hold that\nsession. The connection it opens expires with the session grant.\n\nTerminal bytes stream over the mesh; the manager's own HTTP/WS face serves the console. That endpoint binds\n**loopback by default**, so nothing is exposed by accident; `cotal up --host <addr>` passes its bind\naddress down, which is what lets you reach the browser console (`cotal console`) for an agent whose manager runs on another machine.\n`attach` does not use that face: it redeems a signed mesh session grant over the broker instead (see above), so it reaches a\nremote manager regardless of the bind address. A\nbare `cotal supervise` and an embedded manager stay machine-local. Set it directly with\n`supervise --console-host <host>`.\n\nThat address is **recorded on the mesh** and carried forward, because it is a decision rather than\nsomething later commands can work out for themselves (a broker dial address is not a manager bind\naddress). Every later manager launch for the same mesh reuses it, including a same-root `cotal up` repair,\nan adopted preserved or restored listener, and a `spawn -f` manifest deploy. A manager replacement\ndoes not quietly move a reachable attach face back to loopback. Passing `--host` again overrides it,\nso you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only\nand records nothing.\n\nBecause that face mints terminal read and write authority for every managed agent's browser session, it is credentialed in two\ntiers. A mesh caller receives a **ticket** bound to the single agent the manager just authorized,\nsingle-use and short-lived, so one authorized attach can never be re-pointed at someone else's\nagent. The **console token** is the operator's own, reaches every agent, and is printed only to the\nmanager's output. The roster, the live feed, and the PTY stream all answer `401` without one; the\nstatic console shell is served openly, since it describes no agent.\n\n## input\n\n```bash\ncotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | | Managed agent to type into (required) |\n| `--text <text>` | | The text to type, taken verbatim (required) |\n| `--no-enter` | off | Type the text and stop there, without pressing Enter |\n| `--on <instance>` | class anycast | Pin to one manager instance id using the same rules as [`attach`](#managed-seats) |\n\nTypes one line into a running agent's terminal, as if you had typed it there, and returns. This is\nthe half of [`attach`](#managed-seats) that a program wants: `attach` is a live stream that holds a\nsession open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it\nto send a single line. `input` is one authorized call.\n\nWhat it is for is **harness commands**. A line beginning with `/` is not chat and not a message: it\nis something the agent's own harness handles, and the only way in is the keyboard.\n\n```bash\ncotal input --name reviewer --text \"/compact\" # ask the harness to compact its context\ncotal input --name reviewer --text \"/model opus\" # switch its model\ncotal input --name reviewer --text \"hold on that PR\" # ordinary typing works too\n```\n\n**Quoting.** `--text` takes a value, so a payload starting with `/` survives as written. A payload\nstarting with a dash needs the `=` form, because the shell-style `--text --foo` is ambiguous and is\nrefused rather than guessed:\n\n```bash\ncotal input --name reviewer --text=--verbose # dash-leading text: use --text=<value>\n```\n\nEnter is pressed by default, since a command typed but never submitted has not been delivered.\n`--no-enter` types the text and leaves it sitting at the prompt, which is how you stage a line and\nsend it later.\n\nNothing comes back but a delivery receipt (`\u2713 sent 9 bytes to reviewer`, counting the trailing\ncarriage return). Whatever the agent does next shows up where its output already goes: the mesh, its\ntranscript, or an `attach`.\n\n**This one is operator-only, and more narrowly than `stop` or `attach`.** Those two are granted to\nanything holding `spawn`, so an agent can stop and attach to seats under its own owner. `input` is\nnot: it is granted only to operator credentials, which on a user-auth mesh means your ledger row\nneeds the `admin` scope, the same scope [`ps`](#managed-seats) already needs there. The reason is\nthat a write into a terminal is control of whatever is running in it, and on a user-auth mesh the\nown-owner rule covers every seat under you, not only the ones you launched: a `spawn`-scoped agent\ncould otherwise type into a sibling it never started. Seat locality is still resolved for you.\n\nOnly the `pty` runtime can be typed into. The external terminal runtimes (`tmux`, `cmux`, `orca`,\n`herdr`) attach to a process they do not own, so they have no input stream for it and the command\nrefuses by name rather than dropping the keystroke.\n\n## personas\n\n```bash\ncotal personas list [-v] [--running]\ncotal personas show <name>\ncotal personas edit <name>\ncotal personas new <name> (--prompt <t> | --from <f>) [--role <r>] [--model <m>]\ncotal personas rm <name> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh's persona catalog |\n| `--role <r>` | none | `new`: the persona's role |\n| `--model <m>` | none | `new`: the persona's model |\n| `--prompt <t>` | none | `new`: the persona's prompt text |\n| `--from <f>` | none | `new`: seed the prompt from a file |\n| `--verbose`, `-v` | off | `list`: include role / model / description |\n| `--running` | off | `list`: mark personas live on the mesh |\n| `--force` | none | `rm`: required, delete without prompting |\n\nPersonas are the local agent files under the resolved mesh root's `.cotal/agents/`, the same catalog\n`cotal spawn` launches from. `--space` and `--server` therefore move every list, read, write, delete\nand completion operation to the selected mesh. An unresolved target refuses rather than falling back\nto the current directory. See [Agent files](agent-files.md) for the file format.\n\n## supervise\n\n```bash\ncotal supervise [--runtime <name>] [--space <s>] [--server <url>] [--spawn <names>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space to supervise |\n| `--server <url>` | hosting mesh, or matching registered mesh | Broker URL. A registered mesh supplies it when omitted; a different explicit value is refused before anything is dialed. |\n| `--runtime <name>` | `pty` | Agent runtime (`pty` built in; extension runtimes are explicit-only) |\n| `--console-port <n>` | none | Protocol-console port |\n| `--console-host <host>` | loopback | Bind host for the console endpoint. Loopback keeps it machine-local; `cotal up` passes the address it bound the broker to, which is what lets the browser console reach this manager from another machine. `cotal attach` does not use this face: it redeems a mesh session grant over the broker |\n| `--max-sessions <n>` | 64 | Live-session ceiling. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. A capacity refusal names this flag. `cotal up --max-sessions` records the same number on the mesh so a later `supervise` started by repair or `spawn -f` keeps it |\n| `--roster <file>` | none | Declarative roster to boot at startup. See [Roster files](define-a-team.md#roster-files) |\n| `--launch <spec>` | none | Resolved manifest launch spec (from `up -f` / `spawn -f`) |\n| `--spawn <names>` | none | Comma-separated personas to pre-spawn at startup |\n\nThe manager is the agent supervisor and control plane: it answers `spawn --detach`, `stop`, `ps`,\n`attach`, and the `cotal_*` manager tools. `cotal up --detach` starts one for you; run `supervise`\ndirectly to recover a dead manager or drive a custom runtime. Default runtime is `pty`; install an\noptional provider first (`cotal ext add @cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/cmux`, or `@cotal-ai/herdr`) and\nselect it explicitly. A missing provider or app fails loudly; there is no fallback. See [Deploy](deploy.md).\nBoot inventory decides whether this process takes unpinned `spawn`/`launch` on the class rail:\nif every declared connector is unavailable, those commands stay on this instance rail only\n(`status` reports `classSpawn: false`). `describe` still answers on the class rail, so an\nunpinned spawn can bind-fence against a skip member; re-issue, or pin `--on`. A partial\ninventory keeps the class rail and names `--on` on a harness refusal, because sibling\ninventories are not readable from the serve credential. See [control surface](control-surface.md#instance-routing).\n\nOn a normal `SIGINT`/`SIGTERM`, the manager stops every seat and requires the selected runtime to\nprove the seat is gone before it releases the manager lease or service registration. A stop that\ncannot prove exit fails loud and keeps manager authority instead of reporting a clean shutdown while\nan orphan still holds broker rails. When the runtime refuses the stop itself, the shutdown fails at\nonce and its error names the refusal as `stop failed: <message>`. After an abrupt manager death, the same logical successor\nterminalizes only its own durable static slots, verify-evicts the predecessor's broker principal,\nrecords that result in the lifecycle's caller-readable audit detail, reaps the predecessor's seat\nprocess through the runtime's custody reference recorded on the slot (the pty runtime verifies the\nprocess start identity in its seat record, so a reused pid is never signalled), and only then\nretires the lifecycle and frees the alias. A runtime that custodies its seats reserves that\nreference before it launches one, and the manager records it on the slot's first durable row, so a\nmanager that dies part-way through a spawn also leaves a seat its successor can address. A\nsame-lifecycle restart or a resume records the new seat's reference on the slot the same way, and\nwhen the slot does not take it the restart or resume fails and stops any seat it started, so the\nslot never names a seat that has already exited while its replacement runs. A resumed seat keeps\nits retained credentials, so the resume frees it only once its exit is proved; a seat whose stop\ncannot be proved stays managed, and the resume's error says so, naming a stop the runtime refused\nas `stop failed: <message>`. A spawn\nthat launched its seat and then failed is rolled back by the manager that launched it, and that\nrollback reaps the seat through the same reserved reference before the lifecycle retires. Missing or unverified broker evidence keeps the slot\nterminalizing, and so does a runtime that cannot reap by reference. While the name stays held, a\nsame-name spawn or a resume re-drives that teardown with the same recorded reference, so it reaps\nthe seat again or keeps the name held.\n\nA `meshes add --mode user` entry is a **participant** registration, not hosting authority. A\nparticipant may run `supervise` only when the host advertises the remote manager authority service\nand the signed-in actor has the dedicated `supervise` ledger scope. The CLI obtains the closed,\nloopback-only `manager-service` view; `spawn` and `admin` do not substitute for that scope. The\nhost issues the manager's public-nkey JWT material through its lifecycle-bound prepare \u2192 activate\n\u2192 renew protocol, never by handing the participant a signer or static provisioner credential.\nThe host also performs instance-scoped eviction and guarded gate reconciliation. A remote manager\nrefreshes its short-lived registration executor before clean deregistration, so a long-running\nprocess removes its service row on `SIGINT` or `SIGTERM`. After an unclean stop, the same instance\nverify-evicts its superseded family and advances the process epoch. If an abandoned frozen gate\nholds the manager governance slot, a different supervise-scoped manager asks the host to reconcile\nthat holder after a complete gone verdict, then retries its registration once.\n\nA remote supervise never uses local signing trust: with host-issued authority in hand, the\nmanager mints from that authority alone and consults local records only to refuse a conflict,\nnamely the supervised space's own trust records under the cwd root. A root that hosts another\nstatic space beside the sign-in is a normal configuration and is never read as this space's\ntrust.\n\nThe broker URL in the registry entry decides the transport. A remote broker is often published\nover a `wss://` edge rather than a raw `nats://` port, and `supervise` dials whichever scheme the\nrecord holds, starting with the manager-authority registration it runs before the manager exists.\nThe record also decides whether that registration requires TLS, so a participant never downgrades\nthe credential exchange to a plaintext connection the registry did not describe.\n\nWithout that advertised host service or scope, `supervise` refuses before it starts a manager.\nRun `cotal spawn` without `--detach` to launch a foreground agent, or ask the space host to enable\nthe authority service and grant `supervise` for detached agents. If a running remote manager loses\nrenewal, it reports degraded state and refuses unsafe new starts and restarts; live agents are not\nsilently replaced. Do not run `cotal down` or `cotal up` on a participant machine to repair this\ncondition.\n\n## service\n\n```bash\ncotal service install [--mesh <name>] [--linger]\ncotal service status [--mesh <name>] [--json]\ncotal service uninstall [--mesh <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--mesh <name>` | this folder's mesh | The mesh whose manager the service runs; one unit per mesh |\n| `--linger` | off | install: when lingering is off, ask logind to enable it so the user manager starts at boot and the service survives logout. Never enabled silently |\n| `--json` | off | status: machine-readable output |\n\nRuns the manager as a user service so it survives logout and reboot. On Linux this installs a\nsystemd user unit (`~/.config/systemd/user/cotal-manager@<key>.service`, where `<key>` is the\ncase-safe mesh key); on macOS a launchd agent plist under `~/Library/LaunchAgents/`. Any other\nplatform, or an absent systemd/launchd user session, fails with a message naming what is missing.\n\n`install` resolves the mesh from the registry and binds the unit to that entry's root and broker\naddress, so it can be run from any directory. The mesh must be registered (`cotal up` or\n`cotal meshes add`) before installing; an unregistered name refuses before anything is written.\n\nThe unit's `ExecStart` is the bare `supervise` command. The mesh facts travel in the unit's\nenvironment (`COTAL_SPACE`, `COTAL_SERVER` pinned to the registered broker URL, whatever port it\nlistens on) rather than the command line, because command lines are readable by every user on a\nmulti-user host. On Linux that environment is a `0600` `EnvironmentFile`; on macOS it is the\nplist's `EnvironmentVariables`. The same environment gives the service a private `COTAL_HOME` and\n`XDG_CONFIG_HOME` under the unit directory, so the service manager never touches the login\nuser's `~/.cotal`. First-run connector seeding runs synchronously inside `service install`,\nagainst that private config root; the unit itself starts with `COTAL_SKIP_CONNECTOR_SEED=1`\nso a manager is never interrupted mid-seed by a restart. An install whose pre-seed cannot\ncomplete (network unreachable, registry error) refuses instead of deferring.\n\nThe same environment pins `PATH` to the `PATH` of the shell that ran `install`.\nWithout it the unit inherits the service manager's own short\n`PATH`, which usually lacks `~/.local/bin` and Homebrew, so the manager's boot inventory would\nreport a harness unavailable that your shell resolves. Install from a shell that resolves every\nharness the service should launch, and reinstall after moving one. A relative entry, including\nan empty one, is resolved against the directory you ran `install` from, because the unit starts in\nthe mesh root where the same spelling names another directory. An entry with a `..` segment is\npinned as the directory your shell reaches through it, with symlinks followed, and refuses when it\nreaches none. A `PATH` set to the empty string is one empty entry, so it pins that directory. An\nunset `PATH` refuses.\n\nEvery value the unit derives from a path (`WorkingDirectory`, the `EnvironmentFile` path, the\n`ExecStart` tokens) is escaped for systemd specifiers (`%` becomes `%%`), so a mesh root that\ncontains `%` starts over its real path instead of a path systemd rewrote by expanding it. The\nprovenance comment records the root unescaped.\n\nOn Linux a user unit starts at boot and survives logout only while the user lingers. Without\nlingering, systemd starts no user manager at boot, so an enabled unit stays inert until the next\nlogin and stops at the last logout. `install` checks lingering before it writes anything, and when\nlingering is off it fails with the root command that turns it on (`sudo loginctl enable-linger\n<user>`). With `--linger` it first asks logind to enable lingering for the current user, and fails\nwith the same command when logind refuses (unprivileged users over SSH get `Access denied`).\n`service status` prints that command while lingering is off. A Linger query that does not answer\n`yes` or `no` (logind unreachable, no `loginctl`) is never read as off: `install` refuses with\nthe query's own error and enables nothing, and `service status` shows lingering as unknown with\nthat error (`--json` gives `\"linger\": { \"error\": ... }`).\n\n`service install` also refuses while a manager is already running for the mesh (`cotal down\nmanager` first). The restart policy is `Restart=always` with `RestartSec=20s`, chosen for\nmanager units in production: a manager exits for reasons that are not failures (broker\nrestarts, host suspend), where `on-failure` with a short interval thrashes.\n\nThe unit also sets a start limit (`StartLimitIntervalSec=30min`, `StartLimitBurst=20`). A manager\nthat keeps failing to start stops after 20 attempts, about seven minutes at 20 seconds apart, and\nthe unit is left `failed` instead of restarting forever. One such failure is deliberate. After an\nunclean stop, a manager that cannot verify eviction of its predecessor's credentials exits 1 and\nleaves the issuance gate frozen, because starting without that proof could let two incarnations\nserve at once (SPEC 13.1). It first waits up to 60 seconds for the delivery daemon to answer, so a\ndaemon that is still starting does not fail the start. The log names the cause. When the delivery\ndaemon is down, it says the daemon is not reachable on the `ctl.delivery-admin` rail. When the\ndaemon answers and refuses, for example because the space is missing a `$SYS` cred, it prints the\ndaemon's own reason and repair step. Fix that cause, then run `systemctl --user reset-failed\n<unit>` and `systemctl --user start <unit>`. The macOS agent has no start limit: launchd's\n`ThrottleInterval` only spaces restarts.\n\n`service status` reports the unit state from systemd/launchd, the manager's own health read from\nits pidfile at the unit's recorded root, and the machine facts a hosting side asks for:\narchitecture, OS (the platform, never the hostname), whether `/dev/kvm` is present and\naccessible, CPU count, and total memory. `--json` returns the same fields as one object. The\nmanager row names the recorded pid, and the command it runs when another program has reused that\npid. `--json` also gives the command of a live recorded pid whenever it can be read.\n\n`service uninstall` stops and disables the unit and removes it plus the private state directory.\nIt works from any directory: the unit's own records name the mesh and root it serves, and an\nexplicit `--mesh <name>` selects it. It refuses any unit that was not written by `service\ninstall` (the files carry a provenance comment), whose recorded mesh is missing, or that was\ninstalled for a different mesh, so operator-written units are never destroyed; `service status`\napplies the same rule and never reports a mesh a unit does not record. `service status` also\nrefuses a unit that records no absolute root, because the manager's health is read at that root\nand is never guessed from the current directory. Remove such a unit with `service uninstall`\nand install it again.\n\nThis command installs only the manager. The per-space auth service and the delivery daemon are\nnot installed by it: on a shared broker an operator runs three units per space with `After=`\nedges (auth service, then manager, then delivery) and stops them in reverse. A broker-side `cotal\nup` unit is a separate unit documented in [Run a mesh](run-a-mesh.md).\n\n## reconcile-gate\n\n```bash\ncotal reconcile-gate [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the frozen gate lives in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint whose gate is frozen |\n| `--instance <id>` | this folder's persisted manager instance | Instance id |\n\n**When you need this.** A manager restart killed after deregistration begins but before the new\nincarnation finishes leaves the endpoint's issuance gate *frozen*, held by a\nprocess that no longer exists. The freeze is what stops two incarnations serving at once, which is\ncorrect. The successor manager now completes that dead registration itself on boot, including on\nthe remote user-auth path. A foreign remote manager blocked by this gate also asks the host to repair\nit before one registration retry. Both use the same guard this command uses: they act only when the freeze-holder is affirmatively gone under a complete\nCONNZ sweep (`gone` and `sweepComplete=true`). If that registration's spec write already committed,\nit finishes the same freeze at the committed registration revision. If the spec did not advance, it\nabort-reopens the gate at generation+1 with processEpoch unchanged and continues the normal takeover.\nLive, unknown, unestablishable, and\nwrong-op-kind still refuse; there is no TTL.\n\nUse this command when the automatic path cannot run: the delivery daemon is down, the repair targets a\nnon-manager endpoint, or you want to lift the freeze without starting a manager. It checks that the\nholder really is gone, prints what it found, and then finishes the dead operation the same way as the\ninterrupted restart would have: revoke the old credentials, evict their holders with verification,\nand reopen the gate.\n\nThe command revokes the old credentials 16 at a time. It then verifies the holders' eviction in\nshared sweeps of up to 256 holders on the delivery daemon. Each sweep scans the broker a fixed number\nof times and kicks live connections 16 at a time, so holders that are already gone add almost\nnothing and live ones add one broker round trip per 16 connections. The daemon must serve the\n`evictPrincipals` verb; an older daemon refuses it and the gate stays frozen.\n\nEach sweep durably records the holders it verified before the next sweep starts. If a holder is not\nverified gone, the command leaves the gate frozen with those records kept. An interrupted sweep\nrecords nothing, and the sweeps before it stay recorded. A retry still repeats the freeze-holder\nliveness check, then skips only progress bound to the same registration operation, frozen-gate\nrevision, and holder set.\nThe output reports holders completed before this attempt, completed now, and still remaining. A new\nfreeze or changed holder set starts from zero. Cursor cleanup happens only after reopen; a retained\ncursor is harmless because its old gate revision cannot authorize a later freeze. The governance slot\nrelease after the reopen is cleanup in the same way: a slot it fails to release does not fail the\nrepair, because the reopened gate is already past the slot's generation.\n\n**It refuses far more often than it acts, on purpose**, and always says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `holder-alive` | The freeze-holder still has a live connection: a manager *is* running | Stop that process first. Reconciling would evict a live manager's credentials |\n| `holder-unknown` | The connection sweep could not prove the holder absent | Not safe to proceed: an unprovable holder is treated as a live one. Re-run once the broker answers completely |\n| `liveness-unestablishable` | The delivery daemon gave no verdict: it was unreachable, timed out, or refused | Act on the delivery lease line in the refusal (below). Silence is never read as death |\n| `not-frozen` / `no-gate` | The gate is open, or there is no gate at that coordinate | Nothing to repair: check `--endpoint` / `--instance` |\n| `wrong-op-kind` | Frozen under a takeover or retirement, not a registration | Out of scope for this command; it will not reinterpret another operation's intent |\n| `eviction-unverified` | The holder looked gone but eviction could not be verified | The gate is left frozen, unchanged. Investigate the broker before retrying |\n| `raced` | A newer manager moved the gate mid-repair | Re-run `cotal doctor` and look again |\n\nWhen the daemon gives no verdict, the refusal also reads the delivery lease (`lease.0`) and names\nwhat is blocking the rail:\n\n| Lease reading | What to do |\n|---|---|\n| absent | No daemon is running. Start it (`cotal up` runs it) and re-run |\n| unreadable | The daemon cannot be named, so do not assume none is running. Fix the lease read, then re-run |\n| held, not ready | That holder claimed the shard and has not bound its rails. Wait for it, or stop it so its lease lapses |\n| held, ready, no answer | The query may have gone to another daemon still subscribed to the rail, such as a stopped one whose lease lapsed. Re-run before stopping anything. If no run gets an answer, stop any other delivery daemon for the space, then stop or restart the holder |\n| changed hands | The holder took the shard after the query was sent, so it was never asked. Re-run before stopping anything |\n\nThe command reads the lease before it sends the query and again after the query fails. It names a\nholder as the blocker only when the same run of the same daemon held the lease both times, and two\nrows from a daemon too old to record its run never count as the same run. Even then a ready holder\nmay not have been asked: the rail is queue-grouped, so any daemon still subscribed to it can take\nthe query. A row whose times are not valid dates reads as unreadable.\n\nA daemon that answered and refused keeps its own reason, followed by the same lease line. The lease\nline names the holder, whether it is ready, the space account that holds the lease bucket, when that\nholder acquired the shard, and when the row was last written. A ready holder rewrites the row on\nevery renewal and keeps its acquisition time, which only a successful acquisition sets. A row\nwritten by a daemon that predates the acquisition time reports it as unknown. The lease reads never\nchange the outcome: the gate stays frozen and the command exits 2. A manager's boot self-heal uses\nthe same check and reports the same line.\n\nThere is no `--force`, and no path that discards gate state: the only way this reopens a gate is by\nproving the holder is gone and then completing the operation properly.\n\n**What reopening the gate does for the endpoint's governance slot.** A registration takes the\nendpoint-wide governance slot before it publishes its spec, and holds it until its gate reopens. An\ninstance that died between those two points leaves the slot held with no registration behind it.\nThis command does not write that slot and never has; the registration path is its only writer. What\nthe reopen does is advance the holder's gate past the generation the slot is stamped with, which is\nwhat marks the slot abandoned. The next registration for that endpoint then reclaims it as part of\nits ordinary start. So the repair here is still one command followed by starting the manager, and\nthe slot needs no separate step.\n\n## deregister-instance\n\n```bash\ncotal deregister-instance [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the instance is registered in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint the instance serves |\n| `--instance <id>` | this folder's persisted manager instance | Instance id, the whole id as `cotal ps` prints it |\n\n**When you need this.** The service registry records *registration*, not liveness, and nothing in\nthe model expires a row. A manager that stops cleanly removes its own registration. One whose host\ndied without writing anything cannot, so its record goes on claiming a live instance forever: every\nclass scatter in that space freezes the dead slot in, and `cotal ps`, `stop` and `attach` each pay\ntheir whole deadline waiting for a machine that is never coming back. A laptop that was reimaged, a\ncontainer that was deleted, a box that will not be back on the network: those registrations have no\nother exit.\n\nThis command is that exit. It asks the instance first, and it removes a record only when the broker\naffirms the instance's own rail is empty: nothing subscribed there. Then it deletes the\nregistration's two records keys, each pinned to the revision it read, and prints what it removed.\n\n**Silence alone never passes.** An unanswered describe is what a dead host, a wedged process and a\nslow one all look like, and a hung process still holds its subscriptions, so the broker sees\ninterest on its rail. That instance is refused and the observation is printed. A dead process holds\nno connection and therefore no subscription, so a real corpse is still removed.\n\n**Every refusal names the failed check:**\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `instance-answered` | The instance answered a pinned describe. It is alive | Nothing to repair. If it is wedged rather than gone, stop the process first; its own clean stop removes the record |\n| `instance-not-affirmed-gone` | It did not answer, and the broker did not report its rail empty, which is what a held subscription looks like: slow or hung, not affirmed gone | Nothing was removed. Stop the process; its record goes on its own clean stop, or re-run this once it is down |\n| `liveness-unestablishable` | The probe itself failed, so nothing was learned | Fix the probe's path (credential, broker) and re-run. A probe that could not run is never read as death |\n| `not-registered` | No registration at that coordinate | Check `--instance` and `--endpoint`. This takes the whole id, never a prefix |\n| `registration-in-flight` | The instance holds the endpoint governance slot at the live issuance-gate generation, so a registration is still completing | Nothing was removed. Wait for that registration to finish, then re-run |\n| `superseded` | The record moved between the read and the delete | Something is writing to it. The registration was not removed, and a status that was removed observed the inspected registration or an earlier one. Re-observe before retrying |\n\n**A held slot with an unreadable issuance gate is not one of those refusals.** When the instance\nstill holds the endpoint governance slot, the command reads its issuance-gate generation to tell a\ncompleting registration from a leftover. If that gate is absent or carries a delete marker, the\nread fails and the command refuses the delete rather than guess. The caller receives an\n`unavailable` error, not a named refusal: the message begins `could not observe the issuance-gate\ngeneration for \"<endpoint>/<instance>\" while its governance slot is held` and ends with the reader's\nown explanation, either `no endpoint gate at <key>` or `the endpoint gate <key> carries a\n<operation> marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`, where\n`<operation>` is the single token `DEL` or `PURGE`. The two cases are told apart by that text\nalone; the error class and the exit are the same.\nNothing was removed. A gate is never deleted in normal operation, so treat either text as a damaged\nissuance gate for that instance and repair it before re-running. A manager's own clean stop hits the\nsame read; it logs the failure and finishes stopping, and its registration stays in place.\n\nThere is no `--force` and no sweep: silence is not death, and a rule that removed rows on silence\nwould eventually remove a live instance that was merely slow. An operator names one instance, the\nbroker's verdict on its rail is what authorizes the removal, and the guard's job is to show them\nthey named a dead one. Removal is not a one way door either. The same instance re-registers over\nthe tombstone on its next start, under the same identity.\n\n## runtimes\n\n```bash\ncotal runtimes\n```\n\nLists every agent runtime the manager can spawn through: the built-in `pty`, the official providers\n(`orca`, `tmux`, `cmux`, `herdr`), and any custom provider installed via `cotal ext add`. Each installed\nprovider is probed so you can see what is actually reachable on this machine before selecting it:\n\n```\npty built in\norca installed \xB7 reachable @cotal-ai/orca\ntmux available \xB7 cotal ext add @cotal-ai/tmux\ncmux available \xB7 cotal ext add @cotal-ai/cmux\nherdr available \xB7 cotal ext add @cotal-ai/herdr\n```\n\n`installed \xB7 reachable` / `unreachable` is the provider's own `available()` probe; `available` means\nit is a known runtime you can add with the shown command. Selecting an unknown or uninstalled runtime\nvia `up`/`spawn --runtime <name>` fails loud and, for a known one, points at the exact `cotal ext add`\npackage. There is no silent fallback to `pty`.\n\n## seats\n\n```bash\ncotal seats [--drain]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--drain` | off | Retire every seat whose agent has exited. A seat whose agent still runs is kept |\n\nThe pty runtime used to start a detached custodian process for every Linux seat. It now spawns\nin-process, but custodians that an earlier manager started keep running, and one whose agent has\nexited stays resident while a manager still holds its connection. This command lists the custody\nrecords under `COTAL_SEAT_ROOT` (default `~/.cotal/seats`), one line per seat:\n\n| State | Meaning |\n|---|---|\n| `live-child` | The agent process still runs. The seat is never signalled, and a manager can still adopt it |\n| `childless` | The agent has exited, or the record comes from an earlier boot. `--drain` retires the seat |\n| `drained` | `--drain` proved the custodian and the agent gone and removed the record |\n| `refused` | The record cannot be read, carries no start or boot identity, this host publishes no boot identity, or the reap could not prove the processes gone. The record stays on disk |\n\nA drain signals only a custodian whose recorded start identity still matches the live process, so\na reused pid is never touched. No process outlives a reboot, so a record from an earlier boot is\nreported childless and `--drain` removes it without signalling anything. A record with no start or\nboot identity is refused with or without `--drain`, and is never reported as running or exited.\nOn a host that publishes no boot identity (`/proc/sys/kernel/random/boot_id`) every record is\nrefused the same way, because no record can be tied to this boot.\nThat refusal and an unreadable record signal nothing. A refusal from the reap itself can come after the drain already\nsent `SIGKILL` to the custodian. Its detail names the pid or process group the reap could not prove\ngone, so check those processes before you retry. The command exits non-zero when any record is\nrefused. It is Linux-only and throws on other platforms.\n\n## send\n\n```bash\ncotal send dm <agent> \"<text>\" [--space <s>] [--server <url>] [--creds <path>]\ncotal send msg <channel> \"<text>\"\ncotal send ask <role> \"<text>\"\n```\n\nA `send dm` prints one line naming three facts: `\u2192 <name> stored seq <N>; recipient <status>\nat send; delivery not confirmed <text>`. `stored seq N` is the JetStream sequence the broker\nassigned to the publish; `recipient <status> at send` is the roster status (`idle`, `working`,\nor `offline`) resolved right before the publish, which can change the instant after; the send\nnever prints `delivered`, because the sender's credential cannot read the recipient's durable\nto confirm it. Inspect what the broker actually holds for a recipient with\n[`cotal deliver pending`](#deliver).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and (off-registry) which credential |\n\nOne-shot messaging: connect, send a single direct message (`dm`), channel post (`msg`), or role\nask/anycast (`ask`), then exit. For a running conversation, agents use the mesh tools instead\n([MCP tools](mcp-tools.md)).\n\n`cotal send` works from an operator shell or from a seat. Its display name is `<login>@<host>` of\nthe shell that ran it, so the recipient can tell one operator's send from another's; it is taken\nfrom the operating system, never from `COTAL_NAME`. The wire principal comes from the resolved\noperator credential or user bearer, not from `COTAL_NAME`, `COTAL_ID`, `COTAL_OWNER`, or\n`COTAL_ACTOR`. On an open mesh the transient endpoint self-mints its principal.\n\nThe transient endpoint never joins the roster and binds no inbox. A recipient can still answer a\n`send dm` or `send ask` with `cotal_dm`, by the sender's name or by the id on the message it\nholds: the reply is stored under the sender's id in the space's DM history, which an operator's DM\nview such as the dashboard's Direct messages lens shows. The `cotal send` that asked has already\nexited, so the reply never reaches that shell.\n\n## channels\n\n```bash\ncotal channels list\ncotal channels set <name> [--replay | --no-replay] [--window <n>] [--desc <s>] [--instructions <s>]\ncotal channels default --replay | --no-replay\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--replay` / `--no-replay` | none | `set`/`default`: replay history to new joiners, or not |\n| `--window <n>` | none | `set`: replay window size |\n| `--desc <s>` | none | `set`: one-line channel description |\n| `--instructions <s>` | none | `set`: instructions shown to joiners |\n\nInspects and edits the channel registry: replay policy, description, and joiner instructions. ACL\nsemantics (who may read or post) are set at mint / provision time, not here; see\n[Channels and permissions](channels-and-permissions.md). On a user-auth mesh, `list` rides your\nown login as is; `set` and `default` edit the registry over a short-lived\nchannel-writer view, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\nOn a remote user-auth mesh that view is served by the public exchange; space-history `purger`\nand the read-only admin view are not.\n\n\n## history\n\n```bash\ncotal history clear --force [--dms] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--dms` | off | Also clear DM history |\n| `--force` | none | Required: clear without prompting |\n\nPurges retained channel history; `--dms` extends it to direct-message history. An alias of\n[`clean history`](#clean). On a user-auth mesh the purge rides a short-lived purger view over\nyour login, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n## console\n\n```bash\ncotal console [--plain] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to watch |\n| `--plain` | off | Line stream instead of the TUI |\n\nA live protocol view for a space: a lazygit-style TUI, or a plain line stream on `--plain`. On a\nuser-auth mesh it rides the read-only admin view over your login, which needs ledger scope\n`admin`. Inside the TUI, operator control (`D` kill, `:spawn`, `:status`, `:purge`) rides the\nsame per-action instrument path as `cotal stop` and `cotal ps`, never the observer; a raw\n`--creds` file cannot drive it. `a` (or `:attach <agent>`) runs\n[`cotal attach`](#managed-seats) in place and returns to the console on detach. See\n[Watch a mesh](watch-a-mesh.md).\n\n## web\n\n```bash\ncotal web [--detach] [--host <host>] [--port <n>] [--no-open] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to serve |\n| `--host <host>` | `127.0.0.1` | Concrete HTTP bind and browser host; wildcard addresses are refused |\n| `--port <n>` | `7799` | HTTP port, a decimal number from 1 to 65535 |\n| `--detach` | off | Run in the background; stop with `cotal down web` or bare `cotal down` |\n| `--no-open` | off | Don't open the browser |\n\nThe browser observability dashboard: presence, channels, and a live feed. It is **not** part of\n`cotal up`: it ships inside `cotal-ai` as the `@cotal-ai/web` extension, seeded automatically on first\nrun (like the built-in connectors) so it always matches your CLI version. It self-registers `cotal web`\ninto this surface and serves\n`http://cotal.localhost:7799` by default (loopback; `*.localhost` resolves in Chrome/Firefox/Edge; for Safari\nor a system resolver such as WSL2's, the launch link is also printed at `http://127.0.0.1:7799`).\nOn a user-auth mesh the dashboard rides the read-only admin view\nover your login, and a channel purge asks for its own channel-purger view per click; both need\nledger scope `admin`. The public exchange serves `channel-purger` for a remote owner; it still\nrefuses the startup admin view, so a remote `cotal web` is not a complete channel-management\nsurface. Detached mode re-execs the current Cotal installation, writes diagnostics to\nthe mesh root's `.cotal/web.log`, and reports success only after the HTTP server answers. It requires\na recorded mesh root, but can be launched from any directory once `cotal up` has recorded the mesh.\nSee [Watch a mesh](watch-a-mesh.md).\n\n## linear\n\n```bash\ncotal ext add @cotal-ai/linear\ncotal linear account add <name> --mode <write|readonly> (--token-stdin | --token-file <path>)\ncotal linear account login <name> --mode <write|readonly>\ncotal linear account show <name>\ncotal linear inventory <account> [--json]\ncotal linear call <account> <tool> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear resource <account> <uri> [--inventory <digest>] [--timeout <ms>]\ncotal linear prompt <account> <name> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear serve <account> --endpoint <reverse-dns-name> [--space <s>] [--server <url>]\ncotal linear caller <name> --endpoint <reverse-dns-name> --out <path> [--channels <a,b>] [--expires-in <s>]\n```\n\nServes one Linear account as a registered Cotal endpoint that agents call with `cotal_describe` and\n`cotal_invoke`, and gives the operator a direct client for the same server. It only talks to `https://mcp.linear.app/mcp`\n(mode `write`) or `https://mcp.linear.app/mcp/readonly` (mode `readonly`); there is no URL or header\noption. Use a separate account per Linear workspace, and prefer a `readonly` account with a\nrestricted read key wherever writes are not needed.\n\nAn account's credential is sent as `Authorization: Bearer` and is never taken from argv. `account add`\ntakes a Linear API key: `--token-stdin` stores it under the cotal home as a 0600 file, and\n`--token-file` points at an existing file that must not be readable by other users. The file is read\nat each use, so rotating it needs no restart. `account login` runs Linear's OAuth flow instead: it\nregisters a client, prints the authorize URL, and waits on a loopback redirect. It asks for scope\n`read` in `readonly` mode and `read write` in `write` mode, keeps the tokens in a 0600 file, and\nrefreshes them before a request is sent. OAuth requests only go to `https://mcp.linear.app`.\nRedirects are refused so the credential is never forwarded.\n\n`inventory` reads the server's capabilities and every page of its tools, plus resources, resource\ntemplates and prompts when the server advertises them. Names and schemas are printed as the server\nsent them. The digest covers the whole inventory; pass it to `--inventory` so a call is refused\nbefore dispatch if the inventory changed. Capabilities this command does not represent, such as\nresource subscriptions or logging, are listed under `unsupported`.\n\n`call`, `resource` and `prompt` print the server's reply as JSON. A tool result with `isError: true`\nis a normal result (exit 0). A refusal before dispatch exits 2 with outcome `not-executed`; that\nincludes a deadline that expires while queued, discovering or connecting. A protocol error, a\ntimeout, Ctrl-C, a response over the size cap, an expired session, an HTTP 401, 403 or 429 answer,\nor a transport failure after the request was sent exits 3 and is never retried. Its outcome is\n`unknown`, for read-only tools too: read-only means a repeat is safe, not that the first call did\nnot run. Errors carry an HTTP status or an error class, never the response body.\n\n### Serving the endpoint\n\n`serve` registers the account as an endpoint named by `--endpoint` and serves it until Ctrl-C. The\nname must be a reverse-DNS name in a namespace you own, such as `com.example.linear`; it is\nauthorized for this mesh's local owner only because you configured it. `serve` reads the inventory\nfirst, so an account that cannot be reached is never registered. It then walks the same path every\nregistered endpoint does: it publishes the contract to the contract store, opens the issuance gate,\nregisters a fresh instance, writes its ready status, and mints a scoped serve credential through\nthe gate. Every step runs on short-lived executor credentials scoped to this one endpoint instance.\nThe space signer stays in the `serve` process for renewal at 75% of the credential's life and is\nnever written out, printed, or handed to the serving connection. Ctrl-C stops serving, closes the\nLinear session and removes the instance's service record, each within 10 seconds. A start that\nfails after registration removes the record before it exits and says whether that removal\ncompleted; a registration that fails part-way rolls its own record back and reports when it could not.\n\nThe endpoint serves five fixed commands: `inventory`, `call-tool`, `read-resource`, `get-prompt` and\n`complete`. Each needs the one `linear.mcp` capability. That capability is permission to call the\nendpoint; which tools a call can reach is decided by the Linear account and its mode. `inventory`\nreplies in pages that fit a broker message: the first page carries the server, its capabilities,\nits instructions and per-section counts, and every page carries entries verbatim with an opaque\ncursor bound to the inventory digest. An entry is never cut: discovery refuses an inventory whose\nfirst-page head (server, instructions, capabilities) or any entry prints over 48 KiB the way an\nagent tool prints it (indented JSON, which grows with nesting), naming the entry, so the first page\nand every entry it accepts fit one reply and the tool an agent reads it with. A cursor or `inventoryDigest` from an older inventory is refused before anything is sent to\nLinear. `call-tool`, `read-resource`, `get-prompt` and `complete` require the digest the caller read.\n\n### Callers\n\n`caller` provisions an isolated caller on a static-auth mesh: an `agent` credential with its own\nmailboxes and request rows for only the five Linear commands on that endpoint, plus the channels\nin `--channels`. It carries no spawn, run, admin or provisioner capability. The credential is\nwritten 0600 to `--out` and never printed. Start a hand-driven connector session with it, using the\nprinted `COTAL_CREDS` and `COTAL_LIFECYCLE_UID`; that session then sees the endpoint through\n`cotal_describe` and calls it through `cotal_invoke`, and the broker refuses any command its\ncredential does not name. A Claude Code or jcode session also serves a local control endpoint and\nrefuses to start without one: set `COTAL_CONTROL_SOCKET` to a socket path you choose and\n`COTAL_CONTROL_TOKEN` to a random secret. OpenCode, Codex and pi sessions start without them. Managed `cotal spawn` agents on a static-auth mesh still cannot carry\nendpoint capabilities, so a Linear caller is a hand-launched seat.\n\nA per-user-auth mesh is refused by both `serve` and `caller`: there, endpoint and caller credentials\ncome from the remote auth service, which this package does not support yet. Nothing falls back to a\nlocal signer. An open mesh is refused too, because it has no credentials to scope.\n\nNot represented yet, and listed under `unsupported` when the server advertises them: resource\nsubscriptions, logging, progress notifications and experimental capabilities. The server cannot\ncall back for sampling, elicitation or roots. Every call acts as the account's one Linear identity.\n\n## deliver\n\n```bash\ncotal deliver [--space <s>] [--server <url>] [--tls] [--creds <file>] [--root <dir>] [--shard <n>] [--shards <n>] [--dev-mint]\ncotal deliver pending <name> [--limit <n>] [--durable <name>] [--json]\n```\n\nWith no positional, `cotal deliver` runs the delivery daemon (see\n[the delivery daemon](delivery-daemon.md)). `deliver pending <name>` never starts the daemon: it\nis an operator-only read over one recipient's DM durable, for the moment after a send when the\nquestion is \"what does the broker actually hold for them.\" It resolves `<name>` against a short\npresence watch (an `offline` card still counts, since the recipient may be dead, that is what\nthe verb exists to inspect); when neither a card nor the durable can be found, it prints\n`\u2717 not-found: no agent \"<name>\" and no DM durable for it in space <s>` and exits non-zero, never\n`pending 0`. On a match it prints the durable name and one fact per line: `pending`,\n`ack-pending`, `delivered`, `ack-floor`, `created`, `frontier`, and the stream's `max_age` /\n`max_msgs_per_subject` / `discard` limits (`--json` prints the same facts as one object), followed\nby a bounded, unacked read of up to `--limit` (default 20) recent candidate message ids under the\nheading `recent candidate ids (from the ack floor; not proof of a hole)`, a list of what is\nthere, not proof that nothing was lost.\n\nThe verb needs the `admin` credential profile: it runs through the same static-mesh route as\n`cotal mint --profile admin`, and refuses a user-mode mesh, naming the retired static credential,\nbecause there is no user-mode inspection authority yet. Pass `--creds <file>` for an off-registry\nadmin credential. A same-name respawn never inherits a predecessor's held DMs (the durable is\nlifecycle-keyed); an old lifecycle's durable is reachable only by the name a live read printed\n(the `<durable>` line on the first line of this verb's output). Pass that name with `--durable\n<name>` to read it directly once the lifecycle's card is gone from the roster. This skips the\npresence watch on `<name>` entirely, so `<name>` is required but only echoed in error text.\n\n## mint\n\n```bash\ncotal mint <name> [--profile <agent|observer|admin>] [--out <path>] [--signer]\ncotal mint <name> --provision [--role <role>] [--space <s>] [--server <url>]\ncotal mint <name> --expires-in <seconds> | --expires-at <unix-seconds>\ncotal mint <name> --identity <creds> [--expires-in <seconds>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--profile <agent\\|observer\\|admin>` | `agent` | Credential profile |\n| `--out <path>` | `.cotal/auth/creds/space.<key>/<name>.creds` | Output path - the default sits under the resolved space's segment (`<key>` is that space's hex encoding, as in [Project files](config.md#project-files)) |\n| `--signer` | off | Emit a stripped account-signing file instead |\n| `--force` | off | With `--signer`: overwrite an existing file |\n| `--allow-subscribe <a,b>` | the agent file's, else subscribe | Read-ACL override, **agent profile only**: `observer` and `admin` carry a fixed read set, and `mint` refuses this flag there rather than narrowing nothing |\n| `--allow-publish <a,b>` | the agent file's, else deny | Post-ACL override, **agent profile only** |\n| `--role <role>` | the agent file's | Agent profile: the anycast task queue the identity pulls (`svc_<role>`) |\n| `--provision` | off | Agent profile: also pre-create the identity's bind-only DM/deliver durables (and its role's task queue) on the live mesh, so the credential can consume |\n| `--expires-in <seconds>` | unbounded | Bound the credential's lifetime: the JWT `exp` is `iat + <seconds>`. A positive integer; refused together with `--expires-at` |\n| `--expires-at <unix-seconds>` | unbounded | Bound the credential to an absolute `exp` (unix seconds). Refused together with `--expires-in` |\n| `--identity <creds>` | a fresh identity | Re-mint for the nkey carried by this creds file, keeping the principal and every durable keyed to it. The file is read by the same loader the endpoint uses; a file with no seed is refused by name |\n| `--space <s>`, `--server <url>` | the resolved mesh | Which root supplies the agent file, static trust and default credential storage; with `--provision`, also which live mesh receives the durables |\n\nMints a NATS creds file for a space in **static** auth mode, scoped to a profile and (optionally)\nexplicit read/post ACLs. `--signer` emits an account-signing file for delegating minting to another\nhost. On POSIX the file `mint` writes is mode `0600`, including when it replaces an existing file at\n`--out`. A per-user-auth space refuses `mint`: agents there join under a logged-in user\n([`login`](#login) + [`actor grant`](#actor)), never via a handed-out creds file. See\n[Identity and auth](identity-and-auth.md).\n\nFor an agent profile, the resolved mesh root supplies the persona ACL, the signing material and the\ndefault credential destination as one authority. If the current folder also holds trust for a\ndifferent space or account, mint refuses before writing and names both roots. It never combines a\npersona from one root with credentials signed or stored under another.\n\nA plain mint is creds only: the identity can publish within its post ACL at once, but on an authed\nmesh its DM inbox and task queue are provisioner-pre-created and bind-only, so a **consuming**\nconnect fails until they exist. `--provision` performs that pre-create in the same command (a\nprovisioner cred is minted from the space's trust material, used, and dropped), so a long-running\nclient you start yourself can receive DMs and role anycasts like a spawned seat. The command prints\nthe identity's principal (its wire id) and lifecycle uid; a consuming client passes that uid as its\n`lifecycleUid`. Agent profile only; an open mesh needs none of this (peers self-create there). The\nsame resolved authority is used for both the credential and `--provision`, so the broker\nfootprint cannot be created under a different root's trust material.\n\nThe CLI-mintable profiles carry no default TTL: without a lifetime flag the credential is\nunbounded, and a standing-renewal consumer refuses it. `--expires-in <seconds>` (or\n`--expires-at`) is the door the renewal seam's own error names. `--identity <creds>` re-mints for\nthe nkey the file already carries, so the new credential presents the SAME principal and every\ndurable keyed to it survives; combine it with a lifetime flag to rotate an expiring credential\nwithout churning the identity.\n\n## Login\n\n```bash\ncotal login --idp <auth base URL> [--client-id <id>]\ncotal logout --idp <auth base URL>\n```\n\nSigns you in to a per-user-auth mesh's IdP (device code flow) and caches the session; run it\nonce per machine. The IdP URL must use `https://`. Plain `http://` is accepted only on a loopback IP\nliteral such as `127.0.0.1` or `::1`, for an IdP on this machine. `localhost` is refused because it\nis a name: a hosts entry would choose the IdP. It prints your IdP subject, the id the operator\ngrants against. When the trusted\n`/token` response advertises a same-origin space catalog, login validates and records that account's\nspaces immediately. After a\nlogin, every command on that mesh works under your identity: each connect takes a fresh IdP\nproof, exchanges it locally for a short-lived bearer, and is authorized against the actor\nledger at connect time. `logout` revokes the IdP session, clears its cache, and removes only that\naccount's discovered registry entries. See\n[identity & auth](identity-and-auth.md).\n\n## actor\n\n```bash\n# an upsert of the WHOLE row: name all three ACL flags, or pass --full for the wide defaults below\ncotal actor grant <actor> --sub <IdP subject> --scope a,b --allow-subscribe a,b --allow-publish a,b [--role <r>] [--label <l>]\ncotal actor grant <actor> --sub <IdP subject> --full [--scope a,b] [--allow-subscribe a,b] [--allow-publish a,b] [--role <r>] [--label <l>]\ncotal actor revoke <actor> (--sub <IdP subject> | --owner <u_\u2026>)\ncotal actor list\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | the folder's | Space whose ledger to manage |\n| `--sub <subject>` | none | The IdP subject (shown by `cotal login`) the actor belongs to |\n| `--owner <u_\u2026>` | none | The derived owner token (alternative to `--sub`) |\n| `--full` | off | Fill each ACL flag left off with its wide default; without it, `grant` refuses unless all three are named |\n| `--scope <a,b>` | `spawn,role:default` with `--full` | Capability scope (`''` = none; `spawn` = may run agents; `role:<r>` = may delegate role r; `admin` = cross-agent control; `supervise` = eligible for the closed remote manager-service view when the host enables it) |\n| `--allow-subscribe <a,b>` | `>` (all channels) with `--full` | Channel read ACL; the user's envelope, their agents can never read beyond it |\n| `--allow-publish <a,b>` | `>` (all channels) with `--full` | Channel post ACL; also the envelope for their agents' posting |\n| `--role <r>` | none | Role (scopes the task-queue consumer) |\n| `--label <l>` | none | Display label for `actor list` (never the IdP subject) |\n\nThe actor ledger is the single authorization source of a user-auth space: no row, no access.\n`grant --full` is the **full** envelope (all channels; scope `spawn,role:default`, so it may spawn and may delegate the default role). A\n`grant` that leaves off `--scope`, `--allow-subscribe` or `--allow-publish` without `--full` is\nrefused and writes nothing. A re-grant **replaces the whole row**, not the one field you name, so to add a capability spell\nevery field out: the new scope plus the row's current read set, post set, role and label\n(`cotal actor list` shows what a row holds). Under `--full`, a field left off does not stay as it\nwas: it reverts to the wide default in the table above. A re-grant retires the current interactive lifecycle through the running auth\nservice before it rotates the row, so copied bearers cannot cross an authorization update. If that\nretirement cannot be confirmed, the row is left unchanged and the command fails with the recovery\naction. `revoke` uses the same retirement before deleting the row, which lets a later grant create a\nreal successor instead of colliding with a live predecessor. `supervise` is separate from `spawn` and `admin`: it only makes a signed-in\nperson eligible for the host-provided closed remote manager-service view; it does not grant\nmanagement of another owner or a general host profile. `revoke` denies the next exchange and\nthe next connect with no restart, and evicts the principal's live connections. Managed-agent rows\n(written by the spawn path) live in a disjoint row space this command never touches. See\n[identity & auth](identity-and-auth.md).\n\n## doctor\n\n```bash\ncotal doctor auth [--fix]\n```\n\nCredential-health diagnosis and repair for this folder's mesh: renders every managed\ncredential as healthy / near-expiry / expired and ends in `healthy` or the exact next\ncommand; `--fix` applies the repairs it can. The one surface every stale-credential error\npoints at. `--fix` takes the mesh's renewal lease when the broker answers and refuses while\na manager or another doctor holds it; with no broker it repairs offline and says so.\n\n## join\n\n```bash\ncotal join --space <s> --name <n> [--role <r>] [--channel <c>]\ncotal join --link <url> | --token <t>\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and which credential |\n| `--name <n>` | none | Your presence name |\n| `--role <r>` | none | Your role |\n| `--channel <c>` | none | Channel to join |\n| `--kind <k>` | `agent` | Endpoint kind |\n| `--link <url>` | none | Join link (`cotal://\u2026`) |\n| `--token <t>` | none | Join token |\n| `--lifecycle-uid <uid>` | none | Required with `--creds`: the lifecycle UID minted alongside the credential (`COTAL_LIFECYCLE_UID` works too). A credential's durable grants name exact lifecycle-keyed resources, so `join` refuses to invent one |\n| `--tls` | off | Connect over TLS |\n\nAn interactive presence: join a space under your own name and role, without launching an agent\nharness. A `--link` or `--token` supplies the where and the auth in one value. See\n[Spaces](spaces.md) and [Identity and auth](identity-and-auth.md).\n\n## Manifest deploys\n\nA `cotal.yaml` manifest declares a whole mesh (channels, personas, roles, and ACLs) in one file.\nThree commands consume it, plus a read-only validator:\n\n```bash\ncotal up -f cotal.yaml # boot a fresh mesh from the manifest\ncotal spawn -f cotal.yaml # deploy the manifest additively onto a running mesh\ncotal down -f cotal.yaml # tear that deploy down (or --run <id> for one run)\ncotal topology view -f cotal.yaml # validate + view the access graph, change nothing\n```\n\n`up -f` and `spawn -f` differ in target: `up -f` brings up a new broker and applies the manifest;\n`spawn -f` requires an already-reachable mesh and applies additively (ownership-scoped). On a\nuser-auth mesh, `spawn -f` deploys over your own login (the deployer view, gated on ledger scope\n`spawn`): the manifest's agents land under your owner, a manifest claiming another owner is\nrefused, and seeding new channels additionally needs scope `admin`. Both take\n`--dry-run` to print the plan without mutating anything. `topology` validates the manifest and\nrenders its channel / role / ACL graph. See [Define a team](define-a-team.md) and the\n[manifest reference](manifest.md).\n\n## ext\n\n```bash\ncotal ext # same as `list`\ncotal ext add <npm-package>\ncotal ext remove <name>\ncotal ext list [--json]\ncotal ext root # print just the install prefix (scriptable)\ncotal ext seed [--repair|--reset|--force]\n```\n\nOperator-installed extensions: `add` installs an npm package into a cotal-owned prefix and records\nevery registry provider it contributes. Commands appear in help, completion, and dispatch; runtime\nproviders are lazy-loaded by commands such as `supervise`; local process providers participate in\n`status` and selective `down`. `remove` and `list` manage them. The `@cotal-ai/web` dashboard is the\ncanonical command/process example. Installed packages and their location are described in\n[config](config.md). When a package needs an export its linked `@cotal-ai/*` peer does not have,\n`add` rolls back and names which install is behind, as a later load of an installed one does.\n\nBare `cotal ext` lists the inventory, headed by the install prefix. That prefix is a cotal-owned npm\nroot kept **separate** from npm's own global tree. These packages never show up in `npm list -g`,\n`cotal ext` (or the Extensions section of `cotal status`) is the canonical inventory. `cotal ext root`\nprints only the path, for scripts. The versions shown are the manifest pin recorded at add time.\n\n`ext list --json` (or bare `ext --json`) prints one JSON object per installed extension per line:\n`pkg`, `version`, `spec` (what `ext add` was given), `seeded` (`true` for an entry the built-in seed\ninstalled) and `provides` (the `kind:name` refs the table shows). No header or footer reaches stdout,\nand an empty prefix prints nothing and exits 0. The table is presentation and is not a stable parsing\ntarget. The other `ext` subcommands refuse `--json`.\n\nRemoving an extension that owns a running local process is refused with the mesh root and its\n`cotal down <component>` command; stop it first so uninstalling the package never strands a process\nwhose lifecycle provider is gone.\n\n### Built-in connectors are seeded extensions\n\nThe first-party agent connectors (`claude`, `opencode`, `codex`, `hermes`, `jcode`, `pi`) are not compiled into\nthe binary. They are seeded on first run through the **same** `ext add` path a third party uses, and\nappear in `cotal ext list` like any other extension. So you can remove one you do not want\n(`cotal ext remove @cotal-ai/connector-hermes`), and a deliberately-removed connector STAYS removed\nacross upgrades. `cotal ext add <your-package>` adds a third-party connector the same way. The web\ndashboard (`@cotal-ai/web`, providing `command:web`) is the seventh built-in seeded on the same path.\n\n`cotal ext seed` is the maintenance entry for that seeding (it runs automatically on the first real\ncommand of each boot, so you rarely call it). Each seeded connector's `\u2713 added` line goes to stderr,\nso the command that triggered the seed keeps stdout to itself:\n\n| Flag | Meaning |\n|---|---|\n| (none) | Reconcile: seed any never-seeded built-in, refresh a seeded one whose version the binary bumped, leave a removed one removed. A no-op once current. |\n| `--repair` | Recover after an interrupted seed or a lost authority (rebuilds the interrupted connector; restores the removed-vs-never-seeded record from its durable backup). |\n| `--reset` | Discard the record and re-seed all seven built-ins (the six connectors plus the web dashboard). **Resurrects any you removed.** Rebuilds cleanly over corrupt seed state. |\n| `--force` | Re-seed the built-ins even when the version stamp is current or a downgrade. |\n\nWhen a newer `cotal` advances the operator-global seed store to its generation, it prints one\nmigration line naming the old and new generations, the exact CLI entry that wrote the store, the\ncommit timestamp, and `seed/stamp.json`. That writer and timestamp are kept in the stamp, so a later\nolder CLI refusal can say which executable wrote the generation it will not overwrite and when.\nLegacy generation-only stamps remain readable; their refusal simply has no writer provenance to add.\n\nAn older `cotal` refuses a seed store written by a newer version. When it can verify a sufficient\n`cotal` executable on PATH or at the installer's `~/.local/bin/cotal` location, the refusal names\nthat absolute path so a reduced service PATH does not select the older binary again. Otherwise it\nkeeps the generic newer-version instruction. `--force` rebuilds the store for the running older\nversion without discarding the ever-seeded authority. `--reset` still exists for corrupt state and\nresurrects deliberately-removed connectors.\n\nA source-checkout CLI (`pnpm cotal`, `tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of\nthose) refuses to write or garbage-collect that store. The refusal names the path, the generation\nit declined, and `COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout,\nbecause pointing `$XDG_CONFIG_HOME` at a scratch dir alone does not lift it. With the skip set, a fresh\nconfig gets no built-in connectors (`cotal ext list` shows none), so add one from the checkout with\n`cotal ext add <checkout>/extensions/connector-claude-code` against that scratch `$XDG_CONFIG_HOME`. `COTAL_HOME` does not relocate this\nstore. An entry that cannot be proven as a released install is refused the same way. Isolated\nrelease tests that must seed from a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` after\npointing `$XDG_CONFIG_HOME` at a scratch dir; that override is documented here, not on the refusal\nline. An opt-in write still records the checkout path in `seed/stamp.json` as `writtenBy`.\n\nThe default connector for a bare `cotal spawn` (no `--agent`) is the persona's `agent:` pin if it\nhas one, else `claude`; set `COTAL_DEFAULT_AGENT` (e.g. `opencode`) to change the fallback. It is\na default, so a persona that pins its harness still wins over it. An `--agent` naming a removed\nconnector fails loud with the exact\n`cotal ext add` to restore it. Set `COTAL_SKIP_CONNECTOR_SEED=1` to turn off the automatic first-run\nseed/refresh entirely (for a controlled or offline setup that manages connectors by hand); `cotal ext\nseed` still runs on request. `cotal agent-bearer` never takes the seed at all: it is exec'd by\nspawned seats on every bearer refresh, so it neither reconciles nor is refused by the store's\ngeneration (see [Plumbing](#plumbing)).\n\n## completion\n\n```bash\ncotal completion <bash|zsh|fish|powershell> # print a stub to eval / source\ncotal completion install [shell] # install it persistently\n```\n\nPrints or installs shell completion. Completion candidates come from each command's declared flags\nand, where useful, live mesh state (spaces, personas, managed agents) resolved offline.\n\n## feedback\n\n```bash\ncotal feedback \"<summary>\" [--type <t>] [--email <e>] [--details <text>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--type <t>` | none | `bug` \\| `idea` \\| `friction` \\| `praise` \\| `other` |\n| `--details <text>` | none | Longer free-form details |\n| `--severity <s>` | none | `low` \\| `medium` \\| `high` |\n| `--area <a>` | none | The part of Cotal this concerns |\n| `--email <e>` | git email | Contact email (required on the keyless public path) |\n| `--name <n>` | none | Your name (optional) |\n| `--url <url>` | keyed / public intake | Intake URL override |\n| `--key <k>` | `COTAL_FEEDBACK_KEY` | Feedback key |\n\nSends feedback to the Cotal developers. With a key (`--key` / `COTAL_FEEDBACK_KEY`) it routes to the\nkeyed beta intake; without one it goes to the public `cotal.ai` intake and requires a contact email\n(`--email` / `COTAL_FEEDBACK_EMAIL`, else your git email). Run a self-hosted intake with\n[`feedback-intake`](#server-daemons).\n\n## run\n\nOperate durable workflow runs (cotal-lang programs) from the terminal.\n\n```bash\ncotal run start --file <program> [--timeout <dur>] [--local]\ncotal run resume <runId> [--local --file <program>]\ncotal run ps [--endpoint <ep>] [--json]\ncotal run journal <runId> [--endpoint <ep>] [--json]\ncotal run answer <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run amend <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run migrate <runId> --local --file <program> [--endpoint <ep>]\n```\n\n`start` hands the program to the mesh's manager, which validates it, mints the run id (the record\nnever takes a caller-supplied one), drives it in its own process, and answers with the id once the\nrun is recorded; a program that does not validate is refused with every problem listed. `resume`\nasks the manager to take an existing run back and continue it from its step journal; the source is\nthe recorded program, so no `--file` is taken. Neither takes `--endpoint`: the manager records\nits runs under its own endpoint, and naming another is refused. `ps` lists the run records and\n`journal` renders one run's durable records; both only inspect. An open pause prints its question.\nA pause settled with an accepted answer prints its value as JSON plus the recorded answerer,\nartifact when present, time, and answer id, then one `amended` line per later amendment, in the\norder the store committed them, so the last is the current position.\nExpired pauses and ordinary steps print no answer line.\n`--json` on `ps` or `journal` prints each row the manager answers with (or `--local` reads) as one\nJSON object per line. A `ps` row carries `runId`, `endpoint`, `state`, `holder`, `epoch`,\n`journalHigh`, `forkedFrom`, `startedAt` and `programHash` (the values the program's `run()`\nreports; `programHash` is absent for a run with no recorded program), and `revoked` or\n`revocationUnreadable` when the marker says so. A `journal` row is an `activation` or a `step`. A\nstep row carries its `step` key, the `effect` kind and its `name`, `state`, `outcome`, the recorded\n`status` and `errorCode` once settled, and `startedAt` and `endedAt` in epoch milliseconds. An open\npause adds its `asks`, its `deadlineAt`, and for a checkpoint the `onExpiry` it was armed with; a\nsettled pause adds its `answer` and its `amendments`, as the text view prints them. A field the\njournal does not record is absent: a checkpoint opened before `onExpiry` was recorded carries none.\nThe run header and errors go to stderr, so stdout carries only rows; an unreadable revocation marker\nprints its reason there and still exits 1. The text view is presentation and is not a stable\nparsing target. `--json` on any other verb is refused.\n`answer` resolves an open\ncheckpoint through the manager, presenting as the holder that armed it; the manager records the\nanswerer from your credential, so no `--by` is taken there. A settled step refuses a second\n`answer`. `amend` records a changed position on a settled checkpoint or `ask`: it files a new\nanswer beside the accepted one, naming it, and the journal lists it under the step. The pause stays\nsettled and the run keeps the answer it acted on. A step that is still open or settled without an\nanswer refuses an amend. A spawned seat may amend only an answer recorded under its own name. `migrate` runs the migrate check of an\nedited program against a run's journal, from this terminal under a read credential (`--local`\nonly; the manager serves no run-migrate command): it prints whether the migration is admissible,\nevery orphaned step with its verdict and code, and exits 0 on admissible and non-zero on not. It\nwrites nothing: the commit that would file the migration is not reachable yet, and the report\nsays so. `--timeout` sets the default\ncheckpoint timeout for a drive (default 1h). `--local` drives in this process instead, over one\nconnection per invocation under the run's own credential minted from the project folder's trust\nmaterial, and is the path on a bare broker with no manager or for a run with no recorded program\n(`cotal run resume <runId> --local --file <program>`); `answer --local` and `amend --local` take\n`--by <who>`. On a\nuser-auth mesh the host's own manager refuses the family by name, and `--local` has no credential\nthere. A participant's manager started with `cotal supervise` hosts a logged-in user's runs through\nits issuing host: the auth callout issues the user's manager connection, and every `run` verb rides\nthe versioned rail under that issuance.\n[User-auth run start](design/user-auth-run-start.md)\nrecords the path. The guide is [workflows](workflows.md).\n\n## Server daemons\n\nTwo long-lived infra roles ship with the CLI. They are not part of everyday operation; the delivery\ndaemon comes up automatically with `cotal up --detach` in auth mode.\n\n```bash\ncotal deliver --space <s> [--server <url>] [--creds <file>] [--root <dir>]\ncotal auth-service --space <s> --server <url> [--port <n>] [--exchange-public-port <n>] [--exchange-public-url <https://\u2026>] [--exchange-trusted-proxy]\ncotal feedback-intake --keys <keys.json> [--port <n>] [--creds <file>]\n```\n\n`auth-service` runs a user-auth space's identity plane: the NATS auth callout, the\ncapability-gated local exchange and JWKS, and, when `--exchange-public-port` is set, the closed public\nexchange/discovery face forwarded by an HTTPS reverse proxy. `--exchange-public-url` is the proxy URL\nadvertised to clients; `--exchange-trusted-proxy` opts into last-hop `X-Forwarded-For` attribution.\n`cotal up --user-auth` starts and supervises the service for you, so you run it directly only to\nrecover one by hand.\n\n`deliver` runs the server-side Plane-3 delivery daemon: the durable backstop and membership/ACL\nauthority. It is auth-mode-only and single-instance (`--shard`/`--shards` accept only `N=1`);\n`--dev-mint` mints a scoped cred from the local signer for standalone dev. `--creds` can start a\ndaemon that already looks healthy, but production renewal is not that file alone: the manager and\nthe daemon must address one credential store. On a stock split host with two project roots, a\ndirect `deliver` is not an independent repair; keep the daemon under `cotal up` on the broker\nhost, or inject the same store into both processes ([embedding](embedding.md#supervisor-signing-authority)).\nTyped by hand on the workstation, `deliver` dials the broker recorded for `--space` in the mesh\nregistry (a mismatching `--server` is refused before any dial, and a record for a different\nworkspace root is refused outright); with no record for the space it falls back to the local mesh.\nThe daemon serves the workspace root that `--root <dir>` names, which must hold `.cotal/`, or else\nthe nearest `.cotal/` above its working directory. With neither, it refuses at start and names the\ndirectory it searched from, before it reads a credential or dials a broker.\nSee the [delivery daemon](delivery-daemon.md). `feedback-intake` runs a self-hosted feedback server\n(requires `--keys` and a scoped `--creds`), announcing submissions into a space channel; flags\ninclude `--host`/`--port`, `--store`, `--space`/`--channel`, `--max-bytes`, and `--rate-limit`.\n`--port` takes a decimal port from 1 to 65535 and is checked before the intake dials the broker.\n\n## Plumbing\n\n`cotal __complete <words\u2026>` is the internal entry the shell-completion stubs call to emit candidates\nfor the current command line; you never run it directly. `cotal agent-bearer` is machine-facing\nplumbing on user-auth meshes: spawned agents exec it to print a fresh short-lived bearer from their\nspawn-time secret; you never run it directly either. Its local arm uses `--dir` to discover the\ncapability-gated loopback service. A remotely enrolled, already-granted agent instead receives\n`--exchange-url <https://base>` in its launch argv: that arm sends `{owner, actor, actorToken}` to the\npinned public exchange with no local capability, follows no redirects, and refuses every non-HTTPS\nURL because the actor token is the credential in the request body. Because a seat execs it on every\nbearer refresh, it skips the connector-seed boot gate entirely: it reads one 0600 token file,\nexchanges it and prints the bearer without consulting or writing the operator-global seed store, so\na newer store generation cannot refuse a live seat's refresh. `--manager-call` asks for the\ninstance-bound `manager-caller` view; `--manager-instance <id>` selects an explicit live candidate.\nThat mode still prints only the raw token and does not update `--health-file`. A spawn runs it once as the agent auth\npreflight. When it fails there without printing a sentence of its own, the refusal names the cause: the 30 second\ntimeout, the signal that killed it, or its exit code. (`cotal start` is a removed tombstone: it\nerrors and points you to `cotal spawn --detach`.)\n"
62596
+ "body": "# `cotal` CLI reference\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\n`cotal` is the operator command line for the reference implementation: bring a mesh up, mint\nidentities, launch agents, watch what they do, and tear it all down. It is a thin client over the\nwire contract: the normative subjects and schemas live in the [SPEC](../SPEC.md); this page is\nlookup material for the commands, not a walkthrough; if you are new, start with\n[Getting started](getting-started.md).\n\n## Running it\n\n```bash\nnpm install -g cotal-ai # puts `cotal` on your PATH (needs Node 22+)\ncotal --help # every command, grouped\ncotal --version # cotal-ai version + each installed extension's (also `cotal -v`)\ncotal <command> --help # one command's flags and usage\n```\n\n`npx cotal-ai <command>` runs it without a global install; in a dev clone, `pnpm cotal <command>`\nruns it through `tsx` with no build step. Bare `cotal` prints help. Every command generates its own\n`--help`, usage, and shell completion from its declared flags.\n\nAn undeclared flag is a usage error, and so is a flag given more than once unless it is\nrepeatable, as `--opt` and `down --session-store` are. The command prints the error and its help,\nexits 1, and does not run.\n\nCommand output, including error lines on stderr and the guided `setup` and `meshes add` prompts,\nis colored only when stdout is a terminal, so piped or redirected output is plain text. A non-empty\n`NO_COLOR` turns color off on a terminal too. `FORCE_COLOR` turns color on even when output is\npiped, unless it is `0` or `false`, and it takes precedence over `NO_COLOR`.\n\nCommands come from the surfaces the binary composes: the base mesh CLI, the manager\n(`supervise`), and the delivery daemon (`deliver`), plus any operator-installed extensions.\n`cotal ext add <npm-package>` installs any registry providers a package contributes: commands,\nruntimes, and local process lifecycle descriptors. The `web` dashboard and optional manager\nruntimes ship this way.\n\n## Commands\n\n| Area | Command | Purpose |\n|---|---|---|\n| Set up & lifecycle | [`setup`](#setup) | Guided, configure-only setup (installs, seeds personas; launches nothing) |\n| Set up & lifecycle | [`update`](#update) | Reconcile first-party extensions and check or opt into a coherent CLI upgrade |\n| Set up & lifecycle | [`up`](#up) | Start a local mesh (nats-server + JetStream), or boot a whole manifest with `-f` |\n| Set up & lifecycle | [`down`](#down) | Stop the whole stack, selected registered components, or a manifest deploy |\n| Set up & lifecycle | [`backup`](#backups) | Create an offline full-space or registry-only artifact from a preserved cut |\n| Set up & lifecycle | [`clean`](#clean) | Configurable cleanup: purge history (live), or wipe the local store / identity (stopped) |\n| Set up & lifecycle | [`meshes`](#mesh-registry) | List the running meshes on this machine |\n| Set up & lifecycle | [`sync`](#mesh-registry) | Refresh the signed-in account's advertised spaces |\n| Set up & lifecycle | [`use`](#mesh-registry) | Set the default mesh a bare `cotal spawn` joins |\n| Set up & lifecycle | [`status`](#mesh-registry) | Read-only diagnostics for setup, processes, and the selected mesh |\n| Agents & personas | [`spawn`](#spawn) | Launch an agent from a persona (foreground, or `--detach` via the manager) |\n| Agents & personas | [`models`](#models) | List connector model catalogs and variants from the manager |\n| Agents & personas | [`ps`](#managed-seats) | List managed agents and their mesh status |\n| Agents & personas | [`stop`](#managed-seats) | Ask the manager to stop a managed agent |\n| Agents & personas | [`attach`](#managed-seats) | Stream and drive a managed agent's terminal (pty runtime) |\n| Agents & personas | [`input`](#input) | Type one line into a managed agent's terminal without attaching |\n| Agents & personas | [`personas`](#personas) | List, show, edit, create, or remove local personas |\n| Agents & personas | [`supervise`](#supervise) | Run a manager daemon (the agent supervisor / control plane) |\n| Agents & personas | [`service`](#service) | Run the manager as a user service (survives logout and reboot) |\n| Agents & personas | [`runtimes`](#runtimes) | List the agent runtimes the manager can spawn through and whether each is reachable |\n| Agents & personas | [`seats`](#seats) | List the pty seat custodians an earlier Linux manager left, and drain the ones whose agent has exited |\n| Agents & personas | [`reconcile-gate`](#reconcile-gate) | Unfreeze an issuance gate left frozen by a crashed restart when the successor cannot boot-heal it (holder gone, complete CONNZ sweep) |\n| Messaging & watching | [`endpoints`](#endpoints) | List every endpoint in the live presence roster, including infrastructure |\n| Messaging & watching | [`describe` / `invoke`](#endpoint-control) | Resolve a v0.4 service's command surface off the wire; invoke one command by name |\n| Messaging & watching | [`send`](#send) | Send one message, then exit: DM a peer, post a channel, or ask a role |\n| Messaging & watching | [`channels`](#channels) | Inspect or set the channel registry |\n| Messaging & watching | [`history`](#history) | Clear retained message history |\n| Messaging & watching | [`console`](#console) | Live protocol view for a space (TUI, or `--plain` line stream) |\n| Messaging & watching | [`web`](#web) | Browser dashboard (installed as the `@cotal-ai/web` extension) |\n| Extensions & misc | [`linear`](#linear) | Serve the official Linear MCP server as an endpoint agents can call (installed as the `@cotal-ai/linear` extension) |\n| Auth & meshes | [`mint`](#mint) | Mint a creds file for a space (static auth mode) |\n| Auth & meshes | [`login`](#login) | Sign in to a per-user-auth mesh's IdP (once per machine) |\n| Auth & meshes | [`logout`](#login) | Revoke the IdP session and clear the cached login |\n| Auth & meshes | [`actor`](#actor) | Manage a user-auth space's actor ledger (grant / revoke / list) |\n| Auth & meshes | [`doctor`](#doctor) | Credential-health diagnosis and repair (`doctor auth`) |\n| Auth & meshes | [`join`](#join) | Join a space as your own presence (interactive) |\n| Manifest | [`topology`](#manifest-deploys) | Validate and view a mesh manifest's access graph (read-only) |\n| Extensions & misc | [`ext`](#ext) | Install / remove operator CLI extensions |\n| Extensions & misc | [`completion`](#completion) | Print or install shell completion |\n| Extensions & misc | [`feedback`](#feedback) | Send feedback to the Cotal developers |\n| Extensions & misc | [`deliver`](#server-daemons) | Run the server-side Plane-3 delivery daemon |\n| Workflow runs | [`run`](#run) | Operate durable workflow runs: start, resume, list, inspect, answer a checkpoint, check an edited program with migrate |\n| Extensions & misc | [`feedback-intake`](#server-daemons) | Run a self-hosted feedback intake server |\n\nThe manifest modes of `up`, `spawn`, and `down` (`-f <cotal.yaml>`) plus `topology` are covered\ntogether under [Manifest deploys](#manifest-deploys).\n\n## setup\n\n```bash\ncotal setup [--full] [--demo] [--yes] [--skills]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--full` | off | Redo the full guided flow (implies `--demo`) |\n| `--demo` | off | Also seed the guided expert team (`david`, `sven`, `me`) |\n| `--yes`, `-y` | off | Non-interactive accept-all (for agents / CI) |\n| `--skills` | off | Reconcile Cotal skills only through installed connector providers, plus `~/.agents/skills`. Refused with `--full` or `--demo`. |\n\nGuided setup is **configure-only**: it checks prerequisites, invokes installed connectors' declared setup providers, and\nseeds persona files, and it launches nothing (no mesh, no web, no manager). First run gets the\nnarrated flow; later runs print a status card. By default it seeds one `default` persona; the\n`david`/`sven`/`me` team is opt-in via `--demo`. `cotal status` points stale Claude skills and\nout-of-date `.agents` skills at `cotal setup --skills`, not unscoped `setup`. See [Getting started](getting-started.md) and, for\nmaintainers, [setup internals](setup-internals.md).\n\nWhen a mesh resolves, setup seeds that mesh's recorded `.cotal/agents` catalog, the same catalog a\nfollowing `cotal spawn` reads. It prints the absolute destination. On a fresh machine with no mesh it\nuses this folder and says why; when several meshes are available and none is selected, it refuses\nrather than choosing a catalog.\n\n## update\n\n```bash\ncotal update [--self] [--space <s>] [--server <url>] [--creds <path>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--self` | off | If a newer release exists, install that exact validated `cotal-ai` version globally and reconcile through the newly installed binary |\n| `--space`, `--server`, `--creds` | resolved mesh | Select the running manager whose continuity state is reported |\n\nWithout `--self`, `update` keeps the installed first-party surfaces coherent with the running\nbinary: it force-reconciles the four built-in connectors, then reinstalls other `@cotal-ai/*`\noperator extensions at the binary's exact version. Each extension runs in an isolated child, so one\nfailure cannot poison later replays. It then checks npm; a newer binary is an informational notice\nwith `cotal update --self` as the next command, not an automatic install.\n\nAfter disk reconciliation, `update` reads the selected running manager. A machine with no recorded\nmesh has no running manager to observe, so that read is skipped and the command completes. The same\nholds when every recorded mesh is down and none is selected. A remote user mesh, registered with\n`cotal meshes add --mode user`, is named and skipped: its manager runs under another install, so\nthere is no custody on this machine to preserve, and a `legacy` verdict still comes only from a\nmanager this machine read. A recorded mesh that is down is still a\nrefusal when the command selects it, with `--space` or by running inside its project, and so is a\nnamed space that is not running. With several meshes running and no `--space`, `--server` or\n`--creds`, the install is machine-wide, so every running manager is reported in turn, each under its\nspace name, before anything is written; a `legacy` verdict on any of them makes the whole run not a\nhot update. A selector flag still reports one manager. A manager without a\ncustody generation is reported as `legacy`: it cannot preserve its manager-owned PTYs, so the\ncommand says that this is not a hot update and prints `exact`, `fork`, `fresh`, or `drain-only`\nfor every seat. This report sends no stop, preservation-commit, or replacement command.\nIt does not preserve a running PTY on a legacy manager. The built-in pty runtime spawns\nin-process on every platform and reports `legacy`. On Linux it still adopts seats that an earlier\nmanager left under a detached custodian, but it starts no new custodian. An incompatible native\n`@lydell/node-pty` or ConPTY ABI break remains an explicit per-seat maintenance cut.\n\nWith `--self`, the selected running manager is reported before any global install. When a newer\nrelease exists, Cotal then installs the exact version it validated, resolves and verifies that\npackage in npm's global root, then launches that binary with the same `--space` / `--server` /\n`--creds` selection to reconcile connectors and first-party extensions to the new generation. An npx\nor dev-clone invocation therefore installs and continues through a separate global copy; it never\nclaims the already-running process changed. If the binary is current, `--self` performs the normal\nlocal reconcile without reinstalling it.\n\nThird-party extensions are listed with their installed version and recorded spec but are not\nauto-updated in v1. Floating third-party updates require `@cotal-ai/*` peer-range validation and are\na future follow-up. A failed connector/extension install, npm metadata check, or requested global\ninstall is reported and makes the command exit nonzero. Independent extension attempts continue so\nthe output includes every failure; an unavailable npm registry does not undo a completed local\nreconcile, but the command still exits nonzero because it could not establish that the install is\ncurrent.\n\n## up\n\n```bash\ncotal up [--detach] [--open] [--space <s>] [--server <url>] [--channels <path>] [--runtime <name>]\ncotal up --user-auth --idp <url> [--exchange-public-port <n> --exchange-public-url <https://\u2026> [--exchange-trusted-proxy]]\ncotal up --tls-cert <cert.pem> --tls-key <key.pem> # serve broker TLS (both, or neither)\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\ncotal up -f <cotal.yaml> [--dry-run] [--runtime <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--server <url>` | auto (free local port) | Listen URL override |\n| `--host <host>` | none | Bind host override for a **fresh** broker boot: an IP or hostname only, never a URL (that is `--server`) and never `host:port` (the port comes from `--server` or its default); a URL or port-bearing value is refused pointing at the right flag. With no `--server`, the broker URL is derived from it, so `--host <addr>` alone is enough to make a mesh reachable at that address; a `--host`/`--server` pair naming different addresses is refused. A wildcard bind (`0.0.0.0`, `::`) keeps a dialable loopback URL. Recorded on the mesh and reused by every later manager launch, so a repair or resume keeps remote [`attach`](#managed-seats) working. A live refresh (`\u2713 mesh already running`) does not rewrite `.cotal/auth/server.conf` or rebind nats; stop the broker, then re-run `up --host` |\n| `--space <s>` | the folder's name | Space name |\n| `--store-dir <dir>` | none | JetStream store directory (recorded; a repair up reuses it) |\n| `--max-file-store <bytes>` | nats-server's dynamic cap | JetStream file storage cap in bytes (a positive integer, no unit suffix). Without it nats-server sizes the store at start as three quarters of the free space on its filesystem. The cap is fixed at broker start: a running broker cannot change it (`cotal down` first), `down --preserve-state` keeps it for the resume, and a resume with a different value is refused. Not accepted with `-f` |\n| `--channels <path>` | `.cotal/channels.json` if present | Channel-registry seed file (JSON). An explicit path that is missing is an error |\n| `--restore <dir>` | none | Restore a completed offline backup before exposing the normal listener |\n| `--restore-only registry` | artifact selection | Restore only the registry component |\n| `--accept-missing-source` | off | Explicit disaster consent when the inode-bound preserved source is absent |\n| `--accept-stale-checkpoint` | off | Explicit consent to resume a seat whose checkpoint was captured outside its recorded recency horizon |\n| `--open` | off (auth) | Unauthenticated dev mesh: no JWT, no ACLs |\n| `--user-auth` | off | Per-user auth: people `cotal login`; connects are authorized against the actor ledger |\n| `--idp <url>` | none | With `--user-auth`: the IdP auth base URL to pin on first enable. With `-f`, overrides the manifest's `broker.idp` under the same rules: https (or http on a loopback IP literal), with no credentials, query or fragment |\n| `--exchange-public-port <n>` | none | With `--user-auth`: add the public exchange face on this loopback port, for an HTTPS reverse proxy to forward to |\n| `--exchange-public-url <https://\u2026>` | none | With `--exchange-public-port`: advertise the reverse proxy's HTTPS URL in discovery |\n| `--exchange-trusted-proxy` | off | With `--exchange-public-port`: attribute public failure buckets to the last `X-Forwarded-For` hop. Enable only when the listener is reachable solely through a trusted proxy; otherwise the socket address is used |\n| `--detach` | off | Run in the background (stop with `cotal down`) |\n| `--tls-cert <path>` | none | PEM certificate to serve TLS with. Must be given together with `--tls-key`. Before starting the broker, Cotal checks readability, private-key mode, key/certificate match, the validity window, and host coverage. `nats-server` accepts an expired certificate and leaves the failure to clients, so Cotal performs these checks first. The decision is recorded; a later bare `cotal up` keeps serving TLS |\n| `--tls-key <path>` | none | PEM private key for `--tls-cert`. Refused if group- or other-readable (tighten to `600`) |\n| `--file <cotal.yaml>`, `-f` | none | Launch a whole mesh from a manifest |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--runtime <name>` | `pty` (or the manifest's, with `-f`) | Agent runtime for the mesh manager (`pty` built in; others are installed extensions, explicit-only). Resolved + probed before the broker starts; an uninstalled/unreachable runtime fails loud. With `-f`, overrides the manifest's runtime |\n| `--max-sessions <n>` | 64 | Live-session ceiling for the mesh manager. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. Recorded on the mesh and reused by every later manager launch, so a repair or resume does not silently drop back to 64. A running manager cannot change it: `cotal down` first, then `cotal up --max-sessions <n>` |\n| `--no-manager` | off | Broker-only boot: start the broker and, in auth mode, the delivery daemon, and no local manager. A refresh under the flag of a mesh whose manager is live refuses rather than keeping or stopping it (`cotal down manager` first). Cannot be combined with `--runtime`, `--max-sessions`, or an agent-declaring manifest |\n| `--rotate-sys` | off | Rotate the space's system account and re-mint its two `$SYS` creds. Needs a stopped mesh; refused with `--open` |\n\n`cotal up` boots a local nats-server with JetStream and, in auth mode (the default), JWT auth and\nper-agent ACLs; `--detach` records the mesh so `cotal spawn` from any directory can find it. With no\n`--server`, it auto-selects a free port if the default address is taken; an explicit `--server`\nstays fail-loud on collision. `--detach` also brings up the control plane (delivery daemon in auth\nmode, then the manager). `--no-manager` is the broker-only mode: it boots\nthe broker (and the delivery daemon in auth mode) and starts no manager, so there is no manager\npidfile to leave stale. A refresh under the flag of a mesh whose manager is live refuses rather\nthan keeping or stopping it: `cotal down manager` first. For a split topology with a manager, wait for `.cotal/manager.<spaceKey>.log` to contain `\u2713 manager up`, then `cotal down manager` on that\nhost and run [`supervise`](#supervise) against the remote broker; see\n[Run a mesh](run-a-mesh.md). `cotal up --detach` prints `\u2713 running in the background:` with\n`manager` listed (pidfile liveness, not a teardown boundary); with `--no-manager` the line lists\nonly what actually started. Ctrl-C on a foreground `up` stops the manager through the same stop as\nbare `cotal down` (see [`down`](#down)), then the rest of the stack, and reports managed agents\nunder the same rule: when the manager stop is refused, Ctrl-C prints the refusal with the reap route\nand leaves the stack running. The `-f` form is a\n[manifest deploy](#manifest-deploys).\n\nA repair `up` on a mesh whose broker died reopens the store its record names, and refuses a\ndifferent `--store-dir` rather than silently opening a second store.\n\nThe generated `.cotal/auth/server.conf` is written on a real broker boot and is not an\noperator-owned config. `--host` changes that file only when nats is actually started. A unit\nrestart that leaves an answering listener in place is a refresh, not a rebind.\n\nOn an existing mesh, `cotal up` reconciles the presence and lease bucket TTLs. It writes a reserved\ncanary and waits for the bucket to expire it before reporting success. If the broker accepts the\nstream update but the backing store does not persist or enforce it, `up` exits nonzero with a TTL\npersistence error instead of trusting the value returned by stream info. A refresh that restores a\nmissing manager waits for that process to come up, then says so with its pid (`\u2713 restored in the\nbackground: manager (pid N)`). A manager that `up` starts and that exits at boot instead, for example\nwhile a crashed predecessor's lease has not expired, is reported as `! control plane degraded:` with\nthe path of its log, and a refresh then exits nonzero. The same happens when another\n`cotal supervise` takes over the manager pidfile before the started process comes up. A refresh that\nfinds everything already running prints only the `\u2713 mesh \"<space>\" already running` line. A first\nboot starts its manager without the restore line.\n\n\n`--user-auth --idp <url>` starts the space's auth service alongside the broker: the NATS\nauth callout plus its capability-gated local exchange, and optionally the closed public exchange\nface configured by the three `--exchange-*` flags above. The service is torn down with `cotal down`,\nand a re-run of `cotal up` heals a dead service on a running broker. `up` waits for the service to\nfinish binding: while the daemon it launched (or found running) stays alive, the wait extends past\nthe base 15s up to 60s; a daemon that exits is refused at once with \"exited before becoming ready\",\nand one alive past 60s is refused as \"alive and still starting\" (wedged), naming the pid record and\nthe service log. `--user-auth` and `--open`\ncontradict each other and are refused loudly; a running broker cannot change auth mode\nwithout a `cotal down` first. See [identity & auth](identity-and-auth.md).\n\n`--rotate-sys` renews the two `$SYS` credentials (`membership-observer`, `connection-evictor`).\nThey carry a 30-day expiry and nothing re-signs them in place, because the system-account seed is\nnever persisted, so they are renewed by issuing a **new system account** under the same broker\noperator and minting fresh creds against it. A plain re-`up` does **not** do this: it reuses the\nexisting trust record, and its `$SYS` creds along with it.\n\nThe rotation is safe to run on a real space, with one operational cost. The data account, the account\nsigning key, every agent credential minted from it, and the JetStream store are all untouched; what\ndies is the retired system account, and with it any out-of-band copy of the old `$SYS` creds, on every\nbroker that loads the rotated config. The cost is that **earlier full backups stop being restorable**\n(see below), so this is not a no-consequence operation. It needs the broker to restart on the rewritten\nconfig, so it runs as part of a boot:\n\n```bash\ncotal down\ncotal up --rotate-sys --detach # agents reconnect; nothing is re-provisioned\ncotal doctor auth # both $SYS creds healthy again, 30 days out\n```\n\nA rotation is a stopped, fresh boot, and anything that is not one refuses it, all for the same reason\n(the on-disk material and the broker it runs on must never end up on different generations):\n\n- a live mesh, because the running broker would keep serving the retired account;\n- an open mesh, whether that comes from `--open` or from `broker.auth: false` in a manifest, which\n has no system account at all;\n- `--restore`, because reinstating a trust root and superseding it in one command leaves no way to\n say which authority the mesh came up on;\n- an unfinished restore or resume attempt on this root, including one `cotal up` would recover on\n its own, because those paths can adopt a live listener and return without booting a broker;\n- a root that hosts more than one space, because the system account lives in the shared broker\n record and a rotation would retire every tenant's, while the root holds one `$SYS` cred pair\n pinned to one data account.\n\nTwo things to know before you run it:\n\n- **The retirement is config-load-bound.** Old `$SYS` creds are refused by any broker that loads the\n rotated config. A stale `nats-server` still running the *previous* config in memory would keep\n honouring them, so stop every broker for this root first. `--rotate-sys` refuses if this root's\n mesh is recorded as running, if anything unidentified is answering at the address it was given, or\n if the root's pid file names a live (or unreadable) process. Those are Cotal's own ownership\n records, not a scan of the process table: a `nats-server` you started by hand against this root's\n `server.conf` on some other port writes none of them and will not be seen. Do not run one.\n- **It invalidates earlier full backups.** A full artifact binds to the trust chain it was taken\n against, and that commitment covers the operator JWT and the system account. Every full backup\n taken before a rotation refuses to restore afterwards, so take a fresh `cotal backup` once the\n rotated mesh is up. `cotal up --restore` names this case when the data account still matches.\n\nThe commit is not atomic (a trust-record write plus two credential writes), so an interrupted\nrotation leaves the record ahead of the creds. That split is detected rather than silent: every\n`cotal up` on an auth mesh, and every `cotal doctor auth`, compares each `$SYS` cred's issuer against\nthe persisted record and names the retired account. `up` warns rather than refusing, because these\ncreds power the membership graph and live eviction, both of which degrade fail-soft; the mesh is not\nworth taking down over them. Re-running the rotation heals it, at the cost of one generation.\n\nWhile those creds are expired the mesh keeps delivering messages, but the\n[membership feed](delivery-daemon.md) and live connection eviction stay down; `cotal doctor auth`\nand the manager's log both name the credential and this repair.\n\n## down\n\n```bash\ncotal down\ncotal down --with-agents\ncotal down --preserve-state [--store-dir <dir>] [--session-store <dir> \u2026]\ncotal down manager [delivery auth web nats ...]\ncotal down web [--space <name>]\ncotal down -f <cotal.yaml> | --run <id> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--file <cotal.yaml>`, `-f` | none | Tear down this manifest's deploy |\n| `--run <id>` | none | Tear down one `spawn -f` run by id |\n| `--space <name>` | current mesh | With components: the mesh whose target-addressed components (e.g. `web`) to stop |\n| `--dry-run` | off | Print the manifest teardown or selected components, mutate nothing |\n| `--with-agents` | off | Bare whole stack only: also stop and deprovision every managed agent |\n| `--preserve-state` | off | Bare whole stack only: fence the manager, retain principals and durable state, stop and prove the stack down, then publish `ready` |\n| `--store-dir <dir>` | `.cotal/nats` | With `--preserve-state`: the actual store path (required for a custom store) |\n| `--session-store <dir>` | none | With `--preserve-state`: a harness transcript store directory to capture with every continuation-capable retained seat. Repeatable. No default and never inferred from a connector name; a path that does not exist or is not a directory is refused before anything stops |\n\nBare `cotal down` stops the whole local stack in dependency order and leaves managed agents running\nwhen their runtime lets them outlive the manager. Before signalling the manager it verifies the spare\ncapability of the exact recorded manager, which records what that manager's stop does with its\nseats, and it reports the agents left behind plus `cotal down --with-agents` as the explicit reap.\nWhen the manager had no managed agents, it prints no report.\nThe built-in pty runtime keeps each PTY inside the manager process, so those seats cannot outlive\nit: every manager stop stops and deprovisions them, and `down` reports them as stopped. Every manager\nstop the CLI makes runs this one path: `down`, Ctrl-C on a foreground `cotal up`, the teardown after\nthat `up`'s broker exits, the leftover-manager stop before `cotal up -f`, and the delivery cutover.\nEach holds the manager's stop reservation, so a second stop while one is in flight is refused,\nnames the process holding it, and leaves that stop's `--with-agents` policy in place. Each sends\n`SIGKILL` to a manager still running 15s after `SIGTERM`. Ctrl-C stops the manager first; when that\nstop is refused or the manager's exit cannot be confirmed, Ctrl-C signals nothing else, prints the\nrefusal with the reap route, and leaves the stack running; end it with `cotal down --with-agents`.\n`--with-agents` is a one-shot destructive policy bound to the exact verified manager process\nand the exact live `down` stop reservation; a stale, malformed, crashed, or different stop attempt\ncannot turn a later bare shutdown destructive. If a managed agent cannot be proven stopped within\nthe manager's stop timeout, the manager logs which one, still closes its broker connections and\nconsole listener, and exits with code 1. It does not release its pidfile, liveness lease or\nservice registration in that case, so no successor is handed authority while that agent may still\nrun; the lease lapses on its TTL. Positional component names stop\nonly those self-registered local processes; for example, `cotal down manager` leaves delivery and\nthe broker running, and `cotal down web` is available when the web extension is installed. A\ncomponent that starts target-resolved (the web dashboard) is stopped the same way: `cotal down web`\nresolves the mesh the same way as `cotal web` (registry current mesh first, `--space` to name one), so\nit works from any directory; the other components always stop under the folder you run it in. The\n`-f` / `--run` forms tear down a [manifest deploy](#manifest-deploys) without stopping the whole mesh\nand cannot be combined with component names. Stopping `nats` alone is refused while an unselected\nregistered daemon is still live; include those components or use bare `cotal down`.\n\nA pinned manager with no spare-capability record is not signalled by bare `cotal down` or `cotal\ndown manager`. A current manager always publishes the record, so a missing one means an older\nmanager: one that predates capability reporting, or one whose pty runtime reported that it cannot\ndetach its agents. Stop each managed agent explicitly, then run `cotal down --with-agents` from the\nmesh root to stop the whole stack. An older manager does not understand\nthe reap request, which is why the agents must already be stopped.\n\nBare `cotal down` inventories by pidfile. When this folder's registered broker answers and no\n`nats.pid` records it, the command does not say nothing is running. It names the space and the\nbroker address, says no pidfile records that process, says it will not stop a process it did not\nstart, and exits 1. Stop that broker with whatever started it (an init unit, a container, or the\nhand-run process). `cotal meshes rm <space>` only drops the registration. The probe runs whether or\nnot other owned components were running: they stop and clear their artifacts first, then the broker\nis named. A component stop and `--dry-run` stay pidfile-only and do not probe.\n\n`down` reads each process record once. A component that exits and removes its own record while\n`down` runs counts as having no record, so the stop goes on. Any other failed read is an error.\n\n**Teardown verifies pinned process identity before signalling.** PIDs are recycled by every OS,\nso a recorded pid alone is not a durable target identity. `up` and `cotal web` record each\nprocess's creation identity in a sibling `<pidfile>.identity` pin, which holds the pid and the\nprocess start reported by the OS. Every stop path, including `down` for the broker, web and\nextension components, and the manager, delivery and auth-service stops, applies the same rule. A pin\nthat names a different start means the pid was reused, so teardown refuses and preserves it. A torn\nor unreadable pin also refuses.\n\nThe pidfile and its pin are published by renames, and the pidfile rename is the commit point. Just\nbefore it, the pin holds two lines: the old process's and the new one's. A launcher that dies\nmid-publish therefore leaves the old record or the new one, each checked against its own pin line,\nnever a pidfile without its pin. An old record with no pin is legacy, so its line holds `-` in place\nof the token and it stays legacy until the commit. A CLI older than this change reads a two-line pin\nas torn and refuses.\n\nPublishes of one pidfile are serialized by a lock file beside it, `<pidfile>.publish.lock`, because\nthe launcher and the daemon it starts both publish the same record. The next publisher reclaims a\nlock left by a crashed one. When no start token can be read for the new process, its pin line holds\n`-` in place of the token, which reads as a legacy record, and the publish ends in the legacy shape:\na pidfile with no pin. Teardown, and a daemon removing its own record on exit, take the same lock and\nremove the record only while the pidfile still names the pid they stopped, so a stop that races a\npublish leaves the new record whole.\n\nThe web dashboard claims `web.pid` with an exclusive create, so a second dashboard for the same mesh\nis refused, and writes its pin right after the claim. A stop that runs between the two reads a\nlegacy record. A refused claim calls the record stale, and points at `cotal down web`, only when the\nfile is empty or its pid is proven gone. Content that is not a pid, or a pid whose liveness the\nkernel will not report, is refused as possibly fronting a running process, as `cotal down web`\nrefuses it.\n\nThe pidfile pid and the pin pid are two coordinates. Automatic cleanup follows **proven death of\nthe pidfile target** (ESRCH on that pid): a torn sibling pin does not wedge a dead pidfile pid.\nA torn pairing where the pin names another pid, while the pidfile pid is still live or not proven\ndead, still refuses. Inspect both pids with `ps`. Do not delete `<pidfile>.identity` to force a\nstop; that weakens target-identity protection. Once the pidfile process is dead, rerunning\nteardown clears the stale record automatically.\n\nThe first teardown after upgrading a running pre-pin stack has a narrower guarantee. A live record\nwith no identity pin is signalled after a loud warning that it predates identity pinning. Restarting\nthe component writes the pin, so later teardowns receive full match and mismatch protection. The\nsame warning applies on platforms where no stable start token is available. For a legacy manager,\nbare `cotal down` also warns that agent sparing cannot be verified before it signals. Because the\nCLI cannot establish which SIGTERM handler that already-running binary carries, it never presents\nthe pre-signal seat inventory as confirmed spared; a genuinely older destructive handler may still\nreap those agents. `--with-agents` publishes a one-shot reduced-guarantee handoff bound to the\nrecorded manager pid and the live `.stopping` reservation's inode, then signals unconditionally.\nThat handoff cannot be replayed by a later stop attempt. A pin that exists and does not match the\nlive process still refuses before signal.\n\n`--with-agents` performs the old destructive logical teardown: managed processes stop and their\ncredentials, ACL rows, and delivery footprints are deprovisioned. `--preserve-state` is a different\nmaintenance transition: it stops retained processes while suppressing leave/deprovision cleanup, persists the manager's\nsame-principal resume inventory, stops the entire stack without removing run/auth artifacts, and\npublishes a stable inode-bound cut only after every recorded process is proven stopped and the exact\nrecorded NATS endpoint is unreachable. A missing or stale broker pidfile never counts as stopped. The\nattempt is bound durably before the manager is fenced, the resume document and attempt-bound\n`cut-intent` are fsynced before manager commit, and the manager's commitment itself is journaled\n(`cut-committed`) before any process stops. A retry after a crash at any of those boundaries reuses\nthe exact recorded attempt and finishes the remaining stop and endpoint proofs idempotently, without\nneeding the (by then intentionally dead) manager. A partial cut never publishes `ready`. It cannot\nbe combined with component names, manifest teardown, or `--dry-run`.\n\nOn a mesh that runs the delivery daemon, the cut stops it first so no join or leave can change\nmembership under the manager's inventory. Before any manager-owned endpoint stops, the cut then\nreads the presence roster and refuses while an endpoint the manager does not own is live. A refusal\nleaves the delivery daemon stopped. A deleted presence key is the only row it reads as absent. Any\nother row that is not a card with a non-empty string `id` and a string `name` refuses the cut with\n`presence record <subject> is malformed`.\n\n**Seat checkpoints.** After the stack is proven down, the cut writes one checkpoint per retained\nseat under `.cotal/maintenance/v1/checkpoints/<attempt>/<seat>/`, and prints the path, the\ncontinuity class and the generation for each. The path carries the preservation attempt because a\ncheckpoint is immutable once sealed: a shared directory would make the second cut in a root refuse\non the first cut's leftovers, and clearing it would destroy an artifact a rollback still needs. The\ncapture happens only at that point because anything earlier races a harness that is still writing\nits transcript and its working tree.\n\nEach checkpoint directory is created 0700, refuses a destination that already exists, and holds:\n\n- `repo.bundle`, the seat `cwd`'s reachable history, anchored on the base commit the record names\n by full object id;\n- `repo.index.diff` and `repo.worktree.diff`, the staging state as two diffs, base to index and\n index to worktree. Two rather than one because a single combined diff restores a mixed tree with\n the right bytes and the wrong index: a source reporting `MM README` would come back as ` M README`;\n- `repo.untracked.tar`, the untracked files in scope;\n- the harness session pointer, when the seat's connector declares one, and the transcript store\n files the operator named with `--session-store`. Each records where the destination puts it back\n as an anchor (the workspace root, the account home, or the seat's `cwd`) plus a relative path,\n because the destination's root and home are its own and the source host's absolute spelling would\n either miss them or write outside them;\n- `checkpoint.json`, written last, after every digest is computed over the bytes that landed.\n\nThe record carries the manager's resume entry unchanged as its first field, then the space, the seat\nname, the recovered `lifecycleUid`, the writer generation the cut was taken at, `capturedAt`, the\nrecency horizon, the applied profile revision, the seat's `git status --porcelain` as the cut read\nit, and the continuity class. Every captured file is\nlisted with its byte size and sha256, so an operator verifies the whole artifact with `sha256sum`\nand `git bundle verify`. No secret values, no operator keys and no source-host launch material\nenter it.\n\nThe generation is the highest one this root has claimed for the seat in `.cotal/auth`. A\ngeneration entry there that is a symlink, a directory or anything else that is not a regular file\nrefuses the cut, as every other auth record does.\n\nThe continuity class is what the connector declares, capped by what the checkpoint carries. A\nconnector declaring session continuation classifies as `exact`, but reopening a session takes both\nhalves, the pointer that names it and the store that holds its transcript. A checkpoint missing\neither one cannot reopen that session, so it is recorded as `fresh` when the connector declares a\nfresh start and `drain-only` otherwise. A pointer with no store is capped the same way as a cut\ncarrying neither, because it names a session whose bytes the artifact does not contain. A class is a promise the destination is entitled\nto act on, so it never describes bytes the artifact does not contain. The transcript store stays an\noperator input: this repository does not know where a harness keeps its transcript, so `exact`\nrequires `--session-store` to name one.\n\nThe recorded status is read under the same selection rule as the untracked set, so it describes the\nstate the captured bytes can reproduce. The destination re-reads it in the promoted tree and refuses\na difference.\n\nThe untracked selection rule is recorded in the record and is\n`git ls-files --others --exclude-standard -z, excluding .cotal/`. It honors `.gitignore`, so an\nignored file the seat needs does not travel and has to be moved separately. The `.cotal/` exclusion\nis a secrecy boundary rather than a size one: when a seat's `cwd` is also the mesh root, the control\ndirectory is untracked, and without the exclusion the broker trust material, the space account, the\nmanager instance identity's private seed and the seat's own credentials would land inside the\nartifact. A checkpoint carries credential references only; the destination resolves that material\nitself.\n\nA seat whose launch options could not be resolved is refused rather than checkpointed, with the\nmanager's own wording: `imperative launch options have no non-secret durable source (<keys>)`. The\nrefusal arrives at prepare time, so the cut stops before any child does.\n\nA delegated seat (SPEC \xA713.17) is refused at prepare time too, with\n`a delegated seat is not resumed by a later manager; stop it before preserving`. A manager stop\nafter a refused cut retires that seat through its retirement path.\n\n## clean\n\n```bash\ncotal clean <history|store|all> --force\ncotal clean restore-attempt --attempt <id> --force\ncotal clean restore-fallback --attempt <id> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | `history`: target mesh |\n| `--dms` | off | `history`: also clear DM history |\n| `--store-dir <dir>` | `.cotal/nats` | `store`/`all`: JetStream store directory |\n| `--force` | none | Required: destructive, no prompting |\n| `--attempt <id>` | none | `restore-attempt`: exact stale pre-commit attempt; `restore-fallback`: matching healthy committed restore |\n\nOne configurable cleanup verb; every target requires `--force`.\n\n- `history` purges the retained message backlog on the **running** broker (channels, plus DMs\n with `--dms`). The same operation as [`history clear`](#history), which stays as an alias.\n- `store` deletes the **stopped** mesh's JetStream store (`.cotal/nats`): streams, durable\n consumers, and messages. This is the reset for stale on-disk broker state, e.g. durables\n minted by an older, incompatible Cotal generation surviving a `down`/`up` cycle.\n- `all` is `store` plus the space identity (`.cotal/auth`), the local creds and markers tied to\n it, any crash residue a normal `down` would have swept (stale pidfiles, `run/`), and the mesh's\n registry entry; the next `cotal up` mints a fresh identity.\n\n`history` needs the mesh up; `store` and `all` refuse while any recorded mesh process may still be\nrunning or any same-root recorded broker endpoint remains reachable (run `cotal down` first). A\nrecord counts as stopped only when it is absent, empty, or names a pid the kernel reports gone, so\nunattributable content, a removal reservation, or a pid whose liveness cannot be read refuses. They\nalso refuse outright on a root that holds accounts for several spaces: the store and the broker\ntrust record are shared by every space on the broker, so both targets would take out all of them\nand no `--space` can narrow that. `down`, `backup` and `up --restore` refuse there for the same\nreason. `cotal status` lists the tenants on such a root. Personas\n(`.cotal/agents`) and logs are never touched. The mesh record now carries a custom\nstore location for `up`'s own repair, but `clean` still takes `--store-dir` itself; `clean` does\nnot read the record. Custom cleanup targets must contain either the Cotal store-generation marker or a\nreal `jetstream/` store directory; filesystem roots, project roots, and Cotal auth/maintenance trees\nare always refused.\n\n`store` and `all` also refuse every maintenance journal state. After a healthy committed restore,\n`restore-fallback` is the only supported way to remove the recorded unchanged old-store inode; it\nnever deletes the active target, requires both the exact attempt id and `--force`, and retires the\ncompleted restore journal so a later `down --preserve-state` can start a new backup cycle.\n\n## Backups\n\n```bash\ncotal down --preserve-state [--store-dir <dir>]\ncotal backup create <dir> [--only full|registry] [--store-dir <dir>]\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\n```\n\nBackup is offline-only. It requires the stable `ready` record from `down --preserve-state`, an exact\nstore match, no recorded process that may still be running (the same test `clean` uses), and an\nunreachable exact endpoint from the recorded cut.\nThat endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile\nis still refused. It claims the cut, reflink/copies the stopped source to a\nprivate attempt clone, and opens only that clone on a random loopback bootstrap broker with an\nindependent parent/deadline watchdog. It validates the canonical stream and pull-consumer inventory,\nwrites native snapshots with consumers excluded, and stores conservative contiguous ACK-floor\ncheckpoints separately. The presence bucket is memory-backed, so it does not survive the cut and\nthe clone may lack it. Every other stream must be present. The original store is never opened by\nthe backup broker, and the stack is not restarted implicitly. Artifact destinations must not overlap\nthe preserved source or maintenance\nattempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the\npreserved source, or the maintenance attempt tree.\n\nStopped client-managed KV ordered consumers are ephemeral read residue, not backup state. Backup\nignores only the pinned client's exact stopped shapes: ordinary last-value watchers and the\nwhole-bucket scanner that uses all-history delivery to collapse concurrent tombstones. A bound\nconsumer or any lookalike with a different filter, inbox, lifetime, or other config is still refused.\n\n`full` is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, and\nvalidated durable checkpoints. `registry` is the sole partial artifact. Presence, derived membership\nfeed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor\nledger files are excluded. `full` means every transferable message and registry stream, not every\nJetStream resource: endpoint submissions/facts/events/timers/workflow state, contract artifacts, and\nthe records/auth/session stores are nonportable control state. Restore recreates those streams empty\nwith their canonical configs before exposing the normal listener, so active endpoint runs,\nlifecycles, and sessions do not cross a backup. Artifacts are exclusively created `0700`;\nsnapshot/checkpoint files and\nthe manifest are `0600`; `manifest.json` is written last with exact sizes and SHA-256 values. The\ndirectory is trusted operator input: hashes detect corruption, not malicious rewriting.\n\nRestore validates and stages the exact allowlisted artifact bytes before moving or creating a store.\nIt requires the same space and existing trust state. The whole pre-commit window holds a journaled\nliveness claim (coordinator, watchdogs, brokers, absolute deadline): ordinary `up` and a repeated\n`up --restore` refuse while the claim is live, and a stale attempt is recovered only after the\ndeadline has elapsed and every recorded owner is proven dead. A retried `up --restore` handles this\nautomatically; an operator can also recover it explicitly with `cotal clean restore-attempt --attempt <id> --force`. Nothing\never rolls back a live attempt. A registry-only artifact restores as registry-only whether or not\n`--restore-only registry` is passed; omitted infrastructure is always created and the exact\npost-restore stream inventory is asserted before commit intent. Ordinary `up` from a preserved cut\nresumes only the exact recorded source store and runtime; a contradicting `--store-dir` or\n`--runtime` fails in preflight.\n\n**Admitting a seat checkpoint.** An ordinary `up` from a preserved cut admits that cut's seat\ncheckpoints before it journals the resume attempt and before any process starts, so a refusal costs\nnothing. Three gates run in order, each naming what it saw.\n\n1. *Integrity.* Every file the record names must be present, a regular non-symlink file, the\n recorded byte size and the recorded sha256, re-stat'd after the read so a file that moved is a\n refusal. Failure here consults no other gate.\n2. *Identity.* The recorded space must match, the recorded `lifecycleUid` must not belong to a live\n incarnation, and the profile revision must match this host's or be resumed under deliberately\n this host's. A differing revision is refused with both digests and the remedy, and there is no\n override: the checkpoint carries the recorded digest and not the config bytes, so nothing could\n run the seat under the recorded revision, and the manager re-digests the same file and refuses\n drift on its own. This gate has no blanket override, which is the only reason the next one may\n have one.\n3. *Recency.* `capturedAt` is compared to this host's clock against the horizon the record carries.\n Inside it, the seat resumes. Outside it, `up` refuses and prints the capture instant, the clock\n reading and the horizon; `--accept-stale-checkpoint` admits it anyway and the exercised consent\n is printed with the actual age. An unreadable `capturedAt` is refused with no override, because a\n freshness gate that fails open is not a gate.\n\nCustody transfers only after all three pass. The destination claims the recorded generation plus one\nby exclusive create, before it launches anything. A lost create means another destination is already\nclaiming that seat, and it refuses with `seat-writer-generation-create-lost` rather than adopting\nthe winner and becoming a second writer. The recorded `lifecycleUid` is reused and never minted, so\nthe resumed seat binds the same lifecycle-keyed durables.\n\nAdmission is reconciled against the inventory the resume is about to hand the manager, and that\nreconciliation finishes before the restore moves a single tree. A checkpoint whose recorded\n`lifecycleUid` is not the one the retained inventory carries describes a different incarnation of\nthat seat, and it refuses with both uids while every live working tree is still untouched and no\ngeneration is claimed. A retained\nseat with no admitted checkpoint refuses the resume by name: an absent checkpoint directory and an\nabsent record are indistinguishable from a seat that was never checkpointed, and a seat that starts\nwithout passing the gates has claimed no generation. `--accept-stale-checkpoint` is recorded in the\nresume journal with the seat, the capture instant, the admitted age and the horizon, so the consent\nsurvives the terminal it was typed into.\n\nThe whole admission is all or nothing. Coverage is settled first, then every gate runs over every\ncheckpoint, and only then is any generation claimed. A refusal at any point leaves every generation\nunclaimed, including a lost exclusive create during the claim itself: the claims that attempt made\nare removed before the refusal is raised, by the exact paths it wrote, so a generation another\ndestination holds is never touched. A claim is a create that can never be made again, so a refusal\nthat left one behind would consume the retry over the same checkpoint set.\n\n**Restoring a seat checkpoint.** Once every gate has passed over every checkpoint, and before a\nsingle generation is claimed, `up` puts each admitted seat's captured bytes back. A refusal here\ncosts nothing for the same reason a gate failure does: no claim has been made and nothing has\nstarted.\n\nA restore never moves or replaces the destination's own control directory. A checkpoint excludes\n`.cotal/` by design, so a seat whose `cwd` holds one, which is the layout an operator gets by\nrunning `up` and `spawn` in a single directory, is refused before anything is staged: promoting a\ntree that cannot contain `.cotal/` over that `cwd` would carry this host's live trust material and\nmaintenance state away with the superseded tree. The refusal names the control directory it found\nand the remedy, which is to give the seat a working tree that is not a workspace root.\n\nEach seat is staged beside its own `cwd`, in `<cwd>.incoming`:\n\n1. every recorded digest is verified again over the files as they are now;\n2. the bundle is cloned into `<cwd>.incoming`, which is refused when that path already exists;\n3. the recorded base commit is verified in the clone and checked out detached, so a bundle that does\n not contain it stops the resume instead of continuing against a different history;\n4. the index diff is applied with `--index` and the worktree diff without it, both `--binary\n --allow-empty`. That order is what puts staged content back in the index rather than only in the\n worktree, and `--allow-empty` is why a seat with a clean tree is still restorable;\n5. the untracked archive is extracted.\n\nEvery seat stages before any seat is promoted. Promotion moves an existing `cwd` aside to\n`<cwd>.superseded.<timestamp>` and renames the staging directory into place, then puts the session\npointer and store files where the destination's connector reads them, then re-reads\n`git status --porcelain` in the promoted tree and compares it to the status the checkpoint recorded.\nA restore that applied without error and produced a different index is a refusal, not a warning. The\ntwo renames are the only steps that touch the path the seat will use, so a failure anywhere leaves\nevery seat's live `cwd` as it was.\n\nThe rename itself claims the superseded name, and a taken name gets a numeric suffix. The timestamp\nhas one-second resolution, so two promotions of the same seat within one second compute the same\npath; a rename onto a name that already holds a tree fails on every platform, and that failure is\nread as taken. Nothing creates the name ahead of the move, because Windows refuses to rename onto an\nexisting directory at all. A superseded tree is the thing that rename exists to keep.\n\n`git` and `tar` run as child processes with argument arrays, never a shell string.\n\nA leftover `<cwd>.incoming` refuses the resume by name. A staging directory from a failed run is the\nonly record of what failed, so nothing removes one automatically: inspect it, remove it by hand, and\nresume. A pre-existing `cwd` is renamed rather than deleted, so a wrong checkpoint costs a rename\ninstead of a tree. When a promotion fails, the renames that attempt made are undone and the staging\ntree is left where it is, as the evidence for what did not verify.\n\nA session pointer whose recorded `sessionId` is not the one the retained inventory reopens is\nrefused before anything is cloned. A session file already present at its destination is judged by\ncontent: bytes equal to the recorded digest are already restored, and different bytes under the path\nthe connector is about to read are refused with both digests rather than clobbered.\n\n`up --restore <dir>` reaches the same admission and the same restore, after the store is restored\nand validated and before commit intent is journaled. A registry-only restore resumes no seat, so it\nadmits and restores nothing.\n\nOne limit is worth stating plainly. The writer generation is claimed by exclusive create inside one\nworkspace root, so it fences two resumes on the same host and does not fence two independent\ndestinations: copy a checkpoint to two roots and both claim the same successor. A real cross-host\nfence needs a coordinate neither root owns.\n\nAuthenticated restores validate the complete\nspace trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;\nfull restores commit to the validated operator, system-account, data-account, and active-signer root\nchain in addition to the static/user authority fingerprint. Because the system account is part of that\ncommitment, a [`cotal up --rotate-sys`](#up) makes every full artifact taken before it unrestorable\nagainst this root: take a fresh full backup after each rotation. The composed commitment is revalidated\nimmediately before store mutation and never includes secret seeds. Restore never creates fresh auth.\nSame-path restores atomically retain the old\nsource at the journaled fallback path; alternate targets retain it in place; a missing canonical\nsource needs explicit `--accept-missing-source`. Quarantine and target restores use current canonical\nconfigs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a\ncommit-intent immediately before the normal listener starts. Archive bytes never instantiate the real\ntarget: after quarantine validation, every stream is re-snapshotted from the validated quarantine\nstate into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back\nthe attempt-owned target; after it, ambiguity preserves both stores and records forward-repair\nrecourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.\n\nBootstrap brokers in every auth mode, including open, mount the store under a local account with\nrandom operation-specific logins only, each carrying the exact per-phase subject permission matrix;\nnormal static credentials and user-auth sentinel/bearer connections are rejected, and no auth\nservice or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,\nrestore initiation, exact upload id, validation, and each checkpoint recreation use separate exact\nauthorities. Every checkpoint carries the source stream's message/first/last sequence state and must\nmatch its snapshot record before mutation; core then derives and validates the only allowed start\npolicy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical `DeliverAll`\nWorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a\nstart-sequence policy there. Registry-only restore creates every omitted canonical stream and transient\nbucket on the isolated target before the normal listener is exposed. It deliberately does not resume\nretained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and\nstopped rather than being reprovisioned into a partial restore.\n\nAfter listener readiness, the manager starts attempt-bound, validates retained credentials/tokens\nwithout granting or reprovisioning, and resumes the exact persisted principals under cleanup\nsuppression. Registry-only restore uses the same flow with an empty agent set. On a user-auth mesh\nthese manager calls run as the logged-in operator's `cli` actor, the caller the preserve cut used, so\nthat actor needs a current `admin` grant. `commitResume` is an\nidempotent validation barrier only: success must be `awaitingFinalize` with an attempt-bound 64-hex\ncommit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that\nexact evidence as `manager-committed` (restore) or `resume-committed` (ordinary resume), then calls\ntoken-bound `finalizeResume`; only an `active` response for the exact token releases suppression. The\nCLI records the same token in finalization evidence before a restore becomes `active`, or before an\nordinary resume retires and consumes the marker. Re-entry from either committed state skips the prior\nidempotent activation/commit phases, retries finalization with the durable token, and finishes the\nworkspace transition. Failure before finalization preserves the committed state and cleanup\nsuppression; it is not rewritten through a degraded transition. Re-entry between any two earlier\nboundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or\nchanged per-agent dependency is a named fail-closed result; the journal becomes degraded and remains\navailable for forward repair. A retry from `resume-intent`,\n`resume-active`, or `resume-degraded` reuses the same attempt and inventory after the prior listener is\nproven stopped. A retained agent the lost manager already launched can still be running, for example\nin a tmux window, while the journal reads `resume-intent`. On a static mesh the replacement manager\ncloses that seat through the reference the lost manager recorded on the agent's slot, waits for the\nprincipal to leave presence, and launches it again. A live principal with no such record, or one that\nstays live after the seat is closed, is refused. Every normal restore listener has an unguessable\nattempt-bound NATS server name. The CLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity\nimmediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID\nrecord, endpoint, and target identity all match that proof; degraded restore repair then moves through\nthe guarded workspace transition only after manager commit. If an uncommitted bound owner is provably\ndead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the\nsame attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener\nor ambiguous owner is preserved and refused, never adopted by reachability alone. A reconstructed\ncommit/degraded attempt without either the exact bound proof or a durable dead-listener replacement\nrecord fails closed even when the recorded port is free. A later ordinary startup may pass an `active`\nrestore only when its details prove manager commit and its exact recorded listener is dead.\n\n## Mesh registry\n\n```bash\ncotal meshes [--json]\ncotal meshes add # guided, on a terminal\ncotal meshes add <space> --server <url> [--root <dir>] [--mode auth|open|user] [--tls] [--force]\ncotal meshes add <space> --mode user (--user-auth-file <bundle.json> | --from <https url>)\ncotal meshes rm <space> [<space> \u2026] [--force]\ncotal sync [--idp <auth base URL>]\ncotal use <space>\ncotal status [--space <s>] [--server <url>] [--components]\n```\n\n`meshes` lists the meshes this machine knows; a `*` marks the `current` default a bare\n`cotal spawn` joins. Entries learned from a signed-in account are marked `discovered`. Their\nregistration trust is stored under the account's private auth state, and the registry contains no\nsession token or sentinel credential bytes. Commands resolve the catalog `slug`; a different human\n`name` is rendered only as a label.\n\n`meshes --json` prints one JSON object per recorded mesh per line: `space`, `server`, `mode`,\n`root`, `default` (the `*`), and `origin` (`up`, `manual`, or `catalog` for a discovered entry). A\nlocal or hand-registered entry also carries `offline`. A discovered entry is never probed, so it has\nno `offline` field. `tlsRequired`, `events: \"required\"` and a discovered entry's `catalogName` appear\nonly when the record has them. An empty registry prints nothing and exits 0. The note about a default\nthat matches no record goes to stderr, so stdout carries only rows, on a first run too. The table is\npresentation and is not a stable parsing target. `meshes add` and `meshes rm` refuse `--json`.\n\nA registry record this build cannot use is refused by name, never rendered and never skipped. One\nthat does not parse, or is missing a field every consumer reads (`server`, `mode`, `root`, `ts`,\n`space`), makes every registry command exit 1 with the file's path and what is wrong with it.\nRemove the file or restore the record; nothing repairs or invents a field for you. A registry\ndirectory that exists but cannot be read, such as one without read permission or a regular file in\nits place, also makes every command that reads the registry exit 1 with its path and the read\nerror. Only an absent directory is an empty registry.\n\nAn IdP may advertise a same-origin space catalog during login. Cotal reads the complete snapshot and\nadds every valid registration without a separate `meshes add`. A snapshot younger than five seconds\nis used without a request. After that, commands that resolve a mesh target conditionally refresh the\nsaved catalogs. An operation targeting a discovered space refreshes only that space's account and\nrefuses if that account fails. Operations targeting local or manually registered meshes refresh every\naccount, print one warning for each failure, and continue. `cotal status` refreshes every account,\nnever refuses on a refresh failure, and lists each account as `fresh`, `updated`, `not-modified`,\n`no-catalog`, or `failed` with its error. `cotal sync` bypasses freshness and reports added, changed,\nremoved, unchanged, and name collisions. `--idp` limits it to one signed-in account. It never connects\nto a broker.\n\nThe registry is updated under the same lock that guards the catalog cache, so a command never lists\na discovered space set that another command is still writing. The cache records a fetched snapshot\nas not yet applied before the first registry write and as applied after the last. If a command dies\nor is stopped in between, the next command applies that snapshot again before it can use it, with\nno request inside the freshness window.\n\nThe shared dispatcher applies this preparation to every command that declares both `--space` and\n`--server` as mesh-target flags, including commands registered by other packages and commands that\ndeclare their own equivalent flag objects. Daemon and startup commands that use those names only as\nconfiguration explicitly opt out. Registry-local `meshes add` and `meshes rm` never refresh a catalog.\nWhile the registry holds a record this build cannot use, the preparation neither refreshes nor\napplies a catalog, so the command's own checks run first. A snapshot left unapplied is applied by the\nnext preparation after the record is restored or removed. A command that resolves its target through\nthe registry still refuses the record by name.\n\nRun on a terminal with the space or `--server` missing, **`meshes add` is guided**: it asks for the\none thing that cannot be derived (the broker URL), probes it, and tells you what answered - open or\nrequiring credentials. It then offers the spaces your `--root` already holds credentials for, states\nthe mode as a fact about that broker rather than asking, and shows the exact record before writing\nanything. A broker that does not answer, or a space name already registered, becomes a choice rather\nthan an error. Anything you pass on the command line is taken as given and not asked again. Without\na terminal - a script, an agent, CI - nothing prompts and the flag form's errors stand\n(`COTAL_NO_PROMPT=1` forces that too).\n\n`cotal up` and `cotal down` maintain their own records. `meshes add` registers a mesh they cannot\nspeak for: one running on another machine, a shared broker, a hosted space. `--root` is the folder\nwhose `.cotal/auth` holds that mesh's credentials and whose `.cotal/agents` holds its personas.\nThe default is the project you run it in. The registry stores that path, never a secret. `--mode`\ndefaults to `auth` when the root holds the space's account record and to `open` otherwise. The\nbroker is probed before anything is recorded, so a wrong address, or credentials that mesh will\nnot accept, fails here instead of at the first `spawn`; `--force` records without verifying (and\nreplaces an existing record).\n\nA hostname or public address is registrable only when the connection will **require TLS**. Pass\n`--tls`, or use a `tls://` URL. The scheme is recorded as enforced intent, so every later dial\nthrough the record demands the handshake (and `meshes add tls://\u2026` against a plaintext broker is\nrefused at registration). Without required TLS the fence admits loopback and private-overlay\nliterals only. RFC1918 addresses are refused in both modes because a cafe LAN is private but does not belong to you.\n\nA **user-auth** mesh registers from supplied pinned trust, never guessed: `--user-auth-file`\ntakes the bundle exported where the mesh runs; `--from` asks before it dials the address at all,\nthen fetches the `/.well-known/cotal-mesh` discovery document under that address (HTTPS only; a URL\nthat already ends in that path is fetched as given), displays the pins, and asks again before\nadopting them. The document must parse as JSON under any content type the host serves it with; an\nanswer that does not is refused with the URL and the content type that came back. Neither fetch follows redirects: a 302 can move a pinned fetch\nonto plaintext or onto another host, so it is refused rather than followed, and the pinned\nexchange must itself be an `https://` URL, except for an exchange on this machine, where plain\n`http://` is accepted for a loopback *literal* (`127.0.0.1`, `::1`, any spelling of them) but not\nfor `localhost`, which is a name rather than an address. Registration verifies that the exchange\nanswers `/health` and `/jwks` as the pinned issuer. It also verifies that the broker refuses a bare\nconnect; that auth-required refusal is the pass. The sentinel credentials land in a 0600 file under\nthe entry's root; the registry records only the path.\n\n`meshes rm` drops records. It never stops a mesh. For a mesh running on this machine `cotal down`\nis the right verb, and `rm` says so unless you pass `--force`. A broker record that cannot be proven\nstopped counts as running, as it does for `clean`. A hand-added record is removed by\n`meshes rm`, by an `add --force` replacement, or by a `cotal up` that actually starts the broker for that same space, server and root, which becomes that\nmesh and so takes the record over (a `cotal up` for that space anywhere else refuses instead).\nNothing that merely *infers* a record is stale from a dead broker touches it: an\nunreachable broker is listed `offline` and stays, whether `cotal up` or `cotal meshes add`\nwrote the record; a foreground `up` whose broker exits unexpectedly keeps its record the same way.\nA bare command does not treat that offline record as a running mesh;\nname it with `--space` to restart it. `cotal down` / `cotal clean all` still drop an `up` record for the project\nthey tear down; a hand-added one they leave alone even when it shares a root, because nothing\non this machine could write it back.\n\nA discovered entry belongs to the normalized IdP origin and proved subject that supplied it. Local\nteardown, cleanup, and liveness pruning do not remove it. A manual or locally started entry with the\nsame name wins and remains untouched; that discovered name is reported as a collision. Logging out\nremoves only the discovered entries owned by that account.\n\n`cotal meshes` and `cotal status` print `events: required` for a registration carrying\n`policy: { events: \"required\" }`. On that space, foreground spawn, detached spawn, manager starts,\nand interactive `join` cannot opt out or join without an event plane. `--no-events` is refused with\nthe space named. A connector without an event plane is refused with both the space and connector\nnamed. A session whose own grant omits `events.<owner>.<actor>` is refused before joining and the\nmessage names a full-row `actor grant` repair. A running seat whose event plane stops for good on\nthat space stops too. A seat the manager resumes from a preserved cut meets the same rules as a fresh\nspawn: a seat retained with `--no-events`, or on a connector without an event plane, is refused, and\na resumed seat carries the requirement in its launch material.\n\n`use <space>` sets that default; the selection applies from every directory,\nincluding inside another mesh's project. `status` is a read-only report: machine prerequisites\n(starting with the installed `cotal-ai` version), the installed extensions and their versions, this\nfolder's `.cotal/`, the recorded meshes, and a live snapshot of the selected mesh (roster, channels,\nmembership feed). Stale Claude skills and out-of-date `.agents` skills recommend `cotal setup --skills`,\nnot unscoped `cotal setup`. `status` takes `--space` / `--server` to pick the mesh to inspect; it starts\nnothing. The manager row asks the service endpoint once: a live process that does not answer is\n`not serving`, and a probe that could not be made leaves the row `running \xB7 service unchecked`.\nA process row whose PID record exists but cannot be read, or exists under both its current and a\npre-upgrade name, reads `pidfile unreadable` with the error, and the other rows still print. A live\nmanager whose delivery-aware marker cannot be read keeps its row and names the failure as\n`delivery-aware marker unreadable` with the error. The `Web process` row\nprints the address the selected mesh's dashboard recorded in `web.session` once it was listening,\nwhile the PID in its `web.pid` is alive. A `web.pid` or `web.session` that exists but cannot be read\nis named on the row with the error. Otherwise it reads `down`, or `not installed` without the web\nextension. Status and the `cotal setup` card count the web extension as installed only when a\npackage in the extensions manifest provides `web` and that package is on disk. A web package record\nthat cannot be read is named on the `Web extension` row and on the card's web row, after the\ndashboard's address when it is listening and beside an unreadable `web.pid` or `web.session`. The\n`Web process` row still prints, and reads `down` unless the dashboard is listening.\n\nIf a refresh fails, `status` may still show the kept catalog bytes for diagnosis. It labels them\nstale with the last successful snapshot timestamp and the refresh error. It never calls that state\nsynchronized or online. If a selected discovered space vanishes from a successful snapshot, the\nselection is cleared and the command reports that no default is selected.\n\nFor a user-auth mesh the selected-mesh section reports the login `status` works as: the signed-in\nsubject when this machine holds a cached session for the entry's pinned IdP, or the exact `cotal\nlogin --idp <url>` line when it does not, with no network round trip either way. A locally\nprovisioned space also shows the actor grant row; a discovered or registered remote entry reports\nthe grant as not checkable on this machine, because the ledger runs where the space was\nprovisioned. `--components` on a user-mode target probes as that same signed-in login (`ps`'s\ncredential), never a static mint; when the login cannot supply a credential, the row says why\ninstead of printing the broker's refusal of an unauthenticated probe.\n\nPersona rows name the catalog they describe. If this folder and the selected mesh use different\ncatalogs, status names both and marks which one spawn launches from. A green `default` means the file\npasses the same agent-file loader spawn uses; a present but invalid file is reported as invalid.\n\n`cotal status --components` adds a fail-loud per-component health pass. It reads **each\ncomponent's own control surface**, rather than treating a PID, a lease, or a successful probe of a\nsibling as proof that the component serves. It prints one of `serving`, `absent`, `not-serving`, or\n`refused` for each component and exits `0`, `1`, `2`, or `3` respectively (the highest observed\nstate wins):\n\n- **manager**: local PID record, its liveness-lease holder and PID, then the manager's own typed\n `status` service reachability from this host. Manager builds that do not report static\n reconciliation say `static reconciliation not reported by this manager build`; the line stays\n visible even when the manager is otherwise `serving`.\n- **delivery**: local PID record, its ready lease (`ready` is the daemon's own bound-control\n signal), and the latest `renewal.<spaceKey>.json` adoption verdict, the record of the space the\n command was asked about, keyed per space the way the pidfiles are. A re-signed credential and a\n broker-accepted adoption stay distinct facts. A root-only `renewal.json` left by an older build\n names no space and is never read as any space's verdict (`doctor auth` names it as a leftover).\n- **web**: local PID record, then the `/api/meta` response at the address the dashboard recorded in\n `web.session` once it was listening, which must name the same PID. The probe presents the\n readiness nonce recorded beside that address, the one credential the dashboard accepts on\n `/api/meta`. A live PID with no recorded address (the dashboard is still writing it, or an\n earlier build started it), or an unrecognizable process record, is `refused`, not a green\n default-port guess. A `web.session` that exists but cannot be read is also `refused`, and the row\n names the read error.\n- **broker**: the registered mesh URL dialed from this host with its recorded TLS requirement.\n\n`absent` means Cotal has no live local component record (or has a stale record); `not-serving`\nmeans the component record is live but its service/readiness surface did not answer or is not ready.\nThose are intentionally separate exit cases. A failed or unreadable probe is `refused`, never an\nabsent component or a clean zero. A PID record that exists but cannot be read, or exists under both\nits current and a pre-upgrade name, refuses only its own row. A record that its component removes\nwhile the pass runs reads as `absent`.\n\nEach component prints on one line. A control character inside a reported reason, such as a newline\nin a stored row's field name, prints as its `\\uXXXX` escape.\n\n## spawn\n\n```bash\ncotal spawn [<persona>] [--detach] [--name <n>] [--agent <a>] [--model <m>] [--variant <v>] [--prompt <text>] [--cwd <dir>]\ncotal spawn -f <cotal.yaml> [--dry-run]\n```\n\nFor a foreground spawn onto a remote user-auth mesh, a launcher may supply a one-time enrollment\ninstead of a cached human login. Prefer a private file:\n\n```bash\nCOTAL_ENROLLMENT_FILE=/run/secrets/cotal-enrollment \\\n cotal spawn --config ./seat.md --space main\n```\n\nThe file contains only the enrollment URL, ending with at most one line terminator, and must be\nmode `0600` on POSIX. An orchestrator that cannot mount a file may set `COTAL_ENROLLMENT_URL`\ninstead; that value is redeemed byte for byte, so a trailing newline in it is refused. Setting both\nis refused. Enrollment input\nrequires `--space` and applies only to a foreground persona spawn. If the mesh is not registered yet,\nthe enrollment response must carry the stock user-bundle fields and the command needs\n`--config <persona-file>` because there is no local remote-mesh persona catalog to read. The client\nredeems the URL once, registers the returned mesh material, exchanges the returned actor token at the\npinned auth service, and removes both enrollment variables before starting any child process.\n\nA cached login for the same IdP and an enrollment are conflicting proofs, so the command refuses\nrather than choosing one. An invalid enrollment never falls back to login provisioning. Unknown,\nexpired, revoked, and already-used enrollments all produce one response: ask the owner for a fresh\none. See [Enrollment redeem](identity-and-auth.md#enrollment-redeem) for the HTTP contract.\n\nA runtime that starts a managed seat outside the manager's filesystem hands the child a managed\nhandoff instead: one `0600` file named by `COTAL_MANAGED_HANDOFF_FILE`, carrying the lifecycle the\nmanager already enrolled. The runtime builds the command with `delegatedSeatCommand`:\n\n```bash\nCOTAL_MANAGED_HANDOFF_FILE=/run/seat/handoff.json \\\n cotal spawn --config ./seat.md --space main --name <actor> --agent claude \\\n --expect-owner <owner> --expect-lifecycle-uid <uid>\n```\n\nThe `cotal` entry reads the file, deletes it and drops the variable before it parses flags, prints\nhelp or loads extensions, so every outcome leaves no file. The variable is read under any letter\ncase; spellings that name different files are refused after every one of them was deleted. The\nspawn then refuses a malformed handoff, or one whose space, owner, actor or lifecycle UID differs\nfrom `--space`, `--expect-owner`, `--name` and `--expect-lifecycle-uid`, before any broker\nconnection or exchange request. Every refusal on this path names the field and never a value from\nthe handoff. The registration's server, exchange and enforcement checks, the local state this\nmachine keeps for the space (its mesh record, user-auth state and agent secret files), target\nresolution, the policy refresh, the broker preflight and the agent auth preflight quote the space,\nthe server, the exchange URL, the actor or a path named for one of them in their own diagnostics and\nin the filesystem errors under them. For a handoff each prints one fixed sentence that names the\nfield and the phase instead, whether its check fails or an error is thrown. When the agent auth\npreflight's rollback then fails to remove a secret or file, that sentence is followed by the names of\nthe cleanup steps that failed, without their errors. The event-plane policy\nrefusals name the handoff's space field. An actor outside `[A-Za-z0-9_]` and a space that cannot\nname local state, such as `..`, are refused as malformed before any plane. A handoff conflicts with\nthe enrollment variables, `--detach`, `-f` and `--creds`, and needs `--config <persona-file>`. From\nthere it runs the enrollment consumer above without redeeming anything. See\n[Delegated seats](embedding.md#delegated-seats-outside-the-managers-filesystem).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | resolved mesh | Target space |\n| `--server <url>` | registry entry | Broker URL override |\n| `--creds <path>` | none | Control-caller creds for an off-registry manager (`--detach` only) |\n| `--name <n>` | persona's `name:` | Presence-name override (does not choose the persona) |\n| `--config <persona-or-path>` | none | Persona catalog name or file path; wins over the positional |\n| `--agent <a>` | persona's `agent:`, else `COTAL_DEFAULT_AGENT`, else `claude` | Connector type (`claude`, `opencode`, `jcode`, `hermes`, and so on) |\n| `--role <r>` | persona's `role:` | Role override |\n| `--model <m>` | persona's `model:` | Model override |\n| `--variant <v>` | persona's `variant:` | Model variant override (connector-defined; e.g. OpenCode reasoning tiers) |\n| `--cwd <dir>` | this cwd | Working directory to root the agent at. Refused before launch when the directory does not exist on the serving manager's host. |\n| `--prompt <text>` | none | Initial prompt auto-submitted at start |\n| `--resume <id>` | none | Fork an existing session id into the mesh; only connectors that declare resume support accept it (see [the matrix](connectors.md)). The manager records the source session id, and `ps --wide` shows it. With `--detach --on <instance>`, a Claude session held on this host is carried to that instance first ([Resume a session](connect-claude.md#resume-a-session)); carrying one needs `--on` |\n| `--no-events` | event plane on where supported | Opt out of the session's structured event plane (`--events` only restates the default) |\n| `--share-tools <sel>` | none | Share named operator MCP servers with the agent |\n| `--subscribe <a,b>` | persona's | Channel read-set override |\n| `--allow-subscribe <a,b>` | = subscribe | Read-ACL override |\n| `--allow-publish <a,b>` | deny | Post-ACL override |\n| `--detach`, `-d` | off | Launch via the manager into a detached PTY (reattach with `cotal attach`) |\n| `--on <instance>` | class anycast | With `--detach` only: pin the launch to one manager instance id (the whole id, as `ps` prints it). Refused on a foreground spawn (no manager to pin), with `-f` (a manifest deploy launches through the manager class queue), and when empty |\n| `--file <cotal.yaml>`, `-f` | none | Deploy a manifest onto the running mesh |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--allow-stale <a,b>` | none | With `-f`: waive named stale agents (apply-only) |\n| `--runtime <name>` | manifest's | With `-f`: override the manifest's runtime |\n| `--expect-owner <u_\u2026>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the owner the handoff must carry |\n| `--expect-lifecycle-uid <uid>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the lifecycle UID the handoff must carry |\n\nEach session uses its connector's **event plane** by default: a stream of structured events\ndescribing what the agent did, rather than the prose it wrote, on a channel of its own. The channel is named after\nthe agent's principal, `events.<owner>.<actor>`, never after its display name, because two live\nagents are allowed to share a display name and would then share a stream. The launch grants publish\nrights on that channel alone, foreground and detached alike. On an open mesh, which issues no\ncredentials, the launch still allocates the agent an id, so the channel names a stable actor.\n`--no-events` is the explicit opt-out unless the selected registration says\n`policy: { events: \"required\" }`. Required policy makes the\nevent arm and grant mandatory, so `--no-events` and connectors without an event plane are refused.\n\nThe launch decision and the grant are separate on purpose. Holding publish rights on a channel is\nnot a request to publish to it, so writing an event channel into an agent file's `allowPublish`\ndoes not override `--no-events`.\n\nThe persona (`--config` > positional > `COTAL_DEFAULT_PERSONA` > `default`) is loaded from the\ntarget mesh's `.cotal/agents/` when it is a bare name. A reference that contains a path separator or\nends in `.md` is loaded from that file. A relative path resolves against the mesh root, except that\nan enrollment or a managed handoff resolves `--config` against the working directory. A missing\npersona is refused with the catalog directory or the file that was checked. The launch flags\noverride the file. On a user-auth mesh the\neffective name is also the agent's actor token, so it must match the token grammar (no `-`); the\nspawn is refused with that explanation before any request is sent. Foreground runs the agent\nattached to your terminal; `--detach` hands the launch to the running manager. Both modes get the\ndurable backstop on a mesh that runs the delivery daemon; `--live-only` skips it for a foreground\nspawn (messages posted while it is disconnected are then not replayed). A foreground exit retires\nthe agent's creds and broker footprint, like a manager despawn. The creds and the footprint are\nremoved as separate steps, so a creds file that cannot be deleted does not leave the broker durables\nbehind, and the failed steps are reported together. On a user-auth mesh the two arms\ndiffer: a spawn against a mesh this machine provisioned revokes the actor row on exit, while a\nremote spawn (an enrollment or the advertised provisioning endpoint) removes only this machine's\ncredential files; its grant stays until the mesh operator revokes it, and the launch line says\nwhich arm you are on. A spawn through the advertised provisioning endpoint against a record that\npins no exchange URL is refused before the grant is requested, so no credential lands on this\nmachine. A foreground `--share-tools` selection that names a server the config does not declare is\nrefused before anything is provisioned. A `--detach` spawn is an\n**action**: the manager accepts it and returns the allocated identity at once, then the launch\nfollows to a terminal outcome rather than blocking (see [the control surface](control-surface.md)).\nSee [Connect Claude Code](connect-claude.md) and [Agent files](agent-files.md); `-f` is a\n[manifest deploy](#manifest-deploys). (`cotal start` was merged into `cotal spawn --detach`.)\nA `--detach` spawn onto a manager from another Cotal release is refused before any request is sent\nwhen the manager's contract does not declare a field this CLI sends. The refusal names the field,\ncalls it version skew, and gives this CLI's version. A field you leave unset is not sent, so it\nnever causes that refusal.\n\nA manager has 50 seat slots, and each seat counts once. A slot is held by a managed seat (a row in\nthat manager's `cotal ps`, including a seat still joining), by a reserved launch the manager accepted\nbut has not started a process for, or by a cooling hold. A seat that ends within 10 seconds of\nstarting leaves its slot cooling until those 10 seconds pass, unless an operator stopped it. Such a\nseat holds only that cooling slot, even while its launch is still reporting the failure. A spawn\nrefused at the limit states that split and whether waiting can free a slot:\n\n```text\nat capacity (50 of 50 slots: 49 managed, 0 reserved, 1 cooling); waiting frees a cooling slot in 7s, or despawn one\n```\n\nA cooling slot frees at the stated time. A launch that has not settled frees its slot only if it\nfails, and a managed seat frees its slot only when it stops. The refusal counts a launch as pending\nonly while it holds a slot, so a launch whose seat already ended is not counted. The roster counts\npresence, which also includes peers no manager owns, so its total is a different number.\n\nRun from a managed seat's own shell on a static or open mesh, `cotal spawn --detach` launches as\nthat seat when it targets the seat's own space. Without `--space` it picks that target the way the\noperator path does, so a recorded mesh that is not running is skipped. The CLI reads the seat's\nlaunch identity (`COTAL_NAME`, `COTAL_ID`, `COTAL_LIFECYCLE_UID`, `COTAL_SPACE`, and on a static\nmesh the seat's own credential), so the manager records the seat as the spawner, the same as for\nthe seat's `cotal_spawn` tool. On a static mesh that credential also proves the seat's space, so a\nlaunch without `COTAL_SPACE` still runs as the seat, and a target space holding no credential for\nthe seat is refused. An open mesh acts as the seat only when `COTAL_SPACE` names its space. The\nseat can then stop the child with `cotal_despawn`, and the manager stops the child when the seat\nexits. On a static mesh a seat whose agent file lacks `capabilities: [spawn]` is refused, because\nits credential holds no spawn subject.\n`--on <instance>` keeps its pin: the seat's own credential has no instance route, so on a static\nmesh the CLI mints a one-shot `manager-caller` view for the seat, pinned to that instance and\ncarrying the spawn subject only when the seat's credential holds it. On an open mesh the call keeps\nthe TLS requirement the mesh records. `--creds`, `--server` with an unregistered `--space`, and a\nuser-auth mesh keep the operator path.\n\nForeground user-mode launches serialize their local actor material. A duplicate refuses\nwithout replacing the first launch's files. A new local-user foreground launch also refuses\nan existing grant before rotating it. Departure cleanup is conditional on the launch's token\nand lifecycle generation, so an earlier launch cannot revoke a successor's grant. Remote\nprovisioning remains the mesh operator's authority and can reissue material before the\nclient observes an existing same-host consumer.\n\n## models\n\n```bash\ncotal models [--agent <connector>] [--refresh]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--agent <connector>` | all registered connectors | Connector whose catalog to list |\n| `--refresh` | off | Ask the connector to refresh its provider cache |\n\nAsks the running manager for each connector's model catalog (model ids plus their variants)\nfor connectors that expose one. OpenCode and Codex query harness/provider surfaces; Jcode reads\nproviders that enable `model_catalog = true` in the operator Jcode `config.toml`. Jcode's listed\neffort tiers render as `variants (declared, not provider-verified)`, and launch can still refuse one.\nA connector without a catalog says so. A connector whose harness the manager did not find at boot\nreports the reason boot recorded, as a spawn does, so restart the manager after installing it. Pick\na result with `cotal spawn --model <id> --variant <v>`, where `<id>` is the model id as the catalog\nprinted it. OpenCode and Codex ids are the full\n`provider/model`; Jcode ids are bare (`opus-5`, not `cliproxy/opus-5`), because the provider is\nselected by the operator's Jcode config and a prefixed id is refused at launch with the bare form\nnamed.\n\n## endpoints\n\n```bash\ncotal endpoints [--space <s>] [--server <url>] [--creds <path>]\n```\n\nLists the mesh presence roster: agents, the manager, and any other protocol endpoint, with each\nendpoint's role, kind, status, and current activity. Unlike `ps`, this is a read-only presence view;\nit is not limited to child processes owned by the manager.\n\n## Endpoint control\n\n```bash\ncotal describe <endpoint> [--on <instance>] [--space <s>]\ncotal invoke <endpoint> <command> [--args '<json>'] [--space <s>]\ncotal invoke <endpoint> <command> --name <agent> [--admin] [--space <s>]\n```\n\nThe generic v0.4 service surface. `describe` resolves a registered endpoint's command set off the\nwire - the reserved `describe` command answers the registered contract digests, the schemas are\nfetched from the space's content-addressed contract store, recompiled, and verified against those\ndigests - and prints each command with its capability class and targeting shape. `--on <instance>`\npins `describe` to one manager instance's rail (the whole id, as `ps` prints it under its\n`manager <id>` headers), so an operator can read what that instance serves in a multi-manager space;\nunpinned, the class queue answers and the attribution line names whichever instance did. `invoke`\ncalls one command by name: `--args` is a JSON object validated against the fetched input schema\n*before*\npublish; a targeted command takes `--name <agent>` (resolved to the agent's current principal through\n`inspect`) or `--self`. `--admin` uses the admin instrument credential, whose cross-agent reach rides\nthe operator-only `any` authorization mode. Neither command has compile-time knowledge of any\nendpoint's schemas - this is the same trust chain every built-in control command now uses. Needs an\nauth mesh: the manager registers its service on both static and per-user meshes (a signed-in user\nrides their bearer; each visible or invoked command still requires its existing grant, and cross-agent\nreach needs the `admin` scope). An open mesh has no service registry.\n\n## Managed seats\n\n```bash\ncotal ps [--on <instance>] [--wide | --json] [--slots] [--space <s>]\ncotal stop --name <n> [--on <instance>] [--space <s>]\ncotal attach --name <n> [--on <instance>] [--no-reconnect] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | none | Managed agent to stop / attach (required) |\n| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on \"\"`, an unset shell variable) is refused, never treated as absent. A roster principal id (`local.\u2026`) is refused with a message naming the instance id `ps` prints |\n| `--wide` (`ps`) | off | After each seat's compact row, print extra operational facts the manager records: the provider the connector reported serving the model, `cwd`, `pid`, spawner, lifecycle uid, the owning manager's instance id and host, and for a `--resume` seat the session it forked (`forked from <id>`, with the source title and transcript SHA-256 once a Hermes or Jcode seat has recorded its fork; a carried Claude session prints `forked from <host>:<id>` with its title, SHA-256 and `carried <time>`, the time its bytes reached the manager). Model and requested variant stay in the identity row rather than printing twice. A fact the manager did not record (for example a runtime with no real process, or a connector that reported no provider) prints nothing, never a placeholder |\n| `--json` (`ps`) | off | Machine-readable: one JSON object per seat per line, copied unchanged from the manager row. Instance headers and errors go to stderr, so stdout contains only rows. Mutually exclusive with `--wide` |\n| `--slots` (`ps`) | off | List the durable static slot rows this manager owns instead of live seats, through the `slots` command. Mutually exclusive with `--wide`. A row that is not in the live roster still prints, with `live=false`; a retired row never prints |\n| `--no-reconnect` (`attach`) | off | End the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code |\n\nA raw `--creds` file is refused by `ps`, `stop`, `attach` and the other control commands, because\nthat route mints no endpoint-caller triple; the project folder, or `--space` against the registry\nentry, is the route that does.\n\nThe human `ps` row is presentation text and is not a stable parsing target. Scripts use `--json`,\nwhich is the machine-readable row contract.\n\n`--slots --wide` is refused: `--slots` lists durable static slot rows, `--wide` prints live seat facts, and the two answer different questions. Across a multi-manager scatter, `--slots` prints each manager's rows under its own instance header, the same way the plain `ps` scatter does.\n\nThese are operator clients over the running manager's control plane. The default row includes the\nconnector, model pin, optional requested variant, and runtime as operational descriptors for the\nmanaged row. They do not make a shared display name a unique protocol identity; use `--json` when\nunambiguous owner+actor attribution is required. An omitted variant means no override was requested;\nCotal does not invent an effective provider default it cannot observe. `ps` also prints two state\nfacts per managed agent, because they answer different questions: the process fact from the manager's\nown runtime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact\nfrom the roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has\nno presence row at all: a seat that has not joined yet, or one that never did). When the seat's\nconnector relays a harness-reported condition, the mesh fact carries its code and how long it has\nheld, so a seat whose turn died on a provider rate limit reads `waiting (rate_limit for 40m)` rather\nthan a bare `waiting`, and `--json` carries the whole `condition` object. When the connector reports\nthe seat's last work event (presence `activeAt`), the mesh fact ends with its age, such as\n`\xB7 active 3s ago`, and `--json` carries `activeAt`. A seat whose turn stopped advancing keeps\nheartbeating, so its presence row stays fresh and this age is what shows the stall. A seat can be\n`running` and `mesh offline` at once: the process is alive and its presence has lapsed. That row says\nhow long, as in `mesh offline for <age>` with an age such as `3.5h`, counted from the seat's last\npresence heartbeat, which `--json` carries as `offlineSince` (epoch ms). The age is read only from\nthe seat's own presence record, matched on its principal and lifecycle uid, so a same-named peer or\nan older lifecycle never dates it. The manager log names each managed seat that is offline on the\nmesh while its slot is held\n(`seat offline on the mesh: <name> - last heartbeat <time>; process <state>`), including one its\nwatch first sees offline after a reconnect, and each one that comes back\n(`seat back on the mesh: <name>`), so a watchdog that only checks process liveness has a line to\nact on. The manager does not reap or re-key such a seat. The mesh fact is only a verdict while the\nmanager's own presence watch is fresh: when that watch has been silent past the liveness window, or\nhas not replayed the bucket yet, every row prints `mesh unknown` with the reason instead (`--json`\ncarries it as `meshView: stale | unpopulated`), because `offline` and `not in roster` would then\ndescribe the manager's watch rather than the seat. The manager rebinds a watch that goes\nsilent under a live connection on its own, so `mesh unknown` normally clears within a liveness window.\nOn a user-auth mesh `ps` also renders each managed agent's last credential-refresh outcome, fail-closed.\n\n**Mode split (chosen up front, never try-scatter-then-degrade):**\n\n- **Static / open mesh.** Bare `ps` is a **class scatter**: it freezes the live manager class from\n the records registry, merges every registered instance's agents grouped and attributed per\n instance, and a non-answering instance is shown as `registered, no answer within the deadline`\n (never silently omitted). A refused list or a missing answer makes the census incomplete: rows\n from other instances remain visible, but `ps` prints an incomplete-census warning on stderr and\n exits non-zero, including with `--json`. Those rows are not a complete seat count. A contract\n mismatch prints one plain comparison of the requested and served input/output digest pairs and\n advises aligning manager versions. The no-answer label means only that the instance is registered\n and did not answer. It does not say the host is down, because a dead host never deregisters itself and a\n live one can be slow; if it is gone, deregister it.\n `--on <instance>` pins the read to one exact instance id instead. A wrong pin fails loud\n rather than falling through: a well-formed id that no live manager carries is reported as\n `manager instance <id> did not answer` (nothing else is asked), and a credential without that\n instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager\n that answers with a refusal is shown with its own cause; \"no manager reachable\" is said only when\n nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile),\n `ps` says the manager registry could not be read rather than pronouncing on the managers, which\n may all be up.\n\n**The verdict is scoped to the endpoint rail the request rode.** An issued caller rides the\nversioned `ep.v1` rail, a separate subject space from the legacy `ep` rail, and an endpoint serves\nboth (SPEC 13.15). A manager older than the versioned rail serves `ep` alone, so it can be running,\nregistered and answering while an issued caller's request reaches nobody. Silence on `ep.v1` is\nreported as `no manager answered on the <rail> rail` with `ep.v1` as the rail, and names both causes\nit is consistent with: no manager running, or one older than the rail. The CLI cannot tell them\napart, because the service registry records no package version, so check whether a manager is\nrunning and, if it is, its version. The same scoping applies to `cotal run`'s hosted verbs, which\ndrop the `--local` suggestion there, since `--local` drives the run from the calling process and\nnames the caller as its answerer.\n\n**`stop` and `attach` route by seat locality.** A seat can only be stopped or attached by the\nmanager actually running it, and the class queue does not know which one that is. So on a\nstatic/open mesh both verbs first ask every registered instance which one hosts the named seat, then\naddress that instance directly. This happens by default; you do not need `--on`.\n\n`--on <instance>` remains the override, for when you already know where the seat lives or the\nlookup itself is degraded. On a **user-auth mesh**, the exchange selects one authorized manager\nfor a short-lived `manager-caller` view. `--on` requests a specific instance; without it, selection\nmust be unique. Discovery and the command use that instance route. The caller gains no registry\nread or scatter permission. An absent, ambiguous or unauthorized selection refuses before sending\nthe command.\n\nA seat is reported as **not found** only when every reachable instance answered for itself. An\ninstance that stayed silent past the deadline, or that refused the read rather than answering, said\nnothing about which seats it hosts, so the seat may be running on it. That case reports that the\nlocation could not be established, names the instances that did not answer, and states outright\nthat it is not a report that the seat is gone. Read it as unknown and retry with\n`--on <instance>`; a retry loop that treats it as \"already gone\" stops looking for a seat that is\nstill running. A single manager cannot tell \"hosted elsewhere\" from \"does not exist\": it answers\n`not-found` for both, which is why the search asks all of them and why an incomplete search\nconcludes nothing.\n- **User-auth mesh.** `cotal ps` reports what **one** authorized manager knows about your agents\n (an instance-addressed read against its in-memory roster, owner-filtered). It does **not** report\n other manager instances or establish whether they are reachable. Completeness across a\n multi-manager user-auth space is not claimed.\n A manager that does not answer fails the command outright (exit non-zero), rather than printing\n an empty list that could be read as \"no agents\". Your ledger row needs the `admin` scope to\n reach `ps` at all; `spawn` alone is refused by the broker (the ep tier boundary).\n\n`attach` streams and drives an agent's terminal on the `pty` runtime; detach with the escape key\n(Ctrl-] by default; see [`COTAL_DETACH_KEY`](config.md)). The key is recognised as the legacy\ncontrol byte and as the kitty keyboard protocol and xterm modifyOtherKeys encodings of the same\npress, so a terminal with either protocol enabled detaches too. It does so over a one-use, holder-bound\nmesh session ([SPEC](../SPEC.md) \xA713.6): the manager replies with a signed session grant (never a\n`127.0.0.1` URL), the CLI redeems it once over the broker, and the browser console (`cotal console`)\ndrives the same session. `stop` and `attach` need a running manager to talk to. On a static mesh\nthey are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your\nowner) need only the `spawn` scope; another owner's agent needs `admin` on your ledger row\n([identity & auth](identity-and-auth.md)). Launch detached agents with [`spawn --detach`](#spawn).\n\n**`attach` reconnects when the link dies.** A session lives on a network link, and a laptop that\nsleeps, a VPN that drops or a wifi handover kills it. When that happens `attach` prints\n`[cotal: connection lost, reconnecting]` on stderr and starts asking the manager for a new session:\na fresh grant, a fresh per-session credential, a fresh connection, so every attempt re-runs the same\nauthorization the first attach did. On success it prints `[cotal: reconnected]`, the manager repaints\nthe seat's current screen the way it does for any attach, and you carry on in the same terminal.\nRetries wait 1s, 2s, 5s, 10s, then 30s, for as long as the seat exists. The detach key is read the\nwhole time the loop runs, the waits and the attempts alike, so a reconnect never traps you: press it\nwhile a session is being established and the attach ends there, and a session that lands behind the\npress is handed back to the manager rather than left holding a slot. Everything else you type while\nthere is no session is dropped rather than queued, so keystrokes aimed at a terminal that turned out\nto be frozen, Ctrl-C included, are not delivered to the agent by a reconnect you did not know had\nhappened. That starts before the first session, not at the first reconnect: at a terminal, `attach`\nreads and drops what you type while it is still resolving the mesh, so a key struck at a prompt that\nhas not come up yet does not reach the agent when it does.\nThe terminal is in raw mode for the whole reconnect, including when the link died before the first\nsession finished opening, so the detach key works there too instead of echoing as `^]`.\n\nA **pipe** carries script input. For example, `printf 'ls\\n' | cotal attach --name web` is\nbuffered until the session opens. Buffering continues across reconnects, so\n`tail -f log | cotal attach --name web` does not lose the part of its feed written while the link was\ndown. Only a terminal gets the reader; `--no-reconnect` keeps the old behaviour on both.\n\nIt stops on its own when reconnecting cannot help, and says why: a manager that refuses the attach\nexits non-zero with the manager's own message, and a reconnect that finds the seat no longer there\n(despawned, or its agent exited while the link was down) exits cleanly with `seat <name> is gone`.\nA local connect refusal that retrying cannot fix, such as a static-auth mesh whose seed is now\nmissing, also exits non-zero with the refusal's own sentence. A broker that is still unreachable\nkeeps the loop trying in silence.\nA refusal that could still pass, such as a manager at its session ceiling, is relayed in the\nmanager's own words while the loop keeps trying, once per refusal rather than once per attempt.\nPressing the detach key, or the agent's process exiting while you are attached, ends the attach as\nit always did. `--no-reconnect` turns all of this off and restores the single-session behaviour,\nwhich is what a script wants.\n\nEach reconnect also hands the abandoned session back to the manager, over the first link that can\ncarry the message, so an attach that flaps does not eat the manager's session slots one outage at a\ntime. If that message never gets a link, the attach says so when it ends. The live-session ceiling\ndefaults to 64 concurrent sessions (`--max-sessions`); the browser console opens one session per\npane, so a dashboard over a large mesh should size for agents \xD7 panes. Hitting the ceiling refuses\nbefore a credential is minted and names `--max-sessions`.\n\nWhich mesh `attach` resolves also decides **how it redeems the grant**. On a registered open mesh\nthere is no local seed. The CLI connects bare, the same way other control commands already do, and\nthe session rail is the caller rail that a real open-mode connection already reaches. Telling the\noperator to re-register the root is false: the registered root is already the contract. On a\nstatic-auth mesh the grant is still redeemed by minting a short-lived\nsession-scoped credential from the seed at the root the mesh resolved to, never from a `.cotal`\nfound by walking up from whichever directory you happen to be standing in. The difference is not\nhypothetical: `~/.cotal` exists on every install because the mesh registry lives there, so a command\nrun anywhere under your home directory but outside a project used to mint from your home\ndirectory's trust and present it to a broker that trusts a different chain, which surfaced as a\nbare authorization failure that named nothing. A directory that does hold another chain for the\nsame space is now reported on the way past, and not obeyed:\n\n```text\n! this directory resolves to /Users/you, whose .cotal/auth holds a DIFFERENT trust chain for space \"team\".\n attach used /Users/you/projects/app, the root this mesh resolved to. The other one is not being used, and is worth a look.\n```\n\nWhen a **static-auth** mesh holds no seed at the resolved root, `attach` refuses and names what it\nresolved, the broker and the root, instead of describing a directory it did not use and instead of\ntaking the open-mode path. An authenticated registry entry with a missing seed is still\nauthenticated. On a USER-AUTH mesh `attach` reads no seed. It sends your login and the session grant to\nthe auth service, which issues a `session-caller` bearer only if your owner and actor hold that\nsession. The connection it opens expires with the session grant.\n\nTerminal bytes stream over the mesh; the manager's own HTTP/WS face serves the console. That endpoint binds\n**loopback by default**, so nothing is exposed by accident; `cotal up --host <addr>` passes its bind\naddress down, which is what lets you reach the browser console (`cotal console`) for an agent whose manager runs on another machine.\n`attach` does not use that face: it redeems a signed mesh session grant over the broker instead (see above), so it reaches a\nremote manager regardless of the bind address. A\nbare `cotal supervise` and an embedded manager stay machine-local. Set it directly with\n`supervise --console-host <host>`.\n\nThat address is **recorded on the mesh** and carried forward, because it is a decision rather than\nsomething later commands can work out for themselves (a broker dial address is not a manager bind\naddress). Every later manager launch for the same mesh reuses it, including a same-root `cotal up` repair,\nan adopted preserved or restored listener, and a `spawn -f` manifest deploy. A manager replacement\ndoes not quietly move a reachable attach face back to loopback. Passing `--host` again overrides it,\nso you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only\nand records nothing.\n\nBecause that face mints terminal read and write authority for every managed agent's browser session, it is credentialed in two\ntiers. A mesh caller receives a **ticket** bound to the single agent the manager just authorized,\nsingle-use and short-lived, so one authorized attach can never be re-pointed at someone else's\nagent. The **console token** is the operator's own, reaches every agent, and is printed only to the\nmanager's output. The roster, the live feed, and the PTY stream all answer `401` without one; the\nstatic console shell is served openly, since it describes no agent.\n\n## input\n\n```bash\ncotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | | Managed agent to type into (required) |\n| `--text <text>` | | The text to type, taken verbatim (required) |\n| `--no-enter` | off | Type the text and stop there, without pressing Enter |\n| `--on <instance>` | class anycast | Pin to one manager instance id using the same rules as [`attach`](#managed-seats) |\n\nTypes one line into a running agent's terminal, as if you had typed it there, and returns. This is\nthe half of [`attach`](#managed-seats) that a program wants: `attach` is a live stream that holds a\nsession open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it\nto send a single line. `input` is one authorized call.\n\nWhat it is for is **harness commands**. A line beginning with `/` is not chat and not a message: it\nis something the agent's own harness handles, and the only way in is the keyboard.\n\n```bash\ncotal input --name reviewer --text \"/compact\" # ask the harness to compact its context\ncotal input --name reviewer --text \"/model opus\" # switch its model\ncotal input --name reviewer --text \"hold on that PR\" # ordinary typing works too\n```\n\n**Quoting.** `--text` takes a value, so a payload starting with `/` survives as written. A payload\nstarting with a dash needs the `=` form, because the shell-style `--text --foo` is ambiguous and is\nrefused rather than guessed:\n\n```bash\ncotal input --name reviewer --text=--verbose # dash-leading text: use --text=<value>\n```\n\nEnter is pressed by default, since a command typed but never submitted has not been delivered.\n`--no-enter` types the text and leaves it sitting at the prompt, which is how you stage a line and\nsend it later.\n\nNothing comes back but a delivery receipt (`\u2713 sent 9 bytes to reviewer`, counting the trailing\ncarriage return). Whatever the agent does next shows up where its output already goes: the mesh, its\ntranscript, or an `attach`.\n\n**This one is operator-only, and more narrowly than `stop` or `attach`.** Those two are granted to\nanything holding `spawn`, so an agent can stop and attach to seats under its own owner. `input` is\nnot: it is granted only to operator credentials, which on a user-auth mesh means your ledger row\nneeds the `admin` scope, the same scope [`ps`](#managed-seats) already needs there. The reason is\nthat a write into a terminal is control of whatever is running in it, and on a user-auth mesh the\nown-owner rule covers every seat under you, not only the ones you launched: a `spawn`-scoped agent\ncould otherwise type into a sibling it never started. Seat locality is still resolved for you.\n\nOnly the `pty` runtime can be typed into. The external terminal runtimes (`tmux`, `cmux`, `orca`,\n`herdr`) attach to a process they do not own, so they have no input stream for it and the command\nrefuses by name rather than dropping the keystroke.\n\n## personas\n\n```bash\ncotal personas list [-v] [--running]\ncotal personas show <name>\ncotal personas edit <name>\ncotal personas new <name> (--prompt <t> | --from <f>) [--role <r>] [--model <m>]\ncotal personas rm <name> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh's persona catalog |\n| `--role <r>` | none | `new`: the persona's role |\n| `--model <m>` | none | `new`: the persona's model |\n| `--prompt <t>` | none | `new`: the persona's prompt text |\n| `--from <f>` | none | `new`: seed the prompt from a file |\n| `--verbose`, `-v` | off | `list`: include role / model / description |\n| `--running` | off | `list`: mark personas live on the mesh |\n| `--force` | none | `rm`: required, delete without prompting |\n\nPersonas are the local agent files under the resolved mesh root's `.cotal/agents/`, the same catalog\n`cotal spawn` launches from. `--space` and `--server` therefore move every list, read, write, delete\nand completion operation to the selected mesh. An unresolved target refuses rather than falling back\nto the current directory. See [Agent files](agent-files.md) for the file format.\n\n## supervise\n\n```bash\ncotal supervise [--runtime <name>] [--space <s>] [--server <url>] [--spawn <names>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space to supervise |\n| `--server <url>` | hosting mesh, or matching registered mesh | Broker URL. A registered mesh supplies it when omitted; a different explicit value is refused before anything is dialed. |\n| `--runtime <name>` | `pty` | Agent runtime (`pty` built in; extension runtimes are explicit-only) |\n| `--console-port <n>` | none | Protocol-console port |\n| `--console-host <host>` | loopback | Bind host for the console endpoint. Loopback keeps it machine-local; `cotal up` passes the address it bound the broker to, which is what lets the browser console reach this manager from another machine. `cotal attach` does not use this face: it redeems a mesh session grant over the broker |\n| `--max-sessions <n>` | 64 | Live-session ceiling. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. A capacity refusal names this flag. `cotal up --max-sessions` records the same number on the mesh so a later `supervise` started by repair or `spawn -f` keeps it |\n| `--roster <file>` | none | Declarative roster to boot at startup. See [Roster files](define-a-team.md#roster-files) |\n| `--launch <spec>` | none | Resolved manifest launch spec (from `up -f` / `spawn -f`) |\n| `--spawn <names>` | none | Comma-separated personas to pre-spawn at startup |\n\nThe manager is the agent supervisor and control plane: it answers `spawn --detach`, `stop`, `ps`,\n`attach`, and the `cotal_*` manager tools. `cotal up --detach` starts one for you; run `supervise`\ndirectly to recover a dead manager or drive a custom runtime. Default runtime is `pty`; install an\noptional provider first (`cotal ext add @cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/cmux`, or `@cotal-ai/herdr`) and\nselect it explicitly. A missing provider or app fails loudly; there is no fallback. See [Deploy](deploy.md).\nBoot inventory decides whether this process takes unpinned `spawn`/`launch` on the class rail:\nif every declared connector is unavailable, those commands stay on this instance rail only\n(`status` reports `classSpawn: false`). `describe` still answers on the class rail, so an\nunpinned spawn can bind-fence against a skip member; re-issue, or pin `--on`. A partial\ninventory keeps the class rail and names `--on` on a harness refusal, because sibling\ninventories are not readable from the serve credential. See [control surface](control-surface.md#instance-routing).\n\nOn a normal `SIGINT`/`SIGTERM`, the manager stops every seat and requires the selected runtime to\nprove the seat is gone before it releases the manager lease or service registration. A stop that\ncannot prove exit fails loud and keeps manager authority instead of reporting a clean shutdown while\nan orphan still holds broker rails. When the runtime refuses the stop itself, the shutdown fails at\nonce and its error names the refusal as `stop failed: <message>`. After an abrupt manager death, the same logical successor\nterminalizes only its own durable static slots, verify-evicts the predecessor's broker principal,\nrecords that result in the lifecycle's caller-readable audit detail, reaps the predecessor's seat\nprocess through the runtime's custody reference recorded on the slot (the pty runtime verifies the\nprocess start identity in its seat record, so a reused pid is never signalled), and only then\nretires the lifecycle and frees the alias. A runtime that custodies its seats reserves that\nreference before it launches one, and the manager records it on the slot's first durable row, so a\nmanager that dies part-way through a spawn also leaves a seat its successor can address. A\nsame-lifecycle restart or a resume records the new seat's reference on the slot the same way, and\nwhen the slot does not take it the restart or resume fails and stops any seat it started, so the\nslot never names a seat that has already exited while its replacement runs. A resumed seat keeps\nits retained credentials, so the resume frees it only once its exit is proved; a seat whose stop\ncannot be proved stays managed, and the resume's error says so, naming a stop the runtime refused\nas `stop failed: <message>`. A spawn\nthat launched its seat and then failed is rolled back by the manager that launched it, and that\nrollback reaps the seat through the same reserved reference before the lifecycle retires. Missing or unverified broker evidence keeps the slot\nterminalizing, and so does a runtime that cannot reap by reference. While the name stays held, a\nsame-name spawn or a resume re-drives that teardown with the same recorded reference, so it reaps\nthe seat again or keeps the name held.\n\nA `meshes add --mode user` entry is a **participant** registration, not hosting authority. A\nparticipant may run `supervise` only when the host advertises the remote manager authority service\nand the signed-in actor has the dedicated `supervise` ledger scope. The CLI obtains the closed,\nloopback-only `manager-service` view; `spawn` and `admin` do not substitute for that scope. The\nhost issues the manager's public-nkey JWT material through its lifecycle-bound prepare \u2192 activate\n\u2192 renew protocol, never by handing the participant a signer or static provisioner credential.\nThe host also performs instance-scoped eviction and guarded gate reconciliation. A remote manager\nrefreshes its short-lived registration executor before clean deregistration, so a long-running\nprocess removes its service row on `SIGINT` or `SIGTERM`. After an unclean stop, the same instance\nverify-evicts its superseded family and advances the process epoch. If an abandoned frozen gate\nholds the manager governance slot, a different supervise-scoped manager asks the host to reconcile\nthat holder after a complete gone verdict, then retries its registration once.\n\nA remote supervise never uses local signing trust: with host-issued authority in hand, the\nmanager mints from that authority alone and consults local records only to refuse a conflict,\nnamely the supervised space's own trust records under the cwd root. A root that hosts another\nstatic space beside the sign-in is a normal configuration and is never read as this space's\ntrust.\n\nThe broker URL in the registry entry decides the transport. A remote broker is often published\nover a `wss://` edge rather than a raw `nats://` port, and `supervise` dials whichever scheme the\nrecord holds, starting with the manager-authority registration it runs before the manager exists.\nThe record also decides whether that registration requires TLS, so a participant never downgrades\nthe credential exchange to a plaintext connection the registry did not describe.\n\nWithout that advertised host service or scope, `supervise` refuses before it starts a manager.\nRun `cotal spawn` without `--detach` to launch a foreground agent, or ask the space host to enable\nthe authority service and grant `supervise` for detached agents. If a running remote manager loses\nrenewal, it reports degraded state and refuses unsafe new starts and restarts; live agents are not\nsilently replaced. Do not run `cotal down` or `cotal up` on a participant machine to repair this\ncondition.\n\n## service\n\n```bash\ncotal service install [--mesh <name>] [--linger]\ncotal service status [--mesh <name>] [--json]\ncotal service uninstall [--mesh <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--mesh <name>` | this folder's mesh | The mesh whose manager the service runs; one unit per mesh |\n| `--linger` | off | install: when lingering is off, ask logind to enable it so the user manager starts at boot and the service survives logout. Never enabled silently |\n| `--json` | off | status: machine-readable output |\n\nRuns the manager as a user service so it survives logout and reboot. On Linux this installs a\nsystemd user unit (`~/.config/systemd/user/cotal-manager@<key>.service`, where `<key>` is the\ncase-safe mesh key); on macOS a launchd agent plist under `~/Library/LaunchAgents/`. Any other\nplatform, or an absent systemd/launchd user session, fails with a message naming what is missing.\n\n`install` resolves the mesh from the registry and binds the unit to that entry's root and broker\naddress, so it can be run from any directory. The mesh must be registered (`cotal up` or\n`cotal meshes add`) before installing; an unregistered name refuses before anything is written.\n\nThe unit's `ExecStart` is the bare `supervise` command. The mesh facts travel in the unit's\nenvironment (`COTAL_SPACE`, `COTAL_SERVER` pinned to the registered broker URL, whatever port it\nlistens on) rather than the command line, because command lines are readable by every user on a\nmulti-user host. On Linux that environment is a `0600` `EnvironmentFile`; on macOS it is the\nplist's `EnvironmentVariables`. The same environment gives the service a private `COTAL_HOME` and\n`XDG_CONFIG_HOME` under the unit directory, so the service manager never touches the login\nuser's `~/.cotal`. First-run connector seeding runs synchronously inside `service install`,\nagainst that private config root; the unit itself starts with `COTAL_SKIP_CONNECTOR_SEED=1`\nso a manager is never interrupted mid-seed by a restart. An install whose pre-seed cannot\ncomplete (network unreachable, registry error) refuses instead of deferring.\n\nThe same environment pins `PATH` to the `PATH` of the shell that ran `install`.\nWithout it the unit inherits the service manager's own short\n`PATH`, which usually lacks `~/.local/bin` and Homebrew, so the manager's boot inventory would\nreport a harness unavailable that your shell resolves. Install from a shell that resolves every\nharness the service should launch, and reinstall after moving one. A relative entry, including\nan empty one, is resolved against the directory you ran `install` from, because the unit starts in\nthe mesh root where the same spelling names another directory. An entry with a `..` segment is\npinned as the directory your shell reaches through it, with symlinks followed, and refuses when it\nreaches none. A `PATH` set to the empty string is one empty entry, so it pins that directory. An\nunset `PATH` refuses.\n\nEvery value the unit derives from a path (`WorkingDirectory`, the `EnvironmentFile` path, the\n`ExecStart` tokens) is escaped for systemd specifiers (`%` becomes `%%`), so a mesh root that\ncontains `%` starts over its real path instead of a path systemd rewrote by expanding it. The\nprovenance comment records the root unescaped.\n\nOn Linux a user unit starts at boot and survives logout only while the user lingers. Without\nlingering, systemd starts no user manager at boot, so an enabled unit stays inert until the next\nlogin and stops at the last logout. `install` checks lingering before it writes anything, and when\nlingering is off it fails with the root command that turns it on (`sudo loginctl enable-linger\n<user>`). With `--linger` it first asks logind to enable lingering for the current user, and fails\nwith the same command when logind refuses (unprivileged users over SSH get `Access denied`).\n`service status` prints that command while lingering is off. A Linger query that does not answer\n`yes` or `no` (logind unreachable, no `loginctl`) is never read as off: `install` refuses with\nthe query's own error and enables nothing, and `service status` shows lingering as unknown with\nthat error (`--json` gives `\"linger\": { \"error\": ... }`).\n\n`service install` also refuses while a manager is already running for the mesh (`cotal down\nmanager` first). The restart policy is `Restart=always` with `RestartSec=20s`, chosen for\nmanager units in production: a manager exits for reasons that are not failures (broker\nrestarts, host suspend), where `on-failure` with a short interval thrashes.\n\nThe unit also sets a start limit (`StartLimitIntervalSec=30min`, `StartLimitBurst=20`). A manager\nthat keeps failing to start stops after 20 attempts, about seven minutes at 20 seconds apart, and\nthe unit is left `failed` instead of restarting forever. One such failure is deliberate. After an\nunclean stop, a manager that cannot verify eviction of its predecessor's credentials exits 1 and\nleaves the issuance gate frozen, because starting without that proof could let two incarnations\nserve at once (SPEC 13.1). It first waits up to 60 seconds for the delivery daemon to answer, so a\ndaemon that is still starting does not fail the start. The log names the cause. When the delivery\ndaemon is down, it says the daemon is not reachable on the `ctl.delivery-admin` rail. When the\ndaemon answers and refuses, for example because the space is missing a `$SYS` cred, it prints the\ndaemon's own reason and repair step. Fix that cause, then run `systemctl --user reset-failed\n<unit>` and `systemctl --user start <unit>`. The macOS agent has no start limit: launchd's\n`ThrottleInterval` only spaces restarts.\n\n`service status` reports the unit state from systemd/launchd, the manager's own health read from\nits pidfile at the unit's recorded root, and the machine facts a hosting side asks for:\narchitecture, OS (the platform, never the hostname), whether `/dev/kvm` is present and\naccessible, CPU count, and total memory. `--json` returns the same fields as one object. The\nmanager row names the recorded pid, and the command it runs when another program has reused that\npid. `--json` also gives the command of a live recorded pid whenever it can be read.\n\n`service uninstall` stops and disables the unit and removes it plus the private state directory.\nIt works from any directory: the unit's own records name the mesh and root it serves, and an\nexplicit `--mesh <name>` selects it. It refuses any unit that was not written by `service\ninstall` (the files carry a provenance comment), whose recorded mesh is missing, or that was\ninstalled for a different mesh, so operator-written units are never destroyed; `service status`\napplies the same rule and never reports a mesh a unit does not record. `service status` also\nrefuses a unit that records no absolute root, because the manager's health is read at that root\nand is never guessed from the current directory. Remove such a unit with `service uninstall`\nand install it again.\n\nThis command installs only the manager. The per-space auth service and the delivery daemon are\nnot installed by it: on a shared broker an operator runs three units per space with `After=`\nedges (auth service, then manager, then delivery) and stops them in reverse. A broker-side `cotal\nup` unit is a separate unit documented in [Run a mesh](run-a-mesh.md).\n\n## reconcile-gate\n\n```bash\ncotal reconcile-gate [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the frozen gate lives in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint whose gate is frozen |\n| `--instance <id>` | this folder's persisted manager instance | Instance id |\n\n**When you need this.** A manager restart killed after deregistration begins but before the new\nincarnation finishes leaves the endpoint's issuance gate *frozen*, held by a\nprocess that no longer exists. The freeze is what stops two incarnations serving at once, which is\ncorrect. The successor manager now completes that dead registration itself on boot, including on\nthe remote user-auth path. A foreign remote manager blocked by this gate also asks the host to repair\nit before one registration retry. Both use the same guard this command uses: they act only when the freeze-holder is affirmatively gone under a complete\nCONNZ sweep (`gone` and `sweepComplete=true`). If that registration's spec write already committed,\nit finishes the same freeze at the committed registration revision. If the spec did not advance, it\nabort-reopens the gate at generation+1 with processEpoch unchanged and continues the normal takeover.\nLive, unknown, unestablishable, and\nwrong-op-kind still refuse; there is no TTL.\n\nUse this command when the automatic path cannot run: the delivery daemon is down, the repair targets a\nnon-manager endpoint, or you want to lift the freeze without starting a manager. It checks that the\nholder really is gone, prints what it found, and then finishes the dead operation the same way as the\ninterrupted restart would have: revoke the old credentials, evict their holders with verification,\nand reopen the gate.\n\nThe command revokes the old credentials 16 at a time. It then verifies the holders' eviction in\nshared sweeps of up to 256 holders on the delivery daemon. Each sweep scans the broker a fixed number\nof times and kicks live connections 16 at a time, so holders that are already gone add almost\nnothing and live ones add one broker round trip per 16 connections. The daemon must serve the\n`evictPrincipals` verb; an older daemon refuses it and the gate stays frozen.\n\nEach sweep durably records the holders it verified before the next sweep starts. If a holder is not\nverified gone, the command leaves the gate frozen with those records kept. An interrupted sweep\nrecords nothing, and the sweeps before it stay recorded. A retry still repeats the freeze-holder\nliveness check, then skips only progress bound to the same registration operation, frozen-gate\nrevision, and holder set.\nThe output reports holders completed before this attempt, completed now, and still remaining. A new\nfreeze or changed holder set starts from zero. Cursor cleanup happens only after reopen; a retained\ncursor is harmless because its old gate revision cannot authorize a later freeze. The governance slot\nrelease after the reopen is cleanup in the same way: a slot it fails to release does not fail the\nrepair, because the reopened gate is already past the slot's generation.\n\n**It refuses far more often than it acts, on purpose**, and always says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `holder-alive` | The freeze-holder still has a live connection: a manager *is* running | Stop that process first. Reconciling would evict a live manager's credentials |\n| `holder-unknown` | The connection sweep could not prove the holder absent | Not safe to proceed: an unprovable holder is treated as a live one. Re-run once the broker answers completely |\n| `liveness-unestablishable` | The delivery daemon gave no verdict: it was unreachable, timed out, or refused | Act on the delivery lease line in the refusal (below). Silence is never read as death |\n| `not-frozen` / `no-gate` | The gate is open, or there is no gate at that coordinate | Nothing to repair: check `--endpoint` / `--instance` |\n| `wrong-op-kind` | Frozen under a takeover or retirement, not a registration | Out of scope for this command; it will not reinterpret another operation's intent |\n| `eviction-unverified` | The holder looked gone but eviction could not be verified | The gate is left frozen, unchanged. Investigate the broker before retrying |\n| `raced` | A newer manager moved the gate mid-repair | Re-run `cotal doctor` and look again |\n\nWhen the daemon gives no verdict, the refusal also reads the delivery lease (`lease.0`) and names\nwhat is blocking the rail:\n\n| Lease reading | What to do |\n|---|---|\n| absent | No daemon is running. Start it (`cotal up` runs it) and re-run |\n| unreadable | The daemon cannot be named, so do not assume none is running. Fix the lease read, then re-run |\n| held, not ready | That holder claimed the shard and has not bound its rails. Wait for it, or stop it so its lease lapses |\n| held, ready, no answer | The query may have gone to another daemon still subscribed to the rail, such as a stopped one whose lease lapsed. Re-run before stopping anything. If no run gets an answer, stop any other delivery daemon for the space, then stop or restart the holder |\n| changed hands | The holder took the shard after the query was sent, so it was never asked. Re-run before stopping anything |\n\nThe command reads the lease before it sends the query and again after the query fails. It names a\nholder as the blocker only when the same run of the same daemon held the lease both times, and two\nrows from a daemon too old to record its run never count as the same run. Even then a ready holder\nmay not have been asked: the rail is queue-grouped, so any daemon still subscribed to it can take\nthe query. A row whose times are not valid dates reads as unreadable.\n\nA daemon that answered and refused keeps its own reason, followed by the same lease line. The lease\nline names the holder, whether it is ready, the space account that holds the lease bucket, when that\nholder acquired the shard, and when the row was last written. A ready holder rewrites the row on\nevery renewal and keeps its acquisition time, which only a successful acquisition sets. A row\nwritten by a daemon that predates the acquisition time reports it as unknown. The lease reads never\nchange the outcome: the gate stays frozen and the command exits 2. A manager's boot self-heal uses\nthe same check and reports the same line.\n\nThere is no `--force`, and no path that discards gate state: the only way this reopens a gate is by\nproving the holder is gone and then completing the operation properly.\n\n**What reopening the gate does for the endpoint's governance slot.** A registration takes the\nendpoint-wide governance slot before it publishes its spec, and holds it until its gate reopens. An\ninstance that died between those two points leaves the slot held with no registration behind it.\nThis command does not write that slot and never has; the registration path is its only writer. What\nthe reopen does is advance the holder's gate past the generation the slot is stamped with, which is\nwhat marks the slot abandoned. The next registration for that endpoint then reclaims it as part of\nits ordinary start. So the repair here is still one command followed by starting the manager, and\nthe slot needs no separate step.\n\n## deregister-instance\n\n```bash\ncotal deregister-instance [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the instance is registered in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint the instance serves |\n| `--instance <id>` | this folder's persisted manager instance | Instance id, the whole id as `cotal ps` prints it |\n\n**When you need this.** The service registry records *registration*, not liveness, and nothing in\nthe model expires a row. A manager that stops cleanly removes its own registration. One whose host\ndied without writing anything cannot, so its record goes on claiming a live instance forever: every\nclass scatter in that space freezes the dead slot in, and `cotal ps`, `stop` and `attach` each pay\ntheir whole deadline waiting for a machine that is never coming back. A laptop that was reimaged, a\ncontainer that was deleted, a box that will not be back on the network: those registrations have no\nother exit.\n\nThis command is that exit. It asks the instance first, and it removes a record only when the broker\naffirms the instance's own rail is empty: nothing subscribed there. Then it deletes the\nregistration's two records keys, each pinned to the revision it read, and prints what it removed.\n\n**Silence alone never passes.** An unanswered describe is what a dead host, a wedged process and a\nslow one all look like, and a hung process still holds its subscriptions, so the broker sees\ninterest on its rail. That instance is refused and the observation is printed. A dead process holds\nno connection and therefore no subscription, so a real corpse is still removed.\n\n**Every refusal names the failed check:**\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `instance-answered` | The instance answered a pinned describe. It is alive | Nothing to repair. If it is wedged rather than gone, stop the process first; its own clean stop removes the record |\n| `instance-not-affirmed-gone` | It did not answer, and the broker did not report its rail empty, which is what a held subscription looks like: slow or hung, not affirmed gone | Nothing was removed. Stop the process; its record goes on its own clean stop, or re-run this once it is down |\n| `liveness-unestablishable` | The probe itself failed, so nothing was learned | Fix the probe's path (credential, broker) and re-run. A probe that could not run is never read as death |\n| `not-registered` | No registration at that coordinate | Check `--instance` and `--endpoint`. This takes the whole id, never a prefix |\n| `registration-in-flight` | The instance holds the endpoint governance slot at the live issuance-gate generation, so a registration is still completing | Nothing was removed. Wait for that registration to finish, then re-run |\n| `superseded` | The record moved between the read and the delete | Something is writing to it. The registration was not removed, and a status that was removed observed the inspected registration or an earlier one. Re-observe before retrying |\n\n**A held slot with an unreadable issuance gate is not one of those refusals.** When the instance\nstill holds the endpoint governance slot, the command reads its issuance-gate generation to tell a\ncompleting registration from a leftover. If that gate is absent or carries a delete marker, the\nread fails and the command refuses the delete rather than guess. The caller receives an\n`unavailable` error, not a named refusal: the message begins `could not observe the issuance-gate\ngeneration for \"<endpoint>/<instance>\" while its governance slot is held` and ends with the reader's\nown explanation, either `no endpoint gate at <key>` or `the endpoint gate <key> carries a\n<operation> marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`, where\n`<operation>` is the single token `DEL` or `PURGE`. The two cases are told apart by that text\nalone; the error class and the exit are the same.\nNothing was removed. A gate is never deleted in normal operation, so treat either text as a damaged\nissuance gate for that instance and repair it before re-running. A manager's own clean stop hits the\nsame read; it logs the failure and finishes stopping, and its registration stays in place.\n\nThere is no `--force` and no sweep: silence is not death, and a rule that removed rows on silence\nwould eventually remove a live instance that was merely slow. An operator names one instance, the\nbroker's verdict on its rail is what authorizes the removal, and the guard's job is to show them\nthey named a dead one. Removal is not a one way door either. The same instance re-registers over\nthe tombstone on its next start, under the same identity.\n\n## runtimes\n\n```bash\ncotal runtimes\n```\n\nLists every agent runtime the manager can spawn through: the built-in `pty`, the official providers\n(`orca`, `tmux`, `cmux`, `herdr`), and any custom provider installed via `cotal ext add`. Each installed\nprovider is probed so you can see what is actually reachable on this machine before selecting it:\n\n```\npty built in\norca installed \xB7 reachable @cotal-ai/orca\ntmux available \xB7 cotal ext add @cotal-ai/tmux\ncmux available \xB7 cotal ext add @cotal-ai/cmux\nherdr available \xB7 cotal ext add @cotal-ai/herdr\n```\n\n`installed \xB7 reachable` / `unreachable` is the provider's own `available()` probe; `available` means\nit is a known runtime you can add with the shown command. Selecting an unknown or uninstalled runtime\nvia `up`/`spawn --runtime <name>` fails loud and, for a known one, points at the exact `cotal ext add`\npackage. There is no silent fallback to `pty`.\n\n## seats\n\n```bash\ncotal seats [--drain]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--drain` | off | Retire every seat whose agent has exited. A seat whose agent still runs is kept |\n\nThe pty runtime used to start a detached custodian process for every Linux seat. It now spawns\nin-process, but custodians that an earlier manager started keep running, and one whose agent has\nexited stays resident while a manager still holds its connection. This command lists the custody\nrecords under `COTAL_SEAT_ROOT` (default `~/.cotal/seats`), one line per seat:\n\n| State | Meaning |\n|---|---|\n| `live-child` | The agent process still runs. The seat is never signalled, and a manager can still adopt it |\n| `childless` | The agent has exited, or the record comes from an earlier boot. `--drain` retires the seat |\n| `drained` | `--drain` proved the custodian and the agent gone and removed the record |\n| `refused` | The record cannot be read, carries no start or boot identity, this host publishes no boot identity, or the reap could not prove the processes gone. The record stays on disk |\n\nA drain signals only a custodian whose recorded start identity still matches the live process, so\na reused pid is never touched. No process outlives a reboot, so a record from an earlier boot is\nreported childless and `--drain` removes it without signalling anything. A record with no start or\nboot identity is refused with or without `--drain`, and is never reported as running or exited.\nOn a host that publishes no boot identity (`/proc/sys/kernel/random/boot_id`) every record is\nrefused the same way, because no record can be tied to this boot.\nThat refusal and an unreadable record signal nothing. A refusal from the reap itself can come after the drain already\nsent `SIGKILL` to the custodian. Its detail names the pid or process group the reap could not prove\ngone, so check those processes before you retry. The command exits non-zero when any record is\nrefused. It is Linux-only and throws on other platforms.\n\n## send\n\n```bash\ncotal send dm <agent> \"<text>\" [--space <s>] [--server <url>] [--creds <path>]\ncotal send msg <channel> \"<text>\"\ncotal send ask <role> \"<text>\"\n```\n\nA `send dm` prints one line naming three facts: `\u2192 <name> stored seq <N>; recipient <status>\nat send; delivery not confirmed <text>`. `stored seq N` is the JetStream sequence the broker\nassigned to the publish; `recipient <status> at send` is the roster status (`idle`, `working`,\nor `offline`) resolved right before the publish, which can change the instant after; the send\nnever prints `delivered`, because the sender's credential cannot read the recipient's durable\nto confirm it. Inspect what the broker actually holds for a recipient with\n[`cotal deliver pending`](#deliver).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and (off-registry) which credential |\n\nOne-shot messaging: connect, send a single direct message (`dm`), channel post (`msg`), or role\nask/anycast (`ask`), then exit. For a running conversation, agents use the mesh tools instead\n([MCP tools](mcp-tools.md)).\n\n`cotal send` works from an operator shell or from a seat. Its display name is `<login>@<host>` of\nthe shell that ran it, so the recipient can tell one operator's send from another's; it is taken\nfrom the operating system, never from `COTAL_NAME`. The wire principal comes from the resolved\noperator credential or user bearer, not from `COTAL_NAME`, `COTAL_ID`, `COTAL_OWNER`, or\n`COTAL_ACTOR`. On an open mesh the transient endpoint self-mints its principal.\n\nThe transient endpoint never joins the roster and binds no inbox. A recipient can still answer a\n`send dm` or `send ask` with `cotal_dm`, by the sender's name or by the id on the message it\nholds: the reply is stored under the sender's id in the space's DM history, which an operator's DM\nview such as the dashboard's Direct messages lens shows. The `cotal send` that asked has already\nexited, so the reply never reaches that shell.\n\n## channels\n\n```bash\ncotal channels list\ncotal channels set <name> [--replay | --no-replay] [--window <n>] [--desc <s>] [--instructions <s>]\ncotal channels default --replay | --no-replay\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--replay` / `--no-replay` | none | `set`/`default`: replay history to new joiners, or not |\n| `--window <n>` | none | `set`: replay window size |\n| `--desc <s>` | none | `set`: one-line channel description |\n| `--instructions <s>` | none | `set`: instructions shown to joiners |\n\nInspects and edits the channel registry: replay policy, description, and joiner instructions. ACL\nsemantics (who may read or post) are set at mint / provision time, not here; see\n[Channels and permissions](channels-and-permissions.md). On a user-auth mesh, `list` rides your\nown login as is; `set` and `default` edit the registry over a short-lived\nchannel-writer view, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\nOn a remote user-auth mesh that view is served by the public exchange; space-history `purger`\nand the read-only admin view are not.\n\n\n## history\n\n```bash\ncotal history clear --force [--dms] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--dms` | off | Also clear DM history |\n| `--force` | none | Required: clear without prompting |\n\nPurges retained channel history; `--dms` extends it to direct-message history. An alias of\n[`clean history`](#clean). On a user-auth mesh the purge rides a short-lived purger view over\nyour login, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n## console\n\n```bash\ncotal console [--plain] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to watch |\n| `--plain` | off | Line stream instead of the TUI |\n\nA live protocol view for a space: a lazygit-style TUI, or a plain line stream on `--plain`. On a\nuser-auth mesh it rides the read-only admin view over your login, which needs ledger scope\n`admin`. Inside the TUI, operator control (`D` kill, `:spawn`, `:status`, `:purge`) rides the\nsame per-action instrument path as `cotal stop` and `cotal ps`, never the observer; a raw\n`--creds` file cannot drive it. `a` (or `:attach <agent>`) runs\n[`cotal attach`](#managed-seats) in place and returns to the console on detach. See\n[Watch a mesh](watch-a-mesh.md).\n\n## web\n\n```bash\ncotal web [--detach] [--host <host>] [--port <n>] [--no-open] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to serve |\n| `--host <host>` | `127.0.0.1` | Concrete HTTP bind and browser host; wildcard addresses are refused |\n| `--port <n>` | `7799` | HTTP port, a decimal number from 1 to 65535 |\n| `--detach` | off | Run in the background; stop with `cotal down web` or bare `cotal down` |\n| `--no-open` | off | Don't open the browser |\n\nThe browser observability dashboard: presence, channels, and a live feed. It is **not** part of\n`cotal up`: it ships inside `cotal-ai` as the `@cotal-ai/web` extension, seeded automatically on first\nrun (like the built-in connectors) so it always matches your CLI version. It self-registers `cotal web`\ninto this surface and serves\n`http://cotal.localhost:7799` by default (loopback; `*.localhost` resolves in Chrome/Firefox/Edge; for Safari\nor a system resolver such as WSL2's, the launch link is also printed at `http://127.0.0.1:7799`).\nOn a user-auth mesh the dashboard rides the read-only admin view\nover your login, and a channel purge asks for its own channel-purger view per click; both need\nledger scope `admin`. The public exchange serves `channel-purger` for a remote owner; it still\nrefuses the startup admin view, so a remote `cotal web` is not a complete channel-management\nsurface. Detached mode re-execs the current Cotal installation, writes diagnostics to\nthe mesh root's `.cotal/web.log`, and reports success only after the HTTP server answers. If the server is not ready before the deadline, the timeout error ends with `; last probe <url>: <outcome>`: the bound probe address (`http://127.0.0.1:<port>/api/meta` on the default host, not the browser launch URL), then a transport error such as `ECONNREFUSED`, an HTTP refusal status such as `HTTP 401`, or `answered space <space>, pid <pid>` when a different process on the port answered with another space or pid (space cut to 40 characters, pid cut to 20, a value that is neither text nor a short number named by its type, the `<url>: <outcome>` text capped at 300 bytes including the `...` that marks a cut). It requires\na recorded mesh root, but can be launched from any directory once `cotal up` has recorded the mesh.\nSee [Watch a mesh](watch-a-mesh.md).\n\n## linear\n\n```bash\ncotal ext add @cotal-ai/linear\ncotal linear account add <name> --mode <write|readonly> (--token-stdin | --token-file <path>)\ncotal linear account login <name> --mode <write|readonly>\ncotal linear account show <name>\ncotal linear inventory <account> [--json]\ncotal linear call <account> <tool> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear resource <account> <uri> [--inventory <digest>] [--timeout <ms>]\ncotal linear prompt <account> <name> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear serve <account> --endpoint <reverse-dns-name> [--space <s>] [--server <url>]\ncotal linear caller <name> --endpoint <reverse-dns-name> --out <path> [--channels <a,b>] [--expires-in <s>]\n```\n\nServes one Linear account as a registered Cotal endpoint that agents call with `cotal_describe` and\n`cotal_invoke`, and gives the operator a direct client for the same server. It only talks to `https://mcp.linear.app/mcp`\n(mode `write`) or `https://mcp.linear.app/mcp/readonly` (mode `readonly`); there is no URL or header\noption. Use a separate account per Linear workspace, and prefer a `readonly` account with a\nrestricted read key wherever writes are not needed.\n\nAn account's credential is sent as `Authorization: Bearer` and is never taken from argv. `account add`\ntakes a Linear API key: `--token-stdin` stores it under the cotal home as a 0600 file, and\n`--token-file` points at an existing file that must not be readable by other users. The file is read\nat each use, so rotating it needs no restart. `account login` runs Linear's OAuth flow instead: it\nregisters a client, prints the authorize URL, and waits on a loopback redirect. It asks for scope\n`read` in `readonly` mode and `read write` in `write` mode, keeps the tokens in a 0600 file, and\nrefreshes them before a request is sent. OAuth requests only go to `https://mcp.linear.app`.\nRedirects are refused so the credential is never forwarded.\n\n`inventory` reads the server's capabilities and every page of its tools, plus resources, resource\ntemplates and prompts when the server advertises them. Names and schemas are printed as the server\nsent them. The digest covers the whole inventory; pass it to `--inventory` so a call is refused\nbefore dispatch if the inventory changed. Capabilities this command does not represent, such as\nresource subscriptions or logging, are listed under `unsupported`.\n\n`call`, `resource` and `prompt` print the server's reply as JSON. A tool result with `isError: true`\nis a normal result (exit 0). A refusal before dispatch exits 2 with outcome `not-executed`; that\nincludes a deadline that expires while queued, discovering or connecting. A protocol error, a\ntimeout, Ctrl-C, a response over the size cap, an expired session, an HTTP 401, 403 or 429 answer,\nor a transport failure after the request was sent exits 3 and is never retried. Its outcome is\n`unknown`, for read-only tools too: read-only means a repeat is safe, not that the first call did\nnot run. Errors carry an HTTP status or an error class, never the response body.\n\n### Serving the endpoint\n\n`serve` registers the account as an endpoint named by `--endpoint` and serves it until Ctrl-C. The\nname must be a reverse-DNS name in a namespace you own, such as `com.example.linear`; it is\nauthorized for this mesh's local owner only because you configured it. `serve` reads the inventory\nfirst, so an account that cannot be reached is never registered. It then walks the same path every\nregistered endpoint does: it publishes the contract to the contract store, opens the issuance gate,\nregisters a fresh instance, writes its ready status, and mints a scoped serve credential through\nthe gate. Every step runs on short-lived executor credentials scoped to this one endpoint instance.\nThe space signer stays in the `serve` process for renewal at 75% of the credential's life and is\nnever written out, printed, or handed to the serving connection. Ctrl-C stops serving, closes the\nLinear session and removes the instance's service record, each within 10 seconds. A start that\nfails after registration removes the record before it exits and says whether that removal\ncompleted; a registration that fails part-way rolls its own record back and reports when it could not.\n\nThe endpoint serves five fixed commands: `inventory`, `call-tool`, `read-resource`, `get-prompt` and\n`complete`. Each needs the one `linear.mcp` capability. That capability is permission to call the\nendpoint; which tools a call can reach is decided by the Linear account and its mode. `inventory`\nreplies in pages that fit a broker message: the first page carries the server, its capabilities,\nits instructions and per-section counts, and every page carries entries verbatim with an opaque\ncursor bound to the inventory digest. An entry is never cut: discovery refuses an inventory whose\nfirst-page head (server, instructions, capabilities) or any entry prints over 48 KiB the way an\nagent tool prints it (indented JSON, which grows with nesting), naming the entry, so the first page\nand every entry it accepts fit one reply and the tool an agent reads it with. A cursor or `inventoryDigest` from an older inventory is refused before anything is sent to\nLinear. `call-tool`, `read-resource`, `get-prompt` and `complete` require the digest the caller read.\n\n### Callers\n\n`caller` provisions an isolated caller on a static-auth mesh: an `agent` credential with its own\nmailboxes and request rows for only the five Linear commands on that endpoint, plus the channels\nin `--channels`. It carries no spawn, run, admin or provisioner capability. The credential is\nwritten 0600 to `--out` and never printed. Start a hand-driven connector session with it, using the\nprinted `COTAL_CREDS` and `COTAL_LIFECYCLE_UID`; that session then sees the endpoint through\n`cotal_describe` and calls it through `cotal_invoke`, and the broker refuses any command its\ncredential does not name. A Claude Code or jcode session also serves a local control endpoint and\nrefuses to start without one: set `COTAL_CONTROL_SOCKET` to a socket path you choose and\n`COTAL_CONTROL_TOKEN` to a random secret. OpenCode, Codex and pi sessions start without them. Managed `cotal spawn` agents on a static-auth mesh still cannot carry\nendpoint capabilities, so a Linear caller is a hand-launched seat.\n\nA per-user-auth mesh is refused by both `serve` and `caller`: there, endpoint and caller credentials\ncome from the remote auth service, which this package does not support yet. Nothing falls back to a\nlocal signer. An open mesh is refused too, because it has no credentials to scope.\n\nNot represented yet, and listed under `unsupported` when the server advertises them: resource\nsubscriptions, logging, progress notifications and experimental capabilities. The server cannot\ncall back for sampling, elicitation or roots. Every call acts as the account's one Linear identity.\n\n## deliver\n\n```bash\ncotal deliver [--space <s>] [--server <url>] [--tls] [--creds <file>] [--root <dir>] [--shard <n>] [--shards <n>] [--dev-mint]\ncotal deliver pending <name> [--limit <n>] [--durable <name>] [--json]\n```\n\nWith no positional, `cotal deliver` runs the delivery daemon (see\n[the delivery daemon](delivery-daemon.md)). `deliver pending <name>` never starts the daemon: it\nis an operator-only read over one recipient's DM durable, for the moment after a send when the\nquestion is \"what does the broker actually hold for them.\" It resolves `<name>` against a short\npresence watch (an `offline` card still counts, since the recipient may be dead, that is what\nthe verb exists to inspect); when neither a card nor the durable can be found, it prints\n`\u2717 not-found: no agent \"<name>\" and no DM durable for it in space <s>` and exits non-zero, never\n`pending 0`. On a match it prints the durable name and one fact per line: `pending`,\n`ack-pending`, `delivered`, `ack-floor`, `created`, `frontier`, and the stream's `max_age` /\n`max_msgs_per_subject` / `discard` limits (`--json` prints the same facts as one object), followed\nby a bounded, unacked read of up to `--limit` (default 20) recent candidate message ids under the\nheading `recent candidate ids (from the ack floor; not proof of a hole)`, a list of what is\nthere, not proof that nothing was lost.\n\nThe verb needs the `admin` credential profile: it runs through the same static-mesh route as\n`cotal mint --profile admin`, and refuses a user-mode mesh, naming the retired static credential,\nbecause there is no user-mode inspection authority yet. Pass `--creds <file>` for an off-registry\nadmin credential. A same-name respawn never inherits a predecessor's held DMs (the durable is\nlifecycle-keyed); an old lifecycle's durable is reachable only by the name a live read printed\n(the `<durable>` line on the first line of this verb's output). Pass that name with `--durable\n<name>` to read it directly once the lifecycle's card is gone from the roster. This skips the\npresence watch on `<name>` entirely, so `<name>` is required but only echoed in error text.\n\n## mint\n\n```bash\ncotal mint <name> [--profile <agent|observer|admin>] [--out <path>] [--signer]\ncotal mint <name> --provision [--role <role>] [--space <s>] [--server <url>]\ncotal mint <name> --expires-in <seconds> | --expires-at <unix-seconds>\ncotal mint <name> --identity <creds> [--expires-in <seconds>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--profile <agent\\|observer\\|admin>` | `agent` | Credential profile |\n| `--out <path>` | `.cotal/auth/creds/space.<key>/<name>.creds` | Output path - the default sits under the resolved space's segment (`<key>` is that space's hex encoding, as in [Project files](config.md#project-files)) |\n| `--signer` | off | Emit a stripped account-signing file instead |\n| `--force` | off | With `--signer`: overwrite an existing file |\n| `--allow-subscribe <a,b>` | the agent file's, else subscribe | Read-ACL override, **agent profile only**: `observer` and `admin` carry a fixed read set, and `mint` refuses this flag there rather than narrowing nothing |\n| `--allow-publish <a,b>` | the agent file's, else deny | Post-ACL override, **agent profile only** |\n| `--role <role>` | the agent file's | Agent profile: the anycast task queue the identity pulls (`svc_<role>`) |\n| `--provision` | off | Agent profile: also pre-create the identity's bind-only DM/deliver durables (and its role's task queue) on the live mesh, so the credential can consume |\n| `--expires-in <seconds>` | unbounded | Bound the credential's lifetime: the JWT `exp` is `iat + <seconds>`. A positive integer; refused together with `--expires-at` |\n| `--expires-at <unix-seconds>` | unbounded | Bound the credential to an absolute `exp` (unix seconds). Refused together with `--expires-in` |\n| `--identity <creds>` | a fresh identity | Re-mint for the nkey carried by this creds file, keeping the principal and every durable keyed to it. The file is read by the same loader the endpoint uses; a file with no seed is refused by name |\n| `--space <s>`, `--server <url>` | the resolved mesh | Which root supplies the agent file, static trust and default credential storage; with `--provision`, also which live mesh receives the durables |\n\nMints a NATS creds file for a space in **static** auth mode, scoped to a profile and (optionally)\nexplicit read/post ACLs. `--signer` emits an account-signing file for delegating minting to another\nhost. On POSIX the file `mint` writes is mode `0600`, including when it replaces an existing file at\n`--out`. A per-user-auth space refuses `mint`: agents there join under a logged-in user\n([`login`](#login) + [`actor grant`](#actor)), never via a handed-out creds file. See\n[Identity and auth](identity-and-auth.md).\n\nFor an agent profile, the resolved mesh root supplies the persona ACL, the signing material and the\ndefault credential destination as one authority. If the current folder also holds trust for a\ndifferent space or account, mint refuses before writing and names both roots. It never combines a\npersona from one root with credentials signed or stored under another.\n\nA plain mint is creds only: the identity can publish within its post ACL at once, but on an authed\nmesh its DM inbox and task queue are provisioner-pre-created and bind-only, so a **consuming**\nconnect fails until they exist. `--provision` performs that pre-create in the same command (a\nprovisioner cred is minted from the space's trust material, used, and dropped), so a long-running\nclient you start yourself can receive DMs and role anycasts like a spawned seat. The command prints\nthe identity's principal (its wire id) and lifecycle uid; a consuming client passes that uid as its\n`lifecycleUid`. Agent profile only; an open mesh needs none of this (peers self-create there). The\nsame resolved authority is used for both the credential and `--provision`, so the broker\nfootprint cannot be created under a different root's trust material.\n\nThe CLI-mintable profiles carry no default TTL: without a lifetime flag the credential is\nunbounded, and a standing-renewal consumer refuses it. `--expires-in <seconds>` (or\n`--expires-at`) is the door the renewal seam's own error names. `--identity <creds>` re-mints for\nthe nkey the file already carries, so the new credential presents the SAME principal and every\ndurable keyed to it survives; combine it with a lifetime flag to rotate an expiring credential\nwithout churning the identity.\n\n## Login\n\n```bash\ncotal login --idp <auth base URL> [--client-id <id>]\ncotal logout --idp <auth base URL>\n```\n\nSigns you in to a per-user-auth mesh's IdP (device code flow) and caches the session; run it\nonce per machine. The IdP URL must use `https://`. Plain `http://` is accepted only on a loopback IP\nliteral such as `127.0.0.1` or `::1`, for an IdP on this machine. `localhost` is refused because it\nis a name: a hosts entry would choose the IdP. It prints your IdP subject, the id the operator\ngrants against. When the trusted\n`/token` response advertises a same-origin space catalog, login validates and records that account's\nspaces immediately. After a\nlogin, every command on that mesh works under your identity: each connect takes a fresh IdP\nproof, exchanges it locally for a short-lived bearer, and is authorized against the actor\nledger at connect time. `logout` revokes the IdP session, clears its cache, and removes only that\naccount's discovered registry entries. See\n[identity & auth](identity-and-auth.md).\n\n## actor\n\n```bash\n# an upsert of the WHOLE row: name all three ACL flags, or pass --full for the wide defaults below\ncotal actor grant <actor> --sub <IdP subject> --scope a,b --allow-subscribe a,b --allow-publish a,b [--role <r>] [--label <l>]\ncotal actor grant <actor> --sub <IdP subject> --full [--scope a,b] [--allow-subscribe a,b] [--allow-publish a,b] [--role <r>] [--label <l>]\ncotal actor revoke <actor> (--sub <IdP subject> | --owner <u_\u2026>)\ncotal actor list\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | the folder's | Space whose ledger to manage |\n| `--sub <subject>` | none | The IdP subject (shown by `cotal login`) the actor belongs to |\n| `--owner <u_\u2026>` | none | The derived owner token (alternative to `--sub`) |\n| `--full` | off | Fill each ACL flag left off with its wide default; without it, `grant` refuses unless all three are named |\n| `--scope <a,b>` | `spawn,role:default` with `--full` | Capability scope (`''` = none; `spawn` = may run agents; `role:<r>` = may delegate role r; `admin` = cross-agent control; `supervise` = eligible for the closed remote manager-service view when the host enables it) |\n| `--allow-subscribe <a,b>` | `>` (all channels) with `--full` | Channel read ACL; the user's envelope, their agents can never read beyond it |\n| `--allow-publish <a,b>` | `>` (all channels) with `--full` | Channel post ACL; also the envelope for their agents' posting |\n| `--role <r>` | none | Role (scopes the task-queue consumer) |\n| `--label <l>` | none | Display label for `actor list` (never the IdP subject) |\n\nThe actor ledger is the single authorization source of a user-auth space: no row, no access.\n`grant --full` is the **full** envelope (all channels; scope `spawn,role:default`, so it may spawn and may delegate the default role). A\n`grant` that leaves off `--scope`, `--allow-subscribe` or `--allow-publish` without `--full` is\nrefused and writes nothing. A re-grant **replaces the whole row**, not the one field you name, so to add a capability spell\nevery field out: the new scope plus the row's current read set, post set, role and label\n(`cotal actor list` shows what a row holds). Under `--full`, a field left off does not stay as it\nwas: it reverts to the wide default in the table above. A re-grant retires the current interactive lifecycle through the running auth\nservice before it rotates the row, so copied bearers cannot cross an authorization update. If that\nretirement cannot be confirmed, the row is left unchanged and the command fails with the recovery\naction. `revoke` uses the same retirement before deleting the row, which lets a later grant create a\nreal successor instead of colliding with a live predecessor. `supervise` is separate from `spawn` and `admin`: it only makes a signed-in\nperson eligible for the host-provided closed remote manager-service view; it does not grant\nmanagement of another owner or a general host profile. `revoke` denies the next exchange and\nthe next connect with no restart, and evicts the principal's live connections. Managed-agent rows\n(written by the spawn path) live in a disjoint row space this command never touches. See\n[identity & auth](identity-and-auth.md).\n\n## doctor\n\n```bash\ncotal doctor auth [--fix]\n```\n\nCredential-health diagnosis and repair for this folder's mesh: renders every managed\ncredential as healthy / near-expiry / expired and ends in `healthy` or the exact next\ncommand; `--fix` applies the repairs it can. The one surface every stale-credential error\npoints at. `--fix` takes the mesh's renewal lease when the broker answers and refuses while\na manager or another doctor holds it; with no broker it repairs offline and says so.\n\n## join\n\n```bash\ncotal join --space <s> --name <n> [--role <r>] [--channel <c>]\ncotal join --link <url> | --token <t>\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and which credential |\n| `--name <n>` | none | Your presence name |\n| `--role <r>` | none | Your role |\n| `--channel <c>` | none | Channel to join |\n| `--kind <k>` | `agent` | Endpoint kind |\n| `--link <url>` | none | Join link (`cotal://\u2026`) |\n| `--token <t>` | none | Join token |\n| `--lifecycle-uid <uid>` | none | Required with `--creds`: the lifecycle UID minted alongside the credential (`COTAL_LIFECYCLE_UID` works too). A credential's durable grants name exact lifecycle-keyed resources, so `join` refuses to invent one |\n| `--tls` | off | Connect over TLS |\n\nAn interactive presence: join a space under your own name and role, without launching an agent\nharness. A `--link` or `--token` supplies the where and the auth in one value. See\n[Spaces](spaces.md) and [Identity and auth](identity-and-auth.md).\n\n## Manifest deploys\n\nA `cotal.yaml` manifest declares a whole mesh (channels, personas, roles, and ACLs) in one file.\nThree commands consume it, plus a read-only validator:\n\n```bash\ncotal up -f cotal.yaml # boot a fresh mesh from the manifest\ncotal spawn -f cotal.yaml # deploy the manifest additively onto a running mesh\ncotal down -f cotal.yaml # tear that deploy down (or --run <id> for one run)\ncotal topology view -f cotal.yaml # validate + view the access graph, change nothing\n```\n\n`up -f` and `spawn -f` differ in target: `up -f` brings up a new broker and applies the manifest;\n`spawn -f` requires an already-reachable mesh and applies additively (ownership-scoped). On a\nuser-auth mesh, `spawn -f` deploys over your own login (the deployer view, gated on ledger scope\n`spawn`): the manifest's agents land under your owner, a manifest claiming another owner is\nrefused, and seeding new channels additionally needs scope `admin`. Both take\n`--dry-run` to print the plan without mutating anything. `topology` validates the manifest and\nrenders its channel / role / ACL graph. See [Define a team](define-a-team.md) and the\n[manifest reference](manifest.md).\n\n## ext\n\n```bash\ncotal ext # same as `list`\ncotal ext add <npm-package>\ncotal ext remove <name>\ncotal ext list [--json]\ncotal ext root # print just the install prefix (scriptable)\ncotal ext seed [--repair|--reset|--force]\n```\n\nOperator-installed extensions: `add` installs an npm package into a cotal-owned prefix and records\nevery registry provider it contributes. Commands appear in help, completion, and dispatch; runtime\nproviders are lazy-loaded by commands such as `supervise`; local process providers participate in\n`status` and selective `down`. `remove` and `list` manage them. The `@cotal-ai/web` dashboard is the\ncanonical command/process example. Installed packages and their location are described in\n[config](config.md). When a package needs an export its linked `@cotal-ai/*` peer does not have,\n`add` rolls back and names which install is behind, as a later load of an installed one does.\n\nBare `cotal ext` lists the inventory, headed by the install prefix. That prefix is a cotal-owned npm\nroot kept **separate** from npm's own global tree. These packages never show up in `npm list -g`,\n`cotal ext` (or the Extensions section of `cotal status`) is the canonical inventory. `cotal ext root`\nprints only the path, for scripts. The versions shown are the manifest pin recorded at add time.\n\n`ext list --json` (or bare `ext --json`) prints one JSON object per installed extension per line:\n`pkg`, `version`, `spec` (what `ext add` was given), `seeded` (`true` for an entry the built-in seed\ninstalled) and `provides` (the `kind:name` refs the table shows). No header or footer reaches stdout,\nand an empty prefix prints nothing and exits 0. The table is presentation and is not a stable parsing\ntarget. The other `ext` subcommands refuse `--json`.\n\nRemoving an extension that owns a running local process is refused with the mesh root and its\n`cotal down <component>` command; stop it first so uninstalling the package never strands a process\nwhose lifecycle provider is gone.\n\nLoading an installed extension takes the same locks as `add`, `remove` and `cotal update`. When another\n`cotal` process holds them, the load waits up to five seconds in total (one deadline for both locks)\nwithout blocking the waiting process, then refuses. A running `cotal update`, `cotal ext add` or\n`cotal ext remove` holds the update-pass lock as well as the install/remove lock, so the load refuses with\n\"another extension update or mutation is in progress (pid N) - retry once it finishes\". The install/remove\ntext, \"extension install/remove is in progress (pid N) - retry after the active `cotal ext` command\nfinishes\", appears only when the update-pass lock is free but a live process still holds the\ninstall/remove lock itself, for example an update child that outlived its `cotal update` parent. Re-run\nthe command once the other one has finished.\n\n### Built-in connectors are seeded extensions\n\nThe first-party agent connectors (`claude`, `opencode`, `codex`, `hermes`, `jcode`, `pi`) are not compiled into\nthe binary. They are seeded on first run through the **same** `ext add` path a third party uses, and\nappear in `cotal ext list` like any other extension. So you can remove one you do not want\n(`cotal ext remove @cotal-ai/connector-hermes`), and a deliberately-removed connector STAYS removed\nacross upgrades. `cotal ext add <your-package>` adds a third-party connector the same way. The web\ndashboard (`@cotal-ai/web`, providing `command:web`) is the seventh built-in seeded on the same path.\n\n`cotal ext seed` is the maintenance entry for that seeding (it runs automatically on the first real\ncommand of each boot, so you rarely call it). Each seeded connector's `\u2713 added` line goes to stderr,\nso the command that triggered the seed keeps stdout to itself:\n\n| Flag | Meaning |\n|---|---|\n| (none) | Reconcile: seed any never-seeded built-in, refresh a seeded one whose version the binary bumped, leave a removed one removed. A no-op once current. |\n| `--repair` | Recover after an interrupted seed or a lost authority (rebuilds the interrupted connector; restores the removed-vs-never-seeded record from its durable backup). |\n| `--reset` | Discard the record and re-seed all seven built-ins (the six connectors plus the web dashboard). **Resurrects any you removed.** Rebuilds cleanly over corrupt seed state. |\n| `--force` | Re-seed the built-ins even when the version stamp is current or a downgrade. |\n\nWhen a newer `cotal` advances the operator-global seed store to its generation, it prints one\nmigration line naming the old and new generations, the exact CLI entry that wrote the store, the\ncommit timestamp, and `seed/stamp.json`. That writer and timestamp are kept in the stamp, so a later\nolder CLI refusal can say which executable wrote the generation it will not overwrite and when.\nLegacy generation-only stamps remain readable; their refusal simply has no writer provenance to add.\n\nAn older `cotal` refuses a seed store written by a newer version. When it can verify a sufficient\n`cotal` executable on PATH or at the installer's `~/.local/bin/cotal` location, the refusal names\nthat absolute path so a reduced service PATH does not select the older binary again. Otherwise it\nkeeps the generic newer-version instruction. `--force` rebuilds the store for the running older\nversion without discarding the ever-seeded authority. `--reset` still exists for corrupt state and\nresurrects deliberately-removed connectors.\n\nA source-checkout CLI (`pnpm cotal`, `tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of\nthose) refuses to write or garbage-collect that store. The refusal names the path, the generation\nit declined, and `COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout,\nbecause pointing `$XDG_CONFIG_HOME` at a scratch dir alone does not lift it. With the skip set, a fresh\nconfig gets no built-in connectors (`cotal ext list` shows none), so add one from the checkout with\n`cotal ext add <checkout>/extensions/connector-claude-code` against that scratch `$XDG_CONFIG_HOME`. `COTAL_HOME` does not relocate this\nstore. An entry that cannot be proven as a released install is refused the same way. Isolated\nrelease tests that must seed from a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` after\npointing `$XDG_CONFIG_HOME` at a scratch dir; that override is documented here, not on the refusal\nline. An opt-in write still records the checkout path in `seed/stamp.json` as `writtenBy`.\n\nThe default connector for a bare `cotal spawn` (no `--agent`) is the persona's `agent:` pin if it\nhas one, else `claude`; set `COTAL_DEFAULT_AGENT` (e.g. `opencode`) to change the fallback. It is\na default, so a persona that pins its harness still wins over it. An `--agent` naming a removed\nconnector fails loud with the exact\n`cotal ext add` to restore it. Set `COTAL_SKIP_CONNECTOR_SEED=1` to turn off the automatic first-run\nseed/refresh entirely (for a controlled or offline setup that manages connectors by hand); `cotal ext\nseed` still runs on request. `cotal agent-bearer` never takes the seed at all: it is exec'd by\nspawned seats on every bearer refresh, so it neither reconciles nor is refused by the store's\ngeneration (see [Plumbing](#plumbing)).\n\n## completion\n\n```bash\ncotal completion <bash|zsh|fish|powershell> # print a stub to eval / source\ncotal completion install [shell] # install it persistently\n```\n\nPrints or installs shell completion. Completion candidates come from each command's declared flags\nand, where useful, live mesh state (spaces, personas, managed agents) resolved offline.\n\n## feedback\n\n```bash\ncotal feedback \"<summary>\" [--type <t>] [--email <e>] [--details <text>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--type <t>` | none | `bug` \\| `idea` \\| `friction` \\| `praise` \\| `other` |\n| `--details <text>` | none | Longer free-form details |\n| `--severity <s>` | none | `low` \\| `medium` \\| `high` |\n| `--area <a>` | none | The part of Cotal this concerns |\n| `--email <e>` | git email | Contact email (required on the keyless public path) |\n| `--name <n>` | none | Your name (optional) |\n| `--url <url>` | keyed / public intake | Intake URL override |\n| `--key <k>` | `COTAL_FEEDBACK_KEY` | Feedback key |\n\nSends feedback to the Cotal developers. With a key (`--key` / `COTAL_FEEDBACK_KEY`) it routes to the\nkeyed beta intake; without one it goes to the public `cotal.ai` intake and requires a contact email\n(`--email` / `COTAL_FEEDBACK_EMAIL`, else your git email). Run a self-hosted intake with\n[`feedback-intake`](#server-daemons).\n\n## run\n\nOperate durable workflow runs (cotal-lang programs) from the terminal.\n\n```bash\ncotal run start --file <program> [--timeout <dur>] [--local]\ncotal run resume <runId> [--local --file <program>]\ncotal run ps [--endpoint <ep>] [--json]\ncotal run journal <runId> [--endpoint <ep>] [--json]\ncotal run answer <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run amend <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run migrate <runId> --local --file <program> [--endpoint <ep>]\n```\n\n`start` hands the program to the mesh's manager, which validates it, mints the run id (the record\nnever takes a caller-supplied one), drives it in its own process, and answers with the id once the\nrun is recorded; a program that does not validate is refused with every problem listed. `resume`\nasks the manager to take an existing run back and continue it from its step journal; the source is\nthe recorded program, so no `--file` is taken. Neither takes `--endpoint`: the manager records\nits runs under its own endpoint, and naming another is refused. `ps` lists the run records and\n`journal` renders one run's durable records; both only inspect. An open pause prints its question.\nA pause settled with an accepted answer prints its value as JSON plus the recorded answerer,\nartifact when present, time, and answer id, then one `amended` line per later amendment, in the\norder the store committed them, so the last is the current position.\nExpired pauses and ordinary steps print no answer line.\n`--json` on `ps` or `journal` prints each row the manager answers with (or `--local` reads) as one\nJSON object per line. A `ps` row carries `runId`, `endpoint`, `state`, `holder`, `epoch`,\n`journalHigh`, `forkedFrom`, `startedAt` and `programHash` (the values the program's `run()`\nreports; `programHash` is absent for a run with no recorded program), and `revoked` or\n`revocationUnreadable` when the marker says so. A `journal` row is an `activation` or a `step`. A\nstep row carries its `step` key, the `effect` kind and its `name`, `state`, `outcome`, the recorded\n`status` and `errorCode` once settled, and `startedAt` and `endedAt` in epoch milliseconds. An open\npause adds its `asks`, its `deadlineAt`, and for a checkpoint the `onExpiry` it was armed with; a\nsettled pause adds its `answer` and its `amendments`, as the text view prints them. A field the\njournal does not record is absent: a checkpoint opened before `onExpiry` was recorded carries none.\nThe run header and errors go to stderr, so stdout carries only rows; an unreadable revocation marker\nprints its reason there and still exits 1. The text view is presentation and is not a stable\nparsing target. `--json` on any other verb is refused.\n`answer` resolves an open\ncheckpoint through the manager, presenting as the holder that armed it; the manager records the\nanswerer from your credential, so no `--by` is taken there. A settled step refuses a second\n`answer`. `amend` records a changed position on a settled checkpoint or `ask`: it files a new\nanswer beside the accepted one, naming it, and the journal lists it under the step. The pause stays\nsettled and the run keeps the answer it acted on. A step that is still open or settled without an\nanswer refuses an amend. A spawned seat may amend only an answer recorded under its own name. `migrate` runs the migrate check of an\nedited program against a run's journal, from this terminal under a read credential (`--local`\nonly; the manager serves no run-migrate command): it prints whether the migration is admissible,\nevery orphaned step with its verdict and code, and exits 0 on admissible and non-zero on not. It\nwrites nothing: the commit that would file the migration is not reachable yet, and the report\nsays so. `--timeout` sets the default\ncheckpoint timeout for a drive (default 1h). `--local` drives in this process instead, over one\nconnection per invocation under the run's own credential minted from the project folder's trust\nmaterial, and is the path on a bare broker with no manager or for a run with no recorded program\n(`cotal run resume <runId> --local --file <program>`); `answer --local` and `amend --local` take\n`--by <who>`. On a\nuser-auth mesh the host's own manager refuses the family by name, and `--local` has no credential\nthere. A participant's manager started with `cotal supervise` hosts a logged-in user's runs through\nits issuing host: the auth callout issues the user's manager connection, and every `run` verb rides\nthe versioned rail under that issuance.\n[User-auth run start](design/user-auth-run-start.md)\nrecords the path. The guide is [workflows](workflows.md).\n\n## Server daemons\n\nTwo long-lived infra roles ship with the CLI. They are not part of everyday operation; the delivery\ndaemon comes up automatically with `cotal up --detach` in auth mode.\n\n```bash\ncotal deliver --space <s> [--server <url>] [--creds <file>] [--root <dir>]\ncotal auth-service --space <s> --server <url> [--port <n>] [--exchange-public-port <n>] [--exchange-public-url <https://\u2026>] [--exchange-trusted-proxy]\ncotal feedback-intake --keys <keys.json> [--port <n>] [--creds <file>]\n```\n\n`auth-service` runs a user-auth space's identity plane: the NATS auth callout, the\ncapability-gated local exchange and JWKS, and, when `--exchange-public-port` is set, the closed public\nexchange/discovery face forwarded by an HTTPS reverse proxy. `--exchange-public-url` is the proxy URL\nadvertised to clients; `--exchange-trusted-proxy` opts into last-hop `X-Forwarded-For` attribution.\n`cotal up --user-auth` starts and supervises the service for you, so you run it directly only to\nrecover one by hand.\n\n`deliver` runs the server-side Plane-3 delivery daemon: the durable backstop and membership/ACL\nauthority. It is auth-mode-only and single-instance (`--shard`/`--shards` accept only `N=1`);\n`--dev-mint` mints a scoped cred from the local signer for standalone dev. `--creds` can start a\ndaemon that already looks healthy, but production renewal is not that file alone: the manager and\nthe daemon must address one credential store. On a stock split host with two project roots, a\ndirect `deliver` is not an independent repair; keep the daemon under `cotal up` on the broker\nhost, or inject the same store into both processes ([embedding](embedding.md#supervisor-signing-authority)).\nTyped by hand on the workstation, `deliver` dials the broker recorded for `--space` in the mesh\nregistry (a mismatching `--server` is refused before any dial, and a record for a different\nworkspace root is refused outright); with no record for the space it falls back to the local mesh.\nThe daemon serves the workspace root that `--root <dir>` names, which must hold `.cotal/`, or else\nthe nearest `.cotal/` above its working directory. With neither, it refuses at start and names the\ndirectory it searched from, before it reads a credential or dials a broker.\nSee the [delivery daemon](delivery-daemon.md). `feedback-intake` runs a self-hosted feedback server\n(requires `--keys` and a scoped `--creds`), announcing submissions into a space channel; flags\ninclude `--host`/`--port`, `--store`, `--space`/`--channel`, `--max-bytes`, and `--rate-limit`.\n`--port` takes a decimal port from 1 to 65535 and is checked before the intake dials the broker.\n\n## Plumbing\n\n`cotal __complete <words\u2026>` is the internal entry the shell-completion stubs call to emit candidates\nfor the current command line; you never run it directly. `cotal agent-bearer` is machine-facing\nplumbing on user-auth meshes: spawned agents exec it to print a fresh short-lived bearer from their\nspawn-time secret; you never run it directly either. Its local arm uses `--dir` to discover the\ncapability-gated loopback service. A remotely enrolled, already-granted agent instead receives\n`--exchange-url <https://base>` in its launch argv: that arm sends `{owner, actor, actorToken}` to the\npinned public exchange with no local capability, follows no redirects, and refuses every non-HTTPS\nURL because the actor token is the credential in the request body. Because a seat execs it on every\nbearer refresh, it skips the connector-seed boot gate entirely: it reads one 0600 token file,\nexchanges it and prints the bearer without consulting or writing the operator-global seed store, so\na newer store generation cannot refuse a live seat's refresh. `--manager-call` asks for the\ninstance-bound `manager-caller` view; `--manager-instance <id>` selects an explicit live candidate.\nThat mode still prints only the raw token and does not update `--health-file`. A spawn runs it once as the agent auth\npreflight. When it fails there without printing a sentence of its own, the refusal names the cause: the 30 second\ntimeout, the signal that killed it, or its exit code. (`cotal start` is a removed tombstone: it\nerrors and points you to `cotal spawn --detach`.)\n"
62537
62597
  },
62538
62598
  {
62539
62599
  "slug": "config",
62540
62600
  "title": "Configuration",
62541
62601
  "kind": "Reference: describes the TypeScript reference implementation (the `cotal` CLI and connectors), not the wire contract.",
62542
62602
  "summary": "Three things configure a Cotal workstation: the config file (per-connector settings, notably which of your MCP servers get shared with spawned agents), a set of COTAL environment variables, and the\u2026",
62543
- "body": '# Configuration\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI and connectors), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\nThree things configure a Cotal workstation: the **config file** (per-connector settings, notably\nwhich of your MCP servers get shared with spawned agents), a set of **`COTAL_*` environment\nvariables**, and the **on-disk layout** under a project\'s `.cotal/` and your machine\'s `~/.cotal`.\nNone of these are part of the wire contract; they configure the reference implementation only.\n\n## The config file\n\nThe cotal config file carries per-connector launch settings. It is layered from two locations,\nmost-specific-wins:\n\n| Layer | Path | Scope |\n|---|---|---|\n| Base | `$XDG_CONFIG_HOME/cotal/config.json` (else `~/.config/cotal/config.json`; `%APPDATA%\\Cotal\\config.json` on Windows) | Operator-level, every space |\n| Override | `<project-root>/.cotal/config.json` | Space-local |\n\nThey merge per connector and per server name: a server in the space-local file replaces the\nsame-named server in the operator-level file; connectors or servers present in only one side are\nkept. A missing file is empty (valid); malformed JSON, a non-object top level, or a shared server\nthat cannot launch as written is a loud error.\n\nIt carries three things: which of your personal MCP servers a connector should **share** with the\nagents it spawns, optional `spawn.env` names that deliberately add environment capability to a\nspawned agent (see [Environment variables](#environment-variables) below), and an optional\n`modelPolicy` that limits which models a role may launch on (see [Model policy](#model-policy)).\n\nThe sharing half: the Claude connector launches with `--strict-mcp-config`, dropping every ambient\nMCP server, so a spawned agent gets only the servers this file lists, and none with no list. On its\nfirst run `cotal setup` writes the `claude` list from your own Claude Code user-scope servers, leaving\nout a malformed entry and any with an `env` or `headers` value that is anything but `${VAR}`\nreferences, and keeps a list the file already declares.\nEvery shared server boots once per spawn, so remove the heavy ones for a lighter seat.\n\n```json\n{\n "connectors": {\n "claude": {\n "mcpServers": {\n "github": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-github"],\n "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }\n }\n }\n }\n }\n}\n```\n\nEach server is written in the de-facto `.mcp.json` shape, so you can copy an entry straight out of\nyour own Claude / VS Code / Cursor config. Secrets ride as **`${VAR}` references** (also\n`${VAR:-default}`), resolved from your environment at launch and forwarded to the child **by name**\n(never as literals) so the file stays safe to keep in `~/.config` or a gitignored `.cotal/`. Only\n`command`, `args`, `env`, `url`, and `headers` are expanded; any other key passes through verbatim.\n\nA server that cannot launch as written is refused when the file is read, with its path in the file\n(`connectors.<name>.mcpServers.<server>.<field>`). `command`, `type` and `url` must be strings, `args`\na list of strings, and `env` and `headers` objects of strings. A server must also name a transport to\nstart: a non-empty `command` when `type` is absent or `stdio`, or a non-empty `url` when it is `http`,\n`sse` or `ws`. Any other `type` is refused. Every spawn reads the whole file, so one such entry\nrefuses every spawn until it is fixed, `--share-tools none` included.\n\n**`--share-tools` interplay**. The per-spawn selection narrows what this config declares:\n\n| `--share-tools` | Result |\n|---|---|\n| (flag absent) | Every server declared for the connector |\n| `none` or empty | Nothing |\n| `a,b` | Only those named: each **must** be declared, or the spawn fails (no silent drop) |\n\nA `supervise --roster` entry selects the same way with a `share-tools:` list: an absent key\nshares every declared server, `[]` shares none, and `[a, b]` shares only those named. Each list\nentry is a server name as written, so `[none]` shares a server declared as `none`.\n\nToday only the `claude` connector consumes shared MCP servers; OpenCode inherits config through its\nown merge layer and Hermes has no MCP. See [Connect Claude Code](connect-claude.md) for the full\nsharing model.\n\n### Model policy\n\n`modelPolicy` names, per role, the models a seat in that role may launch on. Each key is a role.\nIts `models` list holds the allowed model ids, and an optional `variants` list holds the allowed\nvariants.\n\n```json\n{\n "modelPolicy": {\n "reviewer": { "models": ["vendor/model-B"], "variants": ["high"] }\n }\n}\n```\n\n`cotal spawn` and the manager behind `cotal spawn --detach` check it before anything is minted or\nlaunched. They judge the effective role (a `--role` override counts) and the effective model and\nvariant (`--model` and `--variant` win over the persona\'s fields, as always). The spawn is refused\nwhen the role has an entry and:\n\n- no model resolves at all, since the harness would then pick one and nothing would record which;\n- the model is not in `models`. Ids compare whole, so `vendor/model-B-fast` does not match\n `vendor/model-B`;\n- `variants` is set and the variant is absent or not in it;\n- the launch carries any launch option (`launchOptions:` in the persona or manifest, or `--opt`).\n The connector applies launch options unread, after the model and variant, and one can select\n another model (an OpenCode `model`, a Claude `--model`), so a role under the policy launches\n without them.\n\nThe refusal names the persona, whether the value came from its own field or from the flag, the\nvalue, and the allowed ids. Roles with no entry, and personas with no role, are not constrained.\nA seat launches on the model and variant that were checked, even if its persona file changes after\nthe check.\n\nA space-local entry for a role replaces the operator-level entry for that role, and a role named in\nonly one file keeps its entry. A policy that cannot be read as written (a `models` list that is\nempty or holds a non-string, or an unknown field) fails every spawn with an error naming the file.\nThe policy is read at each spawn, so an edit applies to the next spawn without a restart. Seats that\nare already running are not re-checked, including a supervised restart or a preserved seat resumed\nafter maintenance.\n\n## Environment variables\n\nThese are the operator-facing variables. Most of the connector-session ones (space, name, role, \u2026)\nare set **for you** by `cotal spawn` / the manager when they launch an agent; you set them by hand\nonly when you drive a connector session yourself (e.g. your own `claude` with the plugin) or a custom\nlauncher. Comma-separated lists are trimmed. The connector on/off flags (`COTAL_CHANNEL`,\n`COTAL_EVENTS`, `COTAL_EVENTS_REQUIRED`, `COTAL_CODEX_TUI`, `COTAL_JCODE_TUI`) take `1`, `true`,\n`yes` or `on` and `0`, `false`, `no` or `off` in any case. The connector refuses any other value\nwhen it starts, naming the variable.\n\n| Variable | Consumed by | Meaning | Default |\n|---|---|---|---|\n| `COTAL_SPACE` | connector session | Space to join | `demo` (or the join link\'s) |\n| `COTAL_NAME` | connector session | Presence name / identity | required (or via `COTAL_AGENT_FILE` / `COTAL_LINK`) |\n| `COTAL_ROLE` | connector session | Role | agent file\'s `role:`, else none |\n| `COTAL_SERVERS` | connector session | Broker URL(s). Hand-driven sessions only: a launcher-spawned seat gets this in its launch material instead (see below) | the default local broker (or the link\'s) |\n| `COTAL_CREDS` | connector session | Path to a NATS creds file (auth mode). Hand-driven sessions only, same as above | none (open mode) |\n| `COTAL_LINK` | connector session | `cotal://token@host/space` join link: supplies server, auth, space | none |\n| `COTAL_AGENT_FILE` | connector session | Path to a persona file: supplies name, role, kind, channels | none |\n| `COTAL_SUBSCRIBE` | connector session | Active channel read set. Set but empty means no channels | agent file / link, else no channels |\n| `COTAL_ALLOW_SUBSCRIBE` | connector session | Read ACL (channels the agent *may* read). Set but empty means the read set alone | agent file\'s, else = `COTAL_SUBSCRIBE` |\n| `COTAL_ALLOW_PUBLISH` | connector session | Post ACL (channels the agent *may* post to). Set but empty means deny | agent file\'s, else deny (empty) |\n| `COTAL_MODEL` | connector session | Model label (display metadata), set by the launcher | none |\n| `COTAL_KIND` | connector session | Endpoint kind | `agent` |\n| `COTAL_TLS` | connector session | Connect over TLS (`1`) | off |\n| `COTAL_TOKEN` | connector session | Auth token (token / open modes) | none |\n| `COTAL_CAPABILITIES` | connector session | Control-plane capabilities (e.g. `spawn`) that gate manager tools | agent file\'s `capabilities:` |\n| `COTAL_QUIET` / `COTAL_MUTED` | connector session | Per-channel attention defaults (never-wake / drop-on-receive) | agent file\'s, else none |\n| `COTAL_CHANNEL` | Claude connector | Force channel wake-nudges on (`1`) / off; set to `1` by the Claude launcher | on when the MCP client declares the `claude/channel` capability in `initialize` |\n| `COTAL_EVENTS` | connector session | Arm this session\'s event plane (`1`); set by the launcher unless the launch used `--no-events` | launcher-managed |\n| `COTAL_EVENTS_REQUIRED` | hand-driven user-mode connector | Trusted registration says events are mandatory; arms the plane and refuses if the session grant omits its event channel. Launcher-managed sessions carry this in launch material instead | off |\n| `COTAL_DEFAULT_AGENT` | `cotal spawn` | Default connector type for a bare spawn (below an explicit `--agent` and the persona\'s `agent:` pin) | `claude` |\n| `COTAL_DEFAULT_PERSONA` | `cotal spawn` | Default persona for a bare spawn | `default` |\n| `COTAL_SKIP_CONNECTOR_SEED` | boot gate | Skip the automatic built-in-connector seed/refresh on a command (`1`); `cotal ext seed` still works. `agent-bearer` skips the gate by name, no flag needed | off |\n| `COTAL_ALLOW_CHECKOUT_SEED` | seed store | Permit a source-checkout CLI to write the operator-global seed store (`1`) after isolating `$XDG_CONFIG_HOME`. Used by in-tree seed smokes that spawn the checkout-shaped `bin/` CLI into a scratch config. Any other value is ignored. The checkout refusal does not name this variable. | off |\n| `COTAL_DETACH_KEY` | `cotal attach` | Detach escape key (ctrl-<char> / ^<char>), matched as the control byte or its kitty / modifyOtherKeys encoding | `ctrl-]` |\n| `COTAL_FEEDBACK_KEY` | `feedback`, connector | Beta feedback key \u2192 keyed intake | none (public intake) |\n| `COTAL_FEEDBACK_EMAIL` | `feedback`, connector | Contact email for the keyless public intake | your git email |\n| `COTAL_FEEDBACK_URL` | `feedback`, connector | Intake URL override (self-hosted) | keyed / public intake |\n| `COTAL_SKIP_ASSIST` | `setup` | Disable the connector debug handoff on a failed step (`1`; for CI) | off |\n| `COTAL_COMPLETE_DEBUG` | `completion` | Print completion-resolution errors to stderr | off |\n| `COTAL_ENROLLMENT_FILE` | foreground `spawn` | Private `0600` file containing one remote enrollment URL; preferred over the environment form | none |\n| `COTAL_MANAGED_HANDOFF_FILE` | `cotal` entry, foreground `spawn` | Private `0600` file holding one managed lifecycle handoff from a delegating runtime; taken and deleted before anything else runs | none |\n| `COTAL_ENROLLMENT_URL` | foreground `spawn` | One remote enrollment URL when a secret file cannot be mounted; conflicts with `COTAL_ENROLLMENT_FILE` | none |\n| `COTAL_SERVE_HEADLESS` | OpenCode runtime | Run the OpenCode server without a foreground TUI (`1`). Stdout gets one `[cotal-serve]` line with the server\'s port and session and no password; a host that drives the server passes its own `OPENCODE_SERVER_PASSWORD` to the launcher | off |\n| `COTAL_HOME` | workspace | Override the machine-home dir for the **mesh registry only** (`meshes/`, `current-mesh`, onboard marker). Does **not** redirect project-root paths (`findCotalRoot` / `.cotal/broker-policy.json`, NATS store, manager/delivery state, auth). Tests that run `cotal up` must also use a temp project root with its own `.cotal/` as `cwd` | `~/.cotal` |\n\n> `--console-port` is a `cotal supervise` flag, not an environment variable; there is no\n> `COTAL_CONSOLE_PORT`.\n\n### Launcher variables\n\nThese are wired into a spawned child\'s environment by the connector / launcher and read back inside\nthe session. They are not operator knobs; listed so you recognize them in a process listing.\n\n| Variable | Purpose |\n|---|---|\n| `COTAL_ID` | Stable agent id chosen by the launcher (static meshes) |\n| `COTAL_MANAGER_INSTANCE` | Stable instance id of the launching manager. User-auth managed calls request a separate control view for this instance; the issuer authorizes the selection. It carries no credential or grant. An unbound session uses the issuer\'s unique authorized selection |\n| `COTAL_ENVIRONMENT` | Opaque provider-issued environment reference published in presence. Read once when the endpoint is constructed; omitted when the launcher sets none |\n| `COTAL_LIFECYCLE_UID` | The incarnation\'s lifecycle UID, minted once per spawn; the session binds its lifecycle-keyed DM/delivery/history consumers by it (its credential pins the same names). Required for an authed launch (`COTAL_CREDS` or user-mode); config parsing fails loud without it. Open mode omits it (the endpoint self-mints per session) |\n| `COTAL_BACKFILL_FLOOR` | The CHAT stream sequence a resumed seat\'s prior incarnation had reached before its preservation cut; the boot backfill reads only what came after it. Set by the manager on a preserved resume, absent on a fresh spawn. Must parse as a non-negative integer; a broken launcher\'s malformed value fails loud rather than silently falling back to a full replay |\n| `COTAL_OWNER` / `COTAL_ACTOR` / `COTAL_SENTINEL_CREDS` / `COTAL_BEARER_CMD` | User-auth launch identity: the agent\'s principal, its sentinel creds path, and the exec-able bearer command; all four together, mutually exclusive with `COTAL_CREDS`. A launcher-spawned seat carries them in its launch material instead of its environment. A remote enrollment\'s bearer argv uses `agent-bearer --exchange-url <https://base>`; the token never falls back to a local service file |\n| `COTAL_LAUNCH_MATERIAL` | Path to this launch\'s private 0600 material file (see [Launch material](#launch-material) below). Carries the broker URL, the creds path, the auth token, the user-auth identity, the required-events flag, and the control token. A PATH, never a secret |\n| `COTAL_CONTROL_SOCKET` | The session\'s local control endpoint path. The MCP server listens on it and the lifecycle hooks connect to it; the token that authenticates the first frame rides the launch material, not the environment |\n| `COTAL_BRIDGE_SOCKET` / `COTAL_TOOLS_FILE` / `COTAL_PARENT_PID` | Hermes sidecar plumbing (bridge socket, generated tool descriptors, launcher pid to watch). The bridge socket\'s first frame carries the control token from the launch material (or `COTAL_CONTROL_TOKEN` in standalone mode) |\n| `OPENCODE_CONFIG_CONTENT` | Inline OpenCode config (the injected cotal plugin, highest merge layer) |\n| `OPENCODE_DB` / `OPENCODE_HOME` / `OPENCODE_PORT` / `OPENCODE_SERVER_URL` / `COTAL_OPENCODE_*` | OpenCode server plumbing (home, port, DB, server URL) |\n\nA spawned agent receives a fixed OS execution allow-list (PATH, HOME, TERM, locale, and\nXDG/Windows config directories), the machine-wide `COTAL_*` operator knobs (`COTAL_HOME`, the\nfeedback set, the default-agent pair, the `*_BIN` overrides, the timing knobs), the provider inputs\nits connector declares, and `${VAR}` names an explicitly shared MCP server requires. It does not\ninherit the manager\'s ambient environment. This keeps host-session markers such as\n`CLAUDE_CODE_CHILD_SESSION` / `CLAUDECODE` (and the analogous names other hosts use to mark a nested\nsession), unrelated service secrets, and environment-only capabilities out of seats unless\ndeliberately supplied. A seat\'s transcript/resume behaviour is a property of the seat, never of how\nmany layers up someone once ran `cotal up` inside an agent. Connection material is not in the\nenvironment at all (see [identity & auth](identity-and-auth.md)).\n\nEnrollment inputs are launcher-only secrets. `spawn` removes both enrollment variable names from the\nconnector\'s child environment, and `spawn.env` cannot list them.\n\nPATH is forwarded whole, including entries such as `~/.local/bin` where connector binaries live, so\na seat can still launch after the strip. There is no inherit mode and no opt-in-to-containment flag:\nthe allow-list is the only path.\n\nTo deliberately add an environment name for a spawned agent, declare `spawn.env` in the config file:\n\n```json\n{ "spawn": { "env": ["MY_PROVIDER_API_KEY"] } }\n```\n\nThe listed names are added to the fixed boundary. That is also the opt-in for a host-session marker\na persona has chosen to receive (`CLAUDE_CODE_CHILD_SESSION` and friends). A `COTAL_` name other\nthan the machine-wide knobs above is refused and the seat is not launched: the launcher sets those\nnames for each seat, so a value from the spawning process would become the seat\'s role, model or\nread set. An empty array adds nothing. A space-local `spawn` block replaces the operator-level one\noutright rather than merging, so a local list stays local. No `spawn` block, `"spawn": { "env": [] }`, and `"spawn": {}`\nall add no names. A `spawn` that is not an object, or an `env` that is not a list of non-empty names,\nis refused when the file is read, with the file and the field named, so every spawn and resume is\nrefused until it is fixed.\n\nBe honest with yourself about what this buys: `HOME` is forwarded, so an agent with a shell reads\n`~/.aws`, `~/.ssh` and `~/.config` regardless. The boundary protects what a file on disk cannot hand\nover anyway, and that is more than a list of secret values. Some variables are **capability\nhandles**: they do not contain a secret, they name a live process that will act on your behalf.\n`SSH_AUTH_SOCK` is the sharp one. Inherit it and the agent can ask your `ssh-agent` to sign, which\nmeans it can reach any host or sign any commit that key authorises, and it keeps that power even\nif the private key file is not on disk at all. Nothing under `~/.ssh` has to exist for it to work,\nso "a shell reads `~/.ssh` regardless" does not cover this case. The same shape covers a\n`gpg-agent` socket and the desktop and cloud credential brokers. So the default boundary protects:\nsecrets that live **only** in the environment, such as an `aws-vault exec` or `op run` shell or\nCI-injected values, and the capability handles above, which it removes along with everything else\nit does not name. Real containment is still a sandbox or a VM.\n\nModel discovery is the exception, and it is deliberate rather than an oversight. When the `codex` or\n`opencode` connector enumerates a model catalog (`cotal models`, and the manager\'s selector), it runs\nthat harness with your environment minus Cotal\'s own `COTAL_*`, and it does **not** consult\n`spawn.env`. Those probes are short-lived catalog reads rather than agent seats, so an allow-list\nthat confines a seat does not confine them.\n\n### Launch material\n\nA process environment is inherited by every descendant. A seat launched with its credential, its\nbroker URL and its control token in the environment hands all three to the build it runs, the linter,\nthe third-party CLI, the test suite that reads its broker from the environment. Nothing in that chain\nasked for any of it.\n\nSo a launcher-spawned seat does not get them in its environment. The launcher writes them to a single\n**0600 file inside a 0700 private directory** and exports only its path, as `COTAL_LAUNCH_MATERIAL`.\nThe session reads the launch-material file once at startup. For a managed creds path, it reads the\ncredential once to pin the seat\'s nkey, then keeps the path as a renewal source. A re-signed file is\nread by renewal and by reconnect after the cached credential expires, and a file for a different\nnkey is refused. An unbounded credential has no renewal point and remains a static boot-time value.\nThis is the same shape\n`cotal agent-bearer` already uses for its spawn-time secret: the material rides a file, never argv\n(which is visible in a process listing) and never the ambient environment (which is inherited).\n\nThree connectors drop the path once they have read it, so the shells and tools those seats run\ninherit no reference at all: **pi** and **codex**, whose sessions run in the seat process, and\n**OpenCode**, whose seat process is a shim that starts `opencode serve` (the plugin runs in that\nserver, which is also what executes the session\'s tool calls). Those three also **delete the file**\nat the same moment, along with the private directory that held it. Nothing reads it again, so leaving\nit on disk would only extend how long a copy of the material exists. The directory is only removed\nwhen it is provably the one the launcher wrote: the right filename inside, the launcher\'s prefix on\nthe directory, the directory sitting directly in the OS temp root, and a non-recursive removal that\nfails rather than deletes if anything else is in there.\n\nTwo keep it, and for the same reason in both cases: a process that starts LATER has to read it.\n**Claude**\'s readers are short-lived children, the MCP server and one process per lifecycle hook,\nwhich begin after the session is already running. **Hermes**\' launcher starts a gateway child that\nneeds the control token. For those two, a shell the seat runs still inherits a path to the material\nfile, though not the material itself.\n\nWhat this does: the values are out of every descendant\'s environment, so an `env` dump, a CI log, a\nsuite that defaults its broker from the environment, or a tool handed a credential it never asked\nfor, all stop seeing them. What it does not do: hide the material from a process running as the same\nuser that deliberately opens the file. No environment-level control can, and the same is already true\nof `~/.cotal/auth/creds`. What changes is that reaching the material is a deliberate act rather than\nan inheritance nobody chose.\n\nDriving a connector session **by hand** still works the documented way: set `COTAL_CREDS` /\n`COTAL_SERVERS` (and the user-auth quartet) yourself, and no material file is involved. Setting both\na material file and any of them is refused rather than resolved by precedence: one launch carries one\nidentity plane. `COTAL_LINK` counts as one of them, because a join link carries the server, the auth\nand the space in a single string.\n\n`eventsRequired` is an additive boolean in launch material. The launcher derives it from the selected\nuser-auth registration. A Claude, OpenCode, Codex, Jcode or Pi launch that carries it also sets\n`COTAL_EVENTS` and the workspace root, even when the launcher passed `events: false`. Connector config\nexposes it and the Claude, OpenCode 1.x, Codex, Jcode and Pi startup gates arm on it even when\n`COTAL_EVENTS` is absent. A direct env launch may use `COTAL_EVENTS_REQUIRED=1` only with the complete\nuser-auth quartet. The session refuses if its post ACL does not cover its own `events.<owner>.<actor>`\nchannel.\n\nThe control endpoint is a pair, and **half a pair is refused**. A launch with a control socket path\nand no resolvable token, or a token and no socket path, does not fall back to running without a\ncontrol plane: it fails with a sentence naming which half is missing. The one exception is the\nlifecycle hook relay, which catches that refusal, writes a single warning to stderr naming no values,\nand then does nothing, because a hook that throws is a hook that blocked the session. Failing open is\ndeliberate; failing open silently is not.\n\n## On-disk layout\n\n### Project files\n\nA project\'s state lives in `.cotal/` at the mesh root (found by walking up from the cwd, like `.git`).\n**It is gitignored**; it holds secrets and machine-local process state.\n\n| Path | What it is |\n|---|---|\n| `auth/broker.json` | Broker trust material: the operator seed and the system account (secret; the system-account signing seed is stripped before writing). One per broker, shared by every space on it |\n| `auth/account.<key>.json` | One space\'s own NATS data account and signing seed (secret). One file per space, all signed by the broker above; `<key>` is a stable, case-safe hex encoding of the space name (never the raw name, so two case-differing spaces can\'t collide) |\n| `auth/space.<key>/` | One space\'s user-auth state (IdP pin, issuer keys, owner secret, callout account), present only when that space enables per-user auth. Keyed by the same case-safe hex encoding; pre-hex layouts (`auth/<space>/`) are renamed here on first touch |\n| `auth/creds/space.<key>/<name>.creds` | Per-agent minted NATS credentials, under the segment of the space they belong to - same case-safe hex encoding as the rows above. Pre-segment layouts (`auth/creds/<name>.creds`) are moved here on first touch. The `creds` directory itself stays shared, so a root\'s tenants keep their agent material in sibling segments rather than sibling roots |\n| `auth/server.conf` | Generated nats-server config for the broker (`# Generated by \\`cotal up\\` - do not edit by hand.`). Path is `<projectRoot>/.cotal/auth/server.conf`, not `~/.cotal` unless that is the mesh root. Default bind is loopback (`host: 127.0.0.1`); `--host` on a **stopped** `cotal up` regenerates it. A live refresh does not rewrite this file. The core renderer accepts every space on the broker; `cotal up` currently orchestrates one space per root, so it renders that one space\'s account |\n| `broker-policy.json` | Durable broker **launch** policy (TLS-required cert/key path references, or plaintext). Survives `cotal down` so a bare re-`up` cannot silently drop TLS. Under the project root: **not** under `COTAL_HOME` |\n| `agents/<name>.md` | Persona / agent files ([Agent files](agent-files.md)) |\n| `manifests/<hash>.json` | Manifest-deploy ledger (records of `up -f` / `spawn -f` runs) |\n| `config.json` | Space-local connector config (the override layer above) |\n| `nats.pid` \xB7 `nats.log` | Background nats-server pid + log |\n| `manager.<key>.pid` \xB7 `manager.<key>.log` | Manager (supervisor) pid + log for one space; every line of the log starts with the UTC time it was written (ISO 8601), so a reap can be placed in time without another file; `manager.<key>.delivery-aware` marks a delivery-aware build. `<key>` is the same case-safe hex space key as the rows above, so one root can run a manager per space. A pre-segmentation root-scoped `manager.pid` is still read while it is the only spelling present, and is removed as the new record is written. A start that finds it already removed, by its exiting owner or by a concurrent start, continues. Both spellings present is reported as ambiguous rather than guessed. The manager writes the pid itself, whatever started it, and removes it on a clean stop only while it still names that process. A reader treats the record as a running manager only if the pid is alive **and** the process is a supervisor: a recycled pid belonging to something else is reported as a stale record, never signalled |\n| `delivery.<key>.pid` \xB7 `delivery.<key>.log` \xB7 `space.<key>/delivery.creds` | Delivery daemon pid and log for one space, and its scoped cred (auth mode). `cotal down` and the teardown of a foreground `cotal up` remove the cred once the daemon is confirmed stopped. Per-space and compatible with a pre-segmentation `delivery.pid` on the same terms as the manager row |\n| `web.pid` \xB7 `web.log` | Web dashboard pid + log |\n| `membership.json` \xB7 `membership-*.creds` | Membership feed state + its scoped creds |\n| `setup.log` | `cotal setup` log, one section appended per run. Each entry is one timestamped line: a control character or Unicode line separator in a path or error message is written as a `\\uXXXX` escape |\n\nA command that acts on the whole folder without being told a space reads one off these runtime\nrecords: `<key>` decodes back to the space name, and a space whose record is running wins over\nresidue from a stopped one. Two spaces running under one root is reported rather than arbitrated.\nThis is what lets `cotal status` and `cotal down` work in a folder whose mesh runs with\n`broker: { auth: false }`, where there is no `auth/account.<key>.json` to name the space.\nA record, or a `.cotal` listing, that exists but cannot be read is reported as that read error\nrather than read as absent.\n\n### Machine files\n\nCross-project machine state, so a `cotal spawn` from any directory can find a running mesh. Location:\n`~/.cotal` on POSIX, `%LOCALAPPDATA%\\Cotal` on Windows; overridable with `COTAL_HOME`.\n\n`COTAL_HOME` overrides **this tree only** (registry + current pointer + onboard marker). It is not a\nfull workstation sandbox. Broker launch policy, the JetStream store, pidfiles, and auth live under\nthe **project** `.cotal/` found by walking up from the cwd ([Project: `.cotal/`](#project-files)\nabove, including `broker-policy.json` on TLS meshes). A probe that sets `COTAL_HOME` alone and runs\n`cotal up --tls-cert \u2026` from a directory whose walked root is the operator home still writes those\nproject paths on the live machine.\n\n| Path | What it is |\n|---|---|\n| `meshes/space.<key>.json` | Registry of running meshes: one file per broker `cotal up` started (server URL, root path, mode, TLS-required client intent when recorded, attach bind host and live-session ceiling when the operator set them); `<key>` is the same case-safe hex encoding of the space name, and the record\'s own `space` field is authoritative |\n| `current-mesh` | Default space a bare `cotal spawn` joins (set by `cotal use`) |\n| `onboarded.json` | First-run marker (with `ONBOARD_VERSION`) that flips setup between first-run and status-card |\n| the Claude plugin marketplace | The installed `cotal-mesh` plugin assets |\n\n### Configuration files\n\nDistinct from `~/.cotal`. Location: `$XDG_CONFIG_HOME/cotal`, else `~/.config/cotal` on POSIX, or\n`%APPDATA%\\Cotal` on Windows.\n\n| Path | What it is |\n|---|---|\n| `config.json` | Operator-level connector config (the base layer above) |\n| `extensions/` | `cotal ext` install prefix: its own npm root (`node_modules`) plus an `extensions.json` provider/command-display cache. Built-in connectors install here too, seeded on first run |\n| `seed/` | Built-in-connector seeding state: the `ever-seeded` authority (+ durable backup), the init witness, the version stamp, the crash cursor, and `store/<version>/<name>` (the stable payloads `ext add --install-links` reifies each seeded connector from) |\n\nBoth `extensions/` and `seed/store/` are operator-global: shared by every space, project directory, and\ncheckout on the machine, and moved only by `$XDG_CONFIG_HOME` (a fresh project dir isolates `.cotal/`,\nnot these). `COTAL_HOME` does not relocate them. A CLI running from a source checkout (`pnpm cotal`,\n`tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of those, identified by a `bin/` package\nroot next to `implementations/` or `pnpm-workspace.yaml`) refuses to write, stamp, or\ngarbage-collect that store: the refusal names the store path, the generation it declined, and\n`COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout. An entry that\ncannot be proven as a released `cotal-ai` install is refused the same way. Isolating\n`$XDG_CONFIG_HOME` (on Windows, `%APPDATA%`) keeps a checkout off the installed config but does not\nlift the refusal by itself. A\nreleased install or an `npx` unpack still seeds as before. The in-tree seed smokes that must seed\nfrom a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` against an isolated config; an\nopt-in write still records that checkout path in `seed/stamp.json` as `writtenBy`. The reconcile\nnames on stderr both the store payloads it writes and any old generation it removes, so a\nmachine-wide re-seed or cleanup is visible when it happens. Those lines are provenance output. When a\nstderr write fails, at once or after waiting in a full pipe, the line is printed on stdout with the\nerror and the reconcile still completes. If stdout fails too, the reconcile still completes and the\nrun exits 1 instead of 0. A line still waiting in a full stderr pipe when the run exits, as when the\nCLI exits on a closed stdout, is lost and also makes the run exit 1. Node does not say which stderr\nbytes are still waiting, so a line that had to wait and got through just before the exit also makes\nthe run exit 1 when later stderr output is still waiting. A run whose stderr is closed or redirected\naway at launch keeps the write and loses the line.\n\nFor how `cotal setup` populates the machine state and the plugin, and how the built-in connectors are\nseeded as removable extensions, see [setup internals](setup-internals.md).\n'
62603
+ "body": '# Configuration\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI and connectors), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\nThree things configure a Cotal workstation: the **config file** (per-connector settings, notably\nwhich of your MCP servers get shared with spawned agents), a set of **`COTAL_*` environment\nvariables**, and the **on-disk layout** under a project\'s `.cotal/` and your machine\'s `~/.cotal`.\nNone of these are part of the wire contract; they configure the reference implementation only.\n\n## The config file\n\nThe cotal config file carries per-connector launch settings. It is layered from two locations,\nmost-specific-wins:\n\n| Layer | Path | Scope |\n|---|---|---|\n| Base | `$XDG_CONFIG_HOME/cotal/config.json` (else `~/.config/cotal/config.json`; `%APPDATA%\\Cotal\\config.json` on Windows) | Operator-level, every space |\n| Override | `<project-root>/.cotal/config.json` | Space-local |\n\nThey merge per connector and per server name: a server in the space-local file replaces the\nsame-named server in the operator-level file; connectors or servers present in only one side are\nkept. A missing file is empty (valid); malformed JSON, a non-object top level, or a shared server\nthat cannot launch as written is a loud error.\n\nIt carries three things: which of your personal MCP servers a connector should **share** with the\nagents it spawns, optional `spawn.env` names that deliberately add environment capability to a\nspawned agent (see [Environment variables](#environment-variables) below), and an optional\n`modelPolicy` that limits which models a role may launch on (see [Model policy](#model-policy)).\n\nThe sharing half: the Claude connector launches with `--strict-mcp-config`, dropping every ambient\nMCP server, so a spawned agent gets only the servers this file lists, and none with no list. On its\nfirst run `cotal setup` writes the `claude` list from your own Claude Code user-scope servers, leaving\nout a malformed entry and any with an `env` or `headers` value that is anything but `${VAR}`\nreferences, and keeps a list the file already declares.\nEvery shared server boots once per spawn, so remove the heavy ones for a lighter seat.\n\n```json\n{\n "connectors": {\n "claude": {\n "mcpServers": {\n "github": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-github"],\n "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }\n }\n }\n }\n }\n}\n```\n\nEach server is written in the de-facto `.mcp.json` shape, so you can copy an entry straight out of\nyour own Claude / VS Code / Cursor config. Secrets ride as **`${VAR}` references** (also\n`${VAR:-default}`), resolved from your environment at launch and forwarded to the child **by name**\n(never as literals) so the file stays safe to keep in `~/.config` or a gitignored `.cotal/`. Only\n`command`, `args`, `env`, `url`, and `headers` are expanded; any other key passes through verbatim.\n\nA server that cannot launch as written is refused when the file is read, with its path in the file\n(`connectors.<name>.mcpServers.<server>.<field>`). `command`, `type` and `url` must be strings, `args`\na list of strings, and `env` and `headers` objects of strings. A server must also name a transport to\nstart: a non-empty `command` when `type` is absent or `stdio`, or a non-empty `url` when it is `http`,\n`sse` or `ws`. Any other `type` is refused. Every spawn reads the whole file, so one such entry\nrefuses every spawn until it is fixed, `--share-tools none` included.\n\n**`--share-tools` interplay**. The per-spawn selection narrows what this config declares:\n\n| `--share-tools` | Result |\n|---|---|\n| (flag absent) | Every server declared for the connector |\n| `none` or empty | Nothing |\n| `a,b` | Only those named: each **must** be declared, or the spawn fails (no silent drop) |\n\nA `supervise --roster` entry selects the same way with a `share-tools:` list: an absent key\nshares every declared server, `[]` shares none, and `[a, b]` shares only those named. Each list\nentry is a server name as written, so `[none]` shares a server declared as `none`.\n\nToday only the `claude` connector consumes shared MCP servers; OpenCode inherits config through its\nown merge layer and Hermes has no MCP. See [Connect Claude Code](connect-claude.md) for the full\nsharing model.\n\n### Model policy\n\n`modelPolicy` names, per role, the models a seat in that role may launch on. Each key is a role.\nIts `models` list holds the allowed model ids, and an optional `variants` list holds the allowed\nvariants.\n\n```json\n{\n "modelPolicy": {\n "reviewer": { "models": ["vendor/model-B"], "variants": ["high"] }\n }\n}\n```\n\n`cotal spawn` and the manager behind `cotal spawn --detach` check it before anything is minted or\nlaunched. They judge the effective role (a `--role` override counts) and the effective model and\nvariant (`--model` and `--variant` win over the persona\'s fields, as always). The spawn is refused\nwhen the role has an entry and:\n\n- no model resolves at all, since the harness would then pick one and nothing would record which;\n- the model is not in `models`. Ids compare whole, so `vendor/model-B-fast` does not match\n `vendor/model-B`;\n- `variants` is set and the variant is absent or not in it;\n- the launch carries any launch option (`launchOptions:` in the persona or manifest, or `--opt`).\n The connector applies launch options unread, after the model and variant, and one can select\n another model (an OpenCode `model`, a Claude `--model`), so a role under the policy launches\n without them.\n\nThe refusal names the persona, whether the value came from its own field or from the flag, the\nvalue, and the allowed ids. Roles with no entry, and personas with no role, are not constrained.\nA seat launches on the model and variant that were checked, even if its persona file changes after\nthe check.\n\nA space-local entry for a role replaces the operator-level entry for that role, and a role named in\nonly one file keeps its entry. A policy that cannot be read as written (a `models` list that is\nempty or holds a non-string, or an unknown field) fails every spawn with an error naming the file.\nThe policy is read at each spawn, so an edit applies to the next spawn without a restart. Seats that\nare already running are not re-checked, including a supervised restart or a preserved seat resumed\nafter maintenance.\n\n## Environment variables\n\nThese are the operator-facing variables. Most of the connector-session ones (space, name, role, \u2026)\nare set **for you** by `cotal spawn` / the manager when they launch an agent; you set them by hand\nonly when you drive a connector session yourself (e.g. your own `claude` with the plugin) or a custom\nlauncher. Comma-separated lists are trimmed. The connector on/off flags (`COTAL_CHANNEL`,\n`COTAL_EVENTS`, `COTAL_EVENTS_REQUIRED`, `COTAL_TLS`, `COTAL_CODEX_TUI`, `COTAL_JCODE_TUI`) take\n`1`, `true`, `yes` or `on` and `0`, `false`, `no` or `off` in any case. The connector refuses any\nother value when it starts, naming the variable.\n\n| Variable | Consumed by | Meaning | Default |\n|---|---|---|---|\n| `COTAL_SPACE` | connector session | Space to join | `demo` (or the join link\'s) |\n| `COTAL_NAME` | connector session | Presence name / identity | required (or via `COTAL_AGENT_FILE` / `COTAL_LINK`) |\n| `COTAL_ROLE` | connector session | Role | agent file\'s `role:`, else none |\n| `COTAL_SERVERS` | connector session | Broker URL(s). Hand-driven sessions only: a launcher-spawned seat gets this in its launch material instead (see below) | the default local broker (or the link\'s) |\n| `COTAL_CREDS` | connector session | Path to a NATS creds file (auth mode). Hand-driven sessions only, same as above | none (open mode) |\n| `COTAL_LINK` | connector session | `cotal://token@host/space` join link: supplies server, auth, space | none |\n| `COTAL_AGENT_FILE` | connector session | Path to a persona file: supplies name, role, kind, channels | none |\n| `COTAL_SUBSCRIBE` | connector session | Active channel read set. Set but empty means no channels | agent file / link, else no channels |\n| `COTAL_ALLOW_SUBSCRIBE` | connector session | Read ACL (channels the agent *may* read). Set but empty means the read set alone | agent file\'s, else = `COTAL_SUBSCRIBE` |\n| `COTAL_ALLOW_PUBLISH` | connector session | Post ACL (channels the agent *may* post to). Set but empty means deny | agent file\'s, else deny (empty) |\n| `COTAL_MODEL` | connector session | Model label (display metadata), set by the launcher | none |\n| `COTAL_KIND` | connector session | Endpoint kind | `agent` |\n| `COTAL_TLS` | connector session | Connect over TLS (`1`); off keeps the join link\'s choice | off (or the link\'s) |\n| `COTAL_TOKEN` | connector session | Auth token (token / open modes) | none |\n| `COTAL_CAPABILITIES` | connector session | Control-plane capabilities (e.g. `spawn`) that gate manager tools | agent file\'s `capabilities:` |\n| `COTAL_QUIET` / `COTAL_MUTED` | connector session | Per-channel attention defaults (never-wake / drop-on-receive) | agent file\'s, else none |\n| `COTAL_CHANNEL` | Claude connector | Force channel wake-nudges on (`1`) / off; set to `1` by the Claude launcher | on when the MCP client declares the `claude/channel` capability in `initialize` |\n| `COTAL_EVENTS` | connector session | Arm this session\'s event plane (`1`); set by the launcher unless the launch used `--no-events` | launcher-managed |\n| `COTAL_EVENTS_REQUIRED` | hand-driven user-mode connector | Trusted registration says events are mandatory; arms the plane and refuses if the session grant omits its event channel. Launcher-managed sessions carry this in launch material instead | off |\n| `COTAL_DEFAULT_AGENT` | `cotal spawn` | Default connector type for a bare spawn (below an explicit `--agent` and the persona\'s `agent:` pin) | `claude` |\n| `COTAL_DEFAULT_PERSONA` | `cotal spawn` | Default persona for a bare spawn | `default` |\n| `COTAL_SKIP_CONNECTOR_SEED` | boot gate | Skip the automatic built-in-connector seed/refresh on a command (`1`); `cotal ext seed` still works. `agent-bearer` skips the gate by name, no flag needed | off |\n| `COTAL_ALLOW_CHECKOUT_SEED` | seed store | Permit a source-checkout CLI to write the operator-global seed store (`1`) after isolating `$XDG_CONFIG_HOME`. Used by in-tree seed smokes that spawn the checkout-shaped `bin/` CLI into a scratch config. Any other value is ignored. The checkout refusal does not name this variable. | off |\n| `COTAL_DETACH_KEY` | `cotal attach` | Detach escape key (ctrl-<char> / ^<char>), matched as the control byte or its kitty / modifyOtherKeys encoding | `ctrl-]` |\n| `COTAL_FEEDBACK_KEY` | `feedback`, connector | Beta feedback key \u2192 keyed intake | none (public intake) |\n| `COTAL_FEEDBACK_EMAIL` | `feedback`, connector | Contact email for the keyless public intake | your git email |\n| `COTAL_FEEDBACK_URL` | `feedback`, connector | Intake URL override (self-hosted) | keyed / public intake |\n| `COTAL_SKIP_ASSIST` | `setup` | Disable the connector debug handoff on a failed step (`1`; for CI) | off |\n| `COTAL_COMPLETE_DEBUG` | `completion` | Print completion-resolution errors to stderr | off |\n| `COTAL_ENROLLMENT_FILE` | foreground `spawn` | Private `0600` file containing one remote enrollment URL; preferred over the environment form | none |\n| `COTAL_MANAGED_HANDOFF_FILE` | `cotal` entry, foreground `spawn` | Private `0600` file holding one managed lifecycle handoff from a delegating runtime; taken and deleted before anything else runs | none |\n| `COTAL_ENROLLMENT_URL` | foreground `spawn` | One remote enrollment URL when a secret file cannot be mounted; conflicts with `COTAL_ENROLLMENT_FILE` | none |\n| `COTAL_SERVE_HEADLESS` | OpenCode runtime | Run the OpenCode server without a foreground TUI (`1`). Stdout gets one `[cotal-serve]` line with the server\'s port and session and no password; a host that drives the server passes its own `OPENCODE_SERVER_PASSWORD` to the launcher | off |\n| `COTAL_HOME` | workspace | Override the machine-home dir for the **mesh registry only** (`meshes/`, `current-mesh`, onboard marker). Does **not** redirect project-root paths (`findCotalRoot` / `.cotal/broker-policy.json`, NATS store, manager/delivery state, auth). Tests that run `cotal up` must also use a temp project root with its own `.cotal/` as `cwd` | `~/.cotal` |\n\n> `--console-port` is a `cotal supervise` flag, not an environment variable; there is no\n> `COTAL_CONSOLE_PORT`.\n\n### Launcher variables\n\nThese are wired into a spawned child\'s environment by the connector / launcher and read back inside\nthe session. They are not operator knobs; listed so you recognize them in a process listing.\n\n| Variable | Purpose |\n|---|---|\n| `COTAL_ID` | Stable agent id chosen by the launcher (static meshes) |\n| `COTAL_MANAGER_INSTANCE` | Stable instance id of the launching manager. User-auth managed calls request a separate control view for this instance; the issuer authorizes the selection. It carries no credential or grant. An unbound session uses the issuer\'s unique authorized selection |\n| `COTAL_ENVIRONMENT` | Opaque provider-issued environment reference published in presence. Read once when the endpoint is constructed; omitted when the launcher sets none |\n| `COTAL_LIFECYCLE_UID` | The incarnation\'s lifecycle UID, minted once per spawn; the session binds its lifecycle-keyed DM/delivery/history consumers by it (its credential pins the same names). Required for an authed launch (`COTAL_CREDS` or user-mode); config parsing fails loud without it. Open mode omits it (the endpoint self-mints per session) |\n| `COTAL_BACKFILL_FLOOR` | The CHAT stream sequence a resumed seat\'s prior incarnation had reached before its preservation cut; the boot backfill reads only what came after it. Set by the manager on a preserved resume, absent on a fresh spawn. Must parse as a non-negative integer; a broken launcher\'s malformed value fails loud rather than silently falling back to a full replay |\n| `COTAL_OWNER` / `COTAL_ACTOR` / `COTAL_SENTINEL_CREDS` / `COTAL_BEARER_CMD` | User-auth launch identity: the agent\'s principal, its sentinel creds path, and the exec-able bearer command; all four together, mutually exclusive with `COTAL_CREDS`. A launcher-spawned seat carries them in its launch material instead of its environment. A remote enrollment\'s bearer argv uses `agent-bearer --exchange-url <https://base>`; the token never falls back to a local service file |\n| `COTAL_LAUNCH_MATERIAL` | Path to this launch\'s private 0600 material file (see [Launch material](#launch-material) below). Carries the broker URL, the creds path, the auth token, the user-auth identity, the required-events flag, and the control token. A PATH, never a secret |\n| `COTAL_CONTROL_SOCKET` | The session\'s local control endpoint path. The MCP server listens on it and the lifecycle hooks connect to it. A launcher-spawned session carries the token that authenticates the first frame in its launch material. A hand-driven Claude Code or jcode session sets this path and `COTAL_CONTROL_TOKEN` itself (see [Launch material](#launch-material)) |\n| `COTAL_BRIDGE_SOCKET` / `COTAL_TOOLS_FILE` / `COTAL_PARENT_PID` | Hermes sidecar plumbing (bridge socket, generated tool descriptors, launcher pid to watch). The bridge socket\'s first frame carries the control token from the launch material (or `COTAL_CONTROL_TOKEN` in standalone mode) |\n| `OPENCODE_CONFIG_CONTENT` | Inline OpenCode config (the injected cotal plugin, highest merge layer) |\n| `OPENCODE_DB` / `OPENCODE_HOME` / `OPENCODE_PORT` / `OPENCODE_SERVER_URL` / `COTAL_OPENCODE_*` | OpenCode server plumbing (home, port, DB, server URL) |\n\nA spawned agent receives a fixed OS execution allow-list (PATH, HOME, TERM, locale, and\nXDG/Windows config directories), the machine-wide `COTAL_*` operator knobs (`COTAL_HOME`, the\nfeedback set, the default-agent pair, the `*_BIN` overrides, the timing knobs), the provider inputs\nits connector declares, and `${VAR}` names an explicitly shared MCP server requires. It does not\ninherit the manager\'s ambient environment. This keeps host-session markers such as\n`CLAUDE_CODE_CHILD_SESSION` / `CLAUDECODE` (and the analogous names other hosts use to mark a nested\nsession), unrelated service secrets, and environment-only capabilities out of seats unless\ndeliberately supplied. A seat\'s transcript/resume behaviour is a property of the seat, never of how\nmany layers up someone once ran `cotal up` inside an agent. Connection material is not in the\nenvironment at all (see [identity & auth](identity-and-auth.md)).\n\nEnrollment inputs are launcher-only secrets. `spawn` removes both enrollment variable names from the\nconnector\'s child environment, and `spawn.env` cannot list them.\n\nPATH is forwarded whole, including entries such as `~/.local/bin` where connector binaries live, so\na seat can still launch after the strip. There is no inherit mode and no opt-in-to-containment flag:\nthe allow-list is the only path.\n\nTo deliberately add an environment name for a spawned agent, declare `spawn.env` in the config file:\n\n```json\n{ "spawn": { "env": ["MY_PROVIDER_API_KEY"] } }\n```\n\nThe listed names are added to the fixed boundary. That is also the opt-in for a host-session marker\na persona has chosen to receive (`CLAUDE_CODE_CHILD_SESSION` and friends). A `COTAL_` name other\nthan the machine-wide knobs above is refused and the seat is not launched: the launcher sets those\nnames for each seat, so a value from the spawning process would become the seat\'s role, model or\nread set. An empty array adds nothing. A space-local `spawn` block replaces the operator-level one\noutright rather than merging, so a local list stays local. No `spawn` block, `"spawn": { "env": [] }`, and `"spawn": {}`\nall add no names. A `spawn` that is not an object, or an `env` that is not a list of non-empty names,\nis refused when the file is read, with the file and the field named, so every spawn and resume is\nrefused until it is fixed.\n\nBe honest with yourself about what this buys: `HOME` is forwarded, so an agent with a shell reads\n`~/.aws`, `~/.ssh` and `~/.config` regardless. The boundary protects what a file on disk cannot hand\nover anyway, and that is more than a list of secret values. Some variables are **capability\nhandles**: they do not contain a secret, they name a live process that will act on your behalf.\n`SSH_AUTH_SOCK` is the sharp one. Inherit it and the agent can ask your `ssh-agent` to sign, which\nmeans it can reach any host or sign any commit that key authorises, and it keeps that power even\nif the private key file is not on disk at all. Nothing under `~/.ssh` has to exist for it to work,\nso "a shell reads `~/.ssh` regardless" does not cover this case. The same shape covers a\n`gpg-agent` socket and the desktop and cloud credential brokers. So the default boundary protects:\nsecrets that live **only** in the environment, such as an `aws-vault exec` or `op run` shell or\nCI-injected values, and the capability handles above, which it removes along with everything else\nit does not name. Real containment is still a sandbox or a VM.\n\nModel discovery is the exception, and it is deliberate rather than an oversight. When the `codex` or\n`opencode` connector enumerates a model catalog (`cotal models`, and the manager\'s selector), it runs\nthat harness with your environment minus Cotal\'s own `COTAL_*`, and it does **not** consult\n`spawn.env`. Those probes are short-lived catalog reads rather than agent seats, so an allow-list\nthat confines a seat does not confine them.\n\n### Launch material\n\nA process environment is inherited by every descendant. A seat launched with its credential, its\nbroker URL and its control token in the environment hands all three to the build it runs, the linter,\nthe third-party CLI, the test suite that reads its broker from the environment. Nothing in that chain\nasked for any of it.\n\nSo a launcher-spawned seat does not get them in its environment. The launcher writes them to a single\n**0600 file inside a 0700 private directory** and exports only its path, as `COTAL_LAUNCH_MATERIAL`.\nThe session reads the launch-material file once at startup. For a managed creds path, it reads the\ncredential once to pin the seat\'s nkey, then keeps the path as a renewal source. A re-signed file is\nread by renewal and by reconnect after the cached credential expires, and a file for a different\nnkey is refused. An unbounded credential has no renewal point and remains a static boot-time value.\nThis is the same shape\n`cotal agent-bearer` already uses for its spawn-time secret: the material rides a file, never argv\n(which is visible in a process listing) and never the ambient environment (which is inherited).\n\nThree connectors drop the path once they have read it, so the shells and tools those seats run\ninherit no reference at all: **pi** and **codex**, whose sessions run in the seat process, and\n**OpenCode**, whose seat process is a shim that starts `opencode serve` (the plugin runs in that\nserver, which is also what executes the session\'s tool calls). Those three also **delete the file**\nat the same moment, along with the private directory that held it. Nothing reads it again, so leaving\nit on disk would only extend how long a copy of the material exists. The directory is only removed\nwhen it is provably the one the launcher wrote: the right filename inside, the launcher\'s prefix on\nthe directory, the directory sitting directly in the OS temp root, and a non-recursive removal that\nfails rather than deletes if anything else is in there.\n\nTwo keep it, and for the same reason in both cases: a process that starts LATER has to read it.\n**Claude**\'s readers are short-lived children, the MCP server and one process per lifecycle hook,\nwhich begin after the session is already running. **Hermes**\' launcher starts a gateway child that\nneeds the control token. For those two, a shell the seat runs still inherits a path to the material\nfile, though not the material itself.\n\nWhat this does: the values are out of every descendant\'s environment, so an `env` dump, a CI log, a\nsuite that defaults its broker from the environment, or a tool handed a credential it never asked\nfor, all stop seeing them. What it does not do: hide the material from a process running as the same\nuser that deliberately opens the file. No environment-level control can, and the same is already true\nof `~/.cotal/auth/creds`. What changes is that reaching the material is a deliberate act rather than\nan inheritance nobody chose.\n\nDriving a connector session **by hand** still works the documented way: set `COTAL_CREDS` /\n`COTAL_SERVERS` (and the user-auth quartet) yourself, and no material file is involved. Setting both\na material file and any of them is refused rather than resolved by precedence: one launch carries one\nidentity plane. `COTAL_LINK` counts as one of them, because a join link carries the server, the auth\nand the space in a single string.\n\nA Claude Code or jcode session also serves a local control endpoint and refuses to start without\none, so a hand-driven one sets `COTAL_CONTROL_SOCKET` to a socket path you choose and\n`COTAL_CONTROL_TOKEN` to a random secret. OpenCode, Codex and pi sessions start without them.\n\n`eventsRequired` is an additive boolean in launch material. The launcher derives it from the selected\nuser-auth registration. A Claude, OpenCode, Codex, Jcode or Pi launch that carries it also sets\n`COTAL_EVENTS` and the workspace root, even when the launcher passed `events: false`. Connector config\nexposes it and the Claude, OpenCode 1.x, Codex, Jcode and Pi startup gates arm on it even when\n`COTAL_EVENTS` is absent. A direct env launch may use `COTAL_EVENTS_REQUIRED=1` only with the complete\nuser-auth quartet. The session refuses if its post ACL does not cover its own `events.<owner>.<actor>`\nchannel.\n\nThe control endpoint is a pair, and **half a pair is refused**. A launch with a control socket path\nand no resolvable token, or a token and no socket path, does not fall back to running without a\ncontrol plane: it fails with a sentence naming which half is missing. The one exception is the\nlifecycle hook relay, which catches that refusal, writes a single warning to stderr naming no values,\nand then does nothing, because a hook that throws is a hook that blocked the session. Failing open is\ndeliberate; failing open silently is not.\n\n## On-disk layout\n\n### Project files\n\nA project\'s state lives in `.cotal/` at the mesh root (found by walking up from the cwd, like `.git`).\n**It is gitignored**; it holds secrets and machine-local process state.\n\n| Path | What it is |\n|---|---|\n| `auth/broker.json` | Broker trust material: the operator seed and the system account (secret; the system-account signing seed is stripped before writing). One per broker, shared by every space on it |\n| `auth/account.<key>.json` | One space\'s own NATS data account and signing seed (secret). One file per space, all signed by the broker above; `<key>` is a stable, case-safe hex encoding of the space name (never the raw name, so two case-differing spaces can\'t collide) |\n| `auth/space.<key>/` | One space\'s user-auth state (IdP pin, issuer keys, owner secret, callout account), present only when that space enables per-user auth. Keyed by the same case-safe hex encoding; pre-hex layouts (`auth/<space>/`) are renamed here on first touch |\n| `auth/creds/space.<key>/<name>.creds` | Per-agent minted NATS credentials, under the segment of the space they belong to - same case-safe hex encoding as the rows above. Pre-segment layouts (`auth/creds/<name>.creds`) are moved here on first touch. The `creds` directory itself stays shared, so a root\'s tenants keep their agent material in sibling segments rather than sibling roots |\n| `auth/server.conf` | Generated nats-server config for the broker (`# Generated by \\`cotal up\\` - do not edit by hand.`). Path is `<projectRoot>/.cotal/auth/server.conf`, not `~/.cotal` unless that is the mesh root. Default bind is loopback (`host: 127.0.0.1`); `--host` on a **stopped** `cotal up` regenerates it. A live refresh does not rewrite this file. The core renderer accepts every space on the broker; `cotal up` currently orchestrates one space per root, so it renders that one space\'s account |\n| `broker-policy.json` | Durable broker **launch** policy (TLS-required cert/key path references, or plaintext). Survives `cotal down` so a bare re-`up` cannot silently drop TLS. Under the project root: **not** under `COTAL_HOME` |\n| `agents/<name>.md` | Persona / agent files ([Agent files](agent-files.md)) |\n| `manifests/<hash>.json` | Manifest-deploy ledger (records of `up -f` / `spawn -f` runs) |\n| `config.json` | Space-local connector config (the override layer above) |\n| `nats.pid` \xB7 `nats.log` | Background nats-server pid + log |\n| `manager.<key>.pid` \xB7 `manager.<key>.log` | Manager (supervisor) pid + log for one space; every line of the log starts with the UTC time it was written (ISO 8601), so a reap can be placed in time without another file; `manager.<key>.delivery-aware` marks a delivery-aware build. `<key>` is the same case-safe hex space key as the rows above, so one root can run a manager per space. A pre-segmentation root-scoped `manager.pid` is still read while it is the only spelling present, and is removed as the new record is written. A start that finds it already removed, by its exiting owner or by a concurrent start, continues. Both spellings present is reported as ambiguous rather than guessed. The manager writes the pid itself, whatever started it, and removes it on a clean stop only while it still names that process. A reader treats the record as a running manager only if the pid is alive **and** the process is a supervisor: a recycled pid belonging to something else is reported as a stale record, never signalled |\n| `delivery.<key>.pid` \xB7 `delivery.<key>.log` \xB7 `space.<key>/delivery.creds` | Delivery daemon pid and log for one space, and its scoped cred (auth mode). `cotal down` and the teardown of a foreground `cotal up` remove the cred once the daemon is confirmed stopped. Per-space and compatible with a pre-segmentation `delivery.pid` on the same terms as the manager row |\n| `web.pid` \xB7 `web.log` | Web dashboard pid + log |\n| `membership.json` \xB7 `membership-*.creds` | Membership feed state + its scoped creds |\n| `setup.log` | `cotal setup` log, one section appended per run. Each entry is one timestamped line: a control character or Unicode line separator in a path or error message is written as a `\\uXXXX` escape |\n\nA command that acts on the whole folder without being told a space reads one off these runtime\nrecords: `<key>` decodes back to the space name, and a space whose record is running wins over\nresidue from a stopped one. Two spaces running under one root is reported rather than arbitrated.\nThis is what lets `cotal status` and `cotal down` work in a folder whose mesh runs with\n`broker: { auth: false }`, where there is no `auth/account.<key>.json` to name the space.\nA record, or a `.cotal` listing, that exists but cannot be read is reported as that read error\nrather than read as absent.\n\n### Machine files\n\nCross-project machine state, so a `cotal spawn` from any directory can find a running mesh. Location:\n`~/.cotal` on POSIX, `%LOCALAPPDATA%\\Cotal` on Windows; overridable with `COTAL_HOME`.\n\n`COTAL_HOME` overrides **this tree only** (registry + current pointer + onboard marker). It is not a\nfull workstation sandbox. Broker launch policy, the JetStream store, pidfiles, and auth live under\nthe **project** `.cotal/` found by walking up from the cwd ([Project: `.cotal/`](#project-files)\nabove, including `broker-policy.json` on TLS meshes). A probe that sets `COTAL_HOME` alone and runs\n`cotal up --tls-cert \u2026` from a directory whose walked root is the operator home still writes those\nproject paths on the live machine.\n\n| Path | What it is |\n|---|---|\n| `meshes/space.<key>.json` | Registry of running meshes: one file per broker `cotal up` started (server URL, root path, mode, TLS-required client intent when recorded, attach bind host and live-session ceiling when the operator set them); `<key>` is the same case-safe hex encoding of the space name, and the record\'s own `space` field is authoritative |\n| `current-mesh` | Default space a bare `cotal spawn` joins (set by `cotal use`) |\n| `onboarded.json` | First-run marker (with `ONBOARD_VERSION`) that flips setup between first-run and status-card |\n| the Claude plugin marketplace | The installed `cotal-mesh` plugin assets |\n\n### Configuration files\n\nDistinct from `~/.cotal`. Location: `$XDG_CONFIG_HOME/cotal`, else `~/.config/cotal` on POSIX, or\n`%APPDATA%\\Cotal` on Windows.\n\n| Path | What it is |\n|---|---|\n| `config.json` | Operator-level connector config (the base layer above) |\n| `extensions/` | `cotal ext` install prefix: its own npm root (`node_modules`) plus an `extensions.json` provider/command-display cache. Built-in connectors install here too, seeded on first run |\n| `seed/` | Built-in-connector seeding state: the `ever-seeded` authority (+ durable backup), the init witness, the version stamp, the crash cursor, and `store/<version>/<name>` (the stable payloads `ext add --install-links` reifies each seeded connector from) |\n\nBoth `extensions/` and `seed/store/` are operator-global: shared by every space, project directory, and\ncheckout on the machine, and moved only by `$XDG_CONFIG_HOME` (a fresh project dir isolates `.cotal/`,\nnot these). `COTAL_HOME` does not relocate them. A CLI running from a source checkout (`pnpm cotal`,\n`tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of those, identified by a `bin/` package\nroot next to `implementations/` or `pnpm-workspace.yaml`) refuses to write, stamp, or\ngarbage-collect that store: the refusal names the store path, the generation it declined, and\n`COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout. An entry that\ncannot be proven as a released `cotal-ai` install is refused the same way. Isolating\n`$XDG_CONFIG_HOME` (on Windows, `%APPDATA%`) keeps a checkout off the installed config but does not\nlift the refusal by itself. A\nreleased install or an `npx` unpack still seeds as before. The in-tree seed smokes that must seed\nfrom a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` against an isolated config; an\nopt-in write still records that checkout path in `seed/stamp.json` as `writtenBy`. The reconcile\nnames on stderr both the store payloads it writes and any old generation it removes, so a\nmachine-wide re-seed or cleanup is visible when it happens. Those lines are provenance output. When a\nstderr write fails, at once or after waiting in a full pipe, the line is printed on stdout with the\nerror and the reconcile still completes. If stdout fails too, the reconcile still completes and the\nrun exits 1 instead of 0. A line still waiting in a full stderr pipe when the run exits, as when the\nCLI exits on a closed stdout, is lost and also makes the run exit 1. Node does not say which stderr\nbytes are still waiting, so a line that had to wait and got through just before the exit also makes\nthe run exit 1 when later stderr output is still waiting. A run whose stderr is closed or redirected\naway at launch keeps the write and loses the line.\n\nFor how `cotal setup` populates the machine state and the plugin, and how the built-in connectors are\nseeded as removable extensions, see [setup internals](setup-internals.md).\n'
62544
62604
  },
62545
62605
  {
62546
62606
  "slug": "connect-claude",
62547
62607
  "title": "Connect Claude",
62548
62608
  "kind": "Guide (informative)",
62549
62609
  "summary": "The Claude Code connector turns a real claude session into a Cotal mesh peer.",
62550
- "body": "# Connect Claude\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nThe Claude Code connector turns a real `claude` session into a Cotal mesh peer. A bundled\nplugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the\nmesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the\nmesh.\n\nThe shared mesh runtime (agent, `cotal_*` tools, hook relay) lives in\n[`@cotal-ai/connector-core`](../extensions/connector-core); this connector is the thin\nClaude-specific adapter over it. Its lifecycle hook imports the relay from the\n`@cotal-ai/connector-core/relay` subpath, so each hook process loads the relay and its environment\nreaders and none of the NATS client, zod or yaml. Siblings: [OpenCode](connect-opencode.md) (beta),\n[Hermes](connect-hermes.md) (alpha), [pi](connect-pi.md) (alpha); the\n[Connectors](connectors.md) matrix compares them feature-by-feature.\n\n## Set up\n\n```bash\ncotal setup # one-time: installs the plugin, seeds one agent; launches nothing\ncotal up # brings up the mesh + delivery daemon + a detached manager\n```\n\n`cotal setup` installs the cotal plugin (so the repo's Claude sessions get the `cotal_*`\ntools), shares your own MCP servers with spawned sessions on its first run (see\n[Sharing your MCP servers](#sharing-your-mcp-servers)), and seeds one `default` persona; `cotal up` brings up the local stack so\n`cotal spawn --detach` / `cotal_spawn` work right away. Re-running either is idempotent.\nThe install mechanics and the invariants behind them are in\n[setup internals](setup-internals.md).\n\n`cotal setup` also installs Cotal's authored Agent Skills (`SKILL.md`, the agentskills.io format) for\ncoordinating agent teams (today `team-topology`), from one canonical source, on two channels:\n\n- **Claude Code** gets a second, skills-only plugin, `cotal-skills`, from the same `cotal-mesh`\n marketplace, at **user scope** (machine-wide). The Claude connector declares and implements this\n setup provider, including the marketplace assets and native plugin commands; the base CLI only passes\n the vendor-neutral Agent Skills directory. The plugin carries no code and no core dependency,\n and uninstalls on its own with `claude plugin uninstall cotal-skills --scope user`. Its plugin version\n is stamped from the running CLI release, so an upgrade + `cotal setup --skills` runs `claude plugin update` and\n the deployed install actually gets the new skill. `cotal setup` installs it on first run and on repeat\n runs, so upgraders are not left behind. The same provider reports the plugin and skills plugin rows\n in `cotal status`, which point a stale or missing skills plugin at `cotal setup --skills`.\n- **Every other harness** (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor\n `~/.agents/skills/` directory convention, which has no remote index, so `cotal setup` **reconciles** it\n (and `cotal setup --skills` does only that):\n it installs/updates each Cotal skill, backs up a copy you have edited to `SKILL.md.bak` before\n replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched;\n your own or third-party skills there are left alone. `cotal status` reports whether the drop is current,\n stale, missing, or has a retired skill to reconcile, and names `cotal setup --skills` as the remedy. This is the working cross-vendor path.\n\nCotal also generates an [Agent Skills discovery index](https://cotal.ai/.well-known/agent-skills/index.json)\non cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet,\nnot a channel to rely on today.\n\n## Spawn a session\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn dave --detach # supervised: the manager runs it in a PTY\n```\n\nA spawn resolves a persona from `.cotal/agents/<name>.md` ([agent files](agent-files.md));\n`--model`, `--variant`, `--cwd`, `--prompt`, ACL overrides, and `--share-tools` apply to\nboth forms ([run a mesh](run-a-mesh.md) has the full resolution rules). The session joins\nwith identity from its environment and auto-registers presence by the time it is\ninteractive.\n\nInside the session, the agent orients with one read-only tool, `cotal_orientation`: its\nidentity, the channels it reads and may post to, its capabilities, the tools available,\nwho's present, and unread counts. The full tool surface is the\n[MCP tool catalog](mcp-tools.md). In auth mode the team-supervision tools\n(`cotal_spawn` / `cotal_persona` / `cotal_personas`) are injected **only** for personas declaring\n`capabilities: [spawn]` (the same grant that opens the privileged control subject), so an\nagent's toolset matches its declared capabilities. `cotal_run` is gated separately by\n`run`; use `capabilities: [spawn, run]` for both. Fresh setup defaults include both.\nSee [workflow tool setup](workflows.md#from-an-agent-session) for a first run and missing-tool checks.\nClearing retained history is\noperator-only ([run a mesh](run-a-mesh.md)), never an agent tool.\n\n## How it binds\n\nClaude Code exposes four integration surfaces, and three of them collapse into a single\ndual-purpose MCP server:\n\n| Surface | Mechanism |\n|---|---|\n| Outbound, ambient | `http` lifecycle hooks \u2192 POST to the connector (presence, activity) |\n| Outbound, deliberate | MCP tools `cotal_send` / `cotal_dm` / `cotal_anycast` (+ `cotal_feedback`) |\n| Inbound, pull | MCP tool `cotal_inbox` (same server) |\n| Inbound, push | Channel nudge + hook drain (below) |\n\nThe manager launches the *real* `claude` (no wrapper):\n\n```\nclaude --strict-mcp-config --mcp-config '{\"mcpServers\":{\"cotal\":{\u2026}}}' \\\n --dangerously-load-development-channels server:cotal\n# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_CHANNEL=1, plus claude's documented auth vars\n```\n\n- **Model auth.** Locally, `claude` still reads macOS Keychain / `~/.claude`. In a container or\n CI there is no Keychain, so the connector forwards the documented credential set:\n `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), `ANTHROPIC_API_KEY` /\n `ANTHROPIC_AUTH_TOKEN`, and the cloud-provider flags plus their credential vars. Host-session\n markers (`CLAUDE_CODE_CHILD_SESSION`, `CLAUDECODE`) stay out so a nested seat still saves a\n transcript. See [Deploy](deploy.md).\n- **Persona privacy.** The persona body is written to a private file and Claude receives only\n `--append-system-prompt-file <path>`. The body never appears in the spawned process argv. The\n carrier is a 0600 file inside a 0700 directory on POSIX, with equivalent owner-only ACL hardening\n on Windows. That is OS-user isolation: any process running as your user can read it while it\n exists. The manager or the foreground `cotal spawn` removes it, and the shared-server MCP config\n file, once it has proved the `claude` process gone. If the launcher is killed first, a watcher\n started beside `claude` removes them when `claude` exits.\n- **MCP servers.** `--strict-mcp-config` ignores every ambient MCP source, so a spawned agent\n loads the cotal server plus the servers the cotal config shares. First-run `cotal setup`\n fills that list with your own user-scope servers, so a spawned session has the tools you know\n (see below).\n- **Installed plugin.** The plugin is installed once (`claude plugin install\n cotal@cotal-mesh --scope local`) because its hooks bind only to an *installed* plugin.\n The repo's `.claude-plugin/marketplace.json` lists the committed plugin tree under\n `claude-plugin/`, which each release regenerates with the built bundles, the skills and the\n release version, so an install from the repo or from a pinned commit runs without a build\n ([Release](release.md)). `cotal setup` (npx, no clone) materializes the same marketplace under\n `~/.cotal/claude-plugin/` from the installed CLI (each plugin dir is rebuilt from scratch and\n atomically replaced, never merged, so no stale file rides in). The\n `cotal-skills` plugin installs from that same marketplace at user scope (`claude plugin install\n cotal-skills@cotal-mesh --scope user`); its manifest and install behavior ship inside the Claude connector, and\n its version tracks the CLI release so updates land.\n- **Identity-gated.** Connector code requires `COTAL_NAME`, `COTAL_LINK` or `COTAL_AGENT_FILE`.\n A plain `claude` with none of them never joins, so your own sessions in a repo do not appear\n as stray peers. Its MCP server still answers `initialize` and lists one static tool,\n `cotal_how_to_join`, which explains how to launch a session on a mesh. It builds no mesh\n agent, opens no broker connection and binds no control socket.\n- **Hands-free.** The dev-channels flag prints a one-time confirm prompt. The runtime waits for the\n dialog title in normalized terminal text and presses Enter once when it appears, so startup speed\n does not affect a supervised launch. The PTY runtime reads the child's output, and the tmux, cmux,\n Orca and Herdr runtimes read the pane's screen. If the declared prompt never appears within 15\n seconds, the seat ends with a bounded error naming the unmatched prompt instead of hanging\n silently or answering another dialog. The PTY runtime writes that error to the seat's output, and\n the other runtimes write it to the manager's log.\n- **Trusted directory.** Claude opens a directory it has not trusted on its workspace-trust dialog,\n and the dialog's default answer exits. No one is at a supervised seat to answer it, so a launch\n whose directory the manager host's own Claude does not trust is refused before it starts, naming\n the directory and the dialog. Trust is read as Claude reads it: trust given to a parent directory\n counts up to the root of the directory's own Git repository, and a linked worktree shares the trust\n of its repository's main checkout. Open `claude` in that directory on the manager host once and\n trust it, then spawn again. A foreground `cotal spawn` shows the dialog in your own terminal instead.\n\nInbound mesh messages arrive in context as\n`<channel source=\"cotal\" from=\"bob\" kind=\"dm\" \u2026>\u2026</channel>`: each meta key a tag\nattribute the agent can read for routing.\n\nThe consuming connector holds a crash-safe claim for its broker target, space, principal and\nlifecycle on this OS user account. A second patched session on the same host refuses before\npulling that inbox, even when its launch files and control socket differ. Reconnect keeps the\nclaim. Hooks and non-consuming helper connections do not claim it. The control listener binds\nbefore the broker connection and never removes an existing socket path.\n\nThis is cooperative same-host containment. Other hosts, broker URL aliases and older clients\nare not fenced. Preserve the existing session before choosing a distinct actor for a new\nlaunch. An uncertain shutdown keeps custody until the consuming process exits.\n\n## How messages reach the session\n\nDurable deliveries land in the connector's inbox from JetStream consumers\n([SPEC \xA78](../SPEC.md#8-nats--jetstream-binding)); live channel traffic can instead arrive\nthrough an at-most-once core subscription. A durable message sent while the agent is busy\nor offline waits on the stream. Two things move a message from inbox to model; one\ndelivers, the other only wakes:\n\n- **Hook drain (delivery).** `SessionStart` / `UserPromptSubmit` hooks read automatic inbox items and\n inject them as `additionalContext`. This is the single authoritative path: deterministic and works\n on any Claude Code build. Quiet ambient is excluded and stays buffered for `cotal_inbox`.\n A message is **acked only once the hook reply carrying it has cleared both legs of its journey**:\n the connector's control socket to the hook process (which gives up after 2s), and the hook\n process's own stdout to Claude Code (which it force-exits 1s after starting to write). The relay\n sends a receipt back down the control socket from that stdout write's callback, and only on a\n clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt,\n not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a\n broken pipe, leaves the message un-acked and JetStream redelivers it. What this does *not* prove is\n that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is\n reported written the moment the kernel takes it. That residual is why the path errs toward\n at-least-once rather than treating a confirmed write as a confirmed read. Acking when\n the reply was merely *formatted* meant a lost reply was a lost message: it was already marked\n handled, so its own redelivery was silently acked on arrival.\n A hook whose handler throws still returns an empty reply so the session is never blocked, and\n that reply carries nothing, so it commits nothing: the batch it had started to surface stays\n un-acked and goes out on a later frame. The seat also drops any `turn-pending` row that breaks\n the manager contract, such as one with no integer deadline, and says so once in its log. A reply\n with no `turns` array changes nothing: the seat keeps the turns it already holds.\n This errs toward **at-least-once**: if a reply lands but its confirmation does not, the batch is\n surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops\n the peer answering at all.\n- **Channel nudge (wake).** An arriving message fires a `notifications/claude/channel`\n event that wakes an *idle* session into a turn, so the drain runs *now* instead of at\n the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a\n bounded backoff while anything is still pending. For an idle session it is the only wake source,\n so dropping it means silence until someone types. When the channel becomes active, the connector\n first re-fires a focus mention remembered during startup, otherwise one buffered wake. A rejected\n push keeps its bounded retry, and JetStream redelivery remains the durable backstop for unacked\n inbox items. Neither a redelivery nor that retry repeats a nudge already pushed for that message,\n whether the message had its own nudge or was counted in a batch one, so a session held in a long\n tool call gets one nudge per message. Once a hook frame carries the message, or the push that\n announced it fails, its next redelivery nudges again, so a reply that never reached Claude Code\n still recovers. If the channel cannot run at all, delivery still waits for the next hook. Live-only\n traffic has no durable retry.\n\n**Two priority tiers.** A *directed* message (DM, anycast, or a channel message that\n`@mentions` us) always nudges. *Ambient* channel chatter does not nudge mid-turn; it\naccumulates, and the `Stop` \u2192 idle transition fires one batch nudge so the backlog drains\ntogether.\n\n**Constraints (accepted).** Channels are a Claude Code research preview (\u2265 v2.1.80;\npermission relay \u2265 v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a\ncustom channel needs the `--dangerously-load-development-channels` launch flag. The hook\ndrain does not depend on any of that; the channel only adds \"wake me when idle.\"\n\nThe same channel also relays **tool-permission requests** onto the mesh, so a peer (a\nhuman at the CLI, a policy node) can approve or deny an agent's pending tool call through\nCotal rather than a per-terminal prompt.\n\n### Attention\n\nAn agent picks how aggressively peer traffic reaches it with\n`cotal_status({ attention })` (three modes, orthogonal to presence):\n\n| arrival | open (default) | dnd | focus |\n|---|---|---|---|\n| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |\n| channel `@mention` | wake + inject | wake + inject | ack-drop; wake to *pull*; not injected |\n| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via `cotal_inbox` |\n\nPer-channel overrides refine this: **quiet** (delivered, never wakes; `@mention` still\nwakes) and **muted** (dropped on receive, mentions included; DMs/anycast unaffected), set\nwith `cotal_channel_mode` or as agent-file defaults (`quiet:` / `muted:`,\n[agent files](agent-files.md)). A per-channel override is the final word for that channel.\nQuiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other\nconnector-driven turn. `cotal_inbox` explicitly surfaces and clears it. A quiet-channel\n`@mention` remains automatic and injects normally.\n\nA pull is bounded too, and clears only what it hands over. One `cotal_inbox` call carries at most a\nreceivable window (direct messages and role requests first, then channel traffic, replayed history\nlast); whatever does not fit stays buffered, is named in the reply, and comes back on the next call.\nA message too large for one whole response is delivered in parts: once no smaller mail is waiting,\neach call carries its next part, and it is cleared only after its last part goes out, because clearing\nwhat was not handed over is the loss this bound exists to stop.\nThat matters most on the path where it is easiest to lose mail: reconnecting brings a channel-history\nreplay with it, so the largest payload and the least expendable message arrive in the same read.\n\nThe local inbox is bounded. On pathological overflow it evicts pull-only items first, then other\nchannel traffic, and a direct message or role request only when the whole buffer is directed mail.\nIt never evicts a message the connector is handing to the session or a turn is still carrying.\nAn evicted channel item is acknowledged. An evicted direct message or role request never is, and\nthe broker redelivers it after the ack wait until a redelivery finds room. A direct message stays\npending on the session's DM durable, where `cotal deliver pending <name>` counts it. A role request\nstays on its role's shared queue, which that command does not read. A full inbox therefore delays\ndirected mail until the session drains it.\nIf the bounded live/durable classification guard also fills, the connector fails closed:\notherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall\nstill take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall\nquiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it\nas incomplete rather than risk resurfacing excluded content. Recall cannot tell one message with an\nempty id from an identical one with another disposition, so in focus such a message is held in the\nlocal inbox as pull-only instead of being dropped, and a mention of it still wakes the agent. When\nthe session settles an id-less copy, it reads the chat stream's last sequence. Identical copies\narrive in stream order, and those reads can answer out of order, so the first read that can see the\ncopies binds them in arrival order, latest first, each to the latest unbound stream copy at or below\nthe lowest sequence read for it or any later identical copy.\nWhile the connection stays up, every stream copy at or below that sequence reached the session\nfirst, so a later identical copy sent during a reconnect gap is above it and stays unbound. A copy\nthat arrives while a read runs may not be in that read, so it binds nothing there and recall reads\nthe channel again, up to three times; if it still could be a stream copy in the last read, recall\nleaves that stream copy in the stream and reports the channel as incomplete. A settled copy a\ncomplete read cannot bind is behind the focus start or out of retention, and is forgotten. Recall\nhands back into the inbox only the stream copies nothing is bound to, such as one sent during a\nreconnect gap or one the inbox evicted on overflow, and `cotal_inbox` hands each over once. Overflow\nfrees only the evicted copy, held or quiet, and a copy no read has bound yet keeps its place in\narrival order, so an identical muted copy stays out of recall. When\nthe inbox is full, recall leaves them in the stream for a later call and reports the channel as\nincomplete. A history read that fails, or a channel with replay off, settles nothing and is reported\nas incomplete, and recall calls run one at a time. If the sequence read for a settled copy fails,\nor answers only after the connection dropped, recall skips that channel for the rest of the focus\nperiod and reports it as incomplete. Recall cannot tell a late copy of a message it handed back from\na new identical message, so every copy takes its own disposition: a new identical quiet mention is\nstill delivered automatically, and a late copy can surface a second time.\nA recalled message that already went out in part is read to its last part, even if an exclusion\nlands after its first part. One session reads its inbox one call at a time: a `cotal_inbox` call\nthat overlaps another waits for it to finish, so neither decides from a view the other has already\nmoved past.\nIf the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the\nsession rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are\nunaffected.\n\nAttention is **advisory UX, not a boundary**: any peer can wake a dnd/focus agent by\nnaming it, and `muted` means \"I opted out of receiving\", not \"the channel is blocked\";\nthe broker still authorizes and delivers. Focus's real effect is shrinking the\nuntrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).\nIt resets to **open** on `SessionStart`, so a restarted agent never stays silently deaf.\nYour attention is mirrored into presence so peers can see it.\n\nWhatever does reach a turn is framed so a peer cannot write the frame. A line that begins at column\nzero is written by the connector; one message is one line plus indented continuations, with the\nsender inside a single bracket pair. A message body, a sender name and role, and a service or\nchannel label are all peer-controlled, so each passes through the same neutralization the\n`cotal_inbox` reply uses: no line break a splitter may honour and no bracket survives into a\nrendered attribution. This matters more for an injected block than for a reply, because the agent\ndid not ask for it and so never had the chance to distrust it.\n\n## Presence mapping\n\nThe connector wires a small subset of Claude Code hooks to presence states; presence is\ncoarse, and \"what it is doing\" rides on activity updates. Presence is **advisory**: a presence\npublish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook\nfrom delivering messages or flushing held ones.\nA `SessionStart` during an open turn, including compaction, preserves the current `working` or\n`waiting` status until `Stop`, `StopFailure`, or `SessionEnd` closes the turn.\n\n| Hook | \u2192 state |\n|---|---|\n| `SessionStart` | `idle` only when no turn is open (join; surfaces the inbox; captures the live model into `meta.model` when no pin) |\n| `UserPromptSubmit` | `working` (turn starts; surfaces the inbox) |\n| `PreToolUse` | no change; records *what* is about to run, so a permission wait can name it |\n| `Notification` (`permission_prompt` / `agent_needs_input`) | `waiting` with condition `approval` / `input` (activity leads with the pending tool, e.g. `Bash: git push \u2026`) |\n| `Stop` / `StopFailure` | `idle` (turn done / died on an API error; flushes anything held while busy). `StopFailure` also relays Claude Code's native error value as `condition.source` and maps it to the closed condition vocabulary. On the [event plane](#event-plane) it closes the run with `RUN_ERROR`. |\n| `SessionEnd` | `offline` (graceful leave) |\n\nThe connector also leaves gracefully when its stdin closes. An MCP client closes it to end the\nsession, and a killed `claude` closes it with no `SessionEnd`, so a dead session drops off the\nroster instead of staying on it as a live peer.\n\n`StopFailure` maps `rate_limit` and `overloaded` directly; auth and credential failures to\n`auth`; account and billing failures to `billing`; `invalid_request` to `request`;\n`model_not_found` to `model`; `server_error` to `server`; `max_output_tokens` to `context`; and\n`unknown` to `failed`. The native value remains in `condition.source`.\n\nHooks are relayed over the connector's **authenticated** local control endpoint (per-user\nsocket + per-launch token, constant-time checked), so a local process that finds the path\nstill can't drive presence or stop the agent. The full Claude Code hook-event list lives\nwith the adapter:\n[`extensions/connector-claude-code`](../extensions/connector-claude-code/README.md).\n\n## Event plane\n\nA spawned session publishes a **structured** account of what it\ndid: run boundaries per turn, assistant text, reasoning, and each tool call with its start\nand its end. Not prose about the work, the work itself, in a vocabulary a program can\nread. The launcher sets `COTAL_EVENTS` by default; pass `--no-events` to opt out on an unrestricted\nspace. A user-auth registration with `policy: { events: \"required\" }` carries `eventsRequired` in the\nprivate launch material, so the connector arms even without `COTAL_EVENTS`; `--no-events` is refused.\nA hand-driven user-mode session may carry the same decision as `COTAL_EVENTS_REQUIRED=1`. Its own\npublish grant must cover `events.<owner>.<actor>` or the connector refuses before joining. An unmanaged\nsession with no launch material and no required-policy fallback keeps the generic default behavior.\n\nIf the event plane stops for good, the space's policy decides what happens to the seat, on every\nconnector. On a space that requires events the seat stops and leaves the mesh. On any other space\nit keeps running without events, and the connector log records `AG-UI emitter stopped` with the\nreason. For Claude Code the connector is the MCP server: it leaves the mesh and exits with code 1,\nand its stderr carries that line.\n\nA new session includes its first run even when Claude writes a positional startup prompt before the\nconnector receives `SessionStart`. That from-zero read is keyed only to Claude's explicit\n`source: \"startup\"`; resumed, forked, cleared, and compacted sessions adopt at the transcript boundary\ncaptured at that adopt, before the mesh link connects, so nothing Claude appends while the connector\nis still starting up lands behind the cursor and is silently dropped. Crash recovery follows the\ncursor already stored in the event write-ahead log, regardless of the new process's startup label.\n\nClaude starts each hook in its own process, so a prompt or stop relay can reach Cotal before the\n`SessionStart` relay. The connector holds those event flushes and the terminal until `SessionStart`\nsupplies the source, then enqueues adopt, flush, and close in that order.\n\n`SessionStart` can also run before the connector process has bound its local control socket. The hook\nthe `SessionStart` relay retries only transient pre-connect listener errors, with capped backoff\ninside its existing two-second budget. Later hooks and permanent local faults still fail open\nimmediately. Once a socket has connected, a broken exchange is not retried: the connector may\nalready have handled the frame, so replaying it could apply one lifecycle event twice.\nThat retained `SessionStart` can itself arrive before Claude creates the transcript path. A genuinely\nnew startup waits up to five seconds for that file with capped backoff, and the same deadline bounds\none stalled file read; expiry fails loud instead of silently losing the first run. A forked session\ngets the same wait, because Claude copies the parent transcript into the fork's own file after the\nhook, and then adopts at the end of that copy. Resumed, cleared and compacted starts and recovered\ncursors still require their existing source at once.\n\nTool arguments (`TOOL_CALL_ARGS`) and tool results (`TOOL_CALL_RESULT`) are not republished\nonto this channel. The durable emitter drops those events before they are written to the\nwrite-ahead log, because this channel's read ACL is not the ACL the tool ran under. Content is\nmandatory on both kinds, so the event is suppressed rather than emptied or replaced with a\nplaceholder. Tool start and end still go out. A restart that finds a pending pre-fix frame\nstill carrying those kinds HALTS rather than republishing it.\n\nThe channel is **`events.<owner>.<actor>`**, named after the session's principal. What the actor\nhalf is depends on the mesh, and the difference matters when you go looking for it: on a static mesh\nit is a key the manager allocated, never the display name, so two live agents sharing a display name\ndo not share a stream; on a user-auth mesh it is the agent's own name, because that is what the\nledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights\non that channel alone. A spawn\nthat asks for a *different* agent's event channel is refused at the door rather than granted, since\nthat channel is that session's event stream. The same rule runs on restart: a manager\nresume document that names another agent's event channel is refused rather than adopted, because the\nmanaged row is re-armed from that document and the credential is re-minted from the row.\n\nThe rule reads a **concrete** channel, two principal tokens and nothing else. A pattern such as\n`events.<owner>.>` is not an event channel to it and passes untouched, governed by ordinary ACL\nauthority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.\nThat is deliberate, because the pattern is the form an operator writes on purpose for an observer,\nand it is worth knowing rather than assuming the fence is total.\n\nTo let something else read a plane, grant it out of band. The refusal prints the command for the\nmesh it is running on, spelled out in full, and only that one.\n\nOn a **user-auth** mesh:\n\n```bash\ncotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''\n```\n\nEvery field, deliberately. `actor grant` is an upsert of the whole row, so it refuses a grant that\nleaves off any of the three ACL flags. Only `--full` turns an omitted flag into the wide default\n(`>` read, `>` post, `spawn,role:default` scope), which is the opposite of what a scoped watcher is for.\n\nOn a **static** mesh there is no actor ledger for `actor grant` to write to, and the refusal says\nso; mint the reader instead:\n\n```bash\ncotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provision\n```\n\nThe **agent** profile, not the observer one. `mint` reads `--allow-subscribe` only for that\nprofile, and refuses it anywhere else: `--profile observer --allow-subscribe <channel>` exits\nnon-zero and writes no creds file, because the observer profile carries a fixed read set over the\nwhole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the\nreader needs, since an authed consuming endpoint refuses to start without one.\n\nOn an **open** mesh there is nothing to grant: the mesh has no credentials and no ACLs, so any peer\nthat lists the channel reads it, and the refusal says so instead of naming a command. The\nown-channel rule still applies there, because a spawn is not the place to hand out a read on\nanother agent's tool inputs and outputs.\n\nTwo things a reader has to do that are not obvious, both on `CotalEndpoint`. It must pass the event\nchannel in `channels`: an endpoint reads the channels it lists, so one constructed without\nthe event channel joins nothing and the frames never arrive. And it reads history with `readHistory(channel)`, the delivery daemon's mediated read, not\n`channelHistory(channel)`: a scoped credential is denied the ad-hoc consumer the direct read\ncreates, by design. `cotal console` and the web console already do both.\n\nThe `<owner>.<actor>` pair is the session's principal. On a user-auth mesh the actor half is the\nagent's own name, so the channel is `events.<your-owner>.<agent-name>`. On a\nstatic mesh the owner half is the literal `local` and the actor is a key the manager allocated, so\nthe channel is `events.local.<key>`; the spawn reply carries that key as `id`. Note\nthat `cotal console` and the web console keep event channels out of their channel lists on purpose,\nsince a plane is a machine feed rather than a conversation; they draw the frames when you open the\nchannel by name.\n\nThe rule governs the manager's doors, which are the ones a caller other than you can reach. A\nforeground `cotal spawn` on your own machine mints from your own signing material, so it can still\ngrant any channel you name: that is the out-of-band grant, not a way around the rule.\n\n**Failed turns publish run errors.** Claude Code decides for itself\nwhether a turn finished or died and fires one of two hooks accordingly, so the connector relays that\ndecision rather than making one of its own: a turn that ended on an API error ends its run with\n`RUN_ERROR` carrying the fixed message `run failed` and no code. Neither the detail Claude Code\nreported nor its error kind is published there: both are upstream values that can echo your prompt or\ntool output, and the events channel has a different read ACL. The error kind still reaches presence\nas the agent's condition (`rate_limit`, `auth`, `billing` and the rest). A turn that ended normally still\nends with a run-finished event carrying no outcome, which says the turn ended and does not claim it\nsucceeded.\n\nEvents are written to a per-session write-ahead log before they are published, so a hook that fires\nafter a restart resumes at the cursor it left rather than replaying or skipping, and a run that was\nopen when the session stopped is closed rather than left dangling.\n\nA delayed initial connection or a broker reconnect pauses event publication without closing MCP.\nThe recorder waits for both the mesh binding and its transport; unpublished records stay in the\ntranscript until the connection recovers. Stopping the connector cancels that wait. Recording errors,\nincluding a conflicting transcript or damaged event state, still stop a session whose policy requires\nrecording.\n\nOne channel carries **every session of one agent**, because it is named after the principal and not\nafter the session. Alongside the per-session logs the connector keeps one small record per principal,\nholding the last sequence the broker assigned on that channel, so a new session continues the stream\nits predecessor left instead of starting again from nothing. Both live under the events state root\n(`COTAL_WORKSPACE_ROOT`), and neither is something you edit by hand.\n\nA **missing** record is not a fault: the connector rebuilds it from the session logs beside it,\nwhich is how an agent that was already running before this record existed keeps its stream. That\nrebuild stops if any one of those session logs is damaged. Unreadable, not valid JSON, and written\nfor a different principal all count, and so does a session directory or a log that is a link rather\nthan the real file the connector wrote, or a log that has more than one name. A tip taken from the\nrest would be too low, and it would stop publication later with nothing left to point at the cause.\nThe connector names the file instead, and the only way past it is the directory removal described\nbelow, under the same condition. A record that **disagrees with the broker** is a fault, and the\nconnector stops publishing and says why rather than guessing. A record that **moved while a session\nwas writing to it** is refused the same way: it means something else wrote the principal's record,\nand the connector reports which value it held and which the file holds rather than writing over the\nlater one. There is no command to clear it. The state is the principal's directory under the events\nroot, and clearing it by hand means removing that directory whole: the sequence, the cursor and the\nper-session logs only mean anything together, so removing part of it leaves a state the next start\nrefuses. Removing it is only half a remedy, and the half that comes first is the channel. The\ndirectory is where the agent's memory of the tip lives, not the tip itself, so on a channel that\nstill holds frames the next session opens expecting an empty one and stops on the same\ndisagreement, with the logs a tip could have been rebuilt from now gone. Purge the channel first,\nthen remove the directory.\n\nReading it: `cotal console` and the web console draw event frames directly. A frame carries no text\npart by design, so a surface that renders a message as flat text shows a marker instead of prose.\n\n**On a per-user-auth mesh, the default event plane needs the spawner's grant to cover the channel.** The event\nchannel is added to the child's publish set, and delegation only narrows: an agent may hand down\na subset of what it holds and no more. So a peer-initiated spawn is refused unless the\nspawning identity's own grant already covers the child's event channel. The refusal prints the\nexact `cotal actor grant` command that widens it. An operator launch, whose chain reaches an\nadmin-scoped or roster row, is unaffected. Passing `events: false` is the explicit opt-out.\n\nArming the event plane through a typed spawn request (`manager.spawn` with `events`, including\nthe CLI's `cotal spawn --detach --events`) is allowed on a user mesh when the child runs under\nthe caller's owner. This also satisfies a registration policy that requires the plane. A spawn\nunder another owner still needs the caller's admin tier. Without it, an explicit request or a\nrequired plane is refused before provisioning. A silent non-owner caller in a space without that\npolicy has the plane disarmed with a notice in a successful reply. Later provisioning can still\nrefuse the spawn. The child's own-channel rule and the ledger's delegation envelope still apply.\n\n## Resume a session\n\n`--resume <session-id>` pulls an existing Claude session, its context and transcript,\ninto the mesh. It **forks**: Claude mints a *new* session id from that transcript\n(`--resume <id> --fork-session`), so the meshed agent gets its own session and the\noriginal is untouched.\n\n- `cotal spawn --resume <id>` (foreground) is the primary surface: the transcript is on\n *your* machine, and errors are Claude's own stderr, inline.\n- `--detach --resume <id> --on <instance>` carries a session held on *your* machine to that\n manager instance, which may run on another host. The CLI finds the transcript under your\n Claude config (`~/.claude`, or `$CLAUDE_CONFIG_DIR`), sends it through a JetStream Object\n Store bucket only that instance reads, under a writer credential pinned to that one transcript,\n and prints `carried session <id> to <instance>:\n sha256:<hex>, <sent> of <size> bytes sent in <chunks> chunks`. Without `--agent`, the CLI first asks that\n instance which harness the persona resolves to, so a persona with `agent: claude` is carried even\n when your `COTAL_DEFAULT_AGENT` names another connector. A re-run of the same bytes\n sends nothing, and an interrupted carry continues where it stopped. The seat forks it in a\n private Claude home under the manager's `.cotal/seat-homes/`, which no other seat's Claude\n lists or finds, and which is removed when the seat stops. When Claude starts the fork, the seat\n records the SHA-256 of the transcript it read from its own project; the manager stops a seat\n whose record names other bytes than the carried ones, or that records none within the join\n timeout after it joins, an uncertain launch included, and otherwise shows that record as the\n seat's provenance. `cotal attach` to such a seat names its source after the seat\n name, as `(resumed from <host>:<id>)`. A remote manager receives a carry when its host issues it a\n transfer reader. On a user-auth mesh the CLI exchanges the operator's login for a one-object\n `transfer-writer` view, which needs scope `admin`.\n- A session name in place of an id is refused, listing each session on this host that carries\n that name with its id, SHA-256 and modification time. An id this host does not hold resolves\n against the **manager host's** `~/.claude`, as before.\n- A seat-private home holds no login. The manager host needs `CLAUDE_CODE_OAUTH_TOKEN` (from\n `claude setup-token`), `ANTHROPIC_AUTH_TOKEN`, or a cloud provider selection in its\n environment; `ANTHROPIC_API_KEY` alone is refused. The launch directory must already be\n trusted by the manager host's own Claude, and Claude must be 2.1.234 or later.\n- The manager waits for a real outcome: `\u2713 started` means the agent *joined the mesh*,\n `\u2717 exited on launch` carries Claude's last output and the seat's exit diagnostic, such as a\n startup confirmation that never appeared, and an uncertain launch (~30 s) is reported without\n tearing the agent down.\n- Resume is an **operator surface only**, deliberately not exposed on MCP `cotal_spawn`\n (a mesh peer naming host-local transcripts would widen `spawn` into transcript\n disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud.\n- Needs a `claude` new enough for `--resume \u2026 --fork-session` (verified on 2.1.197).\n\n## Sharing your MCP servers\n\nA spawned session keeps your own MCP servers by default. On its first run, `cotal setup` copies\nthe user-scope servers from your Claude Code config (`~/.claude.json`, or the one under\n`$CLAUDE_CONFIG_DIR`) into the cotal config file (`~/.config/cotal/config.json`) under\n`connectors.claude.mcpServers`, and names them in its output. With none to copy it writes an\nempty list. Each entry is the familiar `.mcp.json` shape ([full format](config.md)). A cotal\nconfig that already declares that list keeps it, and a later `cotal setup` never changes it.\n\nThe cotal config holds secrets only as `${VAR}` references. Setup cannot tell literal text from\na secret, so it leaves out a server with an `env` or `headers` value that is anything but `${VAR}`\nreferences (a `Bearer ${TOKEN}` header among them) and names it in its output. To share one,\nadd it to the cotal config with each secret written as a `${VAR}` reference, and export that\nvariable where you spawn. Setup also leaves out and names an entry no session can start, such as\none with a missing or empty `command` or `url`, or one whose `command` is not a string.\n\nAt launch the connector forwards *only* the named vars the chosen servers declare and\npasses the merged config as an owner-only temp file; `--strict-mcp-config` stays on, so\nonly cotal + the shared servers load.\n\nFor a lighter seat, share fewer. Remove an entry from the cotal config to drop it from every\nspawn, or scope one spawn with `--share-tools tavily,figma` (or `--share-tools none` for cotal\nalone). An empty list (`\"mcpServers\": {}`) in `~/.config/cotal/config.json` keeps every spawn\nisolated, and setup leaves it as it is.\n\nTwo caveats: sharing a server grants its credential to the agent (the var lives in the\nClaude process's environment, so share only when you're fine with that teammate holding\nthe key), and memory adds up, because a heavy server boots once per spawn, multiplied\nacross a team, and can starve a small machine.\n\n## Feedback\n\n`cotal_feedback` works out of the box: without a key it posts to the public intake at\n`https://cotal.ai/v1/feedback` (needs a contact email: `COTAL_FEEDBACK_EMAIL`, then\n`git config user.email`, else the agent asks). Set `COTAL_FEEDBACK_KEY=fbk_<key>` in a\nbeta tester's environment to route to the keyed intake (`Authorization: Bearer`, identity\nderived from the key); `COTAL_FEEDBACK_URL` overrides either endpoint. The CLI can send\ntoo: `cotal feedback \"<summary>\" [--type bug]`. Each submission carries\n`origin: human | agent`, whether the tester asked, or the agent auto-reported a major\nissue.\n"
62610
+ "body": "# Connect Claude\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nThe Claude Code connector turns a real `claude` session into a Cotal mesh peer. A bundled\nplugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the\nmesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the\nmesh.\n\nThe shared mesh runtime (agent, `cotal_*` tools, hook relay) lives in\n[`@cotal-ai/connector-core`](../extensions/connector-core); this connector is the thin\nClaude-specific adapter over it. Its lifecycle hook imports the relay from the\n`@cotal-ai/connector-core/relay` subpath, so each hook process loads the relay and its environment\nreaders and none of the NATS client, zod or yaml. Siblings: [OpenCode](connect-opencode.md) (beta),\n[Hermes](connect-hermes.md) (alpha), [pi](connect-pi.md) (alpha); the\n[Connectors](connectors.md) matrix compares them feature-by-feature.\n\n## Set up\n\n```bash\ncotal setup # one-time: installs the plugin, seeds one agent; launches nothing\ncotal up # brings up the mesh + delivery daemon + a detached manager\n```\n\n`cotal setup` installs the cotal plugin (so the repo's Claude sessions get the `cotal_*`\ntools), shares your own MCP servers with spawned sessions on its first run (see\n[Sharing your MCP servers](#sharing-your-mcp-servers)), and seeds one `default` persona; `cotal up` brings up the local stack so\n`cotal spawn --detach` / `cotal_spawn` work right away. Re-running either is idempotent.\nThe install mechanics and the invariants behind them are in\n[setup internals](setup-internals.md).\n\n`cotal setup` also installs Cotal's authored Agent Skills (`SKILL.md`, the agentskills.io format) for\ncoordinating agent teams (today `team-topology`), from one canonical source, on two channels:\n\n- **Claude Code** gets a second, skills-only plugin, `cotal-skills`, from the same `cotal-mesh`\n marketplace, at **user scope** (machine-wide). The Claude connector declares and implements this\n setup provider, including the marketplace assets and native plugin commands; the base CLI only passes\n the vendor-neutral Agent Skills directory. The plugin carries no code and no core dependency,\n and uninstalls on its own with `claude plugin uninstall cotal-skills --scope user`. Its plugin version\n is stamped from the running CLI release, so an upgrade + `cotal setup --skills` runs `claude plugin update` and\n the deployed install actually gets the new skill. `cotal setup` installs it on first run and on repeat\n runs, so upgraders are not left behind. The same provider reports the plugin and skills plugin rows\n in `cotal status`, which point a stale or missing skills plugin at `cotal setup --skills`.\n- **Every other harness** (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor\n `~/.agents/skills/` directory convention, which has no remote index, so `cotal setup` **reconciles** it\n (and `cotal setup --skills` does only that):\n it installs/updates each Cotal skill, backs up a copy you have edited to `SKILL.md.bak` before\n replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched;\n your own or third-party skills there are left alone. `cotal status` reports whether the drop is current,\n stale, missing, or has a retired skill to reconcile, and names `cotal setup --skills` as the remedy. This is the working cross-vendor path.\n\nCotal also generates an [Agent Skills discovery index](https://cotal.ai/.well-known/agent-skills/index.json)\non cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet,\nnot a channel to rely on today.\n\n## Spawn a session\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn dave --detach # supervised: the manager runs it in a PTY\n```\n\nA spawn resolves a persona from `.cotal/agents/<name>.md` ([agent files](agent-files.md));\n`--model`, `--variant`, `--cwd`, `--prompt`, ACL overrides, and `--share-tools` apply to\nboth forms ([run a mesh](run-a-mesh.md) has the full resolution rules). The session joins\nwith identity from its environment and auto-registers presence by the time it is\ninteractive.\n\nInside the session, the agent orients with one read-only tool, `cotal_orientation`: its\nidentity, the channels it reads and may post to, its capabilities, the tools available,\nwho's present, and unread counts. The full tool surface is the\n[MCP tool catalog](mcp-tools.md). In auth mode the team-supervision tools\n(`cotal_spawn` / `cotal_persona` / `cotal_personas`) are injected **only** for personas declaring\n`capabilities: [spawn]` (the same grant that opens the privileged control subject), so an\nagent's toolset matches its declared capabilities. `cotal_run` is gated separately by\n`run`; use `capabilities: [spawn, run]` for both. Fresh setup defaults include both.\nSee [workflow tool setup](workflows.md#from-an-agent-session) for a first run and missing-tool checks.\nClearing retained history is\noperator-only ([run a mesh](run-a-mesh.md)), never an agent tool.\n\n## How it binds\n\nClaude Code exposes four integration surfaces, and three of them collapse into a single\ndual-purpose MCP server:\n\n| Surface | Mechanism |\n|---|---|\n| Outbound, ambient | `http` lifecycle hooks \u2192 POST to the connector (presence, activity) |\n| Outbound, deliberate | MCP tools `cotal_send` / `cotal_dm` / `cotal_anycast` (+ `cotal_feedback`) |\n| Inbound, pull | MCP tool `cotal_inbox` (same server) |\n| Inbound, push | Channel nudge + hook drain (below) |\n\nThe manager launches the *real* `claude` (no wrapper):\n\n```\nclaude --strict-mcp-config --mcp-config '{\"mcpServers\":{\"cotal\":{\u2026}}}' \\\n --dangerously-load-development-channels server:cotal\n# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_CHANNEL=1, plus claude's documented auth vars\n```\n\n- **Model auth.** Locally, `claude` still reads macOS Keychain / `~/.claude`. In a container or\n CI there is no Keychain, so the connector forwards the documented credential set:\n `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), `ANTHROPIC_API_KEY` /\n `ANTHROPIC_AUTH_TOKEN`, and the cloud-provider flags plus their credential vars. Host-session\n markers (`CLAUDE_CODE_CHILD_SESSION`, `CLAUDECODE`) stay out so a nested seat still saves a\n transcript. See [Deploy](deploy.md).\n- **Persona privacy.** The persona body is written to a private file and Claude receives only\n `--append-system-prompt-file <path>`. The body never appears in the spawned process argv. The\n carrier is a 0600 file inside a 0700 directory on POSIX, with equivalent owner-only ACL hardening\n on Windows. That is OS-user isolation: any process running as your user can read it while it\n exists. The manager or the foreground `cotal spawn` removes it, and the shared-server MCP config\n file, once it has proved the `claude` process gone. If the launcher is killed first, a watcher\n started beside `claude` removes them when `claude` exits.\n- **MCP servers.** `--strict-mcp-config` ignores every ambient MCP source, so a spawned agent\n loads the cotal server plus the servers the cotal config shares. First-run `cotal setup`\n fills that list with your own user-scope servers, so a spawned session has the tools you know\n (see below).\n- **Installed plugin.** The plugin is installed once (`claude plugin install\n cotal@cotal-mesh --scope local`) because its hooks bind only to an *installed* plugin.\n The repo's `.claude-plugin/marketplace.json` lists the committed plugin tree under\n `claude-plugin/`, which each release regenerates with the built bundles, the skills and the\n release version, so an install from the repo or from a pinned commit runs without a build\n ([Release](release.md)). `cotal setup` (npx, no clone) materializes the same marketplace under\n `~/.cotal/claude-plugin/` from the installed CLI (each plugin dir is rebuilt from scratch and\n atomically replaced, never merged, so no stale file rides in). The\n `cotal-skills` plugin installs from that same marketplace at user scope (`claude plugin install\n cotal-skills@cotal-mesh --scope user`); its manifest and install behavior ship inside the Claude connector, and\n its version tracks the CLI release so updates land.\n- **Identity-gated.** Connector code requires `COTAL_NAME`, `COTAL_LINK` or `COTAL_AGENT_FILE`.\n A plain `claude` with none of them never joins, so your own sessions in a repo do not appear\n as stray peers. Its MCP server still answers `initialize` and lists one static tool,\n `cotal_how_to_join`, which explains how to launch a session on a mesh. It builds no mesh\n agent, opens no broker connection and binds no control socket.\n- **Hands-free.** The dev-channels flag prints a one-time confirm prompt. The runtime waits for the\n dialog title in normalized terminal text and presses Enter once when it appears, so startup speed\n does not affect a supervised launch. The PTY runtime reads the child's output, and the tmux, cmux,\n Orca and Herdr runtimes read the pane's screen. If the declared prompt never appears within 15\n seconds, the seat ends with a bounded error naming the unmatched prompt instead of hanging\n silently or answering another dialog. The PTY runtime writes that error to the seat's output and\n stops the child as a graceful stop does, so a child that ignores SIGTERM is killed 3 seconds\n later. The other runtimes write the error to the manager's log.\n- **Trusted directory.** Claude opens a directory it has not trusted on its workspace-trust dialog,\n and the dialog's default answer exits. No one is at a supervised seat to answer it, so a launch\n whose directory the manager host's own Claude does not trust is refused before it starts, naming\n the directory and the dialog. Trust is read as Claude reads it: trust given to a parent directory\n counts up to the root of the directory's own Git repository, and a linked worktree shares the trust\n of its repository's main checkout. Open `claude` in that directory on the manager host once and\n trust it, then spawn again. A foreground `cotal spawn` shows the dialog in your own terminal instead.\n\nInbound mesh messages arrive in context as\n`<channel source=\"cotal\" from=\"bob\" kind=\"dm\" \u2026>\u2026</channel>`: each meta key a tag\nattribute the agent can read for routing.\n\nThe consuming connector holds a crash-safe claim for its broker target, space, principal and\nlifecycle on this OS user account. A second patched session on the same host refuses before\npulling that inbox, even when its launch files and control socket differ. Reconnect keeps the\nclaim. Hooks and non-consuming helper connections do not claim it. The control listener binds\nbefore the broker connection and never removes an existing socket path.\n\nThis is cooperative same-host containment. Other hosts, broker URL aliases and older clients\nare not fenced. Preserve the existing session before choosing a distinct actor for a new\nlaunch. An uncertain shutdown keeps custody until the consuming process exits.\n\n## How messages reach the session\n\nDurable deliveries land in the connector's inbox from JetStream consumers\n([SPEC \xA78](../SPEC.md#8-nats--jetstream-binding)); live channel traffic can instead arrive\nthrough an at-most-once core subscription. A durable message sent while the agent is busy\nor offline waits on the stream. Two things move a message from inbox to model; one\ndelivers, the other only wakes:\n\n- **Hook drain (delivery).** `SessionStart` / `UserPromptSubmit` hooks read automatic inbox items and\n inject them as `additionalContext`. This is the single authoritative path: deterministic and works\n on any Claude Code build. Quiet ambient is excluded and stays buffered for `cotal_inbox`.\n A message is **acked only once the hook reply carrying it has cleared both legs of its journey**:\n the connector's control socket to the hook process (which gives up after 2s), and the hook\n process's own stdout to Claude Code (which it force-exits 1s after starting to write). The relay\n sends a receipt back down the control socket from that stdout write's callback, and only on a\n clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt,\n not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a\n broken pipe, leaves the message un-acked and JetStream redelivers it. What this does *not* prove is\n that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is\n reported written the moment the kernel takes it. That residual is why the path errs toward\n at-least-once rather than treating a confirmed write as a confirmed read. Acking when\n the reply was merely *formatted* meant a lost reply was a lost message: it was already marked\n handled, so its own redelivery was silently acked on arrival.\n A hook whose handler throws still returns an empty reply so the session is never blocked, and\n that reply carries nothing, so it commits nothing: the batch it had started to surface stays\n un-acked and goes out on a later frame. The seat also drops any `turn-pending` row that breaks\n the manager contract, such as one with no integer deadline, and says so once in its log. A reply\n with no `turns` array changes nothing: the seat keeps the turns it already holds.\n This errs toward **at-least-once**: if a reply lands but its confirmation does not, the batch is\n surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops\n the peer answering at all.\n- **Channel nudge (wake).** An arriving message fires a `notifications/claude/channel`\n event that wakes an *idle* session into a turn, so the drain runs *now* instead of at\n the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a\n bounded backoff while anything is still pending. For an idle session it is the only wake source,\n so dropping it means silence until someone types. When the channel becomes active, the connector\n first re-fires a focus mention remembered during startup, otherwise one buffered wake. A rejected\n push keeps its bounded retry, and JetStream redelivery remains the durable backstop for unacked\n inbox items. Neither a redelivery nor that retry repeats a nudge already pushed for that message,\n whether the message had its own nudge or was counted in a batch one, so a session held in a long\n tool call gets one nudge per message. Once a hook frame carries the message, or the push that\n announced it fails, its next redelivery nudges again, so a reply that never reached Claude Code\n still recovers. If the channel cannot run at all, delivery still waits for the next hook. Live-only\n traffic has no durable retry.\n\n**Two priority tiers.** A *directed* message (DM, anycast, or a channel message that\n`@mentions` us) always nudges. *Ambient* channel chatter does not nudge mid-turn; it\naccumulates, and the `Stop` \u2192 idle transition fires one batch nudge so the backlog drains\ntogether.\n\n**Constraints (accepted).** Channels are a Claude Code research preview (\u2265 v2.1.80;\npermission relay \u2265 v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a\ncustom channel needs the `--dangerously-load-development-channels` launch flag. The hook\ndrain does not depend on any of that; the channel only adds \"wake me when idle.\"\n\nThe same channel also relays **tool-permission requests** onto the mesh, so a peer (a\nhuman at the CLI, a policy node) can approve or deny an agent's pending tool call through\nCotal rather than a per-terminal prompt.\n\n### Attention\n\nAn agent picks how aggressively peer traffic reaches it with\n`cotal_status({ attention })` (three modes, orthogonal to presence):\n\n| arrival | open (default) | dnd | focus |\n|---|---|---|---|\n| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |\n| channel `@mention` | wake + inject | wake + inject | ack-drop; wake to *pull*; not injected |\n| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via `cotal_inbox` |\n\nPer-channel overrides refine this: **quiet** (delivered, never wakes; `@mention` still\nwakes) and **muted** (dropped on receive, mentions included; DMs/anycast unaffected), set\nwith `cotal_channel_mode` or as agent-file defaults (`quiet:` / `muted:`,\n[agent files](agent-files.md)). A per-channel override is the final word for that channel.\nQuiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other\nconnector-driven turn. `cotal_inbox` explicitly surfaces and clears it. A quiet-channel\n`@mention` remains automatic and injects normally.\n\nA pull is bounded too, and clears only what it hands over. One `cotal_inbox` call carries at most a\nreceivable window (direct messages and role requests first, then channel traffic, replayed history\nlast); whatever does not fit stays buffered, is named in the reply, and comes back on the next call.\nA message too large for one whole response is delivered in parts: once no smaller mail is waiting,\neach call carries its next part, and it is cleared only after its last part goes out, because clearing\nwhat was not handed over is the loss this bound exists to stop.\nThat matters most on the path where it is easiest to lose mail: reconnecting brings a channel-history\nreplay with it, so the largest payload and the least expendable message arrive in the same read.\n\nThe local inbox is bounded. On pathological overflow it evicts pull-only items first, then other\nchannel traffic, and a direct message or role request only when the whole buffer is directed mail.\nIt never evicts a message the connector is handing to the session or a turn is still carrying.\nAn evicted channel item is acknowledged. An evicted direct message or role request never is, and\nthe broker redelivers it after the ack wait until a redelivery finds room. A direct message stays\npending on the session's DM durable, where `cotal deliver pending <name>` counts it. A role request\nstays on its role's shared queue, which that command does not read. A full inbox therefore delays\ndirected mail until the session drains it.\nIf the bounded live/durable classification guard also fills, the connector fails closed:\notherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall\nstill take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall\nquiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it\nas incomplete rather than risk resurfacing excluded content. Recall cannot tell one message with an\nempty id from an identical one with another disposition, so in focus such a message is held in the\nlocal inbox as pull-only instead of being dropped, and a mention of it still wakes the agent. When\nthe session settles an id-less copy, it reads the chat stream's last sequence. Identical copies\narrive in stream order, and those reads can answer out of order, so the first read that can see the\ncopies binds them in arrival order, latest first, each to the latest unbound stream copy at or below\nthe lowest sequence read for it or any later identical copy.\nWhile the connection stays up, every stream copy at or below that sequence reached the session\nfirst, so a later identical copy sent during a reconnect gap is above it and stays unbound. A copy\nthat arrives while a read runs may not be in that read, so it binds nothing there and recall reads\nthe channel again, up to three times; if it still could be a stream copy in the last read, recall\nleaves that stream copy in the stream and reports the channel as incomplete. A settled copy a\ncomplete read cannot bind is behind the focus start or out of retention, and is forgotten. Recall\nhands back into the inbox only the stream copies nothing is bound to, such as one sent during a\nreconnect gap or one the inbox evicted on overflow, and `cotal_inbox` hands each over once. Overflow\nfrees only the evicted copy, held or quiet, and a copy no read has bound yet keeps its place in\narrival order, so an identical muted copy stays out of recall. When\nthe inbox is full, recall leaves them in the stream for a later call and reports the channel as\nincomplete. A history read that fails, or a channel with replay off, settles nothing and is reported\nas incomplete, and recall calls run one at a time. If the sequence read for a settled copy fails,\nor answers only after the connection dropped, recall skips that channel for the rest of the focus\nperiod and reports it as incomplete. Recall cannot tell a late copy of a message it handed back from\na new identical message, so every copy takes its own disposition: a new identical quiet mention is\nstill delivered automatically, and a late copy can surface a second time.\nA recalled message that already went out in part is read to its last part, even if an exclusion\nlands after its first part. One session reads its inbox one call at a time: a `cotal_inbox` call\nthat overlaps another waits for it to finish, so neither decides from a view the other has already\nmoved past.\nIf the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the\nsession rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are\nunaffected.\n\nAttention is **advisory UX, not a boundary**: any peer can wake a dnd/focus agent by\nnaming it, and `muted` means \"I opted out of receiving\", not \"the channel is blocked\";\nthe broker still authorizes and delivers. Focus's real effect is shrinking the\nuntrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).\nIt resets to **open** on `SessionStart`, so a restarted agent never stays silently deaf.\nYour attention is mirrored into presence so peers can see it.\n\nWhatever does reach a turn is framed so a peer cannot write the frame. A line that begins at column\nzero is written by the connector; one message is one line plus indented continuations, with the\nsender inside a single bracket pair. A message body, a sender name and role, and a service or\nchannel label are all peer-controlled, so each passes through the same neutralization the\n`cotal_inbox` reply uses: no line break a splitter may honour and no bracket survives into a\nrendered attribution. This matters more for an injected block than for a reply, because the agent\ndid not ask for it and so never had the chance to distrust it.\n\n## Presence mapping\n\nThe connector wires a small subset of Claude Code hooks to presence states; presence is\ncoarse, and \"what it is doing\" rides on activity updates. Presence is **advisory**: a presence\npublish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook\nfrom delivering messages or flushing held ones.\nA `SessionStart` during an open turn, including compaction, preserves the current `working` or\n`waiting` status until `Stop`, `StopFailure`, or `SessionEnd` closes the turn.\n\n| Hook | \u2192 state |\n|---|---|\n| `SessionStart` | `idle` only when no turn is open (join; surfaces the inbox; captures the live model into `meta.model` when no pin) |\n| `UserPromptSubmit` | `working` (turn starts; surfaces the inbox) |\n| `PreToolUse` | no change; records *what* is about to run, so a permission wait can name it |\n| `Notification` (`permission_prompt` / `agent_needs_input`) | `waiting` with condition `approval` / `input` (activity leads with the pending tool, e.g. `Bash: git push \u2026`) |\n| `Stop` / `StopFailure` | `idle` (turn done / died on an API error; flushes anything held while busy). `StopFailure` also relays Claude Code's native error value as `condition.source` and maps it to the closed condition vocabulary. On the [event plane](#event-plane) it closes the run with `RUN_ERROR`. |\n| `SessionEnd` | `offline` (graceful leave) |\n\nThe connector also leaves gracefully when its stdin closes. An MCP client closes it to end the\nsession, and a killed `claude` closes it with no `SessionEnd`, so a dead session drops off the\nroster instead of staying on it as a live peer.\n\n`StopFailure` maps `rate_limit` and `overloaded` directly; auth and credential failures to\n`auth`; account and billing failures to `billing`; `invalid_request` to `request`;\n`model_not_found` to `model`; `server_error` to `server`; `max_output_tokens` to `context`; and\n`unknown` to `failed`. The native value remains in `condition.source`.\n\nHooks are relayed over the connector's **authenticated** local control endpoint (per-user\nsocket + per-launch token, constant-time checked), so a local process that finds the path\nstill can't drive presence or stop the agent. The full Claude Code hook-event list lives\nwith the adapter:\n[`extensions/connector-claude-code`](../extensions/connector-claude-code/README.md).\n\n## Event plane\n\nA spawned session publishes a **structured** account of what it\ndid: run boundaries per turn, assistant text, reasoning, and each tool call with its start\nand its end. Not prose about the work, the work itself, in a vocabulary a program can\nread. The launcher sets `COTAL_EVENTS` by default; pass `--no-events` to opt out on an unrestricted\nspace. A user-auth registration with `policy: { events: \"required\" }` carries `eventsRequired` in the\nprivate launch material, so the connector arms even without `COTAL_EVENTS`; `--no-events` is refused.\nA hand-driven user-mode session may carry the same decision as `COTAL_EVENTS_REQUIRED=1`. Its own\npublish grant must cover `events.<owner>.<actor>` or the connector refuses before joining. An unmanaged\nsession with no launch material and no required-policy fallback keeps the generic default behavior.\n\nIf the event plane stops for good, the space's policy decides what happens to the seat, on every\nconnector. On a space that requires events the seat stops and leaves the mesh. On any other space\nit keeps running without events, and the connector log records `AG-UI emitter stopped` with the\nreason. For Claude Code the connector is the MCP server: it leaves the mesh and exits with code 1,\nand its stderr carries that line.\n\nA new session includes its first run even when Claude writes a positional startup prompt before the\nconnector receives `SessionStart`. That from-zero read is keyed only to Claude's explicit\n`source: \"startup\"`; resumed, forked, cleared, and compacted sessions adopt at the transcript boundary\ncaptured at that adopt, before the mesh link connects, so nothing Claude appends while the connector\nis still starting up lands behind the cursor and is silently dropped. Crash recovery follows the\ncursor already stored in the event write-ahead log, regardless of the new process's startup label.\n\nClaude starts each hook in its own process, so a prompt or stop relay can reach Cotal before the\n`SessionStart` relay. The connector holds those event flushes and the terminal until `SessionStart`\nsupplies the source, then enqueues adopt, flush, and close in that order.\n\n`SessionStart` can also run before the connector process has bound its local control socket. The hook\nthe `SessionStart` relay retries only transient pre-connect listener errors, with capped backoff\ninside its existing two-second budget. Later hooks and permanent local faults still fail open\nimmediately. Once a socket has connected, a broken exchange is not retried: the connector may\nalready have handled the frame, so replaying it could apply one lifecycle event twice.\nThat retained `SessionStart` can itself arrive before Claude creates the transcript path. A genuinely\nnew startup waits up to five seconds for that file with capped backoff, and the same deadline bounds\none stalled file read; expiry fails loud instead of silently losing the first run. A forked session\ngets the same wait, because Claude copies the parent transcript into the fork's own file after the\nhook, and then adopts at the end of that copy. Resumed, cleared and compacted starts and recovered\ncursors still require their existing source at once.\n\nTool arguments (`TOOL_CALL_ARGS`) and tool results (`TOOL_CALL_RESULT`) are not republished\nonto this channel. The durable emitter drops those events before they are written to the\nwrite-ahead log, because this channel's read ACL is not the ACL the tool ran under. Content is\nmandatory on both kinds, so the event is suppressed rather than emptied or replaced with a\nplaceholder. Tool start and end still go out. A restart that finds a pending pre-fix frame\nstill carrying those kinds HALTS rather than republishing it.\n\nThe channel is **`events.<owner>.<actor>`**, named after the session's principal. What the actor\nhalf is depends on the mesh, and the difference matters when you go looking for it: on a static mesh\nit is a key the manager allocated, never the display name, so two live agents sharing a display name\ndo not share a stream; on a user-auth mesh it is the agent's own name, because that is what the\nledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights\non that channel alone. A spawn\nthat asks for a *different* agent's event channel is refused at the door rather than granted, since\nthat channel is that session's event stream. The same rule runs on restart: a manager\nresume document that names another agent's event channel is refused rather than adopted, because the\nmanaged row is re-armed from that document and the credential is re-minted from the row.\n\nThe rule reads a **concrete** channel, two principal tokens and nothing else. A pattern such as\n`events.<owner>.>` is not an event channel to it and passes untouched, governed by ordinary ACL\nauthority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.\nThat is deliberate, because the pattern is the form an operator writes on purpose for an observer,\nand it is worth knowing rather than assuming the fence is total.\n\nTo let something else read a plane, grant it out of band. The refusal prints the command for the\nmesh it is running on, spelled out in full, and only that one.\n\nOn a **user-auth** mesh:\n\n```bash\ncotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''\n```\n\nEvery field, deliberately. `actor grant` is an upsert of the whole row, so it refuses a grant that\nleaves off any of the three ACL flags. Only `--full` turns an omitted flag into the wide default\n(`>` read, `>` post, `spawn,role:default` scope), which is the opposite of what a scoped watcher is for.\n\nOn a **static** mesh there is no actor ledger for `actor grant` to write to, and the refusal says\nso; mint the reader instead:\n\n```bash\ncotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provision\n```\n\nThe **agent** profile, not the observer one. `mint` reads `--allow-subscribe` only for that\nprofile, and refuses it anywhere else: `--profile observer --allow-subscribe <channel>` exits\nnon-zero and writes no creds file, because the observer profile carries a fixed read set over the\nwhole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the\nreader needs, since an authed consuming endpoint refuses to start without one.\n\nOn an **open** mesh there is nothing to grant: the mesh has no credentials and no ACLs, so any peer\nthat lists the channel reads it, and the refusal says so instead of naming a command. The\nown-channel rule still applies there, because a spawn is not the place to hand out a read on\nanother agent's tool inputs and outputs.\n\nTwo things a reader has to do that are not obvious, both on `CotalEndpoint`. It must pass the event\nchannel in `channels`: an endpoint reads the channels it lists, so one constructed without\nthe event channel joins nothing and the frames never arrive. And it reads history with `readHistory(channel)`, the delivery daemon's mediated read, not\n`channelHistory(channel)`: a scoped credential is denied the ad-hoc consumer the direct read\ncreates, by design. `cotal console` and the web console already do both.\n\nThe `<owner>.<actor>` pair is the session's principal. On a user-auth mesh the actor half is the\nagent's own name, so the channel is `events.<your-owner>.<agent-name>`. On a\nstatic mesh the owner half is the literal `local` and the actor is a key the manager allocated, so\nthe channel is `events.local.<key>`; the spawn reply carries that key as `id`. Note\nthat `cotal console` and the web console keep event channels out of their channel lists on purpose,\nsince a plane is a machine feed rather than a conversation; they draw the frames when you open the\nchannel by name.\n\nThe rule governs the manager's doors, which are the ones a caller other than you can reach. A\nforeground `cotal spawn` on your own machine mints from your own signing material, so it can still\ngrant any channel you name: that is the out-of-band grant, not a way around the rule.\n\n**Failed turns publish run errors.** Claude Code decides for itself\nwhether a turn finished or died and fires one of two hooks accordingly, so the connector relays that\ndecision rather than making one of its own: a turn that ended on an API error ends its run with\n`RUN_ERROR` carrying the fixed message `run failed` and no code. Neither the detail Claude Code\nreported nor its error kind is published there: both are upstream values that can echo your prompt or\ntool output, and the events channel has a different read ACL. The error kind still reaches presence\nas the agent's condition (`rate_limit`, `auth`, `billing` and the rest). A turn that ended normally still\nends with a run-finished event carrying no outcome, which says the turn ended and does not claim it\nsucceeded.\n\nEvents are written to a per-session write-ahead log before they are published, so a hook that fires\nafter a restart resumes at the cursor it left rather than replaying or skipping, and a run that was\nopen when the session stopped is closed rather than left dangling.\n\nA delayed initial connection or a broker reconnect pauses event publication without closing MCP.\nThe recorder waits for both the mesh binding and its transport; unpublished records stay in the\ntranscript until the connection recovers. Stopping the connector cancels that wait. Recording errors,\nincluding a conflicting transcript or damaged event state, still stop a session whose policy requires\nrecording.\n\nOne channel carries **every session of one agent**, because it is named after the principal and not\nafter the session. Alongside the per-session logs the connector keeps one small record per principal,\nholding the last sequence the broker assigned on that channel, so a new session continues the stream\nits predecessor left instead of starting again from nothing. Both live under the events state root\n(`COTAL_WORKSPACE_ROOT`), and neither is something you edit by hand.\n\nA **missing** record is not a fault: the connector rebuilds it from the session logs beside it,\nwhich is how an agent that was already running before this record existed keeps its stream. That\nrebuild stops if any one of those session logs is damaged. Unreadable, not valid JSON, and written\nfor a different principal all count, and so does a session directory or a log that is a link rather\nthan the real file the connector wrote, or a log that has more than one name. A tip taken from the\nrest would be too low, and it would stop publication later with nothing left to point at the cause.\nThe connector names the file instead, and the only way past it is the directory removal described\nbelow, under the same condition. A record that **disagrees with the broker** is a fault, and the\nconnector stops publishing and says why rather than guessing. A record that **moved while a session\nwas writing to it** is refused the same way: it means something else wrote the principal's record,\nand the connector reports which value it held and which the file holds rather than writing over the\nlater one. There is no command to clear it. The state is the principal's directory under the events\nroot, and clearing it by hand means removing that directory whole: the sequence, the cursor and the\nper-session logs only mean anything together, so removing part of it leaves a state the next start\nrefuses. Removing it is only half a remedy, and the half that comes first is the channel. The\ndirectory is where the agent's memory of the tip lives, not the tip itself, so on a channel that\nstill holds frames the next session opens expecting an empty one and stops on the same\ndisagreement, with the logs a tip could have been rebuilt from now gone. Purge the channel first,\nthen remove the directory.\n\nReading it: `cotal console` and the web console draw event frames directly. A frame carries no text\npart by design, so a surface that renders a message as flat text shows a marker instead of prose.\n\n**On a per-user-auth mesh, the default event plane needs the spawner's grant to cover the channel.** The event\nchannel is added to the child's publish set, and delegation only narrows: an agent may hand down\na subset of what it holds and no more. So a peer-initiated spawn is refused unless the\nspawning identity's own grant already covers the child's event channel. The refusal prints the\nexact `cotal actor grant` command that widens it. An operator launch, whose chain reaches an\nadmin-scoped or roster row, is unaffected. Passing `events: false` is the explicit opt-out.\n\nArming the event plane through a typed spawn request (`manager.spawn` with `events`, including\nthe CLI's `cotal spawn --detach --events`) is allowed on a user mesh when the child runs under\nthe caller's owner. This also satisfies a registration policy that requires the plane. A spawn\nunder another owner still needs the caller's admin tier. Without it, an explicit request or a\nrequired plane is refused before provisioning. A silent non-owner caller in a space without that\npolicy has the plane disarmed with a notice in a successful reply. Later provisioning can still\nrefuse the spawn. The child's own-channel rule and the ledger's delegation envelope still apply.\n\n## Resume a session\n\n`--resume <session-id>` pulls an existing Claude session, its context and transcript,\ninto the mesh. It **forks**: Claude mints a *new* session id from that transcript\n(`--resume <id> --fork-session`), so the meshed agent gets its own session and the\noriginal is untouched.\n\n- `cotal spawn --resume <id>` (foreground) is the primary surface: the transcript is on\n *your* machine, and errors are Claude's own stderr, inline.\n- `--detach --resume <id> --on <instance>` carries a session held on *your* machine to that\n manager instance, which may run on another host. The CLI finds the transcript under your\n Claude config (`~/.claude`, or `$CLAUDE_CONFIG_DIR`), sends it through a JetStream Object\n Store bucket only that instance reads, under a writer credential pinned to that one transcript,\n and prints `carried session <id> to <instance>:\n sha256:<hex>, <sent> of <size> bytes sent in <chunks> chunks`. Without `--agent`, the CLI first asks that\n instance which harness the persona resolves to, so a persona with `agent: claude` is carried even\n when your `COTAL_DEFAULT_AGENT` names another connector. A re-run of the same bytes\n sends nothing, and an interrupted carry continues where it stopped. The seat forks it in a\n private Claude home under the manager's `.cotal/seat-homes/`, which no other seat's Claude\n lists or finds, and which is removed when the seat stops. When Claude starts the fork, the seat\n records the SHA-256 of the transcript it read from its own project; the manager stops a seat\n whose record names other bytes than the carried ones, or that records none within the join\n timeout after it joins, an uncertain launch included, and otherwise shows that record as the\n seat's provenance. `cotal attach` to such a seat names its source after the seat\n name, as `(resumed from <host>:<id>)`. A remote manager receives a carry when its host issues it a\n transfer reader. On a user-auth mesh the CLI exchanges the operator's login for a one-object\n `transfer-writer` view, which needs scope `admin`.\n- A session name in place of an id is refused, listing each session on this host that carries\n that name with its id, SHA-256 and modification time. An id this host does not hold resolves\n against the **manager host's** `~/.claude`, as before.\n- A seat-private home holds no login. The manager host needs `CLAUDE_CODE_OAUTH_TOKEN` (from\n `claude setup-token`), `ANTHROPIC_AUTH_TOKEN`, or a cloud provider selection in its\n environment; `ANTHROPIC_API_KEY` alone is refused. The launch directory must already be\n trusted by the manager host's own Claude, and Claude must be 2.1.234 or later.\n- The manager waits for a real outcome: `\u2713 started` means the agent *joined the mesh*,\n `\u2717 exited on launch` carries Claude's last output and the seat's exit diagnostic, such as a\n startup confirmation that never appeared, and an uncertain launch (~30 s) is reported without\n tearing the agent down.\n- Resume is an **operator surface only**, deliberately not exposed on MCP `cotal_spawn`\n (a mesh peer naming host-local transcripts would widen `spawn` into transcript\n disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud.\n- Needs a `claude` new enough for `--resume \u2026 --fork-session` (verified on 2.1.197).\n\n## Sharing your MCP servers\n\nA spawned session keeps your own MCP servers by default. On its first run, `cotal setup` copies\nthe user-scope servers from your Claude Code config (`~/.claude.json`, or the one under\n`$CLAUDE_CONFIG_DIR`) into the cotal config file (`~/.config/cotal/config.json`) under\n`connectors.claude.mcpServers`, and names them in its output. With none to copy it writes an\nempty list. Each entry is the familiar `.mcp.json` shape ([full format](config.md)). A cotal\nconfig that already declares that list keeps it, and a later `cotal setup` never changes it.\n\nThe cotal config holds secrets only as `${VAR}` references. Setup cannot tell literal text from\na secret, so it leaves out a server with an `env` or `headers` value that is anything but `${VAR}`\nreferences (a `Bearer ${TOKEN}` header among them) and names it in its output. To share one,\nadd it to the cotal config with each secret written as a `${VAR}` reference, and export that\nvariable where you spawn. Setup also leaves out and names an entry no session can start, such as\none with a missing or empty `command` or `url`, or one whose `command` is not a string.\n\nAt launch the connector forwards *only* the named vars the chosen servers declare and\npasses the merged config as an owner-only temp file; `--strict-mcp-config` stays on, so\nonly cotal + the shared servers load.\n\nFor a lighter seat, share fewer. Remove an entry from the cotal config to drop it from every\nspawn, or scope one spawn with `--share-tools tavily,figma` (or `--share-tools none` for cotal\nalone). An empty list (`\"mcpServers\": {}`) in `~/.config/cotal/config.json` keeps every spawn\nisolated, and setup leaves it as it is.\n\nTwo caveats: sharing a server grants its credential to the agent (the var lives in the\nClaude process's environment, so share only when you're fine with that teammate holding\nthe key), and memory adds up, because a heavy server boots once per spawn, multiplied\nacross a team, and can starve a small machine.\n\n## Feedback\n\n`cotal_feedback` works out of the box: without a key it posts to the public intake at\n`https://cotal.ai/v1/feedback` (needs a contact email: `COTAL_FEEDBACK_EMAIL`, then\n`git config user.email`, else the agent asks). Set `COTAL_FEEDBACK_KEY=fbk_<key>` in a\nbeta tester's environment to route to the keyed intake (`Authorization: Bearer`, identity\nderived from the key); `COTAL_FEEDBACK_URL` overrides either endpoint. The CLI can send\ntoo: `cotal feedback \"<summary>\" [--type bug]`. Each submission carries\n`origin: human | agent`, whether the tester asked, or the agent auto-reported a major\nissue.\n"
62551
62611
  },
62552
62612
  {
62553
62613
  "slug": "connect-codex",
@@ -62575,7 +62635,7 @@ function loadDocsBundle() {
62575
62635
  "title": "Connect OpenCode (beta)",
62576
62636
  "kind": "Guide (informative)",
62577
62637
  "summary": "OpenCode joins a Cotal mesh as a lateral peer, at parity with Claude Code: the same cotal tool surface, the same message delivery and attention model.",
62578
- "body": "# Connect OpenCode (beta)\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\n[OpenCode](https://opencode.ai) joins a Cotal mesh as a lateral peer, at parity with Claude\nCode: the same `cotal_*` tool surface, the same message delivery and attention model. You spawn\nit, watch it work in its real TUI, and it coordinates with your other agents.\n\n**Beta** means the everyday path (spawn, watch, coordinate) works, but two spawn options are\nnot wired yet and **fail loud** rather than degrade: resuming an existing session (`--resume`,\n[issue #154](https://github.com/Cotal-AI/Cotal/issues/154)) and tool-sharing\n(`connectors.opencode.mcpServers`). See [Limits](#limits).\n\n## No install needed\n\nOpenCode needs no setup step. The picker in `cotal setup` just records that you want it; there\nis no plugin to install; the connector auto-wires at spawn. You only need the `opencode` binary\non your PATH. (Claude Code, by contrast, installs a plugin because its wake channel needs one.)\n\nThe connector supports two OpenCode lines: 1.x (`opencode-ai` 1.16 and later) and 2.x\n(`@opencode/cli` 2.0 and later). It detects the line from `opencode --version` at spawn. An\nunsupported version is refused with an error naming the version and the two supported lines.\n\n## Spawn it\n\nSame launch grammar as any agent (see [run-a-mesh.md](run-a-mesh.md)):\n\n```bash\ncotal spawn --agent opencode # foreground in this terminal\ncotal spawn researcher --agent opencode -d # detached via the manager; reattach with `cotal attach`\n```\n\nMake OpenCode the default harness for spawns that don't pass `--agent`:\n\n```bash\nCOTAL_DEFAULT_AGENT=opencode cotal spawn # an explicit --agent always wins\n```\n\nOr in a team [manifest](manifest.md), set `agent: opencode` per agent (or as the team default).\nPersona, role, and model come from the agent file the same way as for any connector: see\n[agent-files.md](agent-files.md) and [define-a-team.md](define-a-team.md).\n\n## Choose a model\n\nOpenCode model ids use `provider/model` form, and a model may expose **variants** (a\nconnector-defined selector, e.g. a reasoning-effort tier). List what the running mesh's OpenCode\ncan see:\n\n```bash\ncotal models --agent opencode # ids + variants, from the manager\ncotal models --agent opencode --refresh # refresh the provider cache first\n```\n\nPick one at spawn, or set `model:` / `variant:` in the agent file (the flags win over the file):\n\n```bash\ncotal spawn --agent opencode --model anthropic/claude-sonnet-4-6 --variant high\n```\n\nA `--variant` on a connector that doesn't support variants is rejected up front; the OpenCode\nconnector advertises variant support, so this is the connector where it applies.\n\nFor an explicit model pin, the connector checks the running OpenCode server's `/provider`\nlisting before joining the mesh. If that server does not list the model, launch refuses with\nthe id and names both the server listing and the `opencode models --pure --verbose` CLI catalog.\nThe CLI catalog alone does not prove that the server serving this session has the model.\nOpenCode's cold server bootstrap can take longer than the generic 30-second manager check;\nthe connector declares a two-minute readiness window for this check and the mesh join.\n\n## How it binds\n\nOpenCode has a native plugin runtime, so the adapter is **not** an MCP server; a single\nin-process plugin does everything.\n\n- **Injected, never written.** The plugin and its config ride in `OPENCODE_CONFIG_CONTENT`\n (inline JSON, OpenCode's highest merge layer), so your `~/.config/opencode` is never touched.\n Because it's a *merge* layer, a spawned OpenCode agent **inherits** the operator's MCP servers\n (the opposite of Claude Code's strict isolation), which is why tool-sharing is a separate,\n not-yet-built feature (see [Limits](#limits)).\n- **Per-agent database.** The session SQLite DB is moved per agent\n (`.cotal/opencode/<name>/opencode.db`, rooted at the manager's workspace) so concurrent managed\n agents don't lock each other or drop files into a target repo. A launch checks the agent's\n `serve.pid` before it starts a server. A record that the previous launcher removes during that\n check counts as no record, and so does a recorded server that exits during it. An empty\n directory at that path also counts as no record and is removed, and one that vanishes before\n that removal still counts as no record. The launch refuses a directory that holds files and\n leaves it in place.\n- **The visible TUI.** The connector launches the real `opencode` TUI, foreground and watchable,\n attached to the one session the plugin drives. It injects each incoming peer batch as a turn on\n that session, so a human watching sees the agent work and can type into it. Presence is derived\n from OpenCode's event stream (busy \u2192 working, idle \u2192 idle, permission asked \u2192 waiting).\n- **A password on the server.** The OpenCode server accepts only requests carrying a password the\n launcher mints per launch, or one a headless host supplies as `OPENCODE_SERVER_PASSWORD`. It\n reaches the server and the TUI only through their environment, and the headless `[cotal-serve]`\n line leaves it out.\n- **Observed model.** Each new OpenCode prompt reports its actual `provider/model` and optional\n variant into presence for roster and dashboard display. Before the first prompt it remains `not\n reported`; the connector never invents a default. An explicit `model:` or `variant:` pin wins.\n- **A long turn keeps its batch.** The connector acks a peer batch when the turn carrying it ends,\n so a turn longer than the consumer's 60-second ack wait is ordinary. While the turn runs, the\n connector restarts the ack wait of each message in that batch with JetStream's in-progress ack,\n so the broker does not hand a role request the seat is still working on to another holder of\n the role. A failed turn or a stop releases the batch and a crashed seat stops renewing it, so\n the broker redelivers it after one ack wait; a finished turn or a user Stop acks it. Messages\n still queued behind the turn are not renewed, and a full inbox evicts those, never the batch the\n turn carries. Renewal stops once the seat can no longer vouch that it holds a message, for\n example after its link was down for longer than the ack wait, and the broker may then\n redeliver it. Delivery stays at-least-once: see\n [Durable transport](presence-and-delivery.md#durable-transport).\n- **Quiet stays pull-only.** Quiet-channel ambient never gets prepended to a native human prompt or\n a directed-message turn. `cotal_inbox` explicitly surfaces and clears it; automatic traffic stays\n owned by the connector. Quiet-channel `@mention`s still drive a turn.\n- **Focus `@mention`s are held until delivered on 1.x.** In `focus` the mention's body is dropped\n at ingest, so the connector keeps a wake that tells the agent to read it with `cotal_inbox`. The\n wake stays pending until a turn carrying it is accepted, so a busy session, a refused turn or a\n failed submission only delays it. Several pending mentions share one wake. On a channel with\n replay off the wake only says the agent was mentioned, because the body cannot be recalled.\n- **A stopping seat refuses prompts on 1.x.** Once a stop has begun, a prompt typed into the TUI\n or sent to the server API is refused before OpenCode saves it, so the agent starts no new model\n turn after it has announced it is leaving. OpenCode reports the reason,\n `the prompt was not run: this seat is shutting down`, as a `session.error` event for an\n asynchronous prompt and only in its server log for a synchronous one, which answers with a generic\n server error. A turn already running when the stop began is not cancelled.\n- **An instance dispose ends the seat on 1.x.** OpenCode can dispose an instance while its server\n keeps running, for example after a global config change or on `POST /instance/dispose`. The\n connector then runs the same teardown as a stop and exits the server. Whenever the server exits,\n the launcher closes its TUI, killing it if it is still up 3 seconds later, so the manager sees the\n seat end.\n- **`/new` = context reset.** Running OpenCode's built-in `/new` in that TUI starts a fresh\n context while keeping the same mesh identity and creds.\n- **`/reconnect` = in-process recovery.** OpenCode has no host reconnect surface, so the connector\n injects a `/reconnect` command that calls the shared `cotal_reconnect` tool, rebuilding a wedged\n mesh link in-process.\n- Spawned agents run autonomously (`permission: \"allow\"`) so a supervised agent never stalls on a\n tool-approval prompt.\n\nThe generic tool surface and the inbound-message model are shared across connectors: see\n[mcp-tools.md](mcp-tools.md) and [connect-claude.md](connect-claude.md).\n\n## Event plane\n\nA spawned session publishes a structured account of what it did: run\nboundaries per turn, assistant text, and each tool call with its start and its end. Tool\narguments and tool results are not republished onto this channel.\nThe channel is `events.<owner>.<actor>`, named after the session's principal, and the rules for it\nare the same on every connector: see [connect-claude.md](connect-claude.md#event-plane) for the\nchannel, the grant, and how to read it. The launcher sets `COTAL_EVENTS` by default; pass\n`--no-events` to opt out on an unrestricted space. A required registration carries\n`eventsRequired` in launch material, or `COTAL_EVENTS_REQUIRED=1` on the direct env fallback, so a\npersonal user-mode OpenCode session arms without a separate event flag. Its own publish grant must\ncover the principal-keyed event channel or the connector refuses before joining. The session boundary\nis captured at adopt, before the mesh link connects, so a session created before the first bind still\npublishes and nothing it writes while the connector is still starting up is silently dropped.\n\nFour things are specific to OpenCode and worth knowing before you read a stream:\n\n- **No user-authored text is published, ever.** When a peer message is injected into a native\n prompt, OpenCode prepends it into the human's own text part, so one record holds peer-authored and\n human-authored content with no boundary in it to filter on. Rather than guess where one ends,\n the connector publishes no user text at all. Assistant text, reasoning and tool activity are\n unaffected.\n- **No step events and no usage.** OpenCode's step records carry no step name and no key shared\n between the start and the finish, and what the finish actually carries is cost and token counts.\n So the connector emits no step vocabulary rather than inventing a name, and the usage numbers are\n not carried in this version.\n- **`/new` starts a new thread on the same channel.** OpenCode can hold several sessions in one\n process, and `/new` is a context reset that keeps the mesh identity. Each session publishes under\n its own thread id on the one `events.<owner>.<actor>` channel. Before the switch, the session you\n are leaving is flushed and its open run is closed, so a reader never holds a run that never ends.\n- **Failed turns publish run errors.** OpenCode reports a turn that\n died (an upstream API error, a provider auth failure, or an output-length stop) on its own\n `session.error` event, and that turn ends its run with `RUN_ERROR` carrying the fixed message\n `run failed` and no code. Neither OpenCode's reason nor its error name is published there: both are\n upstream values that can echo your prompt or tool output, and the events channel has a different\n read ACL. A turn **you** stopped is not a failure and is not published as one: a user cancellation\n arrives on the same event, and it closes the run as an ordinary end. A failed turn also re-arms\n the wake it carried, so a focus @mention whose turn failed is driven again after the retry delay.\n\nReasoning is off by default.\n\n## Limits\n\n- **No session resume.** `cotal spawn --resume <id>` throws on OpenCode, because\n forking into an existing session needs session-creation plumbing, not an argv flag\n ([issue #154](https://github.com/Cotal-AI/Cotal/issues/154)). Connectors that support\n resume are listed in [the matrix](connectors.md).\n- **No tool-sharing.** `connectors.opencode.mcpServers` is not implemented and throws if set.\n OpenCode agents currently inherit the operator's MCP servers wholesale through the config merge\n layer; narrowing that to a chosen subset is a separate feature.\n- **On 2.x, the event plane needs `--no-events`.** The AG-UI event plane is not carried on\n OpenCode 2.x yet; spawn with `--no-events`.\n- **On 2.x, `cotal models` is refused.** The 2.x catalog is served by a running opencode\n server, not the CLI, so pass `--model provider/model` directly instead.\n\n## See also\n\n- [Connectors](connectors.md): the feature matrix across all connectors\n- [Run a mesh](run-a-mesh.md) \xB7 [Define a team](define-a-team.md) \xB7 [Watch a mesh](watch-a-mesh.md)\n- [MCP tools](mcp-tools.md) \xB7 [Connect Claude Code](connect-claude.md) \xB7 [Connect Hermes](connect-hermes.md) \xB7 [Connect pi](connect-pi.md)\n- [Deploy against an external broker](deploy.md): running OpenCode agents in containers\n"
62638
+ "body": "# Connect OpenCode (beta)\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\n[OpenCode](https://opencode.ai) joins a Cotal mesh as a lateral peer, at parity with Claude\nCode: the same `cotal_*` tool surface, the same message delivery and attention model. You spawn\nit, watch it work in its real TUI, and it coordinates with your other agents.\n\n**Beta** means the everyday path (spawn, watch, coordinate) works, but two spawn options are\nnot wired yet and **fail loud** rather than degrade: resuming an existing session (`--resume`,\n[issue #154](https://github.com/Cotal-AI/Cotal/issues/154)) and tool-sharing\n(`connectors.opencode.mcpServers`). See [Limits](#limits).\n\n## No install needed\n\nOpenCode needs no setup step. The picker in `cotal setup` just records that you want it; there\nis no plugin to install; the connector auto-wires at spawn. You only need the `opencode` binary\non your PATH. (Claude Code, by contrast, installs a plugin because its wake channel needs one.)\n\nThe connector supports two OpenCode lines: 1.x (`opencode-ai` 1.16 and later) and 2.x\n(`@opencode/cli` 2.0 and later). It detects the line from `opencode --version` at spawn. An\nunsupported version is refused with an error naming the version and the two supported lines.\n\n## Spawn it\n\nSame launch grammar as any agent (see [run-a-mesh.md](run-a-mesh.md)):\n\n```bash\ncotal spawn --agent opencode # foreground in this terminal\ncotal spawn researcher --agent opencode -d # detached via the manager; reattach with `cotal attach`\n```\n\nMake OpenCode the default harness for spawns that don't pass `--agent`:\n\n```bash\nCOTAL_DEFAULT_AGENT=opencode cotal spawn # an explicit --agent always wins\n```\n\nOr in a team [manifest](manifest.md), set `agent: opencode` per agent (or as the team default).\nPersona, role, and model come from the agent file the same way as for any connector: see\n[agent-files.md](agent-files.md) and [define-a-team.md](define-a-team.md).\n\n## Choose a model\n\nOpenCode model ids use `provider/model` form, and a model may expose **variants** (a\nconnector-defined selector, e.g. a reasoning-effort tier). List what the running mesh's OpenCode\ncan see:\n\n```bash\ncotal models --agent opencode # ids + variants, from the manager\ncotal models --agent opencode --refresh # refresh the provider cache first\n```\n\nPick one at spawn, or set `model:` / `variant:` in the agent file (the flags win over the file):\n\n```bash\ncotal spawn --agent opencode --model anthropic/claude-sonnet-4-6 --variant high\n```\n\nA `--variant` on a connector that doesn't support variants is rejected up front; the OpenCode\nconnector advertises variant support, so this is the connector where it applies.\n\nFor an explicit model pin, the connector checks the running OpenCode server's `/provider`\nlisting before joining the mesh. If that server does not list the model, launch refuses with\nthe id and names both the server listing and the `opencode models --pure --verbose` CLI catalog.\nThe CLI catalog alone does not prove that the server serving this session has the model.\nOpenCode's cold server bootstrap can take longer than the generic 30-second manager check;\nthe connector declares a two-minute readiness window for this check and the mesh join.\n\n## How it binds\n\nOpenCode has a native plugin runtime, so the adapter is **not** an MCP server; a single\nin-process plugin does everything.\n\n- **Injected, never written.** The plugin and its config ride in `OPENCODE_CONFIG_CONTENT`\n (inline JSON, OpenCode's highest merge layer), so your `~/.config/opencode` is never touched.\n Because it's a *merge* layer, a spawned OpenCode agent **inherits** the operator's MCP servers\n (the opposite of Claude Code's strict isolation), which is why tool-sharing is a separate,\n not-yet-built feature (see [Limits](#limits)).\n- **Per-agent database.** The session SQLite DB is moved per agent\n (`.cotal/opencode/<name>/opencode.db`, rooted at the manager's workspace) so concurrent managed\n agents don't lock each other or drop files into a target repo. A launch checks the agent's\n `serve.pid` before it starts a server. A record that the previous launcher removes during that\n check counts as no record, and so does a recorded server that exits during it. An empty\n directory at that path also counts as no record and is removed, and one that vanishes before\n that removal still counts as no record. The launch refuses a directory that holds files and\n leaves it in place. The server's process writes its own pid to `serve.pid` before it becomes the\n server, so a launcher killed right after starting it still leaves a record that refuses the next\n launch. Windows has no `exec`, so there the launcher writes the record after the start.\n- **The visible TUI.** The connector launches the real `opencode` TUI, foreground and watchable,\n attached to the one session the plugin drives. It injects each incoming peer batch as a turn on\n that session, so a human watching sees the agent work and can type into it. Presence is derived\n from OpenCode's event stream (busy \u2192 working, idle \u2192 idle, permission asked \u2192 waiting).\n- **A password on the server.** The OpenCode server accepts only requests carrying a password the\n launcher mints per launch, or one a headless host supplies as `OPENCODE_SERVER_PASSWORD`. It\n reaches the server and the TUI only through their environment, and the headless `[cotal-serve]`\n line leaves it out.\n- **Observed model.** Each new OpenCode prompt reports its actual `provider/model` and optional\n variant into presence for roster and dashboard display. Before the first prompt it remains `not\n reported`; the connector never invents a default. An explicit `model:` or `variant:` pin wins.\n- **A long turn keeps its batch.** The connector acks a peer batch when the turn carrying it ends,\n so a turn longer than the consumer's 60-second ack wait is ordinary. While the turn runs, the\n connector restarts the ack wait of each message in that batch with JetStream's in-progress ack,\n so the broker does not hand a role request the seat is still working on to another holder of\n the role. A failed turn or a stop releases the batch and a crashed seat stops renewing it, so\n the broker redelivers it after one ack wait; a finished turn or a user Stop acks it. Messages\n still queued behind the turn are not renewed, and a full inbox evicts those, never the batch the\n turn carries. Renewal stops once the seat can no longer vouch that it holds a message, for\n example after its link was down for longer than the ack wait, and the broker may then\n redeliver it. Delivery stays at-least-once: see\n [Durable transport](presence-and-delivery.md#durable-transport).\n- **Quiet stays pull-only.** Quiet-channel ambient never gets prepended to a native human prompt or\n a directed-message turn. `cotal_inbox` explicitly surfaces and clears it; automatic traffic stays\n owned by the connector. Quiet-channel `@mention`s still drive a turn.\n- **Focus `@mention`s are held until delivered on 1.x.** In `focus` the mention's body is dropped\n at ingest, so the connector keeps a wake that tells the agent to read it with `cotal_inbox`. The\n wake stays pending until a turn carrying it is accepted, so a busy session, a refused turn or a\n failed submission only delays it. Several pending mentions share one wake. On a channel with\n replay off the wake only says the agent was mentioned, because the body cannot be recalled.\n- **A stopping seat refuses prompts on 1.x.** Once a stop has begun, a prompt typed into the TUI\n or sent to the server API is refused before OpenCode saves it, so the agent starts no new model\n turn after it has announced it is leaving. OpenCode reports the reason,\n `the prompt was not run: this seat is shutting down`, as a `session.error` event for an\n asynchronous prompt and only in its server log for a synchronous one, which answers with a generic\n server error. A turn already running when the stop began is not cancelled.\n- **An instance dispose ends the seat on 1.x.** OpenCode can dispose an instance while its server\n keeps running, for example after a global config change or on `POST /instance/dispose`. The\n connector then runs the same teardown as a stop and exits the server. Whenever the server exits,\n the launcher closes its TUI, killing it if it is still up 3 seconds later, so the manager sees the\n seat end.\n- **`/new` = context reset.** Running OpenCode's built-in `/new` in that TUI starts a fresh\n context while keeping the same mesh identity and creds.\n- **`/reconnect` = in-process recovery.** OpenCode has no host reconnect surface, so the connector\n injects a `/reconnect` command that calls the shared `cotal_reconnect` tool, rebuilding a wedged\n mesh link in-process.\n- Spawned agents run autonomously (`permission: \"allow\"`) so a supervised agent never stalls on a\n tool-approval prompt.\n\nThe generic tool surface and the inbound-message model are shared across connectors: see\n[mcp-tools.md](mcp-tools.md) and [connect-claude.md](connect-claude.md).\n\n## Event plane\n\nA spawned session publishes a structured account of what it did: run\nboundaries per turn, assistant text, and each tool call with its start and its end. Tool\narguments and tool results are not republished onto this channel.\nThe channel is `events.<owner>.<actor>`, named after the session's principal, and the rules for it\nare the same on every connector: see [connect-claude.md](connect-claude.md#event-plane) for the\nchannel, the grant, and how to read it. The launcher sets `COTAL_EVENTS` by default; pass\n`--no-events` to opt out on an unrestricted space. A required registration carries\n`eventsRequired` in launch material, or `COTAL_EVENTS_REQUIRED=1` on the direct env fallback, so a\npersonal user-mode OpenCode session arms without a separate event flag. Its own publish grant must\ncover the principal-keyed event channel or the connector refuses before joining. The session boundary\nis captured at adopt, before the mesh link connects, so a session created before the first bind still\npublishes and nothing it writes while the connector is still starting up is silently dropped.\n\nFour things are specific to OpenCode and worth knowing before you read a stream:\n\n- **No user-authored text is published, ever.** When a peer message is injected into a native\n prompt, OpenCode prepends it into the human's own text part, so one record holds peer-authored and\n human-authored content with no boundary in it to filter on. Rather than guess where one ends,\n the connector publishes no user text at all. Assistant text, reasoning and tool activity are\n unaffected.\n- **No step events and no usage.** OpenCode's step records carry no step name and no key shared\n between the start and the finish, and what the finish actually carries is cost and token counts.\n So the connector emits no step vocabulary rather than inventing a name, and the usage numbers are\n not carried in this version.\n- **`/new` starts a new thread on the same channel.** OpenCode can hold several sessions in one\n process, and `/new` is a context reset that keeps the mesh identity. Each session publishes under\n its own thread id on the one `events.<owner>.<actor>` channel. Before the switch, the session you\n are leaving is flushed and its open run is closed, so a reader never holds a run that never ends.\n- **Failed turns publish run errors.** OpenCode reports a turn that\n died (an upstream API error, a provider auth failure, or an output-length stop) on its own\n `session.error` event, and that turn ends its run with `RUN_ERROR` carrying the fixed message\n `run failed` and no code. Neither OpenCode's reason nor its error name is published there: both are\n upstream values that can echo your prompt or tool output, and the events channel has a different\n read ACL. A turn **you** stopped is not a failure and is not published as one: a user cancellation\n arrives on the same event, and it closes the run as an ordinary end. A failed turn also re-arms\n the wake it carried, so a focus @mention whose turn failed is driven again after the retry delay.\n\nReasoning is off by default.\n\n## Limits\n\n- **No session resume.** `cotal spawn --resume <id>` throws on OpenCode, because\n forking into an existing session needs session-creation plumbing, not an argv flag\n ([issue #154](https://github.com/Cotal-AI/Cotal/issues/154)). Connectors that support\n resume are listed in [the matrix](connectors.md).\n- **No tool-sharing.** `connectors.opencode.mcpServers` is not implemented and throws if set.\n OpenCode agents currently inherit the operator's MCP servers wholesale through the config merge\n layer; narrowing that to a chosen subset is a separate feature.\n- **On 2.x, the event plane needs `--no-events`.** The AG-UI event plane is not carried on\n OpenCode 2.x yet; spawn with `--no-events`.\n- **On 2.x, `cotal models` is refused.** The 2.x catalog is served by a running opencode\n server, not the CLI, so pass `--model provider/model` directly instead.\n\n## See also\n\n- [Connectors](connectors.md): the feature matrix across all connectors\n- [Run a mesh](run-a-mesh.md) \xB7 [Define a team](define-a-team.md) \xB7 [Watch a mesh](watch-a-mesh.md)\n- [MCP tools](mcp-tools.md) \xB7 [Connect Claude Code](connect-claude.md) \xB7 [Connect Hermes](connect-hermes.md) \xB7 [Connect pi](connect-pi.md)\n- [Deploy against an external broker](deploy.md): running OpenCode agents in containers\n"
62579
62639
  },
62580
62640
  {
62581
62641
  "slug": "connect-pi",
@@ -62596,7 +62656,7 @@ function loadDocsBundle() {
62596
62656
  "title": "The control surface",
62597
62657
  "kind": "Concept (informative)",
62598
62658
  "summary": "Cotal once had a privileged control rail: a fixed set of named service tiers (self / manager / admin / delivery) on their own ctl.",
62599
- "body": "# The control surface\n\n> **Concept** (informative) \xB7 **For:** operators and client authors who want to know how the manager and other daemons are driven \xB7 **Normative:** [SPEC \xA713](../SPEC.md#13-endpoint-control-surface-v04)\n\nCotal once had a privileged control rail: a fixed set of named service tiers\n(`self` / `manager` / `admin` / `delivery`) on their own `ctl.*` subjects, with the manager\nas a special case the broker recognised by name. That rail is gone. Everything that serves\nstructured commands now, the manager, the delivery daemon, a wrapped MCP server, a\nthird-party service, is an ordinary **endpoint**: a daemon that registers a service\nidentity, publishes its contracts, and answers `describe`. `manager` is an endpoint name\nlike any other; no subject, envelope, or grant in this surface knows it specially. The\nmanager is a service on the mesh, not an authority over it: it holds only the capability\nrows its callers grant it, and serves over a scoped credential.\n\n## The `ep` rails\n\nOne kind, `ep`, carries every request under a mode token that says where the request\nroutes, never which verb it is (the verb rides the envelope): `one` (queue-group\nanycast, one and only one class member), `all` (scatter, every instance), and `inst` (one instance by its\nstable address). Replies come back on a `reply` rail keyed to the serving instance and its\nepoch. Around these sit the sibling planes the composites use: per-goal events, timers,\nsessions, and the journal that holds durable facts. Every request carries the caller as\nthree forge-locked tokens, `owner`, `actor`, and lifecycle `uid`, plus an unguessable\nnonce, so the broker polices who is calling in the subject grammar itself. See\n[SPEC \xA713.2](../SPEC.md#132-grammar) for the grammar and [\xA713.5](../SPEC.md#135-verbs) for\nthe verbs (`call`, `cast`, `watch`, `claim`, `scatter`).\n\n## Lifecycle identity\n\nA principal `owner.actor` is a reusable routing alias: a despawn frees the actor name and a\nlater spawn may legitimately reuse it, so the alias alone is never authority. Two further\ncoordinates make an identity durable: a **lifecycle uid**, an unguessable, never-reused id\nfor one managed lifecycle under a principal, and a **process epoch**, the fenced ownership\nepoch of the process currently animating it, advanced on every restart or takeover. At most\none live epoch owns an identity, and a superseded epoch must stop serving. Durables and\ncredentials key on the lifecycle uid, not the reusable name, which is what lets a\nsupervised restart recover the same lifecycle instead of minting a new one. See\n[SPEC \xA713.1](../SPEC.md#131-lifecycle-identity) and [identity & auth](identity-and-auth.md).\n\n## Service discovery\n\nNo client has compile-time knowledge of any endpoint's commands. `cotal describe\n<endpoint>` resolves a registered endpoint's command set off the wire: the reserved\n`describe` command answers the registered contract digests, the schemas are fetched from the\nspace's content-addressed contract store, recompiled, and verified against those digests.\nEach command prints with its capability class and targeting shape. `cotal invoke <endpoint>\n<command> --args '<json>'` then calls one command by name, validating the arguments as they\nwill be sent (JSON drops a key whose value is undefined) against the fetched input schema before\npublish. A refusal at that check means nothing was sent, and it is marked `not-executed`. An\nendpoint that refuses a request before running the command states `not-executed` in the reply's\n`error.outcome` too, for example on a wrong envelope version, sender, contract digest or target\nmode, or a body that does not parse. A refusal the command itself raises carries only the\noutcome it states. A refusal that does not serialize comes back as `internal`, and one too large\nto publish as `resource-exhausted`, and both keep that outcome. A\nsigned-in user invokes the same surface through their bearer, and the broker enforces each\ncommand's existing capability grant. A manager alias supplied through `--name` resolves through\nits name-keyed `inspect` command, so an authorized targeted call does not need the manager-wide\n`ps` enumeration grant. Every built-in manager command uses this\nsame trust chain, so there is nothing the built-ins can reach that a described contract cannot. The registered\n`auth` endpoint is describable the same way `manager` is: `cotal describe auth` lists\n`retire-lifecycle` and its exact-mode target shape.\nSee [SPEC \xA713.7](../SPEC.md#137-contracts-and-discovery) and [cli.md](cli.md).\n\nThe manager's `resolve-cwd` command is in the `manager.spawn` capability class. It accepts an\nabsolute path on that manager's host and returns its canonical directory plus the host name. It\nrefuses a relative, missing or non-directory path with `failed-precondition`; it creates nothing.\n`spawn` applies the same check at admission, before any credentials or durables are minted.\n\nThe manager's `resolve-agent` command is minted beside `spawn` in the spawn capability and the\noperator instruments. It accepts the persona fields `spawn` takes (`name`, `config`, `agent`,\n`defaultAgent`) and returns `{agent}`, the harness `spawn` on that manager would launch: the flag,\nthen the persona's `agent:` pin, then the caller's default, then the manager's own default. It loads\nthe persona with the same visibility check as `spawn` and launches nothing. A detached `--resume`\nwithout `--agent` asks it before carrying a session, and a manager that does not serve it refuses\nthe call.\n\n`spawn` refuses an empty or whitespace-only value in any of its optional string fields, such as\n`role`, `agent` or `identity`, with `bad-request` naming the field. To take the persona file's value,\nomit the field.\n\n### Inspecting a managed name\n\nManager `inspect` keeps its successful response as the live managed-agent row. A live hit does\nnot read durable lifecycle state, so a temporary records-store failure cannot break inspection of\nan agent the manager currently holds.\n\nOn a live miss, a static manager point-reads its durable slot row. A name with no slot, or a slot\nwhose phase is `retired`, remains `not-found`. A nonterminal slot returns\n`failed-precondition` with `error.details[].kind =\nai.cotal.manager.static-slot-observation`. The detail carries the slot's `slotPhase`,\n`owner`, `actor`, `slotLifecycleUid`, `cleanupComplete` when recorded, and `slotRevision`. It\nthen carries the separate lifecycle head's `headState`, `headOp` when present,\n`headLifecycleUid`, and `headRevision`. Head fields are absent when provisioning has not written\nthe lifecycle head yet.\nThe error message carries the same diagnostic summary so string-only operator paths do not hide\nthe structured detail.\n\nA slot row records the manager instance that owns it. In a space with more than one manager, the\nclass queue can hand `inspect` to an instance that does not host the name. When the row names a\ndifferent instance and is not `retired`, the miss returns `failed-precondition` with the same\ndetail plus `ownerInstanceId`, which names the only manager that can act on it. The message names\nboth instances, so a caller that reads only the string can tell it from `not-found`. A sibling's\n`retired` row remains `not-found`. A named `cotal_despawn` resolves its target through this read\nand cannot address an instance, so it asks again until the owning instance answers, up to 16\ntimes.\n\nThe slot is read before the head. These records do not form one atomic snapshot, so the detail\nalso carries `readOrder: [\"slot\", \"head\"]` and `consistency: \"ordered-not-atomic\"`. A head can\nadvance between the reads. The issuance gate is not projected because the retirement operation\nneeded for this diagnosis is already recorded on the head, and reading a third record would add\nanother non-atomic edge without changing the per-name result.\n\nIf either durable read fails or exceeds its bound, the miss returns `unavailable` with\n`ai.cotal.manager.static-slot-read-failed` rather than claiming the name is absent. That detail\nnames the inspected `name`, the failed `record` (`slot`, `head`, or `slot-or-head` when the layer\ncannot distinguish them), and `operation: \"read\"`. User-auth managers do not own `mgrslot` rows,\nso their inspect misses remain live-map reads.\n\nFor Linux custodied seats, retirement requires the runtime's process-exit evidence before\nfreeing the alias or deleting its credentials and delivery state. Socket loss alone is not\nproof of exit. The runtime retains the record captured at launch or adoption so a clean\ncustodian exit can unlink its file without losing the recorded boot and process identities.\nIf the file is missing, reaping uses that retained record and the existing kernel identity\nchecks. An unknown reference without either record refuses cleanup. Reused process ids\nare never signalled on the strength of the old record.\n\n### Listing the durable slots\n\nManager `slots` (`manager.read`, untargeted) lists the durable static slot rows this manager\nowns. Only static managers hold these rows: a user-mode or open manager answers\n`failed-precondition`, and a manager whose durable store is not standing answers `unavailable`.\nEach row carries the same `readOrder` and `consistency` fields `inspect` uses, because the list\nis read the same way: torn across rows as well as within each row's slot/head pair. A `retired`\nrow is never listed. `live` reflects the manager's live roster at render time, not the durable\nrow. A slot row is never deleted, so a row whose latest operation is a DEL or PURGE marker is\ncorruption: the list answers `unavailable` naming that row, as `inspect` does for its name.\n\n## Spawn is a goal\n\nLong-running commands are **actions** ([SPEC \xA713.6](../SPEC.md#136-composites)): the caller\nsubmits with a client-generated `goalId` and a request fingerprint, the endpoint records a\ndurable accept or reject decision, progress rides per-goal events, and the work ends in one\nterminal outcome (`succeeded`, `failed`, `cancelled`, `expired`, or `uncertain`). Spawn is\nthe reference case. Rather than block the caller for up to 30 seconds while an agent comes\nup, the manager accepts the goal and returns the allocated identity at once:\n\n```json\n{\n \"name\": \"reviewer_2\",\n \"owner\": \"u_...\", \"actor\": \"reviewer_2\", \"uid\": \"...\",\n \"goalId\": \"...\", \"fingerprint\": \"...\",\n \"readinessDeadlineMs\": 30000,\n \"executor\": { \"lifecycleUid\": \"...\", \"epoch\": 3 }\n}\n```\n\nThe `uid` is the lifecycle the agent runs at. On a participant manager whose host enrolls its\nagents, the host picks that uid, so the manager accepts the goal only after the host has answered.\nA host refusal there refuses the spawn, and no goal is bound.\n\nThe name is the one actually allocated: a persona-derived collision is auto-numbered\n(`reviewer`, then `reviewer_2`), while a hard-pinned `--name` that collides with a live\nagent is refused at accept, before anything is minted. Auto-numbering never hands out a numbered\nname it has already issued in that manager process, even after the agent holding it is gone, so\na collision takes the next number. Only numbering consults that history: a hard-pinned `--name`,\nor a persona whose own name is a numbered string, takes that name whenever it is free, and\nnumbering does not skip a string such a spawn held before. The triple plus `goalId` let the\ncaller follow progress (connector handoff, process launched, presence join) and reconcile\nlater against the exact instance that accepted. Presence within the manager's default\n30-second readiness window, or a connector's declared bounded window, settles the goal\n`succeeded`; an early process exit is `failed`; the window passing with neither is `uncertain`,\na bounded, durable outcome that a later `ps` or status read settles against the live roster.\n`uncertain` is a real terminal outcome, not an absence and not a silent hang. It carries the\ndiagnosis of whoever owned the deadline: for a launch that\nnames the agent and says to inspect it rather than re-issue, since re-issuing after a launch\nthat in fact succeeded mints a duplicate. A follower keeps the acceptance as the data of any\nterminal other than `succeeded`, so `cotal_spawn` returns an uncertain launch as a pending result\ninstead of an error: it names the allocated agent, its id, and its manager, and tells the calling\nagent to watch the roster. A committer that supplies no diagnosis falls back to\n\"the success signal did not arrive within the readiness deadline\". The agent's own eventual\nstate is then observable on its presence record.\n\nThe acceptance carries that exact `readinessDeadlineMs`. A synchronous follower treats its own\nrequest deadline as a floor and waits through the accepted readiness budget plus delivery margin,\nso a connector-specific slow boot cannot be reported as a caller timeout while the manager is\nstill legitimately waiting for its terminal.\n\nA spawn that is **refused** because a lifecycle barrier already holds the actor (a frozen\nissuance gate, a retiring alias, a retired uid) is not a wait-timeout. The manager already\nknows the blocked op (`registration` / `retirement` / `activation` / `takeover`), the `opId`\nholding it, and the remedy when one exists (`retry`, `cotal reconcile-gate`). The detail\ncarries `headState` (`active` / `retiring` / `retired`) only when the refusing site read the\nlifecycle head, and `gateState` (`frozen` / `retired`) only when it read the issuance gate. A\ngate frozen by a takeover or a registration says nothing about the head, so that refusal\ncarries `gateState=frozen` and no `headState`. Those facts ride `error.details[]` as\n`kind = ai.cotal.ep.lifecycle-blocked` and are also appended to the error string, so a\ncaller that only prints `error.message` still sees them. The CLI and the connector tools hand a\nrefusal on in one shape, so `cotal spawn -f` keeps the same code, details, rendered facts and\nacceptance data as `cotal spawn --detach` and `cotal_spawn`. A connector that collapses the\nrefusal to \"startup failed (unknown)\" or a SPEC 13.6 wait-timeout is hiding a knowable\nstate, not reporting a missing one.\n\n## Instance routing\n\nA space can run more than one manager. Each manager persists a stable logical instance id\nacross restarts and advances its process epoch when it comes back, so callers address a\nspecific manager without caring which process currently serves it. A start serves only at the\nepoch its own registration committed, never at the epoch of a later start of the same instance.\nOn a static or open mesh,\nan untargeted spawn rides class anycast (any manager may accept, and the acceptance records which one did).\n`cotal spawn <persona> --detach --on <instance>` and `cotal_spawn(instance: \"<instance>\")`\npin one instance by its exact id. A foreground CLI spawn has no manager to pin and refuses the\nflag. An MCP pin that does not resolve is refused without falling back to class anycast. There are no ordinal\naliases and no short forms: wherever a display names an instance you can address, it prints\nthe whole id, because both surfaces take nothing else.\n\nOn a user-auth mesh, manager commands obtain a short-lived `manager-caller` view from the\nexchange. It authorizes one concrete manager instance using the caller's current actor grant and\nthe host's registered service records. Discovery and invocation both use that instance's `inst`\nroute. This view grants no registry scan, class queue, or additional command capability. An absent,\nambiguous or unauthorized selection refuses before the command is sent.\n\nManaged launches carry `COTAL_MANAGER_INSTANCE` so their tools address the manager that launched\nthem. Existing unbound sessions can use the exchange's unique authorized selection without\nreplacing their actor or conversation. The connector uses a separate control connection; the\nstanding message connection and its credential source are unchanged. Accepted spawn goals are\nfollowed on that renewing connection, using its existing caller-scoped progress grant, so a long\nreadiness budget does not depend on the short-lived control credential. The follower confirms its\nprogress subscription with the broker before submitting on the separate connection. A caller still\nchecks the resolved instance and epoch, and never retries an ambiguous mutation outcome.\n\nThe manager's `goal-result` command accepts `{goalId}` and returns `{goalId, result?}`. It reads\nonly the authenticated caller's owner, actor and lifecycle through the manager's separate trusted\ngoal-writer connection. The caller receives an attributed reply, never a raw JetStream reader\ngrant. Each read is admitted by the connection's broker-enforced command grant. A live user-auth\nconnection remains bounded by its bearer expiry after revocation; a renewed connection is checked\nagainst fresh authority. There is no separate per-read ledger check. An absent `result` means no\nterminal is recorded; it does not prove the goal is running or permit another submission. The\nexisting trusted goal-writer's leader-served EPF read is space-wide at the broker; the handler\nconfines it to this endpoint and caller triple.\n\nThe manager's reserved `cancel` command accepts `{goalId, mode?}` and returns `{goalId, state}`.\nIt is served for a turn the manager relays. The goal is the authenticated caller's own, so a caller\nwithdraws only a turn it submitted. The turn ends `cancelled`, its seat is not shown it again, and a\nlater yield of it is answered with that terminal. A goal that already ended is refused\n`failed-precondition` with its cached outcome attached, and a goal this manager does not relay is\nrefused without being changed. So is a second cancel that arrives while a first is still ending the\nturn; a first that fails leaves the turn pending unless something ended it meanwhile. A workflow\nrun sends it for the turn, ask attempt or escalation of a branch it cancelled.\n\nA followed mutation requires a manager whose attributed describe includes `goal-result`. Update\nthe manager, issuer and client together before using that recovery path. Reloading an issuer alone\ncannot change an already-running participant manager. Recovery re-resolves the accepting instance's\nepoch, preserves the caller lifecycle and validates the result against the accepted goal and any\nacceptance fingerprint. Stopping the caller ends its observation, not the already accepted goal.\n\nA followed call resolves the endpoint within its deadline before the submission starts, so a\nrefused or unanswered describe surfaces as its own error. A describe or command publish that the\nbroker refuses reports `not-executed`.\nCancellation before submission reports `not-executed`. Once submission starts, cancellation or a\nlost reply reports an unknown outcome unless an attributed refusal proves otherwise. A received\nrefusal remains a refusal even when stop races it. Local failures do not invent responder identities.\nThe follower owns its subscription, timers and read cancellation signal. Reconciliation begins\nbefore the wait deadline, and late read completions cannot settle an expired observation. Its read\ncallback receives the accepting caller triple, remaining budget and abort signal; borrowed bearer\ncommands and control connections use that signal. An in-flight dial that finishes after cancellation\ncloses without publishing. A local reply-subscription failure prevents publication and is observed\nby the same request promise, including when the transport is closing or draining. Request\ncancellation does not revoke or resubmit the accepted operation.\n\n\"Only one manager per space\" is not the current invariant. A split topology that keeps the\nbroker host manager-free is still a topology choice: `cotal up` on that host starts a\nmanager you then stop with `cotal down manager` after `\u2713 manager up` in\n`.cotal/manager.<spaceKey>.log` (detach stdout listing `manager` is pidfile liveness, not a\nteardown boundary), and `cotal supervise\n--server` runs the manager elsewhere ([Run a mesh](run-a-mesh.md)). Extra live managers\nare addressable, not an error.\n\nThe reserved `describe` bootstrap is the one request the resolver may repeat while waiting: it is\nread-only, it is re-published under the same request binding, and every attempt stays inside the\noriginal deadline. This covers the startup window where Core NATS discards the first request before\nthe manager has subscribed. If the connection closes while the resolver waits, the describe fails\ncleanly instead of throwing from the retry timer. The resolved command is never repeated by this\nreadiness behavior.\n\nThe describe and the contract store reads after it share the resolver's one deadline, so a slow\nstore read fails the resolve with `deadline-exceeded` when that deadline expires. Cancelling a\nresolve settles a store read in flight instead of waiting for it to return.\n\nThe resolve and the invoke are separate trips through the same anycast queue, so in a\nmulti-manager space an unpinned call can land on an instance the caller did not resolve. Every\ncall carries the incarnation it resolved against, and a manager that is not that incarnation\n**refuses before running the command**, so the failure an operator sees says the command did\nnot run, and re-issuing it cannot duplicate the effect. That is the difference that matters for\na mutation: the older behaviour detected the mismatch on the reply, after the manager had\nalready acted, and could only tell you to go and check. `--on` still matters for reaching a\nspecific manager (`ps`, `stop`, `attach`, `spawn --detach`), but it is no longer what stands\nbetween a split and a duplicated spawn. Against a manager older than this fence the refusal is\nstill after the fact, and its message says so. The re-issue is automatic only when the refusal\nstates `not-executed` in its `outcome` field; a refusal that omits the field, or states\n`unknown`, is surfaced to the caller instead of repaired, because neither proves the command did\nnot run. The CLI's manager commands, `cotal invoke`, the `cotal run` verbs and the manager row of\n`cotal status` re-describe and re-issue an unpinned call after each such refusal, up to 16 times,\nso a split reaches the operator only when every attempt split. A hosted run's own manager calls\nuse the same bound. A pinned call is never re-issued. An agent's own manager\ntools, such as `cotal_spawn` and `cotal_despawn`, re-describe and re-issue with the same bound,\nincluding the goal-result read that follows a spawn to its outcome.\n\nAn unpinned targeted call, such as `cotal_despawn` or a hosted run's turn relay, can also reach a\nmanager that does not host its target, because each manager resolves targets against the agents\nit runs. That manager refuses with `expired` and `not-executed` and says it holds no mapping for\nthe target, and the same re-issue repairs it within the same bound. An agent that no manager hosts\nstill ends in that refusal once the re-issues run out. A pinned call gets the refusal of the\ninstance it named.\n\nA manager whose boot inventory marked every declared connector unavailable does not subscribe\n`spawn` or `launch` on the class `one` rail. Those commands stay on scatter and on this\ninstance's `inst` rail, so a sibling that can launch them can take an unpinned spawn, and a\ncaller that pins this instance with `--on` still gets a named harness refusal. `describe`\nstill lists the commands: the instance rail serves them, and `describe` itself stays on the\nclass rail (SPEC 13.7). An unpinned `spawn` can therefore bind-fence: `describe` may land on\nthe skip member while `spawn` lands on a sibling, the command was not run, and the caller\nre-issues or pins `--on`. `status` reports `classSpawn: false` when that skip is in effect.\nA manager that can launch some connectors keeps the class rail. If the queue hands it a\nharness its inventory marked unavailable, the refusal names `--on` because the standing serve\ncredential cannot read sibling inventories. Pin the capable instance (the whole id, as `ps`\nprints it).\n\n`ps` and\n`status` become a **scatter** across every registered instance: the caller freezes the\nexpected set from the service registry, invokes each under a shared deadline, and merges the\nresults with per-instance attribution. A non-answering instance is labelled as registered\nwith no answer within the deadline, never silently omitted. See [SPEC \xA713.5](../SPEC.md#135-verbs) (scatter) and [cli.md](cli.md).\n\nThe expected set comes from the **registry**, which records registration rather than liveness.\nAn instance that crashes never deregisters, so it stays in the set and the gather has nothing\nleft to wait for but an answer that cannot come. It pays the whole deadline, on every scatter,\nindefinitely. A scatter can therefore be given a per-instance liveness probe: when the broker\nitself reports that an instance holds no subscription on its own instance rail, the gather stops\nwaiting for it. Only that affirmative report counts. A lapsed presence entry, a probe that timed\nout, and a probe that failed are all *absence of evidence*, and treating any of them as death\nwould turn a slow correct answer into a fast wrong one, so they leave the full deadline standing.\nNothing about the outcome changes either way: an instance that did not answer is still\nunreachable, still surfaced, and the scatter is still not complete.\n\nThe probe is supplied by the **caller**, not invented by the scatter. Asking about an instance is\na publish on that instance's rail, and a credential that holds no row for it is refused by the\nbroker asynchronously, while the publish itself returns normally. The probe verb watches for that\nrefusal and raises it as `permission-denied` naming the rail, so it is never mistaken for a quiet\ninstance, and it never burns the probe budget waiting out a refusal. Only the layer that\nminted the credential knows which ids it may ask about, so that layer asks about those and no\nothers. `cotal ps` freezes the class on its first connection, re-mints an instrument pinned only\nto the frozen ids, and scatters on a second; a refusal the broker raises anyway is printed and\nthe instance's row says the probe was refused, which is a fact about the credential, not about\nthe instance.\n\nThis does not help against an instance that is **connected but not answering**. A hung manager\nholds its subscriptions, so it is indistinguishable from a slow one, and it still costs the full\ndeadline. That is the correct result, not a gap in the probe.\n\n### Deregistration\n\nA probe makes a dead registration cheap to skip; it does not remove it. Removal is the\nregistration's own exit, and there are two explicit routes to it\n([SPEC \xA713.5](../SPEC.md#135-verbs): a deleted `svc` spec *is* the deregistration).\n\nA manager that stops cleanly removes its own registration, so an ordinary shutdown leaves no stale\nrow. The delete is pinned to the registration revision that process wrote. When a successor has\nregistered the same instance since then, the stop logs that and leaves the successor's registration\nalone. It refuses that delete while this instance holds the endpoint governance slot at the live\nissuance-gate generation (a registration still completing its reopen). A leftover slot whose\ngeneration is behind that live generation is not in-flight and does not block the stop. A manager\nthat cannot renew or read its lease keeps serving, stays registered, and retries. If another process\nholds the same instance key, that process has taken the instance over, so this one logs the conflict\nand exits without deregistering, leaving the successor's registration alone.\n\nA restart that died *mid-registration* is a different residue: the issuance gate stays frozen under\nthat op. The successor completes the dead registration on boot when the freeze-holder is\naffirmatively gone under a complete CONNZ sweep (the same composition as\n[`cotal reconcile-gate`](cli.md#reconcile-gate)). A committed spec write is finished under that\nsame freeze; only a definite no-commit abort-reopens and then runs the normal takeover.\nOnce that reopen commits, the registration has completed: a governance slot it then fails to release\nis left behind the live generation, as on a normal registration, and does not fail the restart.\nIt does not invent a TTL and it does not start a new freeze over a still-held one.\n\nThat residue has a second half, and it is the endpoint governance slot rather than the gate. Every\nregistration takes the endpoint-wide slot before it publishes its spec and holds it until its own\ngate reopens, which is what serializes registration for the endpoint. An instance that stopped\nbetween those two points leaves the slot held with no registration behind it, so the endpoint\nrefuses new registrations while nothing is actually in flight. The slot is stamped with the\ngeneration of the gate its holder had frozen when it took it, and a slot is promoted only at that\nsame generation. So once the holder's gate has reopened past the stamp, the slot can never be\npromoted by anyone, and the next registration for that endpoint replaces it. That reclaim is part of\nan ordinary start and needs no operator step.\n\nA slot whose holder's gate is still at the stamped generation is a registration that is genuinely in\nflight, and it keeps refusing. The two states read differently only in the holder's gate coordinate,\nso reopening that gate is what separates them: the holder's own restart heals it on boot, and\n[`cotal reconcile-gate`](cli.md#reconcile-gate) is the operator's route when the boot path cannot\nrun. The registration path is the slot's only writer, and neither repair command writes it.\nA registration that cannot read the holder's gate at all refuses, because an unreadable gate does\nnot distinguish the two states either. Each of these refusals carries\n`kind = ai.cotal.ep.foreign-slot-held` in `error.details[]` with the holder's instance id and the\n`condition` that refused: `in-flight` for a holder gate still at the stamp, or `no-seam`,\n`unreadable`, `garbled` or `behind` when the registration could not read that gate or read it below\nthe stamp. A remote manager asks its host to reconcile the holder only on `in-flight`, the one\ncondition a gate repair can clear.\n\nFor the instance that cannot cooperate, an operator names it:\n`cotal deregister-instance --instance <id>` ([cli.md](cli.md#deregister-instance)). It removes the\nrecord only on the same evidence `cotal ps` acts on: the broker reporting nothing subscribed on\nthat instance's own rail. It refuses if the instance answers a describe, refuses if the probe could\nnot run at all, and refuses if the instance is merely quiet, because a hung process still holds its\nsubscriptions and is therefore not affirmed gone. It also refuses while that instance holds the\nendpoint governance slot at the live issuance-gate generation (a registration still completing);\na leftover slot behind that generation is not in-flight and does not block. Nothing sweeps the\nregistry on an age threshold or on silence.\nAn instance that is deregistered while it is merely wedged re-registers over the tombstone on its\nnext start, which is what makes the operator's decision a recoverable one.\n\n## Attach sessions\n\n`cotal attach` no longer returns a `ws://127.0.0.1` URL. It creates a one-use, holder-bound\nsession offer: the manager mints a token bound to the caller, the target lifecycle, its own\ninstance id and epoch, and an expiry, and replies with a session id and expiry only, no URL\nand no secret in the reply. The CLI redeems the offer over the mesh (a second redeem is\nrefused). On a registered open mesh that redeem is a bare connection, the same path other\ncontrol commands already use; on a static-auth mesh it is still a session-caller credential\nminted from the resolved root's seed. On a user-auth mesh the CLI holds no seed: it exchanges its\nlogin and the grant for a `session-caller` view bearer, and the callout mints the same caller rails\nwith the grant's expiry. Terminal bytes then stream on core-NATS session subjects\nscoped to the two parties. Backpressure is a bounded in-flight window with an explicit drop notice, never\nsilent loss; a late attach still repaints the full screen from a replayed terminal\nsnapshot. Close, expiry, target despawn, and a manager restart are distinct, surfaced end\nstates: a restarted manager's successor refuses the old epoch's sessions and the client\nshows \"manager restarted; re-attach\".\n\n## Seat input\n\n`attach` is a stream, so it is the wrong shape for a program that wants to send one line: it\nholds a session open and expects a terminal at the caller's end. The `input` command is the\nother half. One authorized call writes text into a running seat's terminal as if it had been\ntyped there, and answers with the seat and the number of bytes delivered.\n\nIt exists for **harness commands**. A line beginning with `/` (`/compact`, `/clear`, `/model`)\nis neither chat nor an event: the agent's own harness handles it, and the keyboard is the only\nway in. An external control surface that can already read a seat's turns and talk to it still\ncannot drive it without this.\n\nThe op is targeted, rides the `manager.lifecycle` capability, and declares authz modes `owner`\nand `any`, the row shape `attach` and `despawn` already carry, checked by the same authorization.\nEnter is appended unless the caller suppresses it, and nothing is echoed back, since the resulting\nturns already have somewhere to go.\n\n**Who may call it is narrower than either of those**, and the reasoning is worth stating because\nthe natural assumption is wrong. `despawn` and `attach` are granted to anything holding `spawn`;\n`input` is granted only to operator credentials. The tempting argument for treating them alike is\nthat an attach session's `write` already reaches the same terminal, so `input` adds nothing. It\ndoes not reach it: an attach yields a signed session offer, and redeeming one needs a per-session\ncredential minted from the space signing seed, which no agent holds. So `input` would be new\nauthority, and the own-owner rule that bounds `despawn` covers every seat under an owner rather\nthan only the ones a caller launched. Killing a peer is denial; typing into a peer is control of\nit. The write therefore sits with the credential that is already the administrative authority for\nthe domain.\n\nOnly a runtime that owns the child's input stream can serve it. The `pty` runtime does; the\nexternal terminal runtimes attach to a process they do not own, and there the command refuses\nand names the runtime rather than dropping the keystroke. A seat that is not running refuses for\nits own reason, and the two are distinguishable, so a caller can tell \"this will never work\"\nfrom \"not right now\". See [cli.md](cli.md#input).\n\n## Grants\n\nThere is no broad control credential. A caller holds one capability row per command it is\nallowed to send, and minting maps each named capability to the request subjects it needs and no\nothers. The manager serves over a scoped serve credential that can answer and\nreply but cannot, for instance, write another endpoint's records or forge a goal terminal;\nthe goal-fact writer and the session writer are separate, narrowly scoped credentials the\nbroker fences by subject. Authorization is checked at the serving boundary, and for actions\nit linearises at acceptance: a spawn refused there mints no reservation and leaves no\nprocess. See [SPEC \xA713.9](../SPEC.md#139-authority-boundary) and\n[identity & auth](identity-and-auth.md).\n\nA carried resume transcript never rides the rails. The operator-only `transcript-receive` command\nanswers whether to upload and hands back a one-time claim for `spawn`, and the bytes travel through\nthe target instance's own transfer bucket under two one-shot credentials: a writer the operator\nmints for that one transcript, and a reader the target instance mints for its own bucket, or that\nthe host issues a remote manager through its `transferReader` authority operation.\n\n## See also\n\n- [Architecture](architecture.md), where the manager and the wire fit in the whole system.\n- [CLI](cli.md), for `describe`, `invoke`, `spawn`, `ps`, `status`, `attach`, and `input`.\n- [SPEC \xA713](../SPEC.md#13-endpoint-control-surface-v04), the normative contract.\n"
62659
+ "body": "# The control surface\n\n> **Concept** (informative) \xB7 **For:** operators and client authors who want to know how the manager and other daemons are driven \xB7 **Normative:** [SPEC \xA713](../SPEC.md#13-endpoint-control-surface-v04)\n\nCotal once had a privileged control rail: a fixed set of named service tiers\n(`self` / `manager` / `admin` / `delivery`) on their own `ctl.*` subjects, with the manager\nas a special case the broker recognised by name. That rail is gone. Everything that serves\nstructured commands now, the manager, the delivery daemon, a wrapped MCP server, a\nthird-party service, is an ordinary **endpoint**: a daemon that registers a service\nidentity, publishes its contracts, and answers `describe`. `manager` is an endpoint name\nlike any other; no subject, envelope, or grant in this surface knows it specially. The\nmanager is a service on the mesh, not an authority over it: it holds only the capability\nrows its callers grant it, and serves over a scoped credential.\n\n## The `ep` rails\n\nOne kind, `ep`, carries every request under a mode token that says where the request\nroutes, never which verb it is (the verb rides the envelope): `one` (queue-group\nanycast, one and only one class member), `all` (scatter, every instance), and `inst` (one instance by its\nstable address). Replies come back on a `reply` rail keyed to the serving instance and its\nepoch. Around these sit the sibling planes the composites use: per-goal events, timers,\nsessions, and the journal that holds durable facts. Every request carries the caller as\nthree forge-locked tokens, `owner`, `actor`, and lifecycle `uid`, plus an unguessable\nnonce, so the broker polices who is calling in the subject grammar itself. See\n[SPEC \xA713.2](../SPEC.md#132-grammar) for the grammar and [\xA713.5](../SPEC.md#135-verbs) for\nthe verbs (`call`, `cast`, `watch`, `claim`, `scatter`).\n\n## Lifecycle identity\n\nA principal `owner.actor` is a reusable routing alias: a despawn frees the actor name and a\nlater spawn may legitimately reuse it, so the alias alone is never authority. Two further\ncoordinates make an identity durable: a **lifecycle uid**, an unguessable, never-reused id\nfor one managed lifecycle under a principal, and a **process epoch**, the fenced ownership\nepoch of the process currently animating it, advanced on every restart or takeover. At most\none live epoch owns an identity, and a superseded epoch must stop serving. Durables and\ncredentials key on the lifecycle uid, not the reusable name, which is what lets a\nsupervised restart recover the same lifecycle instead of minting a new one. See\n[SPEC \xA713.1](../SPEC.md#131-lifecycle-identity) and [identity & auth](identity-and-auth.md).\n\n## Service discovery\n\nNo client has compile-time knowledge of any endpoint's commands. `cotal describe\n<endpoint>` resolves a registered endpoint's command set off the wire: the reserved\n`describe` command answers the registered contract digests, the schemas are fetched from the\nspace's content-addressed contract store, recompiled, and verified against those digests.\nEach command prints with its capability class and targeting shape. `cotal invoke <endpoint>\n<command> --args '<json>'` then calls one command by name, validating the arguments as they\nwill be sent (JSON drops a key whose value is undefined) against the fetched input schema before\npublish. A refusal at that check means nothing was sent, and it is marked `not-executed`. An\nendpoint that refuses a request before running the command states `not-executed` in the reply's\n`error.outcome` too, for example on a wrong envelope version, sender, contract digest or target\nmode, or a body that does not parse. A refusal the command itself raises carries only the\noutcome it states. A refusal that does not serialize comes back as `internal`, and one too large\nto publish as `resource-exhausted`, and both keep that outcome. A\nsigned-in user invokes the same surface through their bearer, and the broker enforces each\ncommand's existing capability grant. A manager alias supplied through `--name` resolves through\nits name-keyed `inspect` command, so an authorized targeted call does not need the manager-wide\n`ps` enumeration grant. Every built-in manager command uses this\nsame trust chain, so there is nothing the built-ins can reach that a described contract cannot. The registered\n`auth` endpoint is describable the same way `manager` is: `cotal describe auth` lists\n`retire-lifecycle` and its exact-mode target shape.\nSee [SPEC \xA713.7](../SPEC.md#137-contracts-and-discovery) and [cli.md](cli.md).\n\nThe manager's `resolve-cwd` command is in the `manager.spawn` capability class. It accepts an\nabsolute path on that manager's host and returns its canonical directory plus the host name. It\nrefuses a relative, missing or non-directory path with `failed-precondition`; it creates nothing.\n`spawn` applies the same check at admission, before any credentials or durables are minted.\n\nThe manager's `resolve-agent` command is minted beside `spawn` in the spawn capability and the\noperator instruments. It accepts the persona fields `spawn` takes (`name`, `config`, `agent`,\n`defaultAgent`) and returns `{agent}`, the harness `spawn` on that manager would launch: the flag,\nthen the persona's `agent:` pin, then the caller's default, then the manager's own default. It loads\nthe persona with the same visibility check as `spawn` and launches nothing. A detached `--resume`\nwithout `--agent` asks it before carrying a session, and a manager that does not serve it refuses\nthe call.\n\n`spawn` refuses an empty or whitespace-only value in any of its optional string fields, such as\n`role`, `agent` or `identity`, with `bad-request` naming the field. To take the persona file's value,\nomit the field.\n\n### Inspecting a managed name\n\nManager `inspect` keeps its successful response as the live managed-agent row. A live hit does\nnot read durable lifecycle state, so a temporary records-store failure cannot break inspection of\nan agent the manager currently holds.\n\nOn a live miss, a static manager point-reads its durable slot row. A name with no slot, or a slot\nwhose phase is `retired`, remains `not-found`. A nonterminal slot returns\n`failed-precondition` with `error.details[].kind =\nai.cotal.manager.static-slot-observation`. The detail carries the slot's `slotPhase`,\n`owner`, `actor`, `slotLifecycleUid`, `cleanupComplete` when recorded, and `slotRevision`. It\nthen carries the separate lifecycle head's `headState`, `headOp` when present,\n`headLifecycleUid`, and `headRevision`. Head fields are absent when provisioning has not written\nthe lifecycle head yet.\nThe error message carries the same diagnostic summary so string-only operator paths do not hide\nthe structured detail.\n\nA slot row records the manager instance that owns it. In a space with more than one manager, the\nclass queue can hand `inspect` to an instance that does not host the name. When the row names a\ndifferent instance and is not `retired`, the miss returns `failed-precondition` with the same\ndetail plus `ownerInstanceId`, which names the only manager that can act on it. The message names\nboth instances, so a caller that reads only the string can tell it from `not-found`. A sibling's\n`retired` row remains `not-found`. A named `cotal_despawn` resolves its target through this read\nand cannot address an instance, so it asks again until the owning instance answers, up to 16\ntimes.\n\nThe slot is read before the head. These records do not form one atomic snapshot, so the detail\nalso carries `readOrder: [\"slot\", \"head\"]` and `consistency: \"ordered-not-atomic\"`. A head can\nadvance between the reads. The issuance gate is not projected because the retirement operation\nneeded for this diagnosis is already recorded on the head, and reading a third record would add\nanother non-atomic edge without changing the per-name result.\n\nIf either durable read fails or exceeds its bound, the miss returns `unavailable` with\n`ai.cotal.manager.static-slot-read-failed` rather than claiming the name is absent. That detail\nnames the inspected `name`, the failed `record` (`slot`, `head`, or `slot-or-head` when the layer\ncannot distinguish them), and `operation: \"read\"`. User-auth managers do not own `mgrslot` rows,\nso their inspect misses remain live-map reads.\n\nFor Linux custodied seats, retirement requires the runtime's process-exit evidence before\nfreeing the alias or deleting its credentials and delivery state. Socket loss alone is not\nproof of exit. The runtime retains the record captured at launch or adoption so a clean\ncustodian exit can unlink its file without losing the recorded boot and process identities.\nIf the file is missing, reaping uses that retained record and the existing kernel identity\nchecks. An unknown reference without either record refuses cleanup. Reused process ids\nare never signalled on the strength of the old record.\n\n### Listing the durable slots\n\nManager `slots` (`manager.read`, untargeted) lists the durable static slot rows this manager\nowns. Only static managers hold these rows: a user-mode or open manager answers\n`failed-precondition`, and a manager whose durable store is not standing answers `unavailable`.\nEach row carries the same `readOrder` and `consistency` fields `inspect` uses, because the list\nis read the same way: torn across rows as well as within each row's slot/head pair. A `retired`\nrow is never listed. `live` reflects the manager's live roster at render time, not the durable\nrow. A slot row is never deleted, so a row whose latest operation is a DEL or PURGE marker is\ncorruption: the list answers `unavailable` naming that row, as `inspect` does for its name.\n\n## Spawn is a goal\n\nLong-running commands are **actions** ([SPEC \xA713.6](../SPEC.md#136-composites)): the caller\nsubmits with a client-generated `goalId` and a request fingerprint, the endpoint records a\ndurable accept or reject decision, progress rides per-goal events, and the work ends in one\nterminal outcome (`succeeded`, `failed`, `cancelled`, `expired`, or `uncertain`). Spawn is\nthe reference case. Rather than block the caller for up to 30 seconds while an agent comes\nup, the manager accepts the goal and returns the allocated identity at once:\n\n```json\n{\n \"name\": \"reviewer_2\",\n \"owner\": \"u_...\", \"actor\": \"reviewer_2\", \"uid\": \"...\",\n \"goalId\": \"...\", \"fingerprint\": \"...\",\n \"readinessDeadlineMs\": 30000,\n \"executor\": { \"lifecycleUid\": \"...\", \"epoch\": 3 }\n}\n```\n\nThe `uid` is the lifecycle the agent runs at. On a participant manager whose host enrolls its\nagents, the host picks that uid, so the manager accepts the goal only after the host has answered.\nA host refusal there refuses the spawn, and no goal is bound.\n\nResubmitting the same `goalId` returns the same acceptance. A sibling instance or a restarted one\nanswers it from the identity the accepting instance recorded before it replied, owner included.\nWhen that record names no owner because an older manager wrote it, the resubmission is refused\n`unavailable`.\n\nThe name is the one actually allocated: a persona-derived collision is auto-numbered\n(`reviewer`, then `reviewer_2`), while a hard-pinned `--name` that collides with a live\nagent is refused at accept, before anything is minted. Auto-numbering never hands out a numbered\nname it has already issued in that manager process, even after the agent holding it is gone, so\na collision takes the next number. Only numbering consults that history: a hard-pinned `--name`,\nor a persona whose own name is a numbered string, takes that name whenever it is free, and\nnumbering does not skip a string such a spawn held before. The triple plus `goalId` let the\ncaller follow progress (connector handoff, process launched, presence join) and reconcile\nlater against the exact instance that accepted. Presence within the manager's default\n30-second readiness window, or a connector's declared bounded window, settles the goal\n`succeeded`; an early process exit is `failed`; the window passing with neither is `uncertain`,\na bounded, durable outcome that a later `ps` or status read settles against the live roster.\n`uncertain` is a real terminal outcome, not an absence and not a silent hang. It carries the\ndiagnosis of whoever owned the deadline: for a launch that\nnames the agent and says to inspect it rather than re-issue, since re-issuing after a launch\nthat in fact succeeded mints a duplicate. A follower keeps the acceptance as the data of any\nterminal other than `succeeded`, so `cotal_spawn` returns an uncertain launch as a pending result\ninstead of an error: it names the allocated agent, its id, and its manager, and tells the calling\nagent to watch the roster. A committer that supplies no diagnosis falls back to\n\"the success signal did not arrive within the readiness deadline\". The agent's own eventual\nstate is then observable on its presence record.\n\nThe acceptance carries that exact `readinessDeadlineMs`. A synchronous follower treats its own\nrequest deadline as a floor and waits through the accepted readiness budget plus delivery margin,\nso a connector-specific slow boot cannot be reported as a caller timeout while the manager is\nstill legitimately waiting for its terminal.\n\nA spawn that is **refused** because a lifecycle barrier already holds the actor (a frozen\nissuance gate, a retiring alias, a retired uid) is not a wait-timeout. The manager already\nknows the blocked op (`registration` / `retirement` / `activation` / `takeover`), the `opId`\nholding it, and the remedy when one exists (`retry`, `cotal reconcile-gate`). The detail\ncarries `headState` (`active` / `retiring` / `retired`) only when the refusing site read the\nlifecycle head, and `gateState` (`frozen` / `retired`) only when it read the issuance gate. A\ngate frozen by a takeover or a registration says nothing about the head, so that refusal\ncarries `gateState=frozen` and no `headState`. Those facts ride `error.details[]` as\n`kind = ai.cotal.ep.lifecycle-blocked` and are also appended to the error string, so a\ncaller that only prints `error.message` still sees them. The CLI and the connector tools hand a\nrefusal on in one shape, so `cotal spawn -f` keeps the same code, details, rendered facts and\nacceptance data as `cotal spawn --detach` and `cotal_spawn`. A connector that collapses the\nrefusal to \"startup failed (unknown)\" or a SPEC 13.6 wait-timeout is hiding a knowable\nstate, not reporting a missing one.\n\n## Instance routing\n\nA space can run more than one manager. Each manager persists a stable logical instance id\nacross restarts and advances its process epoch when it comes back, so callers address a\nspecific manager without caring which process currently serves it. A start serves only at the\nepoch its own registration committed, never at the epoch of a later start of the same instance.\nOn a static or open mesh,\nan untargeted spawn rides class anycast (any manager may accept, and the acceptance records which one did).\n`cotal spawn <persona> --detach --on <instance>` and `cotal_spawn(instance: \"<instance>\")`\npin one instance by its exact id. A foreground CLI spawn has no manager to pin and refuses the\nflag. An MCP pin that does not resolve is refused without falling back to class anycast. There are no ordinal\naliases and no short forms: wherever a display names an instance you can address, it prints\nthe whole id, because both surfaces take nothing else.\n\nOn a user-auth mesh, manager commands obtain a short-lived `manager-caller` view from the\nexchange. It authorizes one concrete manager instance using the caller's current actor grant and\nthe host's registered service records. Discovery and invocation both use that instance's `inst`\nroute. This view grants no registry scan, class queue, or additional command capability. An absent,\nambiguous or unauthorized selection refuses before the command is sent.\n\nManaged launches carry `COTAL_MANAGER_INSTANCE` so their tools address the manager that launched\nthem. Existing unbound sessions can use the exchange's unique authorized selection without\nreplacing their actor or conversation. The connector uses a separate control connection; the\nstanding message connection and its credential source are unchanged. Accepted spawn goals are\nfollowed on that renewing connection, using its existing caller-scoped progress grant, so a long\nreadiness budget does not depend on the short-lived control credential. The follower confirms its\nprogress subscription with the broker before submitting on the separate connection. A caller still\nchecks the resolved instance and epoch, and never retries an ambiguous mutation outcome.\n\nThe manager's `goal-result` command accepts `{goalId}` and returns `{goalId, result?}`. It reads\nonly the authenticated caller's owner, actor and lifecycle through the manager's separate trusted\ngoal-writer connection. The caller receives an attributed reply, never a raw JetStream reader\ngrant. Each read is admitted by the connection's broker-enforced command grant. A live user-auth\nconnection remains bounded by its bearer expiry after revocation; a renewed connection is checked\nagainst fresh authority. There is no separate per-read ledger check. An absent `result` means no\nterminal is recorded; it does not prove the goal is running or permit another submission. The\nexisting trusted goal-writer's leader-served EPF read is space-wide at the broker; the handler\nconfines it to this endpoint and caller triple.\n\nThe manager's reserved `cancel` command accepts `{goalId, mode?}` and returns `{goalId, state}`.\nIt is served for a turn the manager relays. The goal is the authenticated caller's own, so a caller\nwithdraws only a turn it submitted. The turn ends `cancelled`, its seat is not shown it again, and a\nlater yield of it is answered with that terminal. A goal that already ended is refused\n`failed-precondition` with its cached outcome attached, and a goal this manager does not relay is\nrefused without being changed. So is a second cancel that arrives while a first is still ending the\nturn; a first that fails leaves the turn pending unless something ended it meanwhile. A workflow\nrun sends it for the turn, ask attempt or escalation of a branch it cancelled.\n\nA followed mutation requires a manager whose attributed describe includes `goal-result`. Update\nthe manager, issuer and client together before using that recovery path. Reloading an issuer alone\ncannot change an already-running participant manager. Recovery re-resolves the accepting instance's\nepoch, preserves the caller lifecycle and validates the result against the accepted goal and any\nacceptance fingerprint. Stopping the caller ends its observation, not the already accepted goal.\n\nA followed call resolves the endpoint within its deadline before the submission starts, so a\nrefused or unanswered describe surfaces as its own error. On a user-auth mesh that preparation also\nobtains the caller's `manager-caller` view and opens its connection, and a failure there surfaces\nunchanged. A deadline that passes during those steps reports that the request was not run. The\ndescribe then spends what is left of the deadline, so a describe that draws no reply still reports\nitself as unanswered.\nA describe or command publish that the broker refuses reports `not-executed`.\nCancellation before submission reports `not-executed`. Once submission starts, cancellation or a\nlost reply reports an unknown outcome unless an attributed refusal proves otherwise. A received\nrefusal remains a refusal even when stop races it. Local failures do not invent responder identities.\nThe follower owns its subscription, timers and read cancellation signal. Reconciliation begins\nbefore the wait deadline, and late read completions cannot settle an expired observation. Its read\ncallback receives the accepting caller triple, remaining budget and abort signal; borrowed bearer\ncommands and control connections use that signal. An in-flight dial that finishes after cancellation\ncloses without publishing. A local reply-subscription failure prevents publication and is observed\nby the same request promise, including when the transport is closing or draining. Request\ncancellation does not revoke or resubmit the accepted operation.\n\n\"Only one manager per space\" is not the current invariant. A split topology that keeps the\nbroker host manager-free is still a topology choice: `cotal up` on that host starts a\nmanager you then stop with `cotal down manager` after `\u2713 manager up` in\n`.cotal/manager.<spaceKey>.log` (detach stdout listing `manager` is pidfile liveness, not a\nteardown boundary), and `cotal supervise\n--server` runs the manager elsewhere ([Run a mesh](run-a-mesh.md)). Extra live managers\nare addressable, not an error.\n\nThe reserved `describe` bootstrap is the one request the resolver may repeat while waiting: it is\nread-only, it is re-published under the same request binding, and every attempt stays inside the\noriginal deadline. This covers the startup window where Core NATS discards the first request before\nthe manager has subscribed. If the connection closes while the resolver waits, the describe fails\ncleanly instead of throwing from the retry timer. The resolved command is never repeated by this\nreadiness behavior.\n\nThe describe and the contract store reads after it share the resolver's one deadline, so a slow\nstore read fails the resolve with `deadline-exceeded` when that deadline expires. Cancelling a\nresolve settles a store read in flight instead of waiting for it to return.\n\nThe resolve and the invoke are separate trips through the same anycast queue, so in a\nmulti-manager space an unpinned call can land on an instance the caller did not resolve. Every\ncall carries the incarnation it resolved against, and a manager that is not that incarnation\n**refuses before running the command**, so the failure an operator sees says the command did\nnot run, and re-issuing it cannot duplicate the effect. That is the difference that matters for\na mutation: the older behaviour detected the mismatch on the reply, after the manager had\nalready acted, and could only tell you to go and check. `--on` still matters for reaching a\nspecific manager (`ps`, `stop`, `attach`, `spawn --detach`), but it is no longer what stands\nbetween a split and a duplicated spawn. Against a manager older than this fence the refusal is\nstill after the fact, and its message says so. The re-issue is automatic only when the refusal\nstates `not-executed` in its `outcome` field; a refusal that omits the field, or states\n`unknown`, is surfaced to the caller instead of repaired, because neither proves the command did\nnot run. The CLI's manager commands, `cotal invoke`, the `cotal run` verbs and the manager row of\n`cotal status` re-describe and re-issue an unpinned call after each such refusal, up to 16 times,\nso a split reaches the operator only when every attempt split. A hosted run's own manager calls\nuse the same bound. A pinned call is never re-issued. An agent's own manager\ntools, such as `cotal_spawn` and `cotal_despawn`, re-describe and re-issue with the same bound,\nincluding the goal-result read that follows a spawn to its outcome.\n\nAn unpinned targeted call, such as `cotal_despawn` or a hosted run's turn relay, can also reach a\nmanager that does not host its target, because each manager resolves targets against the agents\nit runs. That manager refuses with `expired` and `not-executed` and says it holds no mapping for\nthe target, and the same re-issue repairs it within the same bound. An agent that no manager hosts\nstill ends in that refusal once the re-issues run out. A pinned call gets the refusal of the\ninstance it named.\n\nA manager whose boot inventory marked every declared connector unavailable does not subscribe\n`spawn` or `launch` on the class `one` rail. Those commands stay on scatter and on this\ninstance's `inst` rail, so a sibling that can launch them can take an unpinned spawn, and a\ncaller that pins this instance with `--on` still gets a named harness refusal. `describe`\nstill lists the commands: the instance rail serves them, and `describe` itself stays on the\nclass rail (SPEC 13.7). An unpinned `spawn` can therefore bind-fence: `describe` may land on\nthe skip member while `spawn` lands on a sibling, the command was not run, and the caller\nre-issues or pins `--on`. `status` reports `classSpawn: false` when that skip is in effect.\nA manager that can launch some connectors keeps the class rail. If the queue hands it a\nharness its inventory marked unavailable, the refusal names `--on` because the standing serve\ncredential cannot read sibling inventories. Pin the capable instance (the whole id, as `ps`\nprints it).\n\n`ps` and\n`status` become a **scatter** across every registered instance: the caller freezes the\nexpected set from the service registry, invokes each under a shared deadline, and merges the\nresults with per-instance attribution. A non-answering instance is labelled as registered\nwith no answer within the deadline, never silently omitted. See [SPEC \xA713.5](../SPEC.md#135-verbs) (scatter) and [cli.md](cli.md).\n\nThe expected set comes from the **registry**, which records registration rather than liveness.\nAn instance that crashes never deregisters, so it stays in the set and the gather has nothing\nleft to wait for but an answer that cannot come. It pays the whole deadline, on every scatter,\nindefinitely. A scatter can therefore be given a per-instance liveness probe: when the broker\nitself reports that an instance holds no subscription on its own instance rail, the gather stops\nwaiting for it. Only that affirmative report counts. A lapsed presence entry, a probe that timed\nout, and a probe that failed are all *absence of evidence*, and treating any of them as death\nwould turn a slow correct answer into a fast wrong one, so they leave the full deadline standing.\nNothing about the outcome changes either way: an instance that did not answer is still\nunreachable, still surfaced, and the scatter is still not complete.\n\nThe probe is supplied by the **caller**, not invented by the scatter. Asking about an instance is\na publish on that instance's rail, and a credential that holds no row for it is refused by the\nbroker asynchronously, while the publish itself returns normally. The probe verb watches for that\nrefusal and raises it as `permission-denied` naming the rail, so it is never mistaken for a quiet\ninstance, and it never burns the probe budget waiting out a refusal. Only the layer that\nminted the credential knows which ids it may ask about, so that layer asks about those and no\nothers. `cotal ps` freezes the class on its first connection, re-mints an instrument pinned only\nto the frozen ids, and scatters on a second; a refusal the broker raises anyway is printed and\nthe instance's row says the probe was refused, which is a fact about the credential, not about\nthe instance.\n\nThis does not help against an instance that is **connected but not answering**. A hung manager\nholds its subscriptions, so it is indistinguishable from a slow one, and it still costs the full\ndeadline. That is the correct result, not a gap in the probe.\n\n### Deregistration\n\nA probe makes a dead registration cheap to skip; it does not remove it. Removal is the\nregistration's own exit, and there are two explicit routes to it\n([SPEC \xA713.5](../SPEC.md#135-verbs): a deleted `svc` spec *is* the deregistration).\n\nA manager that stops cleanly removes its own registration, so an ordinary shutdown leaves no stale\nrow. The delete is pinned to the registration revision that process wrote. When a successor has\nregistered the same instance since then, the stop logs that and leaves the successor's registration\nalone. It refuses that delete while this instance holds the endpoint governance slot at the live\nissuance-gate generation (a registration still completing its reopen). A leftover slot whose\ngeneration is behind that live generation is not in-flight and does not block the stop. A manager\nthat cannot renew or read its lease keeps serving, stays registered, and retries. If another process\nholds the same instance key, that process has taken the instance over, so this one logs the conflict\nand exits without deregistering, leaving the successor's registration alone.\n\nA restart that died *mid-registration* is a different residue: the issuance gate stays frozen under\nthat op. The successor completes the dead registration on boot when the freeze-holder is\naffirmatively gone under a complete CONNZ sweep (the same composition as\n[`cotal reconcile-gate`](cli.md#reconcile-gate)). A committed spec write is finished under that\nsame freeze; only a definite no-commit abort-reopens and then runs the normal takeover.\nOnce that reopen commits, the registration has completed: a governance slot it then fails to release\nis left behind the live generation, as on a normal registration, and does not fail the restart.\nIt does not invent a TTL and it does not start a new freeze over a still-held one.\n\nThat residue has a second half, and it is the endpoint governance slot rather than the gate. Every\nregistration takes the endpoint-wide slot before it publishes its spec and holds it until its own\ngate reopens, which is what serializes registration for the endpoint. An instance that stopped\nbetween those two points leaves the slot held with no registration behind it, so the endpoint\nrefuses new registrations while nothing is actually in flight. The slot is stamped with the\ngeneration of the gate its holder had frozen when it took it, and a slot is promoted only at that\nsame generation. So once the holder's gate has reopened past the stamp, the slot can never be\npromoted by anyone, and the next registration for that endpoint replaces it. That reclaim is part of\nan ordinary start and needs no operator step.\n\nA slot whose holder's gate is still at the stamped generation is a registration that is genuinely in\nflight, and it keeps refusing. The two states read differently only in the holder's gate coordinate,\nso reopening that gate is what separates them: the holder's own restart heals it on boot, and\n[`cotal reconcile-gate`](cli.md#reconcile-gate) is the operator's route when the boot path cannot\nrun. The registration path is the slot's only writer, and neither repair command writes it.\nA registration that cannot read the holder's gate at all refuses, because an unreadable gate does\nnot distinguish the two states either. Each of these refusals carries\n`kind = ai.cotal.ep.foreign-slot-held` in `error.details[]` with the holder's instance id and the\n`condition` that refused: `in-flight` for a holder gate still at the stamp, or `no-seam`,\n`unreadable`, `garbled` or `behind` when the registration could not read that gate or read it below\nthe stamp. A remote manager asks its host to reconcile the holder only on `in-flight`, the one\ncondition a gate repair can clear.\n\nFor the instance that cannot cooperate, an operator names it:\n`cotal deregister-instance --instance <id>` ([cli.md](cli.md#deregister-instance)). It removes the\nrecord only on the same evidence `cotal ps` acts on: the broker reporting nothing subscribed on\nthat instance's own rail. It refuses if the instance answers a describe, refuses if the probe could\nnot run at all, and refuses if the instance is merely quiet, because a hung process still holds its\nsubscriptions and is therefore not affirmed gone. It also refuses while that instance holds the\nendpoint governance slot at the live issuance-gate generation (a registration still completing);\na leftover slot behind that generation is not in-flight and does not block. Nothing sweeps the\nregistry on an age threshold or on silence.\nAn instance that is deregistered while it is merely wedged re-registers over the tombstone on its\nnext start, which is what makes the operator's decision a recoverable one.\n\n## Attach sessions\n\n`cotal attach` no longer returns a `ws://127.0.0.1` URL. It creates a one-use, holder-bound\nsession offer: the manager mints a token bound to the caller, the target lifecycle, its own\ninstance id and epoch, and an expiry, and replies with a session id and expiry only, no URL\nand no secret in the reply. The CLI redeems the offer over the mesh (a second redeem is\nrefused). On a registered open mesh that redeem is a bare connection, the same path other\ncontrol commands already use; on a static-auth mesh it is still a session-caller credential\nminted from the resolved root's seed. On a user-auth mesh the CLI holds no seed: it exchanges its\nlogin and the grant for a `session-caller` view bearer, and the callout mints the same caller rails\nwith the grant's expiry. Terminal bytes then stream on core-NATS session subjects\nscoped to the two parties. Backpressure is a bounded in-flight window with an explicit drop notice, never\nsilent loss; a late attach still repaints the full screen from a replayed terminal\nsnapshot. Close, expiry, target despawn, and a manager restart are distinct, surfaced end\nstates: a restarted manager's successor refuses the old epoch's sessions and the client\nshows \"manager restarted; re-attach\".\n\n## Seat input\n\n`attach` is a stream, so it is the wrong shape for a program that wants to send one line: it\nholds a session open and expects a terminal at the caller's end. The `input` command is the\nother half. One authorized call writes text into a running seat's terminal as if it had been\ntyped there, and answers with the seat and the number of bytes delivered.\n\nIt exists for **harness commands**. A line beginning with `/` (`/compact`, `/clear`, `/model`)\nis neither chat nor an event: the agent's own harness handles it, and the keyboard is the only\nway in. An external control surface that can already read a seat's turns and talk to it still\ncannot drive it without this.\n\nThe op is targeted, rides the `manager.lifecycle` capability, and declares authz modes `owner`\nand `any`, the row shape `attach` and `despawn` already carry, checked by the same authorization.\nEnter is appended unless the caller suppresses it, and nothing is echoed back, since the resulting\nturns already have somewhere to go.\n\n**Who may call it is narrower than either of those**, and the reasoning is worth stating because\nthe natural assumption is wrong. `despawn` and `attach` are granted to anything holding `spawn`;\n`input` is granted only to operator credentials. The tempting argument for treating them alike is\nthat an attach session's `write` already reaches the same terminal, so `input` adds nothing. It\ndoes not reach it: an attach yields a signed session offer, and redeeming one needs a per-session\ncredential minted from the space signing seed, which no agent holds. So `input` would be new\nauthority, and the own-owner rule that bounds `despawn` covers every seat under an owner rather\nthan only the ones a caller launched. Killing a peer is denial; typing into a peer is control of\nit. The write therefore sits with the credential that is already the administrative authority for\nthe domain.\n\nOnly a runtime that owns the child's input stream can serve it. The `pty` runtime does; the\nexternal terminal runtimes attach to a process they do not own, and there the command refuses\nand names the runtime rather than dropping the keystroke. A seat that is not running refuses for\nits own reason, and the two are distinguishable, so a caller can tell \"this will never work\"\nfrom \"not right now\". See [cli.md](cli.md#input).\n\n## Grants\n\nThere is no broad control credential. A caller holds one capability row per command it is\nallowed to send, and minting maps each named capability to the request subjects it needs and no\nothers. The manager serves over a scoped serve credential that can answer and\nreply but cannot, for instance, write another endpoint's records or forge a goal terminal;\nthe goal-fact writer and the session writer are separate, narrowly scoped credentials the\nbroker fences by subject. Authorization is checked at the serving boundary, and for actions\nit linearises at acceptance: a spawn refused there mints no reservation and leaves no\nprocess. See [SPEC \xA713.9](../SPEC.md#139-authority-boundary) and\n[identity & auth](identity-and-auth.md).\n\nA carried resume transcript never rides the rails. The operator-only `transcript-receive` command\nanswers whether to upload and hands back a one-time claim for `spawn`, and the bytes travel through\nthe target instance's own transfer bucket under two one-shot credentials: a writer the operator\nmints for that one transcript, and a reader the target instance mints for its own bucket, or that\nthe host issues a remote manager through its `transferReader` authority operation.\n\n## See also\n\n- [Architecture](architecture.md), where the manager and the wire fit in the whole system.\n- [CLI](cli.md), for `describe`, `invoke`, `spawn`, `ps`, `status`, `attach`, and `input`.\n- [SPEC \xA713](../SPEC.md#13-endpoint-control-surface-v04), the normative contract.\n"
62600
62660
  },
62601
62661
  {
62602
62662
  "slug": "define-a-team",
@@ -62624,7 +62684,7 @@ function loadDocsBundle() {
62624
62684
  "title": "Embedding Cotal",
62625
62685
  "kind": "Guide (informative)",
62626
62686
  "summary": "The cotal binary in this repo is one composition root: an operator CLI.",
62627
- "body": "# Embedding Cotal\n\n> **Guide** (informative) \xB7 **For:** implementers building a service on top of Cotal \xB7 **Prereqs:** [Architecture](architecture.md), [Identity and auth](identity-and-auth.md), [Delivery daemon](delivery-daemon.md)\n\nThe `cotal` binary in this repo is one composition root: an operator CLI. A separate service\n(for example a hosted, multi-tenant Cotal) does not fork this repo. It writes its **own**\ncomposition root that depends on the published `@cotal-ai/*` packages and imports the surfaces it\nwants. `bin/cotal.ts` uses the same composition pattern. This page is the contract for that: what is a real library\nexport you can build against, how to boot the server-side daemons from those exports, and where the\ncurrent export surface stops short of a fully hosted composition.\n\nThis is the \"guarded substrate\" boundary in practice. Nothing here reveals or assumes a specific\nhost; it documents the public seams any embedder composes.\n\n## What you embed\n\nThe supported reference shape here is **one broker operator serving one space** (one tenant: a\ndedicated data account, under an operator that also holds the system account and a quarantined\nauth-callout account) plus three standalone processes. The trust layer itself composes many spaces\nunder one broker operator today (`createBrokerAuth` + `createSpaceAccountAuth` + N-space\n`serverConfig`); what does not exist yet is the per-space **lifecycle** on a shared broker (see\n[Known gaps](#hosted-composition-gaps)). The three processes:\n\n| daemon | package | what it is |\n|---|---|---|\n| auth-service | `@cotal-ai/auth` | the NATS auth callout, the IdP token exchange, and JWKS. Plane 1 to Plane 2. |\n| delivery | `@cotal-ai/delivery` | the Plane-3 durable backstop: fan-out writer plus trusted reader, per space. |\n| supervise | `@cotal-ai/manager` | the per-machine agent lifecycle (spawn/despawn/attach), per space. |\n\n`mint`, `deliver`, and `auth-service` expose their behavior as direct library primitives, and the\nsupported one-space bootstrap below re-composes from exported low-level primitives. `supervise` and\nthe full `up` orchestration are **not** public runners: `up` also does broker bring-up, restore,\nprocess and registry management, and lifecycle work, and `supervise`'s orchestration is private (see\n[Supervisor signing authority](#supervisor-signing-authority)).\n\n## Owner-bound platform supervisors\n\nA host can start one user's supervisor without retaining that user's login. Pass\n`platformSupervisor: { authorizePlatformAdmin, observeAssignment }` to `startAuthService`, then call\n`handle.platformSupervisorAuthority(owner)` inside the trusted authority process. The first callback\nmust authenticate the platform's administrative caller through the host's own authority. The second\nreads that owner's recorded `PlatformSupervisorAssignment` fresh on every call. An absent,\nended, expired, foreign-account or wrong-revision assignment refuses issuance.\n\nTo end a supervisor, record its assignment `ended` and call\n`handle.endPlatformSupervisorAssignment(owner)`. It rechecks platform-admin authority and refuses\nunless your record already reads `ended`. It retires the manager gate and the issued supervisor and\nexecutor generations. After that, registration, activation and renewal refuse material returned\nearlier, even before it expires, with `platform supervisor assignment for owner <owner> has ended`\nor the retired-gate refusal. Stop the worker as well: an open broker connection lives until its\ncredential's capped expiry.\n\nThe assignment has `kind: \"platform-supervisor\"`, `scope: [\"supervise\"]`, one derived owner,\none manager instance, one lifecycle UID, a revision and a finite `expiresAt` in Unix seconds. It is\nnot an interactive or managed-agent ledger row and grants no `admin`. The returned door is bound to\nthat owner and accepts `PlatformSupervisorAuthorityRequest` with the same owner, assignment revision\nand an existing `RemoteManagerAuthorityRequest`. It serves only `prepare`, `activate`, `renew` and\n`renewStandingBundle`. Unknown fields, generic profiles, mint, human exchange and another owner\nare refused before issuance. Do not give the worker the auth handle, loopback capability or signer.\nThe host carries only the bound door over its authenticated worker channel.\n\nUse `remoteManagerClient` and `registerRemoteManagerAuthority` from `@cotal-ai/manager` for the\nexisting registration ceremony. The result keeps the stock manager material shape and five nkeys.\nAll returned credentials expire no later than the assignment. The supervisor and executor ceilings\nare recorded in the native issued store. The recorded permission set must be byte-identical to the\nauthority plane's signed native permission set at issuance and every renewal, and must never widen.\nThese JWTs are signed by the authority plane, not returned by the user-token callout. The door\nadds no human bearer, callout view, owner ledger grant or enrollment authority. Run admission,\nhost-backed enrollment and shutdown maintenance need their own host composition.\n\nSee [the issuance design](design/user-auth-run-start.md#platform-supervisor-registration).\n\n## The export surface\n\nEverything below is a real export of a published package, reachable from the package root (each\npackage publishes only `.` via `dist/index.{js,d.ts}` and ships `files: [\"dist\"]`). Type-only names\nare marked; import them with `import type`.\n\n**Daemon runners and lifecycle**\n\n| symbol | package | purpose |\n|---|---|---|\n| `runAuthService(args, store?)` | `@cotal-ai/auth` | boot the auth-service daemon; `store` injects the secret material. |\n| `runDelivery(args, store?)` | `@cotal-ai/delivery` | boot the delivery daemon; `store` injects the scoped `delivery` cred. |\n| `startAuthService(inputs)` | `@cotal-ai/auth` | start one account-scoped auth-service context and return an `AuthServiceHandle` with the loopback `url`, the per-start `cap`, `readiness`, `drain`, and idempotent `close`. `close` rejects when the context did not release its plane claim: the claim row was no longer its own, or the release write failed. The row then stays held, and the next start reclaims it through the liveness oracle. With the optional `publicFace` input it also serves the public exchange face and carries `publicUrl`. With the optional `platformControl` input the handle also has `platformControlAuthority`, the in-process platform control door, `platformControlReadiness`, its read-only readiness read, `observeManagerGate`, the manager gate read a host composing the delegated user intent decisions passes them, and `activateManagedLifecycle`, the activation that host runs at a delegated launch's pinned lifecycle UID. With `platformControl.host` it also has `registerHostIncarnation`, which registers the host process's own endpoint instance and returns the incarnation a delegated execution pins, `observeHostGate`, the read of that endpoint's issuance gate, and `awaitHostFence`, which resolves once a later registration or barrier fences an incarnation. `runAuthService` remains the CLI entry. |\n| `PlatformControlAuthorityRequest`, `PlatformControlInnerRequest`, `PlatformControlAuthorityResult`, `PlatformControlAssignment` *(types)* | `@cotal-ai/core` | the closed envelope, its inner request union, its result and the backend's assignment row for `platformControlAuthority`. `platformControlOwner` in `@cotal-ai/auth` derives the `p_` owner the door issues under. |\n| `startDeliveryService(inputs)` | `@cotal-ai/delivery` | start one account-scoped delivery instance and return a `HostedServiceHandle` with `readiness`, `drain`, and idempotent `close`. `close` rejects when the lease row is still held after it, by another holder or because the release did not commit, or when the release cannot be confirmed. The process runner remains the CLI entry. |\n| `deliveryCredsKey(space, composition)`, `membershipRwCredsKey(space, composition)` | `@cotal-ai/workspace` | build the secret-store keys the delivery cred and the membership feed's rw cred are read/re-signed under. Keys are **per-space**: `space.<hex>/<kind>`. A hosted composition passes `{ injected: true }`. |\n| `retireManagerInstanceIdentity(root, space, expected)` | `@cotal-ai/workspace` | remove a persisted manager identity only if its complete instance id and serve identity still match `expected`. Returns `removed` or `absent`; refuses malformed, nonregular, and changed records. `absent` is not proof of ownership or successful teardown. The caller must separately prove stop and retirement ownership before using it. |\n| `DELIVERY_CREDS_KIND`, `MEMBERSHIP_RW_CREDS_KIND` | `@cotal-ai/workspace` | the operator-facing KIND names (`delivery.creds`, `membership-rw.creds`) those keys are built from, and what renewal results report. A kind is **not** a key: putting a cred under the bare kind writes the pre-0.4 flat location, which nothing reads. |\n| `Manager`, `ManagerOptions` *(type)* | `@cotal-ai/manager` | construct and run a supervisor in-process; `ManagerOptions.secretStore` injects the one store it reads/writes every secret through. `ManagerOptions.remoteAuthority` is the hosted manager-service authority bundle, including host-owned release, retained-validation, goal-index, and serve-time admin-authorization callbacks. |\n| `ManagerOptions.pooled` | `@cotal-ai/manager` | require signerless remote authority and either an explicit non-custodial runtime or `execution: \"none\"`. A pooled composition must supply the assigned account key and all-duty renewal callback; the CLI's default remains unchanged. A signed-in human's manager gets that material from `managerServiceAuthority`. A platform-run control manager gets it from `AuthServiceHandle.platformControlAuthority` with the shipped `remoteManagerClient` builders, as the [platform control authority](design/platform-pooled-control-authority.md) design describes. |\n| `ManagerOptions.execution` | `@cotal-ai/manager` | omitted or `\"runtime\"` keeps runtime execution. Explicit `\"none\"` requires pooled, complete signerless authority and no `runtime` option. It constructs no Runtime and performs no connector inspection, seat launch or workflow hosting. |\n| `createRuntime`, `Runtime` *(type)* | `@cotal-ai/manager` | resolve the spawn backend (pty built in). |\n| `liveKvEntries(kv, filterOrOptions?, options?)`, `LiveKvEntriesOptions` *(type)* | `@cotal-ai/core` | read live KV entries in one finite scan. Pass `{ signal }` as the second argument or after a key filter to cancel. An interrupted scan throws `IncompleteKvScan`; cancellation throws the signal reason, including during an empty-bucket bind. The scan deletes only its owned consumers, including each one nats.js rebuilt from it, after the broker has answered every create those rebuilds sent. A not-found delete counts as gone, a refused one leaves the consumers to broker inactivity expiry (which also covers a crash), and any other delete failure is thrown, as is a not-found delete of a consumer whose create got no reply from the broker before a timeout or a closed connection. A cleanup failure is thrown only when the scan would otherwise return; the scan's own error, its cancellation reason and `IncompleteKvScan` take precedence. |\n\nThe remote manager authority parser accepts `renewStandingBundle` and `renewRunDriver` only with\nan assigned account nkey, the current manager process epoch, and a host-authenticated registration\nproof. A run renewal also names its active holder, takeover, epoch, fencing token, and the two\nexisting nkeys. The host must fresh-check those coordinates against its registration gate and run\njournal before issuing server-selected profiles. A host without that renewal authorization refuses\nthe request. Until the host issuer wires the operations and validates them on real connections,\nthe presence of these types is not an operational pooled renewal guarantee.\n\nAn execution-free control Manager retains native registration, goal recovery, session-ledger,\nrenewal and maintenance duties. Its status reports `execution: \"none\"`, `runtime: \"none\"`,\n`custody: \"none\"`, no children or connectors, and false `classSpawn`, `runHosting` and\n`terminalSessions`. The required status fields change the output digest and manager cluster\nrevision to 25. Execution is recorded per instance. An incompatible record, retained seat/turn\ninventory or an older unclassified instance refuses boot before recovery.\n\nSelect the genuine participant Manager with a host-issued, instance-pinned `manager-caller`\nview for workflow and terminal operations. That Manager retains the children, workflow journal\nand terminal sessions. The control instance neither adopts roster peers nor proxies user runs.\nMissing participant selection or authority refuses. This embedding capability adds no CLI flag\nor application routing.\n\nWith `renewStandingBundle` configured, the manager renews all five standing credentials together.\nIt checks every returned credential for the held nkey and assigned account, test-connects each one,\nand adopts them only if the serve epoch has not moved. A refused or failed candidate leaves the\ncurrent credentials in place and records the refusal as cleanup debt until a later renewal succeeds.\nOn shutdown, after the standing context is drained, an expired maintenance executor is renewed\nthrough the existing scoped host operation so deregistration can finish without restarting duties.\n\n**Provisioning and minting** (all `@cotal-ai/core`)\n\n| symbol | purpose |\n|---|---|\n| `createBrokerAuth(label)` | mint BROKER trust: the operator and system account one nats-server trusts. One per broker, shared by every space on it. |\n| `createSpaceAccountAuth(broker, space)` | mint one space's own data account, signed by that broker's operator: the add-a-tenant primitive. |\n| `createSpaceAuth(space)` | the one-space convenience: broker trust + one account in a single composed bundle. |\n| `setupSpaceStreams({ servers, space, creds })` | create the space's JetStream streams. |\n| `ensureDefaultDeliveryClass({ servers, space, creds?, deliveryClass })` | write the space's default delivery class at creation so it is wire-discoverable (SPEC section 4). |\n| `serverConfig(broker, spaces, { storeDir, maxFileStore?, extraAccounts?, port?, host? })` | render the broker config: one operator, N space accounts. `storeDir` is required, `maxFileStore` caps JetStream file storage in bytes (omitted, nats-server's dynamic default applies), and `extraAccounts` preloads the auth-callout account. |\n| `mintCreds(auth, identity, profile, opts?)` | mint a scoped cred for any `Profile`. |\n| `mintMembershipObserverCreds`, `mintConnectionEvictorCreds` | mint the membership/eviction scoped creds. |\n| `provisionAgent`, `provisionAgentDurables` | create a principal's bind-only durables. |\n| `newIdentity`, `stripSpaceAuth` | a fresh nkey identity; a stripped signer bundle (data signing seed only). |\n| `Profile`, `CredentialKind`, `MintOpts`, `SpaceAuth` *(types)*, `CREDENTIAL_LIFETIMES` | the profile matrix and cred lifetime policy. |\n\n**Auth building blocks** (all `@cotal-ai/auth`)\n\n| symbol | purpose |\n|---|---|\n| `createCalloutAuth`, `startAuthCallout` | the NATS auth-callout responder. |\n| `createUserTokenIssuer`, `pinnedJwksResolver` | mint and verify the Cotal user bearer. |\n| `createIdpBridge` | exchange a verified IdP JWT for a Cotal bearer (see [the callout contract](identity-and-auth.md#the-idp-callout-contract)). |\n| `deriveOwnerToken`, `validateUserToken` | owner derivation; strict bearer validation. |\n| `cotalAuthProvider` | the self-registering `auth-provider` extension. |\n| `ensureCalloutAuth`/`loadCalloutAuth`, `ensureIssuer`/`loadIssuer`, `ensureOwnerSecret`/`loadOwnerSecret` | read/write the auth secret kinds through a `SecretStore`. |\n| `PLANE_CLAIM_REFUSED`, `planeClaimRefusal`, `PlaneClaimRefusal` *(type)* | every plane-claim refusal carries a `PLANE_CLAIM_REFUSED` detail, and `planeClaimRefusal(err)` reads its `reason`: `corrupt`, `live-peer`, `unknown`, `concurrent`, `fenced`, `released` or `lost`. Only `unknown`, an inconclusive liveness observation, is coded `unavailable`. A host can retry contention and stop on a corrupt row without matching message text. |\n\n**Seams and the wire** (all `@cotal-ai/core` unless noted)\n\n| symbol | purpose |\n|---|---|\n| `SecretStore` *(type)* | the durable hosted-secret seam (get/put/delete); `get()` returns raw seeds/keys into process memory, so it is a blob seam, not HSM/KMS signing. |\n| `FsSecretStore`, `workspaceSecretStore(root)` | the filesystem default. **These live in `@cotal-ai/workspace`, not core.** |\n| `AuthProvider` *(type)*, `Connector` *(type)*, `Runtime` *(type)*, `Command` *(type)* | the extension contracts; implementations self-register on import. |\n| `registry` | the shared registry a composition root pulls surfaces into. |\n| `CotalEndpoint`, subjects, message types | the wire client and shapes. |\n| `ParsedArgs` *(type)* | the shape the daemon runners take (see below). |\n\nFor a Linux Unix-socket adapter, `peerCredentials(socket)` from `@cotal-ai/seat` returns\nkernel-observed peer `pid`, `uid` and `gid`. Compare these against the host's authorization\npolicy; request-supplied identity and process liveness do not replace that policy or a\nlifecycle fence. The helper starts no custodian and refuses unsupported platforms or a\nmissing native helper.\n\nThe runners take a CLI-shaped `ParsedArgs`, not a typed options object, so a host fabricates one:\n\n```ts\nconst args: ParsedArgs = { values: { space, server, port: \"0\" }, positionals: [], raw: [] };\n```\n\nFor an embedded delivery instance, use `startDeliveryService` instead. Its `HostedContextInputs`\ninclude the account public key and lifecycle UID, space, broker URL, injected store, stable\n`storeIdentity`, and an explicit `stateDir`. The store must declare that same injected identity.\nThe initial delivery credential must belong to the assigned account. The function returns only\nafter the delivery responder is bound. `close()` withdraws serving and releases only the lease\nowned by that instance. It closes both membership connections even when a disconnected drain\nfails, so they cannot reconnect after closure. It rejects when the lease row is still there\nafterwards, held by another instance or left by a release that did not commit, or when the broker\ncannot confirm the release. `drain()` returns the same promise. A successor is refused while such\na row lives, and the bucket TTL expires a row the instance left. Credential-expiry health state clears after\nsuccessful broker-verified adoption through the existing `reloadCreds` rail. A failed start\nrefuses locally without exiting the host process or stopping another account's delivery service.\nIf a health fault occurs during an asynchronous store read, startup rejects when the read returns\nand closes any resources created by that late completion.\n\n`startAuthService` takes the same `HostedContextInputs`. The store must declare the assigned\ninjected identity, and its data account must be the assigned account. The IdP pin and ledger live\nunder the explicit `stateDir`. The auth plane's instance identity holds its private serve seed, so it\nlives in the store under `authInstanceKey(space)`. A record an earlier release kept under `stateDir`\nmoves into the store on the first start.\nThe context never resolves a workspace root from the working directory and has no local manager, so\nonly remote manager gates can be selected. It returns after\nthe authority plane, the callout subscription and the loopback listener are bound. A start that\nfails closes the connections it opened and releases its plane claim, so a retry on the same space\ncan claim it. A fenced plane or\na lost broker connection makes that context `unavailable` and closes it without exiting the process.\nThe host writes no discovery file for it. The handle carries what `auth-service.json` holds for a\nCLI start: the loopback `url`, `publicUrl` when a public face runs, and the per-start `cap`. The cap\nalone authorizes the loopback host actions, lifecycle retirement and managed-agent enrollment\nverification, so keep it in the authority process. `publicFace` takes the CLI's public face inputs\n(`port`, `url`, `trustedProxy`, `advertisedServer`, `agentProvisioningUrl`) under the same rules,\nand a face without a port refuses to start.\n\nTwo optional inputs serve a platform composition. `platformControl: { observeAssignment }` adds\n`platformControlAuthority` to the handle. It is a typed in-process method, served on no listener,\nthat issues the manager-service request family for the one control manager the backend assigned to\nthis account, under a derived `p_` owner. It reads the assignment fresh on every call and refuses\nan IdP token, another account, a stale revision, another instance or lifecycle, and an instance\nanother owner registered. It refuses `prepare` and `activate` while the assignment's named\npredecessor is still registered or frozen. The same input adds `platformControlReadiness(instanceId)`,\nthe route for a host that needs to know whether its assigned control manager is serving. It returns\nthat instance's attributed `status` reply and refuses any instance the current assignment does not\nname, or one whose gate another owner holds. It reads over the context's own connection, whose\ngrant is the assigned instance's `describe` and `status` and its own reply rail. That connection\nrenews in process like the context's other connections and never leaves it, so the host lends no\nhuman or operator credential to a worker, mints no control instrument per read, and does not read\nliveness off the manager process. Without the input both members are `undefined`.\n`standingRenewableTtlSeconds` is forwarded unchanged to the authority plane, which bounds it to 5\nto 86400 seconds. It is a trusted-host input for the renewal rehearsal, and no request or CLI flag\nsets it. SPEC \xA713.1 and \xA713.6 define the view.\n\nThe auth plane can renew a registered manager's\nfive standing credentials from the current service registration. Run-driver renewal still\nrefuses without an authoritative activated-run reader, so these handles do not yet make a\ncomplete pooled auth and delivery host. A fresh auth plane can initialize without a\ndelivery-admin responder. Reclaiming a held claim from a dead predecessor needs the delivery\ninstance first: its admin rail must complete the broker connection-liveness sweep before the\nauth plane takes the claim. An absent or inconclusive oracle refuses the reclaim.\n\n### Remote manager client composition\n\n`@cotal-ai/manager` exports the `remoteManagerClient` namespace, containing the stock remote\nrequest builders and response validators, and `registerRemoteManagerAuthority` for registration\nwith a host-issued prepare credential. It returns the process epoch and registration revision its\nown registration committed. When a later start of the same instance registers before this start\nauthorizes its serve grant, this start is refused with `expired`.\n`RemoteManagerIdentityState` describes the five private\nmanager identities stored under an explicit account-local root. Use these public exports when\ncomposing `ManagerOptions.remoteAuthority`; do not copy CLI validators or import private modules.\n`managerClusterArtifacts()` returns the canonical document, manifest and their digests used by\nregistration. Pass its `[document, manifest]` pair as `contractArtifacts` to both\n`remoteManagerRegistrationProof(owner, state, contractArtifacts)` from `@cotal-ai/core` and\n`remoteManagerClient.remoteManagerAuthorityRequest(state, actor, \"activate\", { registrationProof, contractArtifacts })`.\nThe request builder takes each operation's coordinates as named fields of its last argument.\nThe host still validates the artifact closure and current registration before activation.\nThe namespace includes closed standing/run renewal, admission, maintenance, enrollment and\nretirement helpers. `publicIdentities(state)` returns the `identities` record every request\ncarries: the five public ids, in the order the host echoes them. `remoteRunHosting` builds the four `runHosting` callbacks from the registration\nand the transport you supply for the host's run admission, run attempt and authority requests, so\nyour manager sends the run requests the stock manager sends. The namespace provides no signer or\nnew grant. The host still owns authenticated issuance, current registration and activated-run\nobservations, and any guarded foreign-holder repair.\nAn embedding must preserve those checks and supply a supported runtime; the client exports alone\ndo not provide a pooled runtime, an authority service or a complete hosted context.\n\n### Long-lived endpoints take a bearer function\n\n`EndpointOptions.bearer` accepts either a string or a function, and the difference is not stylistic.\nA string is minted once, so when it expires (which it will: callout bearers live minutes) the\nendpoint has nothing to renew with. It will not present the dead token to the broker, since that is\na guaranteed denial that still costs a full auth-callout round trip. It refuses to reconnect, emits\n`warning` saying which case it is in, and retries on a widening backoff until the process\nre-authenticates and rebuilds it. Retry notices use `warning` rather than `error` because Node\nrethrows an unhandled `error` event and would kill a host the endpoint is still trying to recover.\n\nPass a **function** for anything that outlives one bearer. That is a renewal source: it is called\nahead of each expiry and again whenever a reconnect finds the cached bearer dead, and it requires\nexplicit `card.owner` and `card.actor`. The first-party surfaces already do this\n(`UserViewAuth.source`, the connector's `agentBearerCommand`). A string bearer is for a short\none-shot connection.\n\nLong-lived hosts must also subscribe to the endpoint's `warning` event. It carries conditions the\nendpoint is surviving, including failed credential renewal, reconnect retries, a durable leave\nit keeps retrying after the broker refuses a durable channel's live subscription, and a channel\nregistry entry that does not decode. The endpoint drops that entry from its cache and applies the\ndefault policy until a valid entry is written. A host may choose\nto ignore warnings for a one-shot endpoint whose awaited operation owns the verdict, but that choice\nshould be explicit. An unhandled warning is nonfatal and silent.\n\nThe `error` event carries a fault the endpoint cannot return from a call, such as a refused\nsubscription or a refused publish that no request was waiting on. Attach a listener before `start()`,\nsince Node throws on an unhandled `error`. A denial the broker returns to a request, such as an\nobserver's read of the DM stream, reaches only that call, which decides what it means, and is not\nemitted again as an `error`.\n\n## Booting the daemons\n\n### auth-service\n\n`runAuthService(args, store?)` reads its provisioned long-lived secret kinds (service keys, callout\naccount, issuer keys, owner secret) through the injected `SecretStore`; a host provisions those into\nthe store first. It is a **signer and identity authority**, not a scoped daemon: at runtime it holds\nthe data-account and callout-account signing seeds, the issuer's private JWKs, and the\nowner-derivation secret in process memory (`SecretStore.get` exports raw values). The IdP pin and the\nactor ledger are **not** store-injected: `runAuthService` resolves them under\n`userAuthStateDir(findCotalRoot(), space)`, a path relative to the process working directory, so a\nhost provisions those into that exact directory (neither `store` nor `COTAL_HOME` selects it). It\nalso writes an ephemeral `auth-service.json` discovery file there that carries the live exchange\ncapability. That file appears only after every plane is bound, so waiting on it is the readiness\nsignal: a host that also passes the daemon's pid to the provider's `ready()` gets a process-bound\nwait. The wait extends past the base timeout while that pid is alive, up to a fixed bound, and it\nends at once when the pid exits.\n\n```ts\nimport { runAuthService } from \"@cotal-ai/auth\";\n// store implements SecretStore over your secret backend; get() returns raw seeds into memory.\n// Provision the auth secret kinds into the store, AND the IdP pin + actor ledger under\n// userAuthStateDir(findCotalRoot(), space), before this call.\nawait runAuthService(\n { values: { space, server: brokerUrl, port: \"8081\" }, positionals: [], raw: [] },\n store,\n);\n```\n\n### delivery\n\n`runDelivery(args, store?)` runs from a **pre-minted scoped `delivery` cred** and never loads the\nsigner. Provide the cred either through the injected store (under\n`deliveryCredsKey(space, { injected: true })`) or with a\n`--creds` file; the two are mutually exclusive. The daemon re-fetches the cred from the store at 75%\nof its JWT lifetime and fails loud rather than riding to expiry, so **something must re-sign a fresh\ncred into that same store**. When that read finds the previous generation still there, the daemon\nreports the missed remint and retries in 60 seconds. The current cred stays live until its expiry,\nand the store is read once per retry rather than once per second.\n\n```ts\nimport { runDelivery } from \"@cotal-ai/delivery\";\nawait runDelivery({ values: { space, server: brokerUrl }, positionals: [], raw: [] }, store);\n```\n\nThat renewal is a **signer** operation, not the delivery daemon's:\n`remintDaemonCreds(root, space, store?, { preflight? })` (`@cotal-ai/workspace`) reads the `SpaceAuth`\nsigner **through the same resolved `store`** (`getSpaceAuth(store ?? workspaceSecretStore(root), space)`,\nkeys `auth/broker.json` + `auth/account.<key>.json`; the pre-split `auth/auth.json` monolith is\nmigration input and the container signer mount only) and re-signs the daemon creds (`delivery.creds` and the membership feed's\n`membership-rw.creds`) back into that store. The injected `store` is both the signer source and the\ncredential destination, never a split. `space` is **required** and validated against the store's signer, so a\nstore swapped to a different space cannot re-sign over the wrong broker's creds. `preflight` is a\ncaller-supplied proof that the broker accepts the credential. The reference `Manager` passes a\n`probeConnect` over its `servers`. It gates **every** candidate before overwriting the last-good,\nwhether the signer is a full bundle or a stripped projection: a bundle's JWT chain proves only that\nit is self-consistent and\nnamed the space, NOT that its account is the broker's *current* account for that space (two\n`createSpaceAuth(space)` calls yield same-named, different-account chains), so a same-label alternate\nsigner would otherwise mint a broker-dead cred and clobber the good one. The offline local repair (`doctor auth --fix`) has no preflight. It permits the overwrite only\nunder **authority continuity**: the candidate must be signed by the same account signing key (`iss`) as the current\n(already broker-accepted) cred. A same-label alternate account breaks continuity and is refused, full or\nstripped; a legitimate local re-sign is continuous and proceeds without a network. The reference\n`Manager` runs it on a schedule against its **own**\n`secretStore` (see below), so passing the manager and the delivery daemon the *same* store closes the\nrenewal loop end-to-end on an injected backend: the manager reads the signer from the store, re-signs\ninto it, and the daemon adopts each generation on a preflight-proven 75% timer. The stock\ncross-host composition cannot satisfy that by writing one filesystem and fingerprinting another:\n`Manager.start()` and every later remint challenge the daemon's `reloadStoreIdentity` and a\ndivergent pair is refused naming both stores. The identity is the store the daemon actually\nreloads: an injected coordinate, the workstation root only when `--creds` is\n`<root>/.cotal/<spaceSegment(space)>/delivery.creds` (matching the canonical arm), the\nfile's own directory for any other `--creds` path, or the workstation root. A filesystem\nstore is also named by a random id it records in `store.id` inside its own directory, so two\nhosts that use the same root path are two stores. No key and no `--creds` file may be that\nfile under any name, and a `store.id` that is a symbolic link or holds anything but a lowercase UUID is refused. Uninjected\n`--creds` that names one real workstation while process cwd resolves another is refused\nat start, naming both, because membership-rw still uses `findCotalRoot`. A `--creds`\npath that is not under any `.cotal` tree is not that case and is not refused here. It never\nwalks ancestors with `findCotalRoot`. No bound daemon is not a named\nstore, so start proceeds; a later daemon on a foreign store is refused on the next remint.\nThe first-party filesystem adapter declares its workspace-root identity on the store itself. Other\ninjected adapters declare their stable coordinate on `SecretStore.identity`, or name it in\n`COTAL_SECRET_STORE` on both processes. It never throws: it\nreturns per-file results (`skipped: \"no-auth\"` when the store holds no signer records),\nso the caller must check them or the cred still rides to expiry. A composition whose signer lives in\nKMS/Vault simply injects that store; no bespoke renewal is needed. A `--creds` file path must be\nreplaced atomically before the 75% read. The signer can now be injected behind the store seam, which\nresolves custody. The remaining hosted gap is signer **isolation**. The seed is still decrypted\nin-process at the manager's uid, so it needs an OS sandbox or remote signer.\n\n### Supervisor signing authority\n\n`@cotal-ai/manager` exports the `Manager` class; there is **no** `runSupervise(opts)` runner. The\nprivate CLI `runManager` also does broker-reachability checks, space/default resolution,\nroster/launch parsing and materialization, installed-extension resolution, signal handling, staged\npre-spawn, and the forever wait. A host composes that lifecycle itself around `Manager`:\n\n```ts\nimport { Manager } from \"@cotal-ai/manager\";\nconst mgr = new Manager({ space, servers: brokerUrl, workspaceRoot });\nawait mgr.start(); // then wire your own SIGINT/SIGTERM -> mgr.stop()\n```\n\n`stop()` runs once. A later call joins the stop in progress and settles with it, and a call that\nasks for a different `withAgents` than the running stop is refused.\n\nUnlike delivery, the manager is **not** a pre-minted-scoped-cred daemon (auth-service is also a\nsigner: it holds fewer artifacts than the full trust bundle, but its data-account signing seed still\ngrants complete data-account mint authority on compromise, so this is not least-privilege). On `start()`\nthe manager reads its space's full trust chain **through its `secretStore`** (`getSpaceAuth(this.secrets,\nthis.space)`, composed from `auth/broker.json` + `auth/account.<key>.json`; a container may instead\nmount a stripped signer bundle at the legacy `auth/auth.json` key) and **self-mints** its supervisor cred and renewals from the\ndata-account signing seed. In static mode it also mints every per-agent cred from that seed; in user\nmode agents instead receive callout-minted bearers, but the manager still holds the signing seed for\nits own creds and renewal. So a hosted supervisor is a **trusted per-tenant account-signer process**,\nnot a least-privilege connect client. It additionally requires a `~/.cotal/meshes/space.<key>.json`\nregistry record and the workspace user-auth marker to start in user mode. `ManagerOptions.secretStore`\ninjects the one `SecretStore` the manager uses for **the signer itself (the split trust\nrecords)**, daemon-credential renewal (`remintDaemonCreds`), and per-agent secrets,\ndefaulting to the workspace filesystem store; pass the delivery daemon the *same* store for end-to-end\nhosted renewal. The store declares the same identity on both processes, or both set\n`COTAL_SECRET_STORE` to the same coordinate. The manager\nremints no daemon credential when the daemon names a different store, including a daemon that binds\nafter start; it keeps running and serving its own agents, so one space can carry a manager on more\nthan one workspace root. That manager also stays off the space's renewal lease, so a manager or a\n`cotal doctor auth --fix` on the daemon's store can still take it. Pointing several managers at one coordinate is safe: the store identity\nalone cannot pick an owner (it carries no holder and no tiebreak, so every manager sharing the store\nmatches), so the manager that also holds the space's renewal lease is the one that remints and the\nrest skip it. Without that lease two owners would remint on independent timers with no ordering\nbetween them, and one write would land between the other's re-sign and its fingerprint-only\n`reloadCreds`. `cotal doctor auth --fix` takes the same lease before it re-signs, so a live manager\nand a local repair never race each other either. The signer IS now injectable: a hosted composition injects a KMS/Vault store and no\nsigning seed lands on the hosted disk. What remains is signer **isolation**. The seed is decrypted\nin-process at the manager's uid. That issue needs an OS sandbox or remote signer; it is no longer a\ncustody problem. The other knobs are `workspaceRoot` and the process-global `COTAL_HOME`.\n\nA store, runtime or extension the manager calls may reject with any value, including `null`. The\nmanager logs or refuses with that value's `message`, or with the value itself as text when it has\nnone, and keeps serving. A value it cannot read reports as `an unreadable rejection`.\n\n> Scope note: the **static-auth** operator paths (`cotal spawn`/`join`/`status`/`web`, via\n> `mesh-target` \u2192 `connect`/`preflight`) still read the signer from the local split records (sync\n> `loadSpaceAuth`). That is the single-machine composition, where the signer is on local disk by the\n> static-auth model; multi-tenant hosting runs **user mode**, which never mints from on-disk trust. The\n> store-injectable signer path is the hosted-server set: the manager, `remintDaemonCreds`, and delivery.\n\nThe typed remote-manager authority contract includes a one-shot terminal phase. A host implements\n`remoteAuthority.prepareAgentRetirement` to revoke the managed grant and finish its resumable\nrelease while preserving the UID, then `remoteAuthority.mintRetirementRequester` returns the\nhost-signed JWT for a fresh participant-owned nkey. The credential is pinned to the authenticated\nowner, server-derived manager serve principal, current instance epoch, and exact target lifecycle.\nThe manager then uses the existing auth `retireLifecycle` rail with the operation id derived by\n`managedRetirementOpId(target.lifecycleUid)`. This derivation is the reference remote-Manager\ncomposition's closed contract, not a rule for every retirement entry point; interactive retirement\nkeeps its existing operation identity and remains compatible. The `retireLifecycle` rail independently\nrecomputes the managed id from its broker-pinned target before any gate, head, intent, or barrier\naccess, so mint-time validation is not the terminal boundary. A failure keeps\nthe alias held. This does not expose the auth barrier or give the participant signer authority.\n\nIf the participant disappears after prepare, the host finishes the retirement itself on the auth\nservice's loopback face: `POST /managed-lifecycle/retire` (exported as `MANAGED_RETIRE_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only\n`{ owner, actor, lifecycleUid }`. It has the interactive door's guards (POST only, no `Origin`, JSON,\ncapability, closed body) and is never served on the public face. The managed grant must already be\nrevoked at that uid, or it answers 409. It runs the same `managedRetirementOpId(uid)` operation as\nthe rail, and the rail and the door share one in-process flight, so a late participant request and\nthe host call converge on one barrier.\n\n| lifecycle head | answer |\n| --- | --- |\n| absent, or `retired` at another uid | `200 { retired: false, lifecycleUid, notStarted: true }` |\n| `active`/`retiring` at another uid | `409` |\n| `retired` at this uid | `200 { retired: true, lifecycleUid, alreadyRetired: true }` |\n| `active`/`retiring` at this uid | the barrier runs, then `200 { retired: true, lifecycleUid }` |\n\nDeprovisioning durables stays with `deprovisionAgent` and a `deprovisioner` credential. Pass `memberChannels` to both to also purge the retired lifecycle's durable membership rows on those concrete channels. The manager fills that list from the launch's concrete read channels and the delivery daemon's read-only `lifecycleMemberships` admin verb. When that verb cannot answer, rows on other channels stay retained, the teardown logs the inventory as incomplete, and retirement remains held pending retry rather than releasing the alias. Each teardown examines two exact consumers, one ACL key and the named member keys. KV deletion uses a native revision condition; a lost condition with a live replacement refuses rather than claiming absence. Consumer INFO checks before and after DELETE distinguish verified prior absence from disappearance. `acknowledged` counts native DELETE success replies. `disappeared` counts observed live-to-absent consumers and live KV rows whose conditional purge lost to a competing deletion. These KV rows were present at the first read, so they never count as prior `absent` or as this caller's `deleted`. The ACL and membership subtotals preserve that distinction. Neither establishes which concurrent caller uniquely removed a consumer, so `consumers.deleted` and the total `deleted` are `null` when a consumer disappears without a winner token. A repeat after verified absence reports zero, not an invented deletion. `refused` counts slots whose cleanup or state remains uncertain. A partial failure raises `DeprovisionError` carrying these bounded observations. The Manager's static sweep preserves unknown uniqueness rather than adding acknowledged requests as physical removals; its slot totals remain separate.\n\nA host that resumes retained managed actors also implements\n`remoteAuthority.validateRetainedAgent`. The participant sends back the actor token and sentinel it\nalready holds, plus the `nextRegistrationProof` returned by the activation response. That proof is\nhost-issued after registration and binds the manager owner, actor, lifecycle, identity nkeys, current\nregistration revision, and serving epoch. The host checks it against the current open manager gate,\nvalidates the retained secrets against its current managed row, and returns only the non-secret\nauthority shape. The manager binds every result coordinate and the returned authority back to its\ninventory before use. Do not copy the provider's `issuer.json` or `callout.json` into the participant\nstore. Both contain private signing or exchange authority.\n\nThe same composition supplies `remoteAuthority.agentBearerExchangeUrl`, the pinned public auth-service\nbase used by retained children. Remote adoption launches `agent-bearer --exchange-url <base>`; it must\nnot select the local `--dir` arm, which depends on a host-only auth-service process record.\n\nA host that lets a remote participant spawn FRESH managed agents implements\n`remoteAuthority.enrollManagedAgent`. The participant generates the standing actor token, writes it\nat mode 0600, and passes only its SHA-256 digest with the requested actor, label, role,\ncapabilities, and channel lists, so the plaintext secret never leaves the participant machine. There\nis deliberately no `lifecycleUid` input: the host selects the UID, because only the host sees the\nretirement tombstones that make a UID permanently unusable, and a participant-chosen UID could aim a\nfresh grant at a dead incarnation. The host authors the ledger grant, pre-creates the lifecycle-keyed\ndurables, clamps the requested lists to what the spawning owner already holds, and returns the owner,\nactor, chosen `lifecycleUid`, the space sentinel credentials, the effective lists, and\n`agentBearerExchangeUrl`. The manager binds every returned coordinate, re-keys the secret family onto\nthe returned UID, and launches `agent-bearer --exchange-url <base>`. When the hook is absent a\nsignerless manager refuses the user-mode spawn rather than authoring a local grant the host knows\nnothing about.\n\nBoth managed-agent operations ride the one verified `POST /manager-service-authority` transport as\n`kind: \"manager-managed-agent-enrollment\"` and `kind: \"manager-managed-agent-prepare-retirement\"`.\nStock `cotal auth-service` answers both itself, because it owns the actor ledger and the space's\nprovisioning authority. An enrollment writes the managed grant at a fresh UID with the supervising\nactor as its parent, provisions that UID's durables, and returns the daemon's public exchange URL as\n`agentBearerExchangeUrl`; a daemon started without `--exchange-public-port` refuses enrollment. A\nretry with the same token digest answers the same UID while the supervising actor's current grant\ncovers it, and a fresh enrollment's refusal otherwise. While the agent's grant stands, an enrollment\nwith another digest is refused with `conflict` until that lifecycle's retirement is prepared. A\nprepare-retirement releases the target UID's broker footprint and then revokes its grant, so the\nmanager's terminal rail finds the grant gone. A platform that keeps these writers in its own storage\nintercepts both kinds instead. It terminates its own public route, authenticates the human there,\nand asks the auth service for the decision at\n`POST /manager-service-authority/verify-enrollment` (exported as `VERIFY_ENROLLMENT_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only `{ owner, request }`. That\ndoor has the managed retirement door's guards, derives the caller's scope from the local ledger rather\nthan the body, checks the manager gate and registration proof in-process, and answers\n`{ authorized: true, owner, actor, instanceId, serveEpoch }`. `authorizeRemoteManagedAgentEnrollment`\nand `authorizeRemoteManagedAgentPrepareRetirement` are exported too, for a host that composes the\ndecision without the HTTP hop. Both require ledger scope `supervise`; `spawn` and `admin` do not\nimply it.\n\nA host that runs managed agents on its own hosted runtime adds two more kinds on the same transport.\n`kind: \"manager-managed-agent-runtime-create\"` asks the host to create the runtime for one agent it\nalready enrolled, and `kind: \"manager-managed-agent-runtime-status\"` reads that runtime's state. Both\ncarry the manager envelope plus `target: { owner, actor, lifecycleUid }`, the coordinate the\nenrollment returned. Both schemas are closed. An unknown top-level or target field, including\n`providerRef`, `handle`, or `name`, is refused as `bad-request`, because the host alone issues and\nholds provider references. There is no stop, adopt, or probe kind: stop goes through\nprepare-retirement. Stock dispatch refuses both kinds with `unimplemented`, and the verify-enrollment\ndoor decides them. `authorizeRemoteManagedAgentRuntimeCreate` and\n`authorizeRemoteManagedAgentRuntimeStatus` apply the enrollment door's checks: host space, a target\nowner equal to the authenticated owner, the manager actor's own ledger row with `supervise`, the open\ngate, the current serve epoch, and the registration proof. Each returns only\n`{ owner, instanceId, actor, target }`. The door touches no provider and writes nothing. The host\nmatches the decision to its own intent record and performs the create afterwards. The host answers\nwith `state` (`reserved`, `creating`, `bound`, `create-unknown`, `closing`, or `closed`), `readiness`\n(`ready`, `bound-not-ready`, or `none`), and an optional `retirementPhase`. A manager builds requests\nwith `remoteManagerClient.remoteManagedAgentRuntimeRequest` and binds the answer with\n`remoteManagedAgentRuntimeState`.\n\nAn enrollment result may also carry `runtimeIntent: { state: \"reserved\" }` when the host reserved a\nhosted runtime for the agent. It is display-only. Older hosts omit it, the manager binds both shapes\nto the same material, and nothing reads it as authority.\n\nNo stock door lets a platform control holder launch or retire an agent for a signed-in user. The\nmanaged-agent kinds above act only under the authenticated owner and refuse a caller that is not\nthat user, so a platform could only run a user's agent by holding the user's login or by enrolling\nthe agent under its own owner. Both are refused. The\n[delegated user launch intent](design/delegated-user-launch-intent.md)\ndesign and SPEC \xA713.16 define the smallest addition. The user admits one launch or one retirement\non the host's authenticated route. The holder consumes that intent once, from its current\nregistration, epoch and lifecycle. The host then enrolls the agent under the user's `u_` owner with\nthe user's own actor as its ledger parent, so the envelope walk, membership and channel lists match\nwhat the user's own manager would produce. Retirement keeps the prepare, provider closure and\nterminal barrier order, and the host finishes it when the holder is gone. A launch the host had to\nundo keeps its agent name held until the host process that ran it confirms it has stopped, and\nwhile the name is held the host also refuses it to the user's own manager. `@cotal-ai/auth` ships the\ntwo decisions, `authorizeDelegatedUserIntentAdmission` and `authorizeDelegatedUserIntentExecution`,\nfor a host that owns an intent store and those writers to compose on its own routes. Both read the\nholder's gate through the handle's `observeManagerGate`, present with `platformControl`, which reads\nover the context's own connection, so the host opens no second data-account connection. It is an\nobservation for the decision: the consuming CAS and the writers still apply their own checks. The\nconsuming CAS pins the incarnation of the host process that runs the flight as the executor, never\nthe auth plane's, because the two can restart independently. `platformControl.host` names that\nprocess's reverse-DNS endpoint, the closure digest of its \xA713.7 cluster and the contract artifacts\nregistration reads, and the auth plane self-authorizes that one name. Registration reads the closure\nmanifest `{ v: 1, root, members }` at `clusterDigest`, then the cluster document at the manifest's\n`root`, and verifies each against its digest. `artifacts` therefore carries both, and `clusterDigest`\nis the digest of the manifest. `members` stays empty: SPEC \xA713.7 lists every reachable artifact\nthere, but this implementation registers single-document clusters only and refuses a manifest that\nlists members. A manifest with any field beyond `v`, `root` and `members` is refused too, as the\ncontract store refuses it. `singleDocumentClosure(document)` returns that manifest and its closure\ndigest. The instance id is a lifecycle token, `[a-z0-9]{26,32}`. The minimal construction below has\none command over the void schema. A host copies it, replaces `document` with its real cluster, and\npasses `host` as `platformControl: { observeAssignment, host }`.\n\n```ts\nimport { mintLifecycleUid, singleDocumentClosure, VOID_SCHEMA_DIGEST } from \"@cotal-ai/core\";\n\nconst document = {\n urn: \"com.example.host\",\n revision: 1,\n attributes: [],\n events: [],\n commands: [{\n name: \"ping\", class: \"ephemeral\", targeted: false, capability: \"host.ping\",\n inputDigest: VOID_SCHEMA_DIGEST, outputDigest: VOID_SCHEMA_DIGEST,\n }],\n};\nconst { manifest, closureDigest } = singleDocumentClosure(document);\nconst host = { endpoint: \"com.example.host\", clusterDigest: closureDigest, artifacts: [document, manifest] };\nconst instanceId = mintLifecycleUid(); // first start only; later starts reuse the persisted id\n```\n\n`registerHostIncarnation(instanceId)` publishes the artifacts, registers that instance through the\nceremony the plane runs for itself, and returns `{ instanceId, processEpoch }` with the epoch that\nregistration committed. The host calls it at every start with its persisted instance id, before it\nadmits or recovers any flight, so a restart fences its predecessor. The first registration of an\ninstance commits epoch 0, which is open and serving like any later epoch, and each later start of\nthat instance commits the previous epoch plus one. A consumer compares epochs for equality and never\nreads 0 as absent or not ready. A start\nwhose confirming read of the gate finds that a later start of the same instance registered is\nrefused with `conflict`. The returned epoch is a committed coordinate and stays current only until\nthe next start registers, which can happen before the call returns. `observeHostGate(instanceId)`\nis a point-in-time read of an executor's gate, and answers null for an absent gate; a sweeper\ndecides on it. `awaitHostFence(instanceId, processEpoch)` resolves with the gate once it is no\nlonger open at that epoch, and with null once it is absent. It takes any non-negative safe integer\nepoch, 0 included, and refuses any other value with `bad-request`. The host arms it with its returned\nincarnation before it admits or recovers any flight, and stops serving when it resolves. It polls\nthe gate, because no runtime credential may watch the auth bucket. The\nhost's launch writer first activates the agent's lifecycle at the pinned UID through the handle's\n`activateManagedLifecycle`, before any ledger row or durable, and its compensation runs the same\ncall before the terminal barrier, so a launch whose agent never exchanged its bearer still reaches\nthe terminal barrier at that UID. The launched agent exchanges its bearer on the context's public\nface, so the host starts it with `publicFace`, and its retirement writer ends at\n`POST /managed-lifecycle/retire` with the handle's `cap`. Stock dispatch refuses both kinds as\n`unimplemented`. The holder's composition passes\n`remoteAuthority.executeDelegatedUserIntent`, which posts the execution request and binds the answer\nwith `parseRemoteDelegatedUserIntentExecutionResult`. It then starts the agent with\n`startAgent({ ..., delegatedIntent: { intentId, owner, parent } })` and retires it with\n`retireDelegatedAgent(name, intentId)`, which stops the agent only after the host confirms\n`retired: true` for its exact target. A delegated agent's stop, exit or failed launch keeps its name\nheld until that retirement confirms.\n\nRemote user-mode managers must also supply `remoteAuthority.authorizeAdmin`. The manager builds each\nrequest only from the caller tuple parsed from the broker-authenticated endpoint subject, then relays\nthat tuple over the current registered manager lifecycle. HTTPS does not separately authenticate the\nrelayed caller. The host authenticates the manager operator, binds the request to the current open\nmanager gate, registration proof, serving epoch, and identity nkeys, then reads the caller's unified\nauthoritative row fresh. It returns only the manager owner and `authorized: boolean`, with every request\ncoordinate echoed. Missing, revoked, narrowed, foreign-owner, and stale-lifecycle callers all return\n`false`; malformed coordinates or corrupt and unavailable authority state fail the operation. The\nparticipant never reads or mirrors the host ledger, and the remote branch has no local fallback. The\nsame callback gates all `manager.admin` handlers, any-mode cross-owner control, and `ps` or `inspect`\ncross-owner visibility. Launch keeps its owner-equality policy.\n\nThe remote authority's instance executor remains the scoped maintenance credential for clean service\nderegistration and exact instance registration operations. It carries no records-stream consumer\nlifecycle authority. The manager's boot `goalidx` sweep uses the authenticated host operation, which\nreturns parsed `goalidx.manager.<owner>.>` entries for that owner only. The host keeps the sealed\nconsumer connection and its create/delete rights. The five-minute executor already renews through\n`remoteAuthority.renewExecutor`. The signerless supervisor, serve, goal-writer, session-ledger and\nper-run driver credentials do not yet have a complete remote renewal and adoption path. The\n[hosted runtime contract](design/hosted-runtime-contracts.md) records the bounded additions and\ntheir ownership; it is not a shipped pooled service.\n\n**Signer isolation needs an OS sandbox.** The default pty runtime\nruns agent children under the *same* OS uid and the *same* `workspaceRoot`, so mode-0600 on\nthe trust records does not stop a hostile same-uid agent from reading their absolute paths. The reference\n[deploy](deploy.md) tree does not solve this: it mounts the signer into the agent's own container, so\nits phase-1 boundary isolates agents from each other, not the signer from the agent. A hosted\ncomposition must run the manager/minter that holds the signer in a different uid, container, or mount\nnamespace from the agent children, which mount no signer at all; that split is future\nhosted-composition work, so until it (or a remote/injected minter) exists, do not run untrusted\nagents under this manager.\n\n### Delegated seats outside the manager's filesystem\n\nThe [portable lifecycle bootstrap](design/portable-lifecycle-bootstrap.md)\ndesign and SPEC \xA713.17 define how a managed agent that `enrollManagedAgent` already enrolled starts\nin a child that cannot see the manager's filesystem. The ordinary `spawn` path writes the token and\nsentinel under the manager's workspace root and hands the runtime a launch whose bearer command and\nmaterial file are paths on that filesystem, so such a child needs this path instead.\n\nThe delegation boundary is one optional runtime method. A runtime that implements\n`Runtime.spawnDelegated(launch, handoff)` receives two values and no paths: a `DelegatedSeatLaunch`\n(connector name, persona text, model and the other launch choices) and a `ManagedLifecycleHandoff`\n(space, owner, actor, the host-chosen `lifecycleUid`, broker and IdP pins, the pinned exchange base,\nthe sentinel, the channel lists, and the raw actor token). The manager enrolls once, as it does\ntoday, and builds the handoff from what it holds. It never sends the token to the host, never copies\na file from its workspace or secret store, and never builds a local launch for that seat. No signer,\nissuer or callout record, loopback capability, provisioner or manager credential, control token, or\nmanager path crosses. A spawn choice that only the manager's host can honour is refused before\nenrollment: `--resume`, a manifest agent's `continuity: exact`, `--cwd`, and any shared MCP server,\nwhether from `--share-tools` or the config default (`--share-tools none` passes).\n\nThe runtime creates one provider resource under `managedRuntimeKey(target)`, writes the handoff into\nit as one 0600 file and the persona beside it, and runs the stock bootstrap there:\n`cotal spawn --config <persona-file> --space <space> --name <actor> --expect-owner <owner> --expect-lifecycle-uid <uid>`\nwith `COTAL_MANAGED_HANDOFF_FILE` naming the file. `delegatedSeatCommand` builds that argv. The\n`cotal` entry reads the file into memory, deletes it and drops the variable before it parses flags,\nprints help or loads extensions, so every outcome, a refusal of its own flags included, leaves no\nfile. It refuses before any broker connection or exchange request when the\nspace, owner, actor, or lifecycle UID differ from the expected values, and then runs the\nenrollment-redeem consumer: it registers the mesh in its own home, writes the token to its own 0600\nfile, and exchanges it through `agent-bearer --exchange-url` unchanged. It never enrolls, redeems, or\nmints a token or UID.\n\nReadiness is still mesh presence. A create whose answer is lost leaves the handle running, so the\nlaunch settles uncertain and stays held; the manager never retries it. A provider read that finds no\nresource under the key is not an exit, because the create may still land. Every close by\n`managedRuntimeKey` is fenced: it completes only once the create was answered or the provider refuses\nany later create under the key. A provider that names its own resources may run the create as a\ndurable operation keyed by `managedRuntimeKey` and close through the identifier its authenticated\ncreate response returned, kept where the host can read it without the manager. Only that response\nbinds an identifier to the key; one derived from the key or found by name or listing is never closed\nor adopted, and while the response is unknown the launch stays held. Every stop, the reap of a child\nwhose parent exited and `Manager.stop({ withAgents: true })` included, runs `prepareAgentRetirement`\nfor the UID-exact target, then `stop()` on the handle `spawnDelegated` returned, then the terminal\nbarrier. `preparePreservation` refuses a cut that holds a delegated seat, and a `Manager.stop()` after\na refused cut retires the seat through the same steps. After the manager is gone\nthe host runs the same steps, makes the same fenced close by `managedRuntimeKey`, and finishes at\n`MANAGED_RETIRE_PATH`. Supply `spawnDelegated` only from a runtime whose host can make that fenced\nclose without the manager. The enrollment redeem\n(`COTAL_ENROLLMENT_FILE`) stays for lifecycles whose token the host generated itself; a host cannot\nmint one for a manager-enrolled lifecycle because it holds only the digest.\n\n## Provisioning a space (one-space reference shape)\n\n```ts\nimport { createSpaceAuth, setupSpaceStreams, ensureDefaultDeliveryClass, mintCreds, newIdentity } from \"@cotal-ai/core\";\nconst auth = await createSpaceAuth(space); // trust bundle (in-memory seeds)\nconst provisionerCreds = await mintCreds(auth, newIdentity(), \"provisioner\");\nawait setupSpaceStreams({ servers: brokerUrl, space, creds: provisionerCreds });\n// SPEC section 4: write the default delivery class at space creation so it is wire-discoverable,\n// never inferred from the resolution fallback. A daemon-backed space is \"durable\".\nawait ensureDefaultDeliveryClass({ servers: brokerUrl, space, creds: provisionerCreds, deliveryClass: \"durable\" });\nconst deliveryCreds = await mintCreds(auth, newIdentity(), \"delivery\");\n// put deliveryCreds into your SecretStore under deliveryCredsKey(space, { injected: true })\n// (@cotal-ai/workspace) before booting delivery \u2014 the key is per-space, not the bare kind.\n```\n\nRendering the broker config for a user-auth space is `serverConfig(broker, spaces, { storeDir,\nmaxFileStore?, extraAccounts })`, where `extraAccounts` must include the callout account from\n`createCalloutAuth` so the auth-service has a broker account to answer on. That account never shares\nthe data account. `maxFileStore` is an optional positive integer byte cap; any other value throws.\n\nBroker trust and space accounts are separate authorities: `createBrokerAuth` mints the one\noperator + system account a broker trusts, `createSpaceAccountAuth(broker, space)` signs each\ntenant's data account under it, and `serverConfig(broker, spaces, opts)` renders them all into one\nconfig. A host composition can therefore provision several spaces on one broker today. `cotal up`\nrenders that config from every tenant the root's auth directory holds, so booting one space keeps\nthe broker trusting its siblings, and it refuses to render at all while any account record is\nunreadable. The rest of the CLI lifecycle is still broker-wide: `down`, `clean` and `backup` refuse\non a multi-space root rather than scoping to one tenant, and the per-space lifecycle is the\nremaining multi-space operator layer. See\n[Known gaps](#hosted-composition-gaps).\n\n## Hazardous provisioning primitives\n\n`mintCreds`, the full `Profile`/`CredentialKind` matrix, `createSpaceAuth`, and `stripSpaceAuth` are\nlow-level operator primitives. Handle them as account-authority material:\n\n- A holder of a `SpaceAuth` (or a `stripSpaceAuth` bundle, which **keeps** the data signing seed) is\n a fully-trusted tenant-account authority: it can mint `admin`, `provisioner`, and destructive\n profiles, not merely `supervisor`, and mint a DM-reading identity. `createSpaceAuth`'s full result\n holds operator, system, and account seeds in memory.\n- Choose `profile` and `MintOpts` from **server-side constants**, never from tenant input. `MintOpts`\n can widen the bounded TTL defaults; cap it at your boundary. `CREDENTIAL_LIFETIMES` is a policy\n record, not an authorization boundary.\n- Never log signer material or export it into env. Do not co-locate signer access with an untrusted\n connector/runtime process at the same OS uid (file permissions do not contain a same-uid reader;\n see the manager's isolation note). Segregate per tenant; rotate on compromise\n (`rotateDataAccountSigningKey`).\n\n## Hosted composition gaps\n\nThe primitives above are present as exports, but three capabilities are **not** cleanly composable\nfrom the public contract today. Each is tied to work in flight; a host either waits for the seam or\nscopes the capability out. None is a wire concern.\n\n1. **Delivery immediate live eviction and a fully-hosted membership feed.** The renewable\n `membership-rw.creds` is now a `SecretStore` kind. `startMembership` reads it through the\n injected store, and the manager re-signs it there. The graph-feed writer therefore renews on a hosted\n backend (its data connection adopts each generation on a preflight-proven 75% timer). What still\n reads from a fixed on-disk path are the *static* `membership-observer.creds` and\n `connection-evictor.creds` ($SYS creds, minted at the `up` that provisions the account and renewed by `up --rotate-sys`) and `membership.json`\n (`{accountId}`, non-secret config); those, plus the private provisioning wrapper, keep immediate\n live eviction and a fully-hosted feed a partial gap. Missing files degrade membership to\n traffic-only and make live eviction refuse (loudly). The supported delivery contract here is the\n Plane-3 durable backstop.\n2. **Supervisor signer isolation.** `ManagerOptions.secretStore` now injects the one `SecretStore` the\n manager reads/writes every secret through, including the composed `SpaceAuth`\n signer (the split trust records), its daemon-cred renewal, and its per-agent kinds. What remains is process\n isolation: the manager still decrypts the signer in-process at its uid, so untrusted agent children\n must run under a different uid/container/mount namespace or behind a future remote signer.\n3. **Per-space lifecycle on a shared broker.** The trust layer is multi-space\n (`createBrokerAuth` + `createSpaceAccountAuth` + N-space `serverConfig`, persisted as\n `broker.json` + `account.<key>.json`) and `cotal up` renders the whole tenant list, but there is\n no per-space provisioning verb and no per-space teardown/backup/restore: the CLI's broker-wide\n lifecycle verbs refuse on a multi-space root, naming the tenants.\n This is the remaining multi-space operator layer.\n4. **A non-Better-Auth production IdP.** The exchange core (`createIdpBridge`) is EdDSA-generic, but\n the stock provider and login client are Better-Auth-endpoint-shaped, `cotalAuthProvider`\n self-registers on import (colliding with a host-owned provider under `resolveAuthProvider`), and\n the login flow speaks Better Auth's device-code endpoints. A different IdP is a host-built auth\n composition on the low-level primitives, not a configuration change (see\n [the IdP callout contract](identity-and-auth.md#the-idp-callout-contract)).\n\n## Hosted durability\n\nSpace-durable **coordination** state (chat/DM/task history, live presence, membership runtime, the\ndurable ACL registry, leases) lives in **JetStream**, written by the delivery daemon and the\nendpoints. It is broker-resident and needs no host-side durable path.\n\nWhat is **not** in JetStream, and is hosting-critical, is trust and authorization state a host must\nplace and keep:\n\n| state | class | where today | hosted injection |\n|---|---|---|---|\n| full `SpaceAuth` trust chain (`auth/broker.json` + `auth/account.<key>.json`, composed; a stripped signer bundle may instead be mounted at the legacy `auth/auth.json` key) | signing authority | `SecretStore` | `SecretStore` (manager + renewal) |\n| auth kinds: callout account/creds/xkey, issuer private keys, owner-derivation secret, data-signer projection | signing/identity authority | four `SecretStore` kinds | `SecretStore` (auth-service) |\n| auth plane instance identity (instance id + serve nkey seed) | restart identity | root `.cotal/space.<hex>/auth-instance.json` | `SecretStore` (`startAuthService`) |\n| `delivery.creds` | standing scoped cred | `SecretStore` or `--creds` | `SecretStore` (delivery) |\n| actor ledger, IdP pin | authorization + trust config | ambient `userAuthStateDir(findCotalRoot(), space)` | none (root-relative; not `store`/`COTAL_HOME`) |\n| `membership-rw.creds` | standing scoped cred | `SecretStore` | `SecretStore` (delivery + manager renewal) |\n| membership-observer / connection-evictor creds + `membership.json` | scoped $SYS creds / config | workspace filesystem | none (see gap 1) |\n| manager agent creds, actor tokens, sentinel creds | lifecycle authority | `SecretStore` | `SecretStore` (manager `secretStore`) |\n| `~/.cotal/meshes/space.<key>.json` record (holds IdP trust pins/root pointers) | non-secret, integrity-critical | machine home | process-global `COTAL_HOME` only |\n| auth-health, renewal records | non-secret diagnostics | workspace filesystem | `workspaceRoot` |\n\nThe `SpaceAuth` trust chain and the auth-service store kinds are **separate** identities/projections,\nnever parts of one document. `auth-service.json` (the live exchange capability) is ephemeral runtime\nstate, not durable, but is sensitive while the daemon runs. `@cotal-ai/workspace` is machine-local\noperator tooling by design; personas, PID files, and the `current-mesh` pointer are truly local and\nmust **not** sit on a hosted durable path. Everything classed above as an authority is what a hosted\ncomposition must provision and persist: signer-bearing server secrets now have `SecretStore` seams;\nthe remaining non-injectable rows are the explicit ambient `workspaceRoot`/cwd paths above.\n\n## See also\n\n- [Substrate stability](stability.md): what v0.3 and the 0.x packages guarantee, and the projected v0.4 break.\n- [Identity and auth](identity-and-auth.md): the profile matrix, the signer, and the IdP callout contract.\n- [Delivery daemon](delivery-daemon.md): the Plane-3 durable backstop.\n- [Deploy](deploy.md): the reference container against an external broker.\n\n## Hosted run revocation\n\nThe authenticated manager-service authority door accepts `manager-run-revoke` with the registered\nmanager envelope and `revoke: { runId, reason }`. The issuing host reads the admission's recorded\nowner and requires the verified requester to be that owner or to hold `admin` in its fresh actor\nledger row. It verifies the current manager registration proof, space, account and epoch. It accepts\nno owner or attribution from the body.\n\n`AuthAuthorityPlane.revokeManagerRun` implements the operation. `AuthProvider.requestRemoteRunRevoke`\ntransports it, and `remoteRunHosting` supplies `RunHostingContext.revokeRun` for a signerless\nmanager. The callback returns the stored `RunRevocation`, including on repeats. A host composing\nits own remote authority must supply that callback to revoke. No signer reaches the participant.\nCLI and MCP revoke verbs are a follow-up described in the [run-start design](design/user-auth-run-start.md).\n"
62687
+ "body": "# Embedding Cotal\n\n> **Guide** (informative) \xB7 **For:** implementers building a service on top of Cotal \xB7 **Prereqs:** [Architecture](architecture.md), [Identity and auth](identity-and-auth.md), [Delivery daemon](delivery-daemon.md)\n\nThe `cotal` binary in this repo is one composition root: an operator CLI. A separate service\n(for example a hosted, multi-tenant Cotal) does not fork this repo. It writes its **own**\ncomposition root that depends on the published `@cotal-ai/*` packages and imports the surfaces it\nwants. `bin/cotal.ts` uses the same composition pattern. This page is the contract for that: what is a real library\nexport you can build against, how to boot the server-side daemons from those exports, and where the\ncurrent export surface stops short of a fully hosted composition.\n\nThis is the \"guarded substrate\" boundary in practice. Nothing here reveals or assumes a specific\nhost; it documents the public seams any embedder composes.\n\n## What you embed\n\nThe supported reference shape here is **one broker operator serving one space** (one tenant: a\ndedicated data account, under an operator that also holds the system account and a quarantined\nauth-callout account) plus three standalone processes. The trust layer itself composes many spaces\nunder one broker operator today (`createBrokerAuth` + `createSpaceAccountAuth` + N-space\n`serverConfig`); what does not exist yet is the per-space **lifecycle** on a shared broker (see\n[Known gaps](#hosted-composition-gaps)). The three processes:\n\n| daemon | package | what it is |\n|---|---|---|\n| auth-service | `@cotal-ai/auth` | the NATS auth callout, the IdP token exchange, and JWKS. Plane 1 to Plane 2. |\n| delivery | `@cotal-ai/delivery` | the Plane-3 durable backstop: fan-out writer plus trusted reader, per space. |\n| supervise | `@cotal-ai/manager` | the per-machine agent lifecycle (spawn/despawn/attach), per space. |\n\n`mint`, `deliver`, and `auth-service` expose their behavior as direct library primitives, and the\nsupported one-space bootstrap below re-composes from exported low-level primitives. `supervise` and\nthe full `up` orchestration are **not** public runners: `up` also does broker bring-up, restore,\nprocess and registry management, and lifecycle work, and `supervise`'s orchestration is private (see\n[Supervisor signing authority](#supervisor-signing-authority)).\n\n## Owner-bound platform supervisors\n\nA host can start one user's supervisor without retaining that user's login. Pass\n`platformSupervisor: { authorizePlatformAdmin, observeAssignment }` to `startAuthService`, then call\n`handle.platformSupervisorAuthority(owner)` inside the trusted authority process. The first callback\nmust authenticate the platform's administrative caller through the host's own authority. The second\nreads that owner's recorded `PlatformSupervisorAssignment` fresh on every call. An absent,\nended, expired, foreign-account or wrong-revision assignment refuses issuance.\n\nTo end a supervisor, record its assignment `ended` and call\n`handle.endPlatformSupervisorAssignment(owner)`. It rechecks platform-admin authority and refuses\nunless your record already reads `ended`. It retires the manager gate and the issued supervisor and\nexecutor generations. After that, registration, activation and renewal refuse material returned\nearlier, even before it expires, with `platform supervisor assignment for owner <owner> has ended`\nor the retired-gate refusal. Stop the worker as well: an open broker connection lives until its\ncredential's capped expiry.\n\nThe assignment has `kind: \"platform-supervisor\"`, `scope: [\"supervise\"]`, one derived owner,\none manager instance, one lifecycle UID, a revision and a finite `expiresAt` in Unix seconds. It is\nnot an interactive or managed-agent ledger row and grants no `admin`. The returned door is bound to\nthat owner and accepts `PlatformSupervisorAuthorityRequest` with the same owner, assignment revision\nand an existing `RemoteManagerAuthorityRequest`. It serves only `prepare`, `activate`, `renew` and\n`renewStandingBundle`. Unknown fields, generic profiles, mint, human exchange and another owner\nare refused before issuance. Do not give the worker the auth handle, loopback capability or signer.\nThe host carries only the bound door over its authenticated worker channel.\n\nUse `remoteManagerClient` and `registerRemoteManagerAuthority` from `@cotal-ai/manager` for the\nexisting registration ceremony. The result keeps the stock manager material shape and five nkeys.\nAll returned credentials expire no later than the assignment. The supervisor and executor ceilings\nare recorded in the native issued store. The recorded permission set must be byte-identical to the\nauthority plane's signed native permission set at issuance and every renewal, and must never widen.\nThese JWTs are signed by the authority plane, not returned by the user-token callout. The door\nadds no human bearer, callout view, owner ledger grant or enrollment authority. Run admission,\nhost-backed enrollment and shutdown maintenance need their own host composition.\n\nSee [the issuance design](design/user-auth-run-start.md#platform-supervisor-registration).\n\n## The export surface\n\nEverything below is a real export of a published package, reachable from the package root (each\npackage publishes only `.` via `dist/index.{js,d.ts}` and ships `files: [\"dist\"]`). Type-only names\nare marked; import them with `import type`.\n\n**Daemon runners and lifecycle**\n\n| symbol | package | purpose |\n|---|---|---|\n| `runAuthService(args, store?)` | `@cotal-ai/auth` | boot the auth-service daemon; `store` injects the secret material. |\n| `runDelivery(args, store?)` | `@cotal-ai/delivery` | boot the delivery daemon; `store` injects the scoped `delivery` cred. |\n| `startAuthService(inputs)` | `@cotal-ai/auth` | start one account-scoped auth-service context and return an `AuthServiceHandle` with the loopback `url`, the per-start `cap`, `readiness`, `drain`, and idempotent `close`. `close` rejects when the context did not release its plane claim: the claim row was no longer its own, or the release write failed. The row then stays held, and the next start reclaims it through the liveness oracle. With the optional `publicFace` input it also serves the public exchange face and carries `publicUrl`. With the optional `platformControl` input the handle also has `platformControlAuthority`, the in-process platform control door, `platformControlReadiness`, its read-only readiness read, `observeManagerGate`, the manager gate read a host composing the delegated user intent decisions passes them, and `activateManagedLifecycle`, the activation that host runs at a delegated launch's pinned lifecycle UID. With `platformControl.host` it also has `registerHostIncarnation`, which registers the host process's own endpoint instance and returns the incarnation a delegated execution pins, `observeHostGate`, the read of that endpoint's issuance gate, and `awaitHostFence`, which resolves once a later registration or barrier fences an incarnation. `runAuthService` remains the CLI entry. |\n| `PlatformControlAuthorityRequest`, `PlatformControlInnerRequest`, `PlatformControlAuthorityResult`, `PlatformControlAssignment` *(types)* | `@cotal-ai/core` | the closed envelope, its inner request union, its result and the backend's assignment row for `platformControlAuthority`. `platformControlOwner` in `@cotal-ai/auth` derives the `p_` owner the door issues under. |\n| `startDeliveryService(inputs)` | `@cotal-ai/delivery` | start one account-scoped delivery instance and return a `HostedServiceHandle` with `readiness`, `drain`, and idempotent `close`. `close` rejects when the lease row is still held after it, by another holder or because the release did not commit, or when the release cannot be confirmed. The process runner remains the CLI entry. |\n| `deliveryCredsKey(space, composition)`, `membershipRwCredsKey(space, composition)` | `@cotal-ai/workspace` | build the secret-store keys the delivery cred and the membership feed's rw cred are read/re-signed under. Keys are **per-space**: `space.<hex>/<kind>`. A hosted composition passes `{ injected: true }`. |\n| `retireManagerInstanceIdentity(root, space, expected)` | `@cotal-ai/workspace` | remove a persisted manager identity only if its complete instance id and serve identity still match `expected`. Returns `removed` or `absent`; refuses malformed, nonregular, and changed records. `absent` is not proof of ownership or successful teardown. The caller must separately prove stop and retirement ownership before using it. |\n| `DELIVERY_CREDS_KIND`, `MEMBERSHIP_RW_CREDS_KIND` | `@cotal-ai/workspace` | the operator-facing KIND names (`delivery.creds`, `membership-rw.creds`) those keys are built from, and what renewal results report. A kind is **not** a key: putting a cred under the bare kind writes the pre-0.4 flat location, which nothing reads. |\n| `Manager`, `ManagerOptions` *(type)* | `@cotal-ai/manager` | construct and run a supervisor in-process; `ManagerOptions.secretStore` injects the one store it reads/writes every secret through. `ManagerOptions.remoteAuthority` is the hosted manager-service authority bundle, including host-owned release, retained-validation, goal-index, and serve-time admin-authorization callbacks. |\n| `ManagerOptions.pooled` | `@cotal-ai/manager` | require signerless remote authority and either an explicit non-custodial runtime or `execution: \"none\"`. A pooled composition must supply the assigned account key and all-duty renewal callback; the CLI's default remains unchanged. A signed-in human's manager gets that material from `managerServiceAuthority`. A platform-run control manager gets it from `AuthServiceHandle.platformControlAuthority` with the shipped `remoteManagerClient` builders, as the [platform control authority](design/platform-pooled-control-authority.md) design describes. |\n| `ManagerOptions.execution` | `@cotal-ai/manager` | omitted or `\"runtime\"` keeps runtime execution. Explicit `\"none\"` requires pooled, complete signerless authority and no `runtime` option. It constructs no Runtime and performs no connector inspection, seat launch or workflow hosting. |\n| `createRuntime`, `Runtime` *(type)* | `@cotal-ai/manager` | resolve the spawn backend (pty built in). |\n| `liveKvEntries(kv, filterOrOptions?, options?)`, `LiveKvEntriesOptions` *(type)* | `@cotal-ai/core` | read live KV entries in one finite scan. Pass `{ signal }` as the second argument or after a key filter to cancel. An interrupted scan throws `IncompleteKvScan`; cancellation throws the signal reason, including during an empty-bucket bind. The scan deletes only its owned consumers, including each one nats.js rebuilt from it, after the broker has answered every create those rebuilds sent. A not-found delete counts as gone, a refused one leaves the consumers to broker inactivity expiry (which also covers a crash), and any other delete failure is thrown, as is a not-found delete of a consumer whose create got no reply from the broker before a timeout or a closed connection. A cleanup failure is thrown only when the scan would otherwise return; the scan's own error, its cancellation reason and `IncompleteKvScan` take precedence. |\n\nThe remote manager authority parser accepts `renewStandingBundle` and `renewRunDriver` only with\nan assigned account nkey, the current manager process epoch, and a host-authenticated registration\nproof. A run renewal also names its active holder, takeover, epoch, fencing token, and the two\nexisting nkeys. The host must fresh-check those coordinates against its registration gate and run\njournal before issuing server-selected profiles. A host without that renewal authorization refuses\nthe request. Until the host issuer wires the operations and validates them on real connections,\nthe presence of these types is not an operational pooled renewal guarantee.\n\nAn execution-free control Manager retains native registration, goal recovery, session-ledger,\nrenewal and maintenance duties. Its status reports `execution: \"none\"`, `runtime: \"none\"`,\n`custody: \"none\"`, no children or connectors, and false `classSpawn`, `runHosting` and\n`terminalSessions`. The required status fields change the output digest and manager cluster\nrevision to 25. Execution is recorded per instance. An incompatible record, retained seat/turn\ninventory or an older unclassified instance refuses boot before recovery.\n\nSelect the genuine participant Manager with a host-issued, instance-pinned `manager-caller`\nview for workflow and terminal operations. That Manager retains the children, workflow journal\nand terminal sessions. The control instance neither adopts roster peers nor proxies user runs.\nMissing participant selection or authority refuses. This embedding capability adds no CLI flag\nor application routing.\n\nWith `renewStandingBundle` configured, the manager renews all five standing credentials together.\nIt checks every returned credential for the held nkey and assigned account, test-connects each one,\nand adopts them only if the serve epoch has not moved. A refused or failed candidate leaves the\ncurrent credentials in place and records the refusal as cleanup debt until a later renewal succeeds.\nOn shutdown, after the standing context is drained, an expired maintenance executor is renewed\nthrough the existing scoped host operation so deregistration can finish without restarting duties.\n\n**Provisioning and minting** (all `@cotal-ai/core`)\n\n| symbol | purpose |\n|---|---|\n| `createBrokerAuth(label)` | mint BROKER trust: the operator and system account one nats-server trusts. One per broker, shared by every space on it. |\n| `createSpaceAccountAuth(broker, space)` | mint one space's own data account, signed by that broker's operator: the add-a-tenant primitive. |\n| `createSpaceAuth(space)` | the one-space convenience: broker trust + one account in a single composed bundle. |\n| `setupSpaceStreams({ servers, space, creds })` | create the space's JetStream streams. |\n| `ensureDefaultDeliveryClass({ servers, space, creds?, deliveryClass })` | write the space's default delivery class at creation so it is wire-discoverable (SPEC section 4). |\n| `serverConfig(broker, spaces, { storeDir, maxFileStore?, extraAccounts?, port?, host? })` | render the broker config: one operator, N space accounts. `storeDir` is required, `maxFileStore` caps JetStream file storage in bytes (omitted, nats-server's dynamic default applies), and `extraAccounts` preloads the auth-callout account. |\n| `mintCreds(auth, identity, profile, opts?)` | mint a scoped cred for any `Profile`. |\n| `mintMembershipObserverCreds`, `mintConnectionEvictorCreds` | mint the membership/eviction scoped creds. |\n| `provisionAgent`, `provisionAgentDurables` | create a principal's bind-only durables. |\n| `newIdentity`, `stripSpaceAuth` | a fresh nkey identity; a stripped signer bundle (data signing seed only). |\n| `Profile`, `CredentialKind`, `MintOpts`, `SpaceAuth` *(types)*, `CREDENTIAL_LIFETIMES` | the profile matrix and cred lifetime policy. |\n\n**Auth building blocks** (all `@cotal-ai/auth`)\n\n| symbol | purpose |\n|---|---|\n| `createCalloutAuth`, `startAuthCallout` | the NATS auth-callout responder. |\n| `createUserTokenIssuer`, `pinnedJwksResolver` | mint and verify the Cotal user bearer. |\n| `createIdpBridge` | exchange a verified IdP JWT for a Cotal bearer (see [the callout contract](identity-and-auth.md#the-idp-callout-contract)). |\n| `deriveOwnerToken`, `validateUserToken` | owner derivation; strict bearer validation. |\n| `cotalAuthProvider` | the self-registering `auth-provider` extension. |\n| `ensureCalloutAuth`/`loadCalloutAuth`, `ensureIssuer`/`loadIssuer`, `ensureOwnerSecret`/`loadOwnerSecret` | read/write the auth secret kinds through a `SecretStore`. Each `ensure*` writes its first value with `create`, so concurrent first calls on one space return the one value the store holds. |\n| `PLANE_CLAIM_REFUSED`, `planeClaimRefusal`, `PlaneClaimRefusal` *(type)* | every plane-claim refusal carries a `PLANE_CLAIM_REFUSED` detail, and `planeClaimRefusal(err)` reads its `reason`: `corrupt`, `live-peer`, `unknown`, `concurrent`, `fenced`, `released` or `lost`. Only `unknown`, an inconclusive liveness observation, is coded `unavailable`. A host can retry contention and stop on a corrupt row without matching message text. |\n\n**Seams and the wire** (all `@cotal-ai/core` unless noted)\n\n| symbol | purpose |\n|---|---|\n| `SecretStore` *(type)* | the durable hosted-secret seam (get/put/create/delete). `create` writes only while the key is absent and reports whether it did, so it must be one atomic step in the backend. `get()` returns raw seeds/keys into process memory, so it is a blob seam, not HSM/KMS signing. |\n| `FsSecretStore`, `workspaceSecretStore(root)` | the filesystem default. **These live in `@cotal-ai/workspace`, not core.** |\n| `AuthProvider` *(type)*, `Connector` *(type)*, `Runtime` *(type)*, `Command` *(type)* | the extension contracts; implementations self-register on import. |\n| `registry` | the shared registry a composition root pulls surfaces into. |\n| `CotalEndpoint`, subjects, message types | the wire client and shapes. |\n| `ParsedArgs` *(type)* | the shape the daemon runners take (see below). |\n\nFor a Linux Unix-socket adapter, `peerCredentials(socket)` from `@cotal-ai/seat` returns\nkernel-observed peer `pid`, `uid` and `gid`. Compare these against the host's authorization\npolicy; request-supplied identity and process liveness do not replace that policy or a\nlifecycle fence. The helper starts no custodian and refuses unsupported platforms or a\nmissing native helper.\n\nThe runners take a CLI-shaped `ParsedArgs`, not a typed options object, so a host fabricates one:\n\n```ts\nconst args: ParsedArgs = { values: { space, server, port: \"0\" }, positionals: [], raw: [] };\n```\n\nFor an embedded delivery instance, use `startDeliveryService` instead. Its `HostedContextInputs`\ninclude the account public key and lifecycle UID, space, broker URL, injected store, stable\n`storeIdentity`, and an explicit `stateDir`. The store must declare that same injected identity.\nThe initial delivery credential must belong to the assigned account. The function returns only\nafter the delivery responder is bound. `close()` withdraws serving and releases only the lease\nowned by that instance. It closes both membership connections even when a disconnected drain\nfails, so they cannot reconnect after closure. It rejects when the lease row is still there\nafterwards, held by another instance or left by a release that did not commit, or when the broker\ncannot confirm the release. If the delivery connection drops or is down while the release is in\nflight, `close()` waits up to the broker-gone window (`COTAL_DELIVERY_BROKER_GONE_MS`, 15 seconds\nby default) from the drop for it to reconnect, and repeats the release once on the reconnected\nconnection before it decides. `drain()` returns the same promise. A successor is refused while such\na row lives, and the bucket TTL expires a row the instance left. Credential-expiry health state clears after\nsuccessful broker-verified adoption through the existing `reloadCreds` rail. A failed start\nrefuses locally without exiting the host process or stopping another account's delivery service.\nIf a health fault occurs during an asynchronous store read, startup rejects when the read returns\nand closes any resources created by that late completion.\n\n`startAuthService` takes the same `HostedContextInputs`. The store must declare the assigned\ninjected identity, and its data account must be the assigned account. The IdP pin and ledger live\nunder the explicit `stateDir`. The auth plane's instance identity holds its private serve seed, so it\nlives in the store under `authInstanceKey(space)`. A record an earlier release kept under `stateDir`\nmoves into the store on the first start.\nThe context never resolves a workspace root from the working directory and has no local manager, so\nonly remote manager gates can be selected. It returns after\nthe authority plane, the callout subscription and the loopback listener are bound. A start that\nfails closes the connections it opened and releases its plane claim, so a retry on the same space\ncan claim it. A fenced plane or\na lost broker connection makes that context `unavailable` and closes it without exiting the process.\nThe host writes no discovery file for it. The handle carries what `auth-service.json` holds for a\nCLI start: the loopback `url`, `publicUrl` when a public face runs, and the per-start `cap`. The cap\nalone authorizes the loopback host actions, lifecycle retirement and managed-agent enrollment\nverification, so keep it in the authority process. `publicFace` takes the CLI's public face inputs\n(`port`, `url`, `trustedProxy`, `advertisedServer`, `agentProvisioningUrl`) under the same rules,\nand a face without a port refuses to start.\n\nTwo optional inputs serve a platform composition. `platformControl: { observeAssignment }` adds\n`platformControlAuthority` to the handle. It is a typed in-process method, served on no listener,\nthat issues the manager-service request family for the one control manager the backend assigned to\nthis account, under a derived `p_` owner. It reads the assignment fresh on every call and refuses\nan IdP token, another account, a stale revision, another instance or lifecycle, and an instance\nanother owner registered. It refuses `prepare` and `activate` while the assignment's named\npredecessor is still registered or frozen. The same input adds `platformControlReadiness(instanceId)`,\nthe route for a host that needs to know whether its assigned control manager is serving. It returns\nthat instance's attributed `status` reply and refuses any instance the current assignment does not\nname, or one whose gate another owner holds. It reads over the context's own connection, whose\ngrant is the assigned instance's `describe` and `status` and its own reply rail. That connection\nrenews in process like the context's other connections and never leaves it, so the host lends no\nhuman or operator credential to a worker, mints no control instrument per read, and does not read\nliveness off the manager process. Without the input both members are `undefined`.\n`standingRenewableTtlSeconds` is forwarded unchanged to the authority plane, which bounds it to 5\nto 86400 seconds. It is a trusted-host input for the renewal rehearsal, and no request or CLI flag\nsets it. SPEC \xA713.1 and \xA713.6 define the view.\n\nThe auth plane can renew a registered manager's\nfive standing credentials from the current service registration. Run-driver renewal still\nrefuses without an authoritative activated-run reader, so these handles do not yet make a\ncomplete pooled auth and delivery host. A fresh auth plane can initialize without a\ndelivery-admin responder. Reclaiming a held claim from a dead predecessor needs the delivery\ninstance first: its admin rail must complete the broker connection-liveness sweep before the\nauth plane takes the claim. An absent or inconclusive oracle refuses the reclaim.\n\n### Remote manager client composition\n\n`@cotal-ai/manager` exports the `remoteManagerClient` namespace, containing the stock remote\nrequest builders and response validators, and `registerRemoteManagerAuthority` for registration\nwith a host-issued prepare credential. It returns the process epoch and registration revision its\nown registration committed. When a later start of the same instance registers before this start\nauthorizes its serve grant, this start is refused with `expired`.\n`RemoteManagerIdentityState` describes the five private\nmanager identities stored under an explicit account-local root. Use these public exports when\ncomposing `ManagerOptions.remoteAuthority`; do not copy CLI validators or import private modules.\n`managerClusterArtifacts()` returns the canonical document, manifest and their digests used by\nregistration. Pass its `[document, manifest]` pair as `contractArtifacts` to both\n`remoteManagerRegistrationProof(owner, state, contractArtifacts)` from `@cotal-ai/core` and\n`remoteManagerClient.remoteManagerAuthorityRequest(state, actor, \"activate\", { registrationProof, contractArtifacts })`.\nThe request builder takes each operation's coordinates as named fields of its last argument.\nThe host still validates the artifact closure and current registration before activation.\nThe namespace includes closed standing/run renewal, admission, maintenance, enrollment,\nretirement and goal-index scan helpers. `publicIdentities(state)` returns the `identities` record every request\ncarries: the five public ids, in the order the host echoes them. `remoteRunHosting` builds the four `runHosting` callbacks from the registration\nand the transport you supply for the host's run admission, run attempt and authority requests, so\nyour manager sends the run requests the stock manager sends. The namespace provides no signer or\nnew grant. The host still owns authenticated issuance, current registration and activated-run\nobservations, and any guarded foreign-holder repair.\nAn embedding must preserve those checks and supply a supported runtime; the client exports alone\ndo not provide a pooled runtime, an authority service or a complete hosted context.\n\n### Long-lived endpoints take a bearer function\n\n`EndpointOptions.bearer` accepts either a string or a function, and the difference is not stylistic.\nA string is minted once, so when it expires (which it will: callout bearers live minutes) the\nendpoint has nothing to renew with. It will not present the dead token to the broker, since that is\na guaranteed denial that still costs a full auth-callout round trip. It refuses to reconnect, emits\n`warning` saying which case it is in, and retries on a widening backoff until the process\nre-authenticates and rebuilds it. Retry notices use `warning` rather than `error` because Node\nrethrows an unhandled `error` event and would kill a host the endpoint is still trying to recover.\n\nPass a **function** for anything that outlives one bearer. That is a renewal source: it is called\nahead of each expiry and again whenever a reconnect finds the cached bearer dead, and it requires\nexplicit `card.owner` and `card.actor`. The first-party surfaces already do this\n(`UserViewAuth.source`, the connector's `agentBearerCommand`). A string bearer is for a short\none-shot connection.\n\nLong-lived hosts must also subscribe to the endpoint's `warning` event. It carries conditions the\nendpoint is surviving, including failed credential renewal, reconnect retries, a durable leave\nit keeps retrying after the broker refuses a durable channel's live subscription, and a channel\nregistry entry that does not decode. The endpoint drops that entry from its cache and applies the\ndefault policy until a valid entry is written. A host may choose\nto ignore warnings for a one-shot endpoint whose awaited operation owns the verdict, but that choice\nshould be explicit. An unhandled warning is nonfatal and silent.\n\nThe `error` event carries a fault the endpoint cannot return from a call, such as a refused\nsubscription or a refused publish that no request was waiting on. Attach a listener before `start()`,\nsince Node throws on an unhandled `error`. A denial the broker returns to a request, such as an\nobserver's read of the DM stream, reaches only that call, which decides what it means, and is not\nemitted again as an `error`.\n\n## Booting the daemons\n\n### auth-service\n\n`runAuthService(args, store?)` reads its provisioned long-lived secret kinds (service keys, callout\naccount, issuer keys, owner secret) through the injected `SecretStore`; a host provisions those into\nthe store first. It is a **signer and identity authority**, not a scoped daemon: at runtime it holds\nthe data-account and callout-account signing seeds, the issuer's private JWKs, and the\nowner-derivation secret in process memory (`SecretStore.get` exports raw values). The IdP pin and the\nactor ledger are **not** store-injected: `runAuthService` resolves them under\n`userAuthStateDir(findCotalRoot(), space)`, a path relative to the process working directory, so a\nhost provisions those into that exact directory (neither `store` nor `COTAL_HOME` selects it). It\nalso writes an ephemeral `auth-service.json` discovery file there that carries the live exchange\ncapability. That file appears only after every plane is bound, so waiting on it is the readiness\nsignal: a host that also passes the daemon's pid to the provider's `ready()` gets a process-bound\nwait. The wait extends past the base timeout while that pid is alive, up to a fixed bound, and it\nends at once when the pid exits.\n\n```ts\nimport { runAuthService } from \"@cotal-ai/auth\";\n// store implements SecretStore over your secret backend; get() returns raw seeds into memory.\n// Provision the auth secret kinds into the store, AND the IdP pin + actor ledger under\n// userAuthStateDir(findCotalRoot(), space), before this call.\nawait runAuthService(\n { values: { space, server: brokerUrl, port: \"8081\" }, positionals: [], raw: [] },\n store,\n);\n```\n\n### delivery\n\n`runDelivery(args, store?)` runs from a **pre-minted scoped `delivery` cred** and never loads the\nsigner. Provide the cred either through the injected store (under\n`deliveryCredsKey(space, { injected: true })`) or with a\n`--creds` file; the two are mutually exclusive. The daemon re-fetches the cred from the store at 75%\nof its JWT lifetime and fails loud rather than riding to expiry, so **something must re-sign a fresh\ncred into that same store**. When that read finds the previous generation still there, the daemon\nreports the missed remint and retries in 60 seconds. The current cred stays live until its expiry,\nand the store is read once per retry rather than once per second.\n\n```ts\nimport { runDelivery } from \"@cotal-ai/delivery\";\nawait runDelivery({ values: { space, server: brokerUrl }, positionals: [], raw: [] }, store);\n```\n\nThat renewal is a **signer** operation, not the delivery daemon's:\n`remintDaemonCreds(root, space, store?, { preflight? })` (`@cotal-ai/workspace`) reads the `SpaceAuth`\nsigner **through the same resolved `store`** (`getSpaceAuth(store ?? workspaceSecretStore(root), space)`,\nkeys `auth/broker.json` + `auth/account.<key>.json`; the pre-split `auth/auth.json` monolith is\nmigration input and the container signer mount only) and re-signs the daemon creds (`delivery.creds` and the membership feed's\n`membership-rw.creds`) back into that store. The injected `store` is both the signer source and the\ncredential destination, never a split. `space` is **required** and validated against the store's signer, so a\nstore swapped to a different space cannot re-sign over the wrong broker's creds. `preflight` is a\ncaller-supplied proof that the broker accepts the credential. The reference `Manager` passes a\n`probeConnect` over its `servers`. It gates **every** candidate before overwriting the last-good,\nwhether the signer is a full bundle or a stripped projection: a bundle's JWT chain proves only that\nit is self-consistent and\nnamed the space, NOT that its account is the broker's *current* account for that space (two\n`createSpaceAuth(space)` calls yield same-named, different-account chains), so a same-label alternate\nsigner would otherwise mint a broker-dead cred and clobber the good one. The offline local repair (`doctor auth --fix`) has no preflight. It permits the overwrite only\nunder **authority continuity**: the candidate must be signed by the same account signing key (`iss`) as the current\n(already broker-accepted) cred. A same-label alternate account breaks continuity and is refused, full or\nstripped; a legitimate local re-sign is continuous and proceeds without a network. The reference\n`Manager` runs it on a schedule against its **own**\n`secretStore` (see below), so passing the manager and the delivery daemon the *same* store closes the\nrenewal loop end-to-end on an injected backend: the manager reads the signer from the store, re-signs\ninto it, and the daemon adopts each generation on a preflight-proven 75% timer. The stock\ncross-host composition cannot satisfy that by writing one filesystem and fingerprinting another:\n`Manager.start()` and every later remint challenge the daemon's `reloadStoreIdentity` and a\ndivergent pair is refused naming both stores. The identity is the store the daemon actually\nreloads: an injected coordinate, the workstation root only when `--creds` is\n`<root>/.cotal/<spaceSegment(space)>/delivery.creds` (matching the canonical arm), the\nfile's own directory for any other `--creds` path, or the workstation root. A filesystem\nstore is also named by a random id it records in `store.id` inside its own directory, so two\nhosts that use the same root path are two stores. No key and no `--creds` file may be that\nfile under any name, and a `store.id` that is a symbolic link or holds anything but a lowercase UUID is refused. Uninjected\n`--creds` that names one real workstation while process cwd resolves another is refused\nat start, naming both, because membership-rw still uses `findCotalRoot`. A `--creds`\npath that is not under any `.cotal` tree is not that case and is not refused here. It never\nwalks ancestors with `findCotalRoot`. No bound daemon is not a named\nstore, so start proceeds; a later daemon on a foreign store is refused on the next remint.\nThe first-party filesystem adapter declares its workspace-root identity on the store itself. Other\ninjected adapters declare their stable coordinate on `SecretStore.identity`, or name it in\n`COTAL_SECRET_STORE` on both processes. It never throws: it\nreturns per-file results (`skipped: \"no-auth\"` when the store holds no signer records),\nso the caller must check them or the cred still rides to expiry. A composition whose signer lives in\nKMS/Vault simply injects that store; no bespoke renewal is needed. A `--creds` file path must be\nreplaced atomically before the 75% read. The signer can now be injected behind the store seam, which\nresolves custody. The remaining hosted gap is signer **isolation**. The seed is still decrypted\nin-process at the manager's uid, so it needs an OS sandbox or remote signer.\n\n### Supervisor signing authority\n\n`@cotal-ai/manager` exports the `Manager` class; there is **no** `runSupervise(opts)` runner. The\nprivate CLI `runManager` also does broker-reachability checks, space/default resolution,\nroster/launch parsing and materialization, installed-extension resolution, signal handling, staged\npre-spawn, and the forever wait. A host composes that lifecycle itself around `Manager`:\n\n```ts\nimport { Manager } from \"@cotal-ai/manager\";\nconst mgr = new Manager({ space, servers: brokerUrl, workspaceRoot });\nawait mgr.start(); // then wire your own SIGINT/SIGTERM -> mgr.stop()\n```\n\n`stop()` runs once. A later call joins the stop in progress and settles with it, and a call that\nasks for a different `withAgents` than the running stop is refused.\n\nUnlike delivery, the manager is **not** a pre-minted-scoped-cred daemon (auth-service is also a\nsigner: it holds fewer artifacts than the full trust bundle, but its data-account signing seed still\ngrants complete data-account mint authority on compromise, so this is not least-privilege). On `start()`\nthe manager reads its space's full trust chain **through its `secretStore`** (`getSpaceAuth(this.secrets,\nthis.space)`, composed from `auth/broker.json` + `auth/account.<key>.json`; a container may instead\nmount a stripped signer bundle at the legacy `auth/auth.json` key) and **self-mints** its supervisor cred and renewals from the\ndata-account signing seed. In static mode it also mints every per-agent cred from that seed; in user\nmode agents instead receive callout-minted bearers, but the manager still holds the signing seed for\nits own creds and renewal. So a hosted supervisor is a **trusted per-tenant account-signer process**,\nnot a least-privilege connect client. It additionally requires a `~/.cotal/meshes/space.<key>.json`\nregistry record and the workspace user-auth marker to start in user mode. `ManagerOptions.secretStore`\ninjects the one `SecretStore` the manager uses for **the signer itself (the split trust\nrecords)**, daemon-credential renewal (`remintDaemonCreds`), and per-agent secrets,\ndefaulting to the workspace filesystem store; pass the delivery daemon the *same* store for end-to-end\nhosted renewal. The store declares the same identity on both processes, or both set\n`COTAL_SECRET_STORE` to the same coordinate. The manager\nremints no daemon credential when the daemon names a different store, including a daemon that binds\nafter start; it keeps running and serving its own agents, so one space can carry a manager on more\nthan one workspace root. That manager also stays off the space's renewal lease, so a manager or a\n`cotal doctor auth --fix` on the daemon's store can still take it. Pointing several managers at one coordinate is safe: the store identity\nalone cannot pick an owner (it carries no holder and no tiebreak, so every manager sharing the store\nmatches), so the manager that also holds the space's renewal lease is the one that remints and the\nrest skip it. Without that lease two owners would remint on independent timers with no ordering\nbetween them, and one write would land between the other's re-sign and its fingerprint-only\n`reloadCreds`. `cotal doctor auth --fix` takes the same lease before it re-signs, so a live manager\nand a local repair never race each other either. The signer IS now injectable: a hosted composition injects a KMS/Vault store and no\nsigning seed lands on the hosted disk. What remains is signer **isolation**. The seed is decrypted\nin-process at the manager's uid. That issue needs an OS sandbox or remote signer; it is no longer a\ncustody problem. The other knobs are `workspaceRoot` and the process-global `COTAL_HOME`.\n\nA store, runtime or extension the manager calls may reject with any value, including `null`. The\nmanager logs or refuses with that value's `message`, or with the value itself as text when it has\nnone, and keeps serving. A value it cannot read reports as `an unreadable rejection`.\n\n> Scope note: the **static-auth** operator paths (`cotal spawn`/`join`/`status`/`web`, via\n> `mesh-target` \u2192 `connect`/`preflight`) still read the signer from the local split records (sync\n> `loadSpaceAuth`). That is the single-machine composition, where the signer is on local disk by the\n> static-auth model; multi-tenant hosting runs **user mode**, which never mints from on-disk trust. The\n> store-injectable signer path is the hosted-server set: the manager, `remintDaemonCreds`, and delivery.\n\nThe typed remote-manager authority contract includes a one-shot terminal phase. A host implements\n`remoteAuthority.prepareAgentRetirement` to revoke the managed grant and finish its resumable\nrelease while preserving the UID, then `remoteAuthority.mintRetirementRequester` returns the\nhost-signed JWT for a fresh participant-owned nkey. The credential is pinned to the authenticated\nowner, server-derived manager serve principal, current instance epoch, and exact target lifecycle.\nThe manager then uses the existing auth `retireLifecycle` rail with the operation id derived by\n`managedRetirementOpId(target.lifecycleUid)`. This derivation is the reference remote-Manager\ncomposition's closed contract, not a rule for every retirement entry point; interactive retirement\nkeeps its existing operation identity and remains compatible. The `retireLifecycle` rail independently\nrecomputes the managed id from its broker-pinned target before any gate, head, intent, or barrier\naccess, so mint-time validation is not the terminal boundary. A failure keeps\nthe alias held. This does not expose the auth barrier or give the participant signer authority.\n\nIf the participant disappears after prepare, the host finishes the retirement itself on the auth\nservice's loopback face: `POST /managed-lifecycle/retire` (exported as `MANAGED_RETIRE_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only\n`{ owner, actor, lifecycleUid }`. It has the interactive door's guards (POST only, no `Origin`, JSON,\ncapability, closed body) and is never served on the public face. The managed grant must already be\nrevoked at that uid, or it answers 409. It runs the same `managedRetirementOpId(uid)` operation as\nthe rail, and the rail and the door share one in-process flight, so a late participant request and\nthe host call converge on one barrier.\n\n| lifecycle head | answer |\n| --- | --- |\n| absent, or `retired` at another uid | `200 { retired: false, lifecycleUid, notStarted: true }` |\n| `active`/`retiring` at another uid | `409` |\n| `retired` at this uid | `200 { retired: true, lifecycleUid, alreadyRetired: true }` |\n| `active`/`retiring` at this uid | the barrier runs, then `200 { retired: true, lifecycleUid }` |\n\nDeprovisioning durables stays with `deprovisionAgent` and a `deprovisioner` credential. Pass `memberChannels` to both to also purge the retired lifecycle's durable membership rows on those concrete channels. The manager fills that list from the launch's concrete read channels and the delivery daemon's read-only `lifecycleMemberships` admin verb. When that verb cannot answer, rows on other channels stay retained, the teardown logs the inventory as incomplete, and retirement remains held pending retry rather than releasing the alias. Each teardown examines two exact consumers, one ACL key and the named member keys. KV deletion uses a native revision condition; a lost condition with a live replacement refuses rather than claiming absence. Consumer INFO checks before and after DELETE distinguish verified prior absence from disappearance. `acknowledged` counts native DELETE success replies. `disappeared` counts observed live-to-absent consumers and live KV rows whose conditional purge lost to a competing deletion. These KV rows were present at the first read, so they never count as prior `absent` or as this caller's `deleted`. The ACL and membership subtotals preserve that distinction. Neither establishes which concurrent caller uniquely removed a consumer, so `consumers.deleted` and the total `deleted` are `null` when a consumer disappears without a winner token. A repeat after verified absence reports zero, not an invented deletion. `refused` counts slots whose cleanup or state remains uncertain. A partial failure raises `DeprovisionError` carrying these bounded observations. The Manager's static sweep preserves unknown uniqueness rather than adding acknowledged requests as physical removals; its slot totals remain separate.\n\nA host that resumes retained managed actors also implements\n`remoteAuthority.validateRetainedAgent`. The participant sends back the actor token and sentinel it\nalready holds, plus the `nextRegistrationProof` returned by the activation response. That proof is\nhost-issued after registration and binds the manager owner, actor, lifecycle, identity nkeys, current\nregistration revision, and serving epoch. The host checks it against the current open manager gate,\nvalidates the retained secrets against its current managed row, and returns only the non-secret\nauthority shape. The manager binds every result coordinate and the returned authority back to its\ninventory before use. Do not copy the provider's `issuer.json` or `callout.json` into the participant\nstore. Both contain private signing or exchange authority.\n\nThe same composition supplies `remoteAuthority.agentBearerExchangeUrl`, the pinned public auth-service\nbase used by retained children. Remote adoption launches `agent-bearer --exchange-url <base>`; it must\nnot select the local `--dir` arm, which depends on a host-only auth-service process record.\n\nA host that lets a remote participant spawn FRESH managed agents implements\n`remoteAuthority.enrollManagedAgent`. The participant generates the standing actor token, writes it\nat mode 0600, and passes only its SHA-256 digest with the requested actor, label, role,\ncapabilities, and channel lists, so the plaintext secret never leaves the participant machine. There\nis deliberately no `lifecycleUid` input: the host selects the UID, because only the host sees the\nretirement tombstones that make a UID permanently unusable, and a participant-chosen UID could aim a\nfresh grant at a dead incarnation. The host authors the ledger grant, pre-creates the lifecycle-keyed\ndurables, clamps the requested lists to what the spawning owner already holds, and returns the owner,\nactor, chosen `lifecycleUid`, the space sentinel credentials, the effective lists, and\n`agentBearerExchangeUrl`. The manager binds every returned coordinate, re-keys the secret family onto\nthe returned UID, and launches `agent-bearer --exchange-url <base>`. When the hook is absent a\nsignerless manager refuses the user-mode spawn rather than authoring a local grant the host knows\nnothing about.\n\nBoth managed-agent operations ride the one verified `POST /manager-service-authority` transport as\n`kind: \"manager-managed-agent-enrollment\"` and `kind: \"manager-managed-agent-prepare-retirement\"`.\nStock `cotal auth-service` answers both itself, because it owns the actor ledger and the space's\nprovisioning authority. An enrollment writes the managed grant at a fresh UID with the supervising\nactor as its parent, provisions that UID's durables, and returns the daemon's public exchange URL as\n`agentBearerExchangeUrl`; a daemon started without `--exchange-public-port` refuses enrollment. A\nretry with the same token digest answers the same UID while the supervising actor's current grant\ncovers it, and a fresh enrollment's refusal otherwise. While the agent's grant stands, an enrollment\nwith another digest is refused with `conflict` until that lifecycle's retirement is prepared. A\nprepare-retirement releases the target UID's broker footprint and then revokes its grant, so the\nmanager's terminal rail finds the grant gone. A platform that keeps these writers in its own storage\nintercepts both kinds instead. It terminates its own public route, authenticates the human there,\nand asks the auth service for the decision at\n`POST /manager-service-authority/verify-enrollment` (exported as `VERIFY_ENROLLMENT_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only `{ owner, request }`. That\ndoor has the managed retirement door's guards, derives the caller's scope from the local ledger rather\nthan the body, checks the manager gate and registration proof in-process, and answers\n`{ authorized: true, owner, actor, instanceId, serveEpoch }`. `authorizeRemoteManagedAgentEnrollment`\nand `authorizeRemoteManagedAgentPrepareRetirement` are exported too, for a host that composes the\ndecision without the HTTP hop. Both require ledger scope `supervise`; `spawn` and `admin` do not\nimply it.\n\nA host that runs managed agents on its own hosted runtime adds two more kinds on the same transport.\n`kind: \"manager-managed-agent-runtime-create\"` asks the host to create the runtime for one agent it\nalready enrolled, and `kind: \"manager-managed-agent-runtime-status\"` reads that runtime's state. Both\ncarry the manager envelope plus `target: { owner, actor, lifecycleUid }`, the coordinate the\nenrollment returned. Both schemas are closed. An unknown top-level or target field, including\n`providerRef`, `handle`, or `name`, is refused as `bad-request`, because the host alone issues and\nholds provider references. There is no stop, adopt, or probe kind: stop goes through\nprepare-retirement. Stock dispatch refuses both kinds with `unimplemented`, and the verify-enrollment\ndoor decides them. `authorizeRemoteManagedAgentRuntimeCreate` and\n`authorizeRemoteManagedAgentRuntimeStatus` apply the enrollment door's checks: host space, a target\nowner equal to the authenticated owner, the manager actor's own ledger row with `supervise`, the open\ngate, the current serve epoch, and the registration proof. Each returns only\n`{ owner, instanceId, actor, target }`. The door touches no provider and writes nothing. The host\nmatches the decision to its own intent record and performs the create afterwards. The host answers\nwith `state` (`reserved`, `creating`, `bound`, `create-unknown`, `closing`, or `closed`), `readiness`\n(`ready`, `bound-not-ready`, or `none`), and an optional `retirementPhase`. A manager builds requests\nwith `remoteManagerClient.remoteManagedAgentRuntimeRequest` and binds the answer with\n`remoteManagedAgentRuntimeState`.\n\nAn enrollment result may also carry `runtimeIntent: { state: \"reserved\" }` when the host reserved a\nhosted runtime for the agent. It is display-only. Older hosts omit it, the manager binds both shapes\nto the same material, and nothing reads it as authority.\n\nNo stock door lets a platform control holder launch or retire an agent for a signed-in user. The\nmanaged-agent kinds above act only under the authenticated owner and refuse a caller that is not\nthat user, so a platform could only run a user's agent by holding the user's login or by enrolling\nthe agent under its own owner. Both are refused. The\n[delegated user launch intent](design/delegated-user-launch-intent.md)\ndesign and SPEC \xA713.16 define the smallest addition. The user admits one launch or one retirement\non the host's authenticated route. The holder consumes that intent once, from its current\nregistration, epoch and lifecycle. The host then enrolls the agent under the user's `u_` owner with\nthe user's own actor as its ledger parent, so the envelope walk, membership and channel lists match\nwhat the user's own manager would produce. Retirement keeps the prepare, provider closure and\nterminal barrier order, and the host finishes it when the holder is gone. A launch the host had to\nundo keeps its agent name held until the host process that ran it confirms it has stopped, and\nwhile the name is held the host also refuses it to the user's own manager. `@cotal-ai/auth` ships the\ntwo decisions, `authorizeDelegatedUserIntentAdmission` and `authorizeDelegatedUserIntentExecution`,\nfor a host that owns an intent store and those writers to compose on its own routes. Both read the\nholder's gate through the handle's `observeManagerGate`, present with `platformControl`, which reads\nover the context's own connection, so the host opens no second data-account connection. It is an\nobservation for the decision: the consuming CAS and the writers still apply their own checks. The\nconsuming CAS pins the incarnation of the host process that runs the flight as the executor, never\nthe auth plane's, because the two can restart independently. `platformControl.host` names that\nprocess's reverse-DNS endpoint, the closure digest of its \xA713.7 cluster and the contract artifacts\nregistration reads, and the auth plane self-authorizes that one name. Registration reads the closure\nmanifest `{ v: 1, root, members }` at `clusterDigest`, then the cluster document at the manifest's\n`root`, and verifies each against its digest. `artifacts` therefore carries both, and `clusterDigest`\nis the digest of the manifest. `members` stays empty: SPEC \xA713.7 lists every reachable artifact\nthere, but this implementation registers single-document clusters only and refuses a manifest that\nlists members. A manifest with any field beyond `v`, `root` and `members` is refused too, as the\ncontract store refuses it. `singleDocumentClosure(document)` returns that manifest and its closure\ndigest. The instance id is a lifecycle token, `[a-z0-9]{26,32}`. The minimal construction below has\none command over the void schema. A host copies it, replaces `document` with its real cluster, and\npasses `host` as `platformControl: { observeAssignment, host }`.\n\n```ts\nimport { mintLifecycleUid, singleDocumentClosure, VOID_SCHEMA_DIGEST } from \"@cotal-ai/core\";\n\nconst document = {\n urn: \"com.example.host\",\n revision: 1,\n attributes: [],\n events: [],\n commands: [{\n name: \"ping\", class: \"ephemeral\", targeted: false, capability: \"host.ping\",\n inputDigest: VOID_SCHEMA_DIGEST, outputDigest: VOID_SCHEMA_DIGEST,\n }],\n};\nconst { manifest, closureDigest } = singleDocumentClosure(document);\nconst host = { endpoint: \"com.example.host\", clusterDigest: closureDigest, artifacts: [document, manifest] };\nconst instanceId = mintLifecycleUid(); // first start only; later starts reuse the persisted id\n```\n\n`registerHostIncarnation(instanceId)` publishes the artifacts, registers that instance through the\nceremony the plane runs for itself, and returns `{ instanceId, processEpoch }` with the epoch that\nregistration committed. The host calls it at every start with its persisted instance id, before it\nadmits or recovers any flight, so a restart fences its predecessor. The first registration of an\ninstance commits epoch 0, which is open and serving like any later epoch, and each later start of\nthat instance commits the previous epoch plus one. A consumer compares epochs for equality and never\nreads 0 as absent or not ready. A start\nwhose confirming read of the gate finds that a later start of the same instance registered is\nrefused with `conflict`. The returned epoch is a committed coordinate and stays current only until\nthe next start registers, which can happen before the call returns. `observeHostGate(instanceId)`\nis a point-in-time read of an executor's gate, and answers null for an absent gate; a sweeper\ndecides on it. `awaitHostFence(instanceId, processEpoch)` resolves with the gate once it is no\nlonger open at that epoch, and with null once it is absent. It takes any non-negative safe integer\nepoch, 0 included, and refuses any other value with `bad-request`. The host arms it with its returned\nincarnation before it admits or recovers any flight, and stops serving when it resolves. It polls\nthe gate, because no runtime credential may watch the auth bucket. The\nhost's launch writer first activates the agent's lifecycle at the pinned UID through the handle's\n`activateManagedLifecycle`, before any ledger row or durable, and its compensation runs the same\ncall before the terminal barrier, so a launch whose agent never exchanged its bearer still reaches\nthe terminal barrier at that UID. The launched agent exchanges its bearer on the context's public\nface, so the host starts it with `publicFace`, and its retirement writer ends at\n`POST /managed-lifecycle/retire` with the handle's `cap`. Stock dispatch refuses both kinds as\n`unimplemented`. The holder's composition passes\n`remoteAuthority.executeDelegatedUserIntent`, which posts the execution request and binds the answer\nwith `parseRemoteDelegatedUserIntentExecutionResult`. It then starts the agent with\n`startAgent({ ..., delegatedIntent: { intentId, owner, parent } })` and retires it with\n`retireDelegatedAgent(name, intentId)`, which stops the agent only after the host confirms\n`retired: true` for its exact target. A delegated agent's stop, exit or failed launch keeps its name\nheld until that retirement confirms.\n\nRemote user-mode managers must also supply `remoteAuthority.authorizeAdmin`. The manager builds each\nrequest only from the caller tuple parsed from the broker-authenticated endpoint subject, then relays\nthat tuple over the current registered manager lifecycle. HTTPS does not separately authenticate the\nrelayed caller. The host authenticates the manager operator, binds the request to the current open\nmanager gate, registration proof, serving epoch, and identity nkeys, then reads the caller's unified\nauthoritative row fresh. It returns only the manager owner and `authorized: boolean`, with every request\ncoordinate echoed. Missing, revoked, narrowed, foreign-owner, and stale-lifecycle callers all return\n`false`; malformed coordinates or corrupt and unavailable authority state fail the operation. The\nparticipant never reads or mirrors the host ledger, and the remote branch has no local fallback. The\nsame callback gates all `manager.admin` handlers, any-mode cross-owner control, and `ps` or `inspect`\ncross-owner visibility. Launch keeps its owner-equality policy.\n\nThe remote authority's instance executor remains the scoped maintenance credential for clean service\nderegistration and exact instance registration operations. It carries no records-stream consumer\nlifecycle authority. The manager's boot `goalidx` sweep uses the authenticated host operation, which\nreturns parsed `goalidx.manager.<owner>.>` entries for that owner only. The host keeps the sealed\nconsumer connection and its create/delete rights. The five-minute executor already renews through\n`remoteAuthority.renewExecutor`. The signerless supervisor, serve, goal-writer, session-ledger and\nper-run driver credentials do not yet have a complete remote renewal and adoption path. The\n[hosted runtime contract](design/hosted-runtime-contracts.md) records the bounded additions and\ntheir ownership; it is not a shipped pooled service.\n\n**Signer isolation needs an OS sandbox.** The default pty runtime\nruns agent children under the *same* OS uid and the *same* `workspaceRoot`, so mode-0600 on\nthe trust records does not stop a hostile same-uid agent from reading their absolute paths. The reference\n[deploy](deploy.md) tree does not solve this: it mounts the signer into the agent's own container, so\nits phase-1 boundary isolates agents from each other, not the signer from the agent. A hosted\ncomposition must run the manager/minter that holds the signer in a different uid, container, or mount\nnamespace from the agent children, which mount no signer at all; that split is future\nhosted-composition work, so until it (or a remote/injected minter) exists, do not run untrusted\nagents under this manager.\n\n### Delegated seats outside the manager's filesystem\n\nThe [portable lifecycle bootstrap](design/portable-lifecycle-bootstrap.md)\ndesign and SPEC \xA713.17 define how a managed agent that `enrollManagedAgent` already enrolled starts\nin a child that cannot see the manager's filesystem. The ordinary `spawn` path writes the token and\nsentinel under the manager's workspace root and hands the runtime a launch whose bearer command and\nmaterial file are paths on that filesystem, so such a child needs this path instead.\n\nThe delegation boundary is one optional runtime method. A runtime that implements\n`Runtime.spawnDelegated(launch, handoff)` receives two values and no paths: a `DelegatedSeatLaunch`\n(connector name, persona text, model and the other launch choices) and a `ManagedLifecycleHandoff`\n(space, owner, actor, the host-chosen `lifecycleUid`, broker and IdP pins, the pinned exchange base,\nthe sentinel, the channel lists, and the raw actor token). The manager enrolls once, as it does\ntoday, and builds the handoff from what it holds. It never sends the token to the host, never copies\na file from its workspace or secret store, and never builds a local launch for that seat. No signer,\nissuer or callout record, loopback capability, provisioner or manager credential, control token, or\nmanager path crosses. A spawn choice that only the manager's host can honour is refused before\nenrollment: `--resume`, a manifest agent's `continuity: exact`, `--cwd`, and any shared MCP server,\nwhether from `--share-tools` or the config default (`--share-tools none` passes).\n\nThe runtime creates one provider resource under `managedRuntimeKey(target)`, writes the handoff into\nit as one 0600 file and the persona beside it, and runs the stock bootstrap there:\n`cotal spawn --config <persona-file> --space <space> --name <actor> --expect-owner <owner> --expect-lifecycle-uid <uid>`\nwith `COTAL_MANAGED_HANDOFF_FILE` naming the file. `delegatedSeatCommand` builds that argv. The\n`cotal` entry reads the file into memory, deletes it and drops the variable before it parses flags,\nprints help or loads extensions, so every outcome, a refusal of its own flags included, leaves no\nfile. It refuses before any broker connection or exchange request when the\nspace, owner, actor, or lifecycle UID differ from the expected values, and then runs the\nenrollment-redeem consumer: it registers the mesh in its own home, writes the token to its own 0600\nfile, and exchanges it through `agent-bearer --exchange-url` unchanged. It never enrolls, redeems, or\nmints a token or UID.\n\nReadiness is still mesh presence. A create whose answer is lost leaves the handle running, so the\nlaunch settles uncertain and stays held; the manager never retries it. A provider read that finds no\nresource under the key is not an exit, because the create may still land. Every close by\n`managedRuntimeKey` is fenced: it completes only once the create was answered or the provider refuses\nany later create under the key. A provider that names its own resources may run the create as a\ndurable operation keyed by `managedRuntimeKey` and close through the identifier its authenticated\ncreate response returned, kept where the host can read it without the manager. Only that response\nbinds an identifier to the key; one derived from the key or found by name or listing is never closed\nor adopted, and while the response is unknown the launch stays held. Every stop, the reap of a child\nwhose parent exited and `Manager.stop({ withAgents: true })` included, runs `prepareAgentRetirement`\nfor the UID-exact target, then `stop()` on the handle `spawnDelegated` returned, then the terminal\nbarrier. `preparePreservation` refuses a cut that holds a delegated seat, and a `Manager.stop()` after\na refused cut retires the seat through the same steps. After the manager is gone\nthe host runs the same steps, makes the same fenced close by `managedRuntimeKey`, and finishes at\n`MANAGED_RETIRE_PATH`. Supply `spawnDelegated` only from a runtime whose host can make that fenced\nclose without the manager. The enrollment redeem\n(`COTAL_ENROLLMENT_FILE`) stays for lifecycles whose token the host generated itself; a host cannot\nmint one for a manager-enrolled lifecycle because it holds only the digest.\n\n## Provisioning a space (one-space reference shape)\n\n```ts\nimport { createSpaceAuth, setupSpaceStreams, ensureDefaultDeliveryClass, mintCreds, newIdentity } from \"@cotal-ai/core\";\nconst auth = await createSpaceAuth(space); // trust bundle (in-memory seeds)\nconst provisionerCreds = await mintCreds(auth, newIdentity(), \"provisioner\");\nawait setupSpaceStreams({ servers: brokerUrl, space, creds: provisionerCreds });\n// SPEC section 4: write the default delivery class at space creation so it is wire-discoverable,\n// never inferred from the resolution fallback. A daemon-backed space is \"durable\".\nawait ensureDefaultDeliveryClass({ servers: brokerUrl, space, creds: provisionerCreds, deliveryClass: \"durable\" });\nconst deliveryCreds = await mintCreds(auth, newIdentity(), \"delivery\");\n// put deliveryCreds into your SecretStore under deliveryCredsKey(space, { injected: true })\n// (@cotal-ai/workspace) before booting delivery \u2014 the key is per-space, not the bare kind.\n```\n\nRendering the broker config for a user-auth space is `serverConfig(broker, spaces, { storeDir,\nmaxFileStore?, extraAccounts })`, where `extraAccounts` must include the callout account from\n`createCalloutAuth` so the auth-service has a broker account to answer on. That account never shares\nthe data account. `maxFileStore` is an optional positive integer byte cap; any other value throws.\n\nBroker trust and space accounts are separate authorities: `createBrokerAuth` mints the one\noperator + system account a broker trusts, `createSpaceAccountAuth(broker, space)` signs each\ntenant's data account under it, and `serverConfig(broker, spaces, opts)` renders them all into one\nconfig. A host composition can therefore provision several spaces on one broker today. `cotal up`\nrenders that config from every tenant the root's auth directory holds, so booting one space keeps\nthe broker trusting its siblings, and it refuses to render at all while any account record is\nunreadable. The rest of the CLI lifecycle is still broker-wide: `down`, `clean` and `backup` refuse\non a multi-space root rather than scoping to one tenant, and the per-space lifecycle is the\nremaining multi-space operator layer. See\n[Known gaps](#hosted-composition-gaps).\n\n## Hazardous provisioning primitives\n\n`mintCreds`, the full `Profile`/`CredentialKind` matrix, `createSpaceAuth`, and `stripSpaceAuth` are\nlow-level operator primitives. Handle them as account-authority material:\n\n- A holder of a `SpaceAuth` (or a `stripSpaceAuth` bundle, which **keeps** the data signing seed) is\n a fully-trusted tenant-account authority: it can mint `admin`, `provisioner`, and destructive\n profiles, not merely `supervisor`, and mint a DM-reading identity. `createSpaceAuth`'s full result\n holds operator, system, and account seeds in memory.\n- Choose `profile` and `MintOpts` from **server-side constants**, never from tenant input. `MintOpts`\n can widen the bounded TTL defaults; cap it at your boundary. `CREDENTIAL_LIFETIMES` is a policy\n record, not an authorization boundary.\n- Never log signer material or export it into env. Do not co-locate signer access with an untrusted\n connector/runtime process at the same OS uid (file permissions do not contain a same-uid reader;\n see the manager's isolation note). Segregate per tenant; rotate on compromise\n (`rotateDataAccountSigningKey`).\n\n## Hosted composition gaps\n\nThe primitives above are present as exports, but three capabilities are **not** cleanly composable\nfrom the public contract today. Each is tied to work in flight; a host either waits for the seam or\nscopes the capability out. None is a wire concern.\n\n1. **Delivery immediate live eviction and a fully-hosted membership feed.** The renewable\n `membership-rw.creds` is now a `SecretStore` kind. `startMembership` reads it through the\n injected store, and the manager re-signs it there. The graph-feed writer therefore renews on a hosted\n backend (its data connection adopts each generation on a preflight-proven 75% timer). What still\n reads from a fixed on-disk path are the *static* `membership-observer.creds` and\n `connection-evictor.creds` ($SYS creds, minted at the `up` that provisions the account and renewed by `up --rotate-sys`) and `membership.json`\n (`{accountId}`, non-secret config); those, plus the private provisioning wrapper, keep immediate\n live eviction and a fully-hosted feed a partial gap. Missing files degrade membership to\n traffic-only and make live eviction refuse (loudly). The supported delivery contract here is the\n Plane-3 durable backstop.\n2. **Supervisor signer isolation.** `ManagerOptions.secretStore` now injects the one `SecretStore` the\n manager reads/writes every secret through, including the composed `SpaceAuth`\n signer (the split trust records), its daemon-cred renewal, and its per-agent kinds. What remains is process\n isolation: the manager still decrypts the signer in-process at its uid, so untrusted agent children\n must run under a different uid/container/mount namespace or behind a future remote signer.\n3. **Per-space lifecycle on a shared broker.** The trust layer is multi-space\n (`createBrokerAuth` + `createSpaceAccountAuth` + N-space `serverConfig`, persisted as\n `broker.json` + `account.<key>.json`) and `cotal up` renders the whole tenant list, but there is\n no per-space provisioning verb and no per-space teardown/backup/restore: the CLI's broker-wide\n lifecycle verbs refuse on a multi-space root, naming the tenants.\n This is the remaining multi-space operator layer.\n4. **A non-Better-Auth production IdP.** The exchange core (`createIdpBridge`) is EdDSA-generic, but\n the stock provider and login client are Better-Auth-endpoint-shaped, `cotalAuthProvider`\n self-registers on import (colliding with a host-owned provider under `resolveAuthProvider`), and\n the login flow speaks Better Auth's device-code endpoints. A different IdP is a host-built auth\n composition on the low-level primitives, not a configuration change (see\n [the IdP callout contract](identity-and-auth.md#the-idp-callout-contract)).\n\n## Hosted durability\n\nSpace-durable **coordination** state (chat/DM/task history, live presence, membership runtime, the\ndurable ACL registry, leases) lives in **JetStream**, written by the delivery daemon and the\nendpoints. It is broker-resident and needs no host-side durable path.\n\nWhat is **not** in JetStream, and is hosting-critical, is trust and authorization state a host must\nplace and keep:\n\n| state | class | where today | hosted injection |\n|---|---|---|---|\n| full `SpaceAuth` trust chain (`auth/broker.json` + `auth/account.<key>.json`, composed; a stripped signer bundle may instead be mounted at the legacy `auth/auth.json` key) | signing authority | `SecretStore` | `SecretStore` (manager + renewal) |\n| auth kinds: callout account/creds/xkey, issuer private keys, owner-derivation secret, data-signer projection | signing/identity authority | four `SecretStore` kinds | `SecretStore` (auth-service) |\n| auth plane instance identity (instance id + serve nkey seed) | restart identity | root `.cotal/space.<hex>/auth-instance.json` | `SecretStore` (`startAuthService`) |\n| `delivery.creds` | standing scoped cred | `SecretStore` or `--creds` | `SecretStore` (delivery) |\n| actor ledger, IdP pin | authorization + trust config | ambient `userAuthStateDir(findCotalRoot(), space)` | none (root-relative; not `store`/`COTAL_HOME`) |\n| `membership-rw.creds` | standing scoped cred | `SecretStore` | `SecretStore` (delivery + manager renewal) |\n| membership-observer / connection-evictor creds + `membership.json` | scoped $SYS creds / config | workspace filesystem | none (see gap 1) |\n| manager agent creds, actor tokens, sentinel creds | lifecycle authority | `SecretStore` | `SecretStore` (manager `secretStore`) |\n| `~/.cotal/meshes/space.<key>.json` record (holds IdP trust pins/root pointers) | non-secret, integrity-critical | machine home | process-global `COTAL_HOME` only |\n| auth-health, renewal records | non-secret diagnostics | workspace filesystem | `workspaceRoot` |\n\nThe `SpaceAuth` trust chain and the auth-service store kinds are **separate** identities/projections,\nnever parts of one document. `auth-service.json` (the live exchange capability) is ephemeral runtime\nstate, not durable, but is sensitive while the daemon runs. `@cotal-ai/workspace` is machine-local\noperator tooling by design; personas, PID files, and the `current-mesh` pointer are truly local and\nmust **not** sit on a hosted durable path. Everything classed above as an authority is what a hosted\ncomposition must provision and persist: signer-bearing server secrets now have `SecretStore` seams;\nthe remaining non-injectable rows are the explicit ambient `workspaceRoot`/cwd paths above.\n\n## See also\n\n- [Substrate stability](stability.md): what v0.3 and the 0.x packages guarantee, and the projected v0.4 break.\n- [Identity and auth](identity-and-auth.md): the profile matrix, the signer, and the IdP callout contract.\n- [Delivery daemon](delivery-daemon.md): the Plane-3 durable backstop.\n- [Deploy](deploy.md): the reference container against an external broker.\n\n## Hosted run revocation\n\nThe authenticated manager-service authority door accepts `manager-run-revoke` with the registered\nmanager envelope and `revoke: { runId, reason }`. The issuing host reads the admission's recorded\nowner and requires the verified requester to be that owner or to hold `admin` in its fresh actor\nledger row. It verifies the current manager registration proof, space, account and epoch. It accepts\nno owner or attribution from the body.\n\n`AuthAuthorityPlane.revokeManagerRun` implements the operation. `AuthProvider.requestRemoteRunRevoke`\ntransports it, and `remoteRunHosting` supplies `RunHostingContext.revokeRun` for a signerless\nmanager. The callback returns the stored `RunRevocation`, including on repeats. A host composing\nits own remote authority must supply that callback to revoke. No signer reaches the participant.\nCLI and MCP revoke verbs are a follow-up described in the [run-start design](design/user-auth-run-start.md).\n"
62628
62688
  },
62629
62689
  {
62630
62690
  "slug": "examples",
@@ -62652,7 +62712,7 @@ function loadDocsBundle() {
62652
62712
  "title": "Mesh manifest (`cotal.yaml`)",
62653
62713
  "kind": "Reference: every field of the mesh manifest.",
62654
62714
  "summary": "A manifest (cotal.yaml, kind: Mesh) describes a whole team (its channels, its agents, and who may read and post where) in one file.",
62655
- "body": "# Mesh manifest (`cotal.yaml`)\n\n> **Reference**: every field of the mesh manifest. \xB7 **For:** operators \xB7 **Walkthrough:** [Define a team](define-a-team.md) \xB7 **ACL semantics:** [SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)\n\nA manifest (`cotal.yaml`, `kind: Mesh`) describes a whole team (its channels, its agents,\nand who may read and post where) in one file. It is **channel-centric**: you list the\nchannels, and under each one name the agents that may read and post; Cotal inverts that\ninto one least-privilege credential per agent. The manifest is a convenience over the CLI;\nit adds no wire concepts. Today it is **single-space** (one `space:` per file).\n\nThe lifecycle (`cotal topology view -f` / `up -f` / `spawn -f` / `down -f`), ownership,\nand teardown behavior are in the guide: [Define a team](define-a-team.md).\n\n## Top level\n\n| Key | Required | Meaning |\n|---|---|---|\n| `apiVersion` | yes | Must be `cotal/v1`. |\n| `kind` | yes | Must be `Mesh`. |\n| `space` | yes | The space name (one per file; `spaces:` is not supported in v1). A space's auth is bound to one root; to run a non-default space in a checkout that already ran `cotal up` (which sets up `main`), use a fresh directory. |\n| `broker` | no | `servers` (comma-separated broker URLs: this sets the address/port; default `nats://127.0.0.1:4222`; **no embedded creds**), `host` (bind interface only, no scheme; does *not* set the port), `auth` (the auth mode: unset/`true`/`\"static\"` = per-agent JWT creds, the default; `false` = an open dev mesh; `\"user\"` = per-user auth, where people `cotal login` and every connect is authorized against the actor ledger; pair with `idp`), `idp` (with `auth: \"user\"`: the IdP auth base URL to pin on first enable; **no embedded creds**). The port comes from `servers`/`--server`, never `host`/`--host`. |\n| `runtime` | no | Registered manager runtime name. `pty` is built in; optional providers such as `tmux`, `cmux`, `orca`, and `herdr` are installed with `cotal ext add`. |\n| `agent` | no | Default harness (`claude` / `opencode` / `hermes`) for agents that don't set their own. There is **no silent default**; an agent needs this or its own `agent:`. |\n| `personaPermissions` | no | `reject` (default): the manifest is the whole truth. `include`: a persona's own channel grants are inherited for channels the manifest doesn't declare. |\n| `defaults` | no | Channel defaults applied unless a channel overrides: `replay`, `replayWindow`, `deliveryClass` (`live` / `durable`). Semantics in [SPEC \xA77](../SPEC.md#7-channels). |\n| `agents` | no | name \u2192 persona (a channels-first manifest can seed rooms now and add agents later). |\n| `channels` | yes | name \u2192 channel (below). |\n\nUnknown keys are rejected (no silent ignore), and every error is reported with its file and line.\n\n## Agent forms\n\n```yaml\nagents:\n planner: ./agents/planner.md # 1) bare path: reuse a persona file as-is\n builder: # 2) a persona file + overrides (manifest wins)\n persona: ./agents/builder.md\n model: sonnet\n cwd: repos/backend # relative to the manager workspace\n role: implementer\n instructions: Prefer the smallest change that works.\n lead: # 3) inline (no file): needs at least model or instructions\n model: opus\n role: lead\n capabilities: [spawn] # may spawn helpers\n instructions: Coordinate the team.\n prompt: Introduce yourself in #general and assign the first task.\n```\n\nPer-agent keys: `persona`, `agent` (harness override), `cwd`, `continuity`, `model`, `variant`, `role`,\n`description`, `instructions`, `prompt`, `capabilities` (`spawn`,\n[what it grants](identity-and-auth.md); on a per-user-auth mesh also `role:<r>`, so the\nagent may delegate that role when spawning; `admin` is never accepted from a manifest),\n`personaPermissions` (override the top-level policy). Model strings and variants pass to\nthe harness as-is: for Claude use the short form (`opus`, `sonnet`) or the full id; for\nOpenCode use `provider/model` plus an optional variant (`cotal models --agent opencode`\nlists both). Persona file format: [agent files](agent-files.md).\n\n`cwd` is the agent's working directory on the **manager host**. A relative path resolves\nagainst the manager workspace, matching `cotal spawn --cwd`; an absolute path is used\nas supplied. Omitting it keeps the manager workspace as the default. It is never resolved\nagainst the manifest or persona directory on the deploying machine. Changing `cwd` marks\nan already-deployed agent stale and requires a restart. Empty paths and NUL bytes are\nrejected. This field controls the directory only; `continuity` restores a harness session.\n\n`continuity` says whether an agent keeps its harness session across launches. `none`, the\ndefault, starts a new session every time. `exact` reopens the session the manager last\nbound to this agent name, so after `cotal down` and `cotal up -f` the agent comes back\nwith its previous context. The manager owns the session id: on the first launch it\nrecords the session the connector proves over its authenticated control endpoint in\n`<manager workspace>/.cotal/continuity/<name>.json`. A connector that offers no such proof\nfails the launch, and nothing is recorded. The record follows the agent: crash recovery\nupdates it when it rebinds a session, and a stop or a preservation cut records the\nsession the agent last ran, including one it switched to itself. Every later launch, and\na preserved resume of the session that cut retained, reopens the session under the same\nproof and fails if the connector reports a different one. A resume that fails this proof\nstops the seat it started and frees it once its exit is proved; a seat whose stop cannot be\nproved stays managed, and the resume's error says so. A reopen also fails when the\nharness no longer has that session, for example after its transcript was deleted or when\nthe agent never answered and so nothing was stored; the manager never starts an empty\nsession under the old id, and a failed launch names the file to remove to start a new\none. The manifest never names a session id, and the imperative `cotal spawn --resume`\nfork stays separate. A reopened session does not get the kickoff\n`prompt` again. The manager refuses to reopen a recorded session whose space, connector\nor resolved `cwd` differs from the declaration, and names the file to remove to start a\nnew session. A connector that cannot reopen an existing session refuses the manifest at\npreflight (today only `pi` can). Switching `continuity` marks an already-deployed agent\nstale.\n\n`instructions` and `prompt` differ in kind: `instructions` become the session's **system\nprompt** (who the agent is), while `prompt` is a **kickoff message** auto-submitted once\nthe session is up (what to do right now). This is the declarative form of `cotal spawn --prompt`.\nIt is submitted on first boot and again on a stale-restart (it is part of the launch form,\nso changing it marks a running agent `stale` like any other launch field); a manager\nreclaiming a still-live session does not re-submit it. A connector that cannot deliver a\nkickoff prompt refuses the manifest at preflight (today: hermes), the same way an\nunsupported `variant:` is refused.\n\n## Channel grants\n\nA channel carries its registry card (`description`, `instructions`, `replay`, \u2026;\n[SPEC \xA77](../SPEC.md#7-channels)) plus three lists of agent names, the same verbs Cotal\nuses everywhere ([channels & permissions](channels-and-permissions.md)):\n\n| Verb | ACL | Meaning |\n|---|---|---|\n| `subscribe` | none | Auto-listen at boot. A subscriber is implicitly allowed to read. |\n| `allowSubscribe` | **read** | May read the channel. Omitted \u21D2 defaults to `subscribe`. Must be a superset of `subscribe`. |\n| `allowPublish` | **post** | May post. **Default-deny**: an empty or omitted list means nobody posts. |\n\nA read-only channel (no agent posts, e.g. an operator writes the record by hand with\n`cotal send`, which is a CLI action outside agent ACLs):\n\n```yaml\nchannels:\n decisions:\n description: The durable record of what we decided.\n subscribe: [lead]\n allowPublish: [] # read-only for agents\n```\n\nEvery name under a channel must be declared in `agents:`. Channel names must be concrete\n(no wildcards in v1).\n\n## How access is resolved\n\nYou declare membership per channel; Cotal inverts it into each agent's minted creds:\n\n- **Read** comes from `allowSubscribe` (or `subscribe` when `allowSubscribe` is omitted).\n- **Post** comes from `allowPublish`, and is default-deny: an agent you don't list cannot\n post, even to a channel it reads.\n- `subscribe` only sets what an agent *auto-listens to* at boot; it never widens read.\n\nWith `personaPermissions: reject` (the default) the manifest is the complete picture; a\npersona file's own channel grants are ignored, so the file you read is what each\nagent can do. Set `include` (top level or per agent) to *also* inherit a persona's own\ngrants for channels the manifest doesn't mention. `cotal topology view -f` always prints\nthe resolved graph, inherited scopes included.\n\n---\n\nFor implementers: the channel-centric \u2192 per-agent inversion lives in\n[`resolve.ts`](../implementations/cli/src/lib/manifest/resolve.ts); the `spawn -f`\nclassification and teardown in\n[`spawn-plan.ts`](../implementations/cli/src/lib/manifest/spawn-plan.ts) and\n[`down-manifest.ts`](../implementations/cli/src/commands/down-manifest.ts).\n"
62715
+ "body": "# Mesh manifest (`cotal.yaml`)\n\n> **Reference**: every field of the mesh manifest. \xB7 **For:** operators \xB7 **Walkthrough:** [Define a team](define-a-team.md) \xB7 **ACL semantics:** [SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)\n\nA manifest (`cotal.yaml`, `kind: Mesh`) describes a whole team (its channels, its agents,\nand who may read and post where) in one file. It is **channel-centric**: you list the\nchannels, and under each one name the agents that may read and post; Cotal inverts that\ninto one least-privilege credential per agent. The manifest is a convenience over the CLI;\nit adds no wire concepts. Today it is **single-space** (one `space:` per file).\n\nThe lifecycle (`cotal topology view -f` / `up -f` / `spawn -f` / `down -f`), ownership,\nand teardown behavior are in the guide: [Define a team](define-a-team.md).\n\n## Top level\n\n| Key | Required | Meaning |\n|---|---|---|\n| `apiVersion` | yes | Must be `cotal/v1`. |\n| `kind` | yes | Must be `Mesh`. |\n| `space` | yes | The space name (one per file; `spaces:` is not supported in v1). A space's auth is bound to one root; to run a non-default space in a checkout that already ran `cotal up` (which sets up `main`), use a fresh directory. |\n| `broker` | no | `servers` (comma-separated broker URLs: this sets the address/port; default `nats://127.0.0.1:4222`; **no embedded creds**), `host` (bind interface only, no scheme; does *not* set the port), `auth` (the auth mode: unset/`true`/`\"static\"` = per-agent JWT creds, the default; `false` = an open dev mesh; `\"user\"` = per-user auth, where people `cotal login` and every connect is authorized against the actor ledger; pair with `idp`), `idp` (with `auth: \"user\"`: the IdP auth base URL to pin on first enable; https, or http on a loopback IP literal such as `127.0.0.1`; **no embedded creds**, query or fragment). The port comes from `servers`/`--server`, never `host`/`--host`. |\n| `runtime` | no | Registered manager runtime name. `pty` is built in; optional providers such as `tmux`, `cmux`, `orca`, and `herdr` are installed with `cotal ext add`. |\n| `agent` | no | Default harness (`claude` / `opencode` / `hermes`) for agents that don't set their own. There is **no silent default**; an agent needs this or its own `agent:`. |\n| `personaPermissions` | no | `reject` (default): the manifest is the whole truth. `include`: a persona's own channel grants are inherited for channels the manifest doesn't declare. |\n| `defaults` | no | Channel defaults applied unless a channel overrides: `replay`, `replayWindow`, `deliveryClass` (`live` / `durable`). Semantics in [SPEC \xA77](../SPEC.md#7-channels). |\n| `agents` | no | name \u2192 persona (a channels-first manifest can seed rooms now and add agents later). |\n| `channels` | yes | name \u2192 channel (below). |\n\nUnknown keys are rejected (no silent ignore), and every error is reported with its file and line.\n\n## Agent forms\n\n```yaml\nagents:\n planner: ./agents/planner.md # 1) bare path: reuse a persona file as-is\n builder: # 2) a persona file + overrides (manifest wins)\n persona: ./agents/builder.md\n model: sonnet\n cwd: repos/backend # relative to the manager workspace\n role: implementer\n instructions: Prefer the smallest change that works.\n lead: # 3) inline (no file): needs at least model or instructions\n model: opus\n role: lead\n capabilities: [spawn] # may spawn helpers\n instructions: Coordinate the team.\n prompt: Introduce yourself in #general and assign the first task.\n```\n\nPer-agent keys: `persona`, `agent` (harness override), `cwd`, `continuity`, `model`, `variant`, `role`,\n`description`, `instructions`, `prompt`, `capabilities` (`spawn`,\n[what it grants](identity-and-auth.md); on a per-user-auth mesh also `role:<r>`, so the\nagent may delegate that role when spawning; `admin` is never accepted from a manifest),\n`personaPermissions` (override the top-level policy). Model strings and variants pass to\nthe harness as-is: for Claude use the short form (`opus`, `sonnet`) or the full id; for\nOpenCode use `provider/model` plus an optional variant (`cotal models --agent opencode`\nlists both). Persona file format: [agent files](agent-files.md).\n\n`cwd` is the agent's working directory on the **manager host**. A relative path resolves\nagainst the manager workspace, matching `cotal spawn --cwd`; an absolute path is used\nas supplied. Omitting it keeps the manager workspace as the default. It is never resolved\nagainst the manifest or persona directory on the deploying machine. Changing `cwd` marks\nan already-deployed agent stale and requires a restart. Empty paths and NUL bytes are\nrejected. This field controls the directory only; `continuity` restores a harness session.\n\n`continuity` says whether an agent keeps its harness session across launches. `none`, the\ndefault, starts a new session every time. `exact` reopens the session the manager last\nbound to this agent name, so after `cotal down` and `cotal up -f` the agent comes back\nwith its previous context. The manager owns the session id: on the first launch it\nrecords the session the connector proves over its authenticated control endpoint in\n`<manager workspace>/.cotal/continuity/<name>.json`. A connector that offers no such proof\nfails the launch, and nothing is recorded. The record follows the agent: crash recovery\nupdates it when it rebinds a session, and a stop or a preservation cut records the\nsession the agent last ran, including one it switched to itself. Every later launch, and\na preserved resume of the session that cut retained, reopens the session under the same\nproof and fails if the connector reports a different one. A resume that fails this proof\nstops the seat it started and frees it once its exit is proved; a seat whose stop cannot be\nproved stays managed, and the resume's error says so. A reopen also fails when the\nharness no longer has that session, for example after its transcript was deleted or when\nthe agent never answered and so nothing was stored; the manager never starts an empty\nsession under the old id, and a failed launch names the file to remove to start a new\none. The manifest never names a session id, and the imperative `cotal spawn --resume`\nfork stays separate. A reopened session does not get the kickoff\n`prompt` again. The manager refuses to reopen a recorded session whose space, connector\nor resolved `cwd` differs from the declaration, and names the file to remove to start a\nnew session. A connector that cannot reopen an existing session refuses the manifest at\npreflight (today only `pi` can). Switching `continuity` marks an already-deployed agent\nstale.\n\n`instructions` and `prompt` differ in kind: `instructions` become the session's **system\nprompt** (who the agent is), while `prompt` is a **kickoff message** auto-submitted once\nthe session is up (what to do right now). This is the declarative form of `cotal spawn --prompt`.\nIt is submitted on first boot and again on a stale-restart (it is part of the launch form,\nso changing it marks a running agent `stale` like any other launch field); a manager\nreclaiming a still-live session does not re-submit it. A connector that cannot deliver a\nkickoff prompt refuses the manifest at preflight (today: hermes), the same way an\nunsupported `variant:` is refused.\n\n## Channel grants\n\nA channel carries its registry card (`description`, `instructions`, `replay`, \u2026;\n[SPEC \xA77](../SPEC.md#7-channels)) plus three lists of agent names, the same verbs Cotal\nuses everywhere ([channels & permissions](channels-and-permissions.md)):\n\n| Verb | ACL | Meaning |\n|---|---|---|\n| `subscribe` | none | Auto-listen at boot. A subscriber is implicitly allowed to read. |\n| `allowSubscribe` | **read** | May read the channel. Omitted \u21D2 defaults to `subscribe`. Must be a superset of `subscribe`. |\n| `allowPublish` | **post** | May post. **Default-deny**: an empty or omitted list means nobody posts. |\n\nA read-only channel (no agent posts, e.g. an operator writes the record by hand with\n`cotal send`, which is a CLI action outside agent ACLs):\n\n```yaml\nchannels:\n decisions:\n description: The durable record of what we decided.\n subscribe: [lead]\n allowPublish: [] # read-only for agents\n```\n\nEvery name under a channel must be declared in `agents:`. Channel names must be concrete\n(no wildcards in v1).\n\n## How access is resolved\n\nYou declare membership per channel; Cotal inverts it into each agent's minted creds:\n\n- **Read** comes from `allowSubscribe` (or `subscribe` when `allowSubscribe` is omitted).\n- **Post** comes from `allowPublish`, and is default-deny: an agent you don't list cannot\n post, even to a channel it reads.\n- `subscribe` only sets what an agent *auto-listens to* at boot; it never widens read.\n\nWith `personaPermissions: reject` (the default) the manifest is the complete picture; a\npersona file's own channel grants are ignored, so the file you read is what each\nagent can do. Set `include` (top level or per agent) to *also* inherit a persona's own\ngrants for channels the manifest doesn't mention. `cotal topology view -f` always prints\nthe resolved graph, inherited scopes included.\n\n---\n\nFor implementers: the channel-centric \u2192 per-agent inversion lives in\n[`resolve.ts`](../implementations/cli/src/lib/manifest/resolve.ts); the `spawn -f`\nclassification and teardown in\n[`spawn-plan.ts`](../implementations/cli/src/lib/manifest/spawn-plan.ts) and\n[`down-manifest.ts`](../implementations/cli/src/commands/down-manifest.ts).\n"
62656
62716
  },
62657
62717
  {
62658
62718
  "slug": "mesh-view",
@@ -62687,7 +62747,7 @@ function loadDocsBundle() {
62687
62747
  "title": "Run a mesh",
62688
62748
  "kind": "Guide (informative)",
62689
62749
  "summary": "Day-to-day operation of a local mesh: what cotal up actually runs, how spawning resolves personas, harnesses, and models, how to reach a mesh from any directory, and the operator-only maintenance v\u2026",
62690
- "body": "# Run a mesh\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nDay-to-day operation of a local mesh: what `cotal up` actually runs, how spawning\nresolves personas, harnesses, and models, how to reach a mesh from any directory, and the\noperator-only maintenance verbs. Every command's full flag set is in the\n[CLI reference](cli.md).\n\n## The stack\n\n`cotal up` brings up the whole local stack and bare `cotal down` stops it. Managed\nagents stay running as unmanaged OS processes; pass `--with-agents` to take them\nwith the stack. Seats of the built-in pty runtime run inside the manager process, so\nthey stop with the manager either way. Ctrl-C on a foreground `up` stops the manager through the\nsame stop as bare down and prints the same report; when that stop is refused, for example because\nthe manager cannot prove it can spare, Ctrl-C leaves the stack running, and you end it with\n`cotal down --with-agents`. A current manager records what its stop does with its seats before\nbare down signals it. A pre-pin legacy manager instead receives a reduced-guarantee\nwarning and is signalled according to the documented upgrade contract. Its running binary\nmay still carry the older destructive SIGTERM handler, so the CLI does not claim its\npre-signal agent inventory was spared; those agents may have been reaped.\n\n- **Broker**: a local `nats-server` (logs to `.cotal/nats.log`).\n- **Delivery daemon**: the durable backstop, auth mode only\n ([what it does](delivery-daemon.md)).\n- **Manager**: a detached supervisor answering the control plane, so\n `cotal spawn --detach` and the `cotal_spawn` tool work right after `up`.\n\nCotal creates the presence bucket in memory storage. Its records are liveness that every endpoint\nrewrites each heartbeat, so nothing is lost when a broker restart empties it, and nats-server's file\nstore write latch cannot reach it. A broker stop removes the memory stream itself, so every `cotal up`,\nincluding the resume after `cotal down --preserve-state`, creates it again before any daemon starts.\nJetStream fixes a stream's storage class when it is created, so a presence bucket created file-backed\nby an older cotal stays file-backed until that stream is recreated.\n\nA file-backed presence bucket can remain open and watchable while refusing every write. A bound\nendpoint reports this as `presence-write-stuck` after one full presence TTL of consecutive failures.\nThe roster is last-known while that condition is active. Restarting the broker clears nats-server's\nin-memory store latch and preserves the JetStream root. Current credentials split the required stream\nauthority: the `cotal up` provisioner can create the presence stream but cannot delete it, while the\nteardown credential can delete it but cannot recreate it. Cotal therefore reports the condition but\ndoes not attempt an unsafe partial delete-and-recreate. Stop and restart the broker to recover.\nA broker below nats-server 2.14.5 carries the latch (nats-server fixed it in 2.14.5). When `cotal up` starts or finds such a broker and the space's presence bucket is file-backed, it says so. A memory-backed bucket gets no warning. A broker below the SPEC \xA713.12 floor of 2.12 is refused at connect with the floor sentence.\n\nThree modes:\n\n- **Default (static auth).** JWT-authed, on by default: sender authenticity and per-agent\n ACLs, enforced by the broker ([how](identity-and-auth.md)).\n- **`--user-auth --idp <url>`.** Per-user auth: people `cotal login` once, the operator\n grants their agents on the actor ledger, and every connect is authorized live against\n that grant. Starts the space's auth service alongside the broker\n ([how](identity-and-auth.md)).\n- **`--open`.** An unauthenticated, live-only dev mesh (no auth, no delivery daemon). For\n quick local experiments.\n\nThe broker and local services bind **loopback** by default. `--host 0.0.0.0` widens the broker\nbind independently of the auth mode, so \"network-reachable\" never silently means\n\"unauthenticated\". With no explicit `--server`, `cotal up` auto-selects a free local port when\nthe default address is already held by another project; an explicit `--server` fails loud on\ncollision.\n\n`--host` is a boot flag, not a live rebind. A fresh `cotal up` writes the generated\n`.cotal/auth/server.conf` (project-local, not `~/.cotal`) with that bind and starts nats against\nit. If anything is already answering at the mesh URL, `up` refreshes the recorded mesh and\nleaves the running nats listener alone, so passing `--host 0.0.0.0` on a live or orphaned\nbroker does not change who can connect. To change the bind: `cotal down`, then `cotal up --host\n<addr>` against a stopped broker so the generated file is rewritten. Do not edit `server.conf`\nby hand; the next real boot overwrites it.\n\nOn a stopped shared broker, `up` renders every persisted space account and every enabled\nspace's auth-callout account into the resolver preload, regardless of which space starts\nthe broker. A missing callout account for an enabled space stops the boot rather than\nstarting with a reduced resolver. An already-running broker is refreshed without rewriting\nits config.\n\nA broker-only host is a first-class `up` mode. `cotal up --no-manager` boots the broker and, in\nauth mode, the delivery daemon, and no local manager, so the broker host never has a manager to\nstop and never leaves a manager slot stale. A refresh under the flag of a mesh whose manager is\nlive refuses rather than keeping or stopping it: `cotal down manager` first. Without the flag,\nauth-mode `up` still starts nats, the delivery daemon, and a\nlocal manager. A space may run more than one manager, addressed by instance id\n([control surface](control-surface.md#instance-routing)); putting no manager on the broker host\nis a topology choice, not a singleton invariant. A manager whose boot inventory has no\navailable connector does not take unpinned `spawn`/`launch` on the class rail, so a sibling\nthat can launch the harness can. `describe` still rides the class rail, so an unpinned spawn\ncan bind-fence when that skip member answered describe; re-issue, or pin `--on`. Pin one\ninstance with `--on` when a partial inventory still answers with a harness refusal. The\nsupported split is:\n\n```bash\n# broker host (project root that owns the generated conf, pidfiles, and logs)\ncotal up --detach --host 0.0.0.0 --space main --no-manager\n# no local manager starts: the summary lists nats-server + delivery daemon, and there is no\n# `.cotal/manager.<spaceKey>.log` to wait for on this host\n\n# manager host (registered remote mesh, same space)\ncotal meshes add --server nats://broker.example:4222 --root ~/meshes/main\ncotal supervise --space main --server nats://broker.example:4222\n```\n\nWait for `\u2713 manager up` in `.cotal/manager.<spaceKey>.log` on the manager host before spawning\nagents. On a broker host started without `--no-manager`, `cotal up --detach` prints `\u2713 running in\nthe background:` with `manager` listed once the manager pidfile is live; stop that local manager\nonly after the `\u2713 manager up` line. A host started WITH `--no-manager` never runs one, so neither\nthe wait nor the stop applies there. That detach stdout is not a safe teardown boundary: it is\npidfile liveness, not `\u2713 manager up`. `\u2713 manager up` is supervise's post-start line after\n`await mgr.start()`. `cotal down manager` after only the detach line can still default-terminate\nthe child during registration after it has taken the governance slot. Stopping before that\npost-start log line can leave the endpoint governance slot held until the holder's gate\nreopens past the stamp (the successor's boot heal, or\n[`cotal reconcile-gate`](cli.md#reconcile-gate) when that boot cannot run). See\n[Gate recovery](#gate-recovery).\n\nStandalone `cotal deliver --creds` is not a repair for that split. Production renewal needs\nthe manager and the daemon to address one credential store. The manager renews its own service\ncredential inside that credential's own window and re-dials its service connection with the\nrenewed credential; if the connection closes and cannot be restored within about forty seconds\nit releases its lease and exits so a restart can serve, while a broker that is briefly gone is\nwaited out. Separate host filesystems still\nleave manager root A writing and the daemon reloading root B; that composition is refused\nwhile the daemon stays up. Before every remint the manager challenges the delivery daemon's\nstore identity, and the answer must come from the process holding the delivery lease: the\nreply names the answering endpoint and the manager reads the lease row itself under its own\ncredential, so a non-holder answering on the queue-grouped admin rail is refused instead of\ncounting as the daemon's store. A rail that reports no responder is also settled from the\nlease row, so a live holder on record makes that outcome a refusal rather than an absent\ndaemon. Keep delivery on the broker host under `up`, and share one store\nonly when you are composing a hosted pair ([embedding](embedding.md#supervisor-signing-authority)).\nOn the `--no-manager` split above, the manager host's manager stays off the daemon-credential\nrenewal lease once its store check finds the daemon on another store. A filesystem store is named\nby its root and by a random id in `.cotal/store.id`, which the copied `.cotal/auth` does not carry,\nso this holds when both hosts use the same root path. `cotal doctor auth --fix` on\nthe broker host then renews the daemon credentials once they pass their renewal point.\n\n### Split host bind\n\nA remote manager cannot reach a loopback broker. After changing `--host`, confirm the\ngenerated `host:` in `.cotal/auth/server.conf` and that nats is listening on that address\nbefore registering the mesh on the manager host. Detached child logs stay under the **project**\n`.cotal/` that `up` ran in (see [When something looks absent](#when-something-looks-absent));\nthey are not `~/.cotal` unless that directory is the mesh root.\n\nA user-auth mesh can expose only its credential exchange through an operator-owned HTTPS reverse\nproxy while leaving the existing local exchange untouched:\n\n```bash\ncotal up --user-auth --idp https://idp.example/api/auth \\\n --exchange-public-port 7443 \\\n --exchange-public-url https://auth.example\n```\n\nThe public listener itself still binds `127.0.0.1:7443`; configure the proxy to terminate TLS and\nforward to it. It serves only `/health`, `/jwks`, `/exchange`, `/manager-service-authority`, and\n`/.well-known/cotal-mesh` with the documented methods. It needs no local file capability: the\nsigned IdP JWT or managed-agent actor token is the proof, while the original loopback listener\nremains capability-gated. Add\n`--exchange-trusted-proxy` only when that listener is reachable exclusively through your trusted\nproxy; it keys failure throttling by the last `X-Forwarded-For` hop instead of the socket address.\nThe well-known bundle includes IdP pins and a deny-all sentinel credential, so fetch it only from\nthe configured HTTPS origin. To change these listener flags, stop and restart the mesh; a refresh\nof an already-running service does not replace its bind or proxy policy. See\n[Identity & auth](identity-and-auth.md#per-user-authentication) for the trust boundary.\n\n### Remote supervised seats by enrollment\n\nA remote seat does not need to run `cotal login` when the mesh owner pre-mints a single-use\nenrollment for it. Mount the enrollment URL as a private file, place the seat persona on the remote\nmachine, and launch the foreground seat:\n\n```bash\nCOTAL_ENROLLMENT_FILE=/run/secrets/cotal-enrollment \\\n cotal spawn --config ./worker.md --space main\n```\n\nThe URL is redeemed once with an unauthenticated GET. Redirects, off-machine plain HTTP, retries,\nand login fallback are refused. If the seat has no mesh record yet, the enrollment response's stock\nuser-bundle fields register it before the launch. The returned actor token then uses the same remote\nauth-service exchange as a login-provisioned agent. The enrollment URL and file path do not enter the\npreflight or harness environment. A failed or reused enrollment leaves no actor material on disk; ask the owner\nfor a fresh enrollment. When the foreground seat exits, this machine's credential files are removed\nand the mesh-side grant stays until the mesh operator revokes it; the launch line says so. The exact\nserver contract is in\n[Enrollment redeem](identity-and-auth.md#enrollment-redeem).\n\n`cotal status` prints the detailed setup, process, registry, and live mesh status. Its Machine\nsection names the running CLI's source checkout, installed package root, or npx package root beside\nthe version. It has one row per installed connector, which reports whether the executables that\nconnector declares in `requires` are on PATH. Status, setup and the manager's preflight resolve them\nthe same way: an entry written as a path is checked as given, and a directory never counts as the\nexecutable. A connector whose setup provider reports health adds its\nown rows above those. The Claude Code connector reports its plugin and its skills plugin, and a stale\nskills row names the installed and CLI versions it compared. `cotal\nsetup` (after the first run) prints the compact card.\n\nBefore reporting ready, the manager resolves every installed connector's declared harness\nbinaries against its own environment. A missing binary does not stop unrelated manager work: boot\ncontinues, but prints a named `connector <name> unavailable` line and records that reason in the\nmanager's `status` response. Available connector rows record the absolute paths boot resolved.\nA spawned seat and a seat resumed after `cotal down --preserve-state` both launch from those paths,\nand both are refused with the recorded reason when their connector's row is unavailable.\n`cotal models` takes the same rule and reports that reason in place of the catalog, so it agrees\nwith a launch about a harness installed or removed after boot. The manager looks again only when it\nrestarts. A connector registered after boot has no row, so spawn, resume and `cotal models` check\nits binaries on PATH when they run.\n\nOn an authenticated manager start, unfinished static lifecycle rows reconcile while the control\nendpoint is already serving. The manager `status` response reports\nthe `staticReconciliation` state, the last sweep counts, and each failed alias with its durable\nphase and literal disposition. `cotal status --components` reports the state and per-alias failure\ndetails. A slot row carrying a DEL or PURGE marker stops the sweep before it plans any alias, and\nthe manager log names the row. A failed exact terminal is retried in the same process after 1, 5,\nand 30 seconds. Each attempt re-reads the durable slot and re-enters the same deterministic terminal\noperation; the delays only schedule work and never release the lifecycle fence. The terminal's\ncleanup removes the lifecycle's credential file and its broker durables and read-ACL row as separate\nsteps. A file that cannot be removed does not leave the broker footprint behind, and its failure\nkeeps the alias held for the next attempt.\n\nOn shutdown, the manager fences new reconciliation work and waits for an exact terminal that already\nstarted. The current serial sweep stops before its next alias, and startup cannot publish the manager\nservice after `stop()` completes.\n\nThe four-attempt budget is per manager process. An exhausted row stays held and reports\n`retry-exhausted` with the remedy to restart the manager. The next process derives a fresh budget\nfrom the still-authoritative durable row. A `recovered` row remains visible until the next static\nreconciliation sweep, then clears. This component reports reconciliation outcomes. It does not say\nwhether footprint cleanup completed independently of the terminal result; that separate durable\nprojection remains tracked by #1274.\n\n`cotal service install` is the supported way to run the manager as a user service\n([CLI reference](cli.md#service)): a systemd user unit on Linux, a launchd agent on macOS, one\nper mesh, surviving logout and reboot. On Linux that needs user lingering: install refuses while\nit is off and prints the root command that enables it. It installs only\nthe manager; the units below remain the process models for every other component, and they are\nstill **examples of process models** for those: copy them only after you decide which processes\nthe unit should own.\n\n### Supervising the detached stack\n\n`cotal up --detach` is a launcher: it starts the broker, delivery daemon, and manager, reports what\nstarted, then exits. Do not wrap it in a systemd service with `Type=oneshot` and\n`RemainAfterExit=yes` and treat `systemctl is-active` as stack health. That unit becomes `active\n(exited)` when the launcher exits successfully and stays active even if every detached process dies.\nWhen `up --detach` can identify that exact unit shape, it prints a warning but keeps the requested\nstartup behavior.\n\nFor a single-host stack, keep `cotal up` itself in the foreground so systemd tracks a long-running\nprocess and restarts the stack if that process fails:\n\n```ini\n[Service]\nType=simple\nWorkingDirectory=/srv/cotal-mesh\nExecStart=/usr/bin/cotal up --space main --host 0.0.0.0\nRestart=on-failure\nRestartSec=5s\n```\n\nAn active unit then proves the foreground launcher and broker are still running, but it still does\nnot prove that every child component serves. Pair it with the component check below. Also remember\nthat `cotal up` starts a local manager as well as the broker and delivery daemon; run\n`cotal up --no-manager` (add the flag to the unit's `ExecStart` too) on a host intended to be\nbroker-only, so the unit and the host agree.\n\nSeats spawned by the built-in `pty` runtime run with `oom_score_adj` 500, so under memory\npressure the kernel prefers a seat over the broker, manager and delivery daemon, which are left as\nthey were started; the extension runtimes do not own the seat's process and get no preference.\n\nThat `Type=simple` shape puts nats in the unit's cgroup with the foreground `up` process. A\n`Restart=always` (or `on-failure`) of **this** unit therefore restarts nats as well, so remote\nmanagers drop for the time it takes the broker to come back. Wrapping `cotal up --detach` in\n`Type=oneshot` with `RemainAfterExit=yes` does not move nats out of that cgroup. Detached\nspawn starts a new process group, not a new systemd cgroup, and the default\n`KillMode=control-group` still signals every process left in the service cgroup on stop or\nrestart, including the nats PID. Escaping that cgroup needs an explicit unit setting such as\n`KillMode=process`, or a separate nats unit; this CLI does not ship that escape. The\n`Type=oneshot` unit below is a `cotal status --components` liveness check, not a\n`--detach` launcher. Neither trade is universal from\n`Type=simple` alone; it follows from which processes the unit actually owns. `cotal service\ninstall` covers only the manager, so for the broker and its siblings pick the example that\nmatches the ownership you want, and treat\n`systemctl is-active` as unit health, not mesh health.\n\nA broker that crashes under that foreground `up` keeps its mesh record and exits non-zero, so the\nunit's restart takes the repair path against the recorded store rather than starting a second one.\n\nIf the deployment deliberately uses `cotal up --detach` as a boot action, monitor observed state\ninstead of the launcher's exit:\n\n```ini\n[Unit]\nDescription=Check Cotal component liveness\n\n[Service]\nType=oneshot\nWorkingDirectory=/srv/cotal-mesh\nExecStart=/usr/bin/cotal status --components --space main\n```\n\nRun that check from a systemd timer or another monitor and alert on a nonzero exit. The command\ndistinguishes `absent`, `not-serving`, and `refused` components and never treats a sibling's health as\nproof. Its delivery-process check is local to the broker host, so run it there. On a split topology,\nalso probe the broker URL from the manager host and monitor the manager's own service there. A remote\nmanager cannot observe the broker host's delivery PID, and an `active` unit on either host says\nnothing about the other host.\n\nStop one part without tearing down the mesh by naming its registered component: `cotal down\nmanager`, `cotal down delivery`, or `cotal down web`. Component names from installed extensions\njoin the same surface; `cotal down` with no names retains whole-stack behavior and\nleaves managed agents running as unmanaged OS processes, except pty seats, which stop with the\nmanager. `cotal down --with-agents` is the previous reap. If a pinned manager has no\nspare-capability record, stop its managed agents explicitly before running that whole-stack\ncommand. A current manager always publishes the record, so it is absent only for an older manager,\nwhich may not understand the reap request.\n\n## Remote supervised agents\n\nOn a remote user-auth mesh, foreground `cotal spawn` remains the default participant path. A\nparticipant can run detached agents only after the host advertises and operates the remote manager\nauthority service, and the participant's actor-ledger row includes `supervise`. This is not implied\nby `spawn` or `admin`.\n\nThe participant's loopback/operator exchange obtains one closed `manager-service` view for its\nordinary derived owner, a fixed server-selected manager actor, and one opaque manager instance.\nThe host, not the participant, issues the public-nkey JWT material via the replay-safe,\nlifecycle-bound prepare \u2192 activate \u2192 renew exchange, plus a one-shot target-pinned retirement\nrequest for a host-managed terminal. It never exports the space signer, a static\nprovisioner credential, or generic storage authority. Remote registration publishes its service\nstatus at the registered revision and current process epoch, so manager-caller selection can find it.\n\nStock participant supervision asks its host to enroll a detached agent and to prepare its terminal\nretirement, over the same manager-authority transport. The stock auth service answers both when it\nruns with a public exchange face: it grants the agent under the participant's owner at a lifecycle\nUID it picks, bounded by the participant actor's own grant, provisions that UID's durables, and on\nretirement releases them and revokes the grant before the manager's terminal rail. It refuses a\nsecond enrollment of a name whose grant still stands until that agent's retirement is prepared. A\nhost platform that keeps these writers in its own storage intercepts both requests on its own route\ninstead. Copying host secrets or actor-ledger files to a participant is not supported. Foreground\nspawning and operator-local hosted managers use their existing paths.\n\nThe remote manager that `cotal supervise` starts can host workflow runs through its host: the host\nadmits each run and signs only the run's own driver, mediator and operator credentials. A logged-in\nuser's `cotal run start` against it is admitted: the auth callout issues the user's manager\nconnection, and the host binds each run to the owner who registered the manager. The run spawns\nagents that user owns, enrolled by the host like any detached spawn, with the reach the user's own\nrow grants when the spawn runs. A spawn may be placed on that manager and on no other instance. The\nhost's own manager refuses user-auth runs by name.\n[User-auth run start](design/user-auth-run-start.md)\nrecords the path.\n\nThe registry entry decides the broker URL `supervise` dials, so a mesh published over `wss://` is\ndialed as a websocket. The manager-authority registration it runs first also takes its TLS\nrequirement from that entry, so the prepare credential is not exchanged over a plaintext\nconnection the record did not describe. `cotal meshes add` records both.\n\nWhen the authority service, login, or renewal is unavailable, the remote manager degrades\nfail-closed: it refuses new agents, restarts, and credential replacement rather than pretending\nlocal authority exists. Existing agents remain live only while their independent credentials are\nvalid. A hosted composition must revoke the managed grant and finish its resumable release before it\nrequests terminal retirement. Deleting DM or delivery consumers is not retirement and must not reset\na resumable lifecycle's frontier or pending state. The alias remains held until the terminal barrier\nconfirms. Restore service and renew successfully before asking it to recover an agent. See\n[Identity & auth](identity-and-auth.md#remote-manager-authority) and the [CLI\nreference](cli.md#supervise).\n\n## Spawning agents\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn reviewer --detach # supervised: the manager runs it in a PTY\ncotal attach --name reviewer # watch/type into a detached agent (Ctrl-] detaches)\ncotal ps # what the manager is running\ncotal stop --name reviewer # stop one\n```\n\nHow a spawn resolves:\n\n- **Persona.** A bare `cotal spawn` uses `.cotal/agents/default.md`; a positional name\n picks `.cotal/agents/<name>.md`; `--config` takes an explicit ref or path. Set\n `COTAL_DEFAULT_PERSONA=<name-or-path>` to change the fallback. Fields and format:\n [agent files](agent-files.md).\n- **Harness.** Resolution order is an explicit `--agent` or `cotal_spawn` `agent` argument,\n then the persona file's `agent:` pin, then the invoking caller's `COTAL_DEFAULT_AGENT`,\n then the manager's `COTAL_DEFAULT_AGENT`, then the product default (Claude). Compared in\n [Connectors](connectors.md); per-connector guides:\n [Claude](connect-claude.md) \xB7 [OpenCode](connect-opencode.md) \xB7\n [Hermes](connect-hermes.md) \xB7 [pi](connect-pi.md).\n- **Model.** `--model` overrides the persona file's `model:` (Claude: `opus` / `sonnet` or\n a full id; OpenCode: `provider/model`). Connectors that expose a catalog report it via\n `cotal models --agent opencode`: model ids plus available variants; pick one with\n `--model provider/model --variant high`.\n- **Tools.** A spawned Claude Code agent gets the cotal tools plus the MCP servers the cotal\n config shares, which first-run `cotal setup` fills with your own; narrow them per spawn with\n `--share-tools` ([config](config.md)).\n- **Launch options.** `--opt key=value` (repeatable) passes a native harness flag straight\n through; a persona or manifest `launchOptions:` mapping does the same declaratively (a\n `--opt` wins per key). It is a **raw passthrough**, with no allow/deny list: Claude renders\n each as `--key value` (a bare `--key` for an empty value), OpenCode merges them into its\n agent config, and Hermes has no option surface so it fails loud. The trust boundary is the\n `spawn` capability itself, not the flag set, so granting `spawn` is host-launch authority\n ([security](security.md)). A key must be a plain flag name; malformed or prototype-polluting\n keys are refused.\n\nDetach from an attached PTY with **Ctrl-]** (the agent keeps running); rebind it with\n`COTAL_DETACH_KEY=ctrl-<char>` when it clashes with a keybinding inside the agent's TUI.\n\n**Runtimes.** The manager spawns into a **pty** by default. It spawns the PTY in-process on\nevery platform, so replacing the manager worker closes its seats and the pty runtime gives no hot\nupdate. Any manager stop, bare `cotal down` included, stops and deprovisions those seats. A stopping\nmanager refuses new spawns and first waits for the ones it already accepted, so their seats stop too. On Linux\nit can still adopt and reap seats that an earlier manager launched under a detached per-seat\ncustodian, so those seats drain under the new manager; it starts no new custodian. A custodian whose agent has exited exits a few seconds later on its own. `cotal seats`\nlists the custodians left on the machine, and `cotal seats --drain` retires the ones whose agent\nhas exited while keeping every seat whose agent still runs ([cli.md](cli.md#seats)). When a pty\nagent exits on its own, in-process or under a custodian, the manager logs a `seat reaped:` line\nwith the exit code and, for a signalled child, the signal number. The line ends with the last line\nthe child printed that starts with a connector's `[cotal-<name>]` or `[cotal-<name>/<part>]`\nprefix, cut to 240 characters, when it printed one. A custodian keeps the same record beside the\nseat's custody record, so a later reap of that seat, including one by a\nsuccessor manager, reports how the child ended. When the custodian cannot write that record, it\nsays why in the seat's `custodian.log`, and a later reap of a child that ended on its own reports\nthe record as missing or unreadable. Optional runtimes are installed\nthrough the extension surface, for example `cotal ext add @cotal-ai/orca`, then selected with\n`--runtime orca` (similarly `@cotal-ai/tmux`, `@cotal-ai/cmux`, and `@cotal-ai/herdr`). They put teammates in native\nterminal surfaces rather than manager-owned PTYs. Those surfaces stream no exit, so the manager\nasks the runtime every five seconds whether a seat's process has ended, and frees a seat that ended\non its own once the runtime proves the exit. Its `seat reaped:` line says the exit detail is\nunavailable from that runtime. Runtime names are open-ended and resolved from\nthe registry; a missing provider or app throws, never silently falls back\n([architecture](architecture.md)).\n\n## Mesh registry\n\n`cotal up` records each running mesh in a machine-local registry\n(`~/.cotal/meshes/space.<key>.json`, named by a case-safe hex encoding of the space: broker URL, the project root holding its creds and\npersonas, and its mode). So a bare `cotal spawn <persona>` from *any* directory joins the\nrunning mesh with the right credentials instead of mistaking the cwd for a space:\n\n- `cotal use <name>` sets the default from every directory, including inside another mesh's\n project. `--space <name>` overrides it for one command.\n- When one broker has records for several spaces, `cotal up --space <name>` refreshes that named\n space.\n- A refresh rewrites only what that command decided: the server, root and mode, the user-auth\n endpoints, and an explicit `--host` or `--max-sessions`. Every other field, such as the TLS\n requirement, is kept as the record stands when the refresh writes it, so a change another\n command made during the refresh survives. If the record was removed during the refresh, `up`\n fails instead of writing it back.\n- With no live selected default, a project with its own `.cotal/` resolves to that project's\n mesh; otherwise one running mesh is used automatically and several are an error.\n- `cotal meshes` lists them (a `*` marks the default); `cotal down` removes the entry.\n\nThe registry stores a *path*, never a secret; trust material stays in each project's\n`.cotal/auth`. If the mesh is down or won't take your creds, spawn fails with one\nsentence, never a raw NATS trace.\n\n### Meshes you did not start here\n\nA mesh running on another machine has no `cotal up` on this one, so register it by hand:\n\n```bash\ncotal meshes add # guided: asks for the broker, probes it, offers what it finds\ncotal meshes add optiplex --server nats://100.90.12.34:4222 --root ~/meshes/optiplex \\\n --allow-unencrypted-overlay # see below: an overlay address needs this\ncotal meshes rm optiplex\n```\n\nOn a terminal, a bare `cotal meshes add` walks you through it: it probes the broker you name and\nreports whether it is open or requires credentials, offers the spaces the folder already holds\ncredentials for, and shows the record before writing it. Scripts and agents keep the flag form -\nwithout a terminal nothing prompts.\n\n`--root` is the local folder holding that mesh's `.cotal/auth` and `.cotal/agents` (its personas);\nthe mode is inferred from what that folder holds.\n\nThe instance identities of the manager and the user-auth service are not part of that folder.\nEach root keeps its own in `.cotal/space.<hex>/`, so `cotal supervise` or `cotal up --user-auth` in\nthe root you copied the folder to starts an instance of its own. A root last run by an older Cotal\nstill holds them in `.cotal/auth`, as `manager-instance.<hex>.json`, `manager-siblings.<hex>.json`\nand `space.<hex>/.cotal/auth/auth-instance.<hex>.json`. Delete those files from a copy of such a\nfolder before the first `cotal supervise` or `cotal up` there. On the root itself, the first start\nmoves each record to `.cotal/space.<hex>/`. It refuses and keeps both entries when either place\nholds a symlink or anything else that is not a regular file, or when the two places hold different\nrecords.\n\n**Know what you are copying.** For an authenticated mesh that folder carries the space's account\n**signing seed**, which is the authority to mint any identity in the space. A machine holding it\nis a certificate authority for the mesh rather than a client of it: anyone who reads it can\nimpersonate any agent, read every retained channel and DM, change ACLs, and keep issuing\nthemselves credentials. There is no per-machine revocation; undoing it means rotating the signing\nkey and re-minting every credential in the space. Copy it only to machines you would trust with\nthe whole mesh. `cotal mint` on its own does not substitute here: registering an `auth` mesh needs\nsigning material that composes, which a minted user credential is not. The\nbroker is probed before the record is written, so a bad address or a credential that mesh will not\naccept fails at registration rather than at your first `spawn` (`--force` records it without verifying,\nuseful when the mesh is simply down right now).\n\n#### Which addresses you may register\n\nRegistering a mesh is how this machine starts sending agent credentials to a broker it does not\nrun. NATS announces itself in plaintext before anyone authenticates, so an attacker on the path\ncan pose as the broker and read the credential out of the connect unless the connection\n**requires TLS**, which is recorded on the entry and enforced on every dial through it.\n\nWhat the record will require decides what you may register:\n\n- **Without required TLS**, the address is the gate: **loopback** (`127.0.0.0/8`, `::1`), or\n **your private overlay** (`100.64.0.0/10`, `fd7a:115c:a1e0::/48`) with\n `--allow-unencrypted-overlay`. The tunnel provides the protection, and this command cannot check\n its state. Hostnames are refused because the lookup would choose which machine receives your\n credentials.\n- **With required TLS**, set `--tls` or use a `tls://` URL. The recorded scheme enforces the TLS\n requirement. A **hostname or public address** is accepted because the certificate chain and\n hostname check identify the peer. A registration whose broker cannot complete the handshake\n fails unless you pass `--force`, which records the entry without verification.\n\nOrdinary private ranges like `10.x` and `192.168.x` are refused in **both** modes. A caf\xE9's wifi\nis private but does not belong to you, and no public CA issues certificates for those ranges. An\naddress spelling changes nothing: `[::ffff:192.168.1.10]`, `3232235786`, `0300.0250.01.012`, and\n`192.168.257` all resolve to private addresses and receive the same refusal as the dotted form.\n`--force` exists for a mesh that is down. It never permits an unsafe credential destination.\n\n#### Registering a hosted user-auth mesh\n\nA user-auth space's IdP pins are established where the mesh runs and are never guessed. Register\none from **supplied** trust: `--user-auth-file bundle.json` (exported on the mesh's machine), or\n`--from https://auth.example`, which asks before it contacts the address at all, fetches the\ndiscovery document at `/.well-known/cotal-mesh` under that address over HTTPS, shows you the pins,\nand asks again before adopting them. A URL that already ends in `/.well-known/cotal-mesh` is\nfetched as given. Redirects are refused because a 302 can walk a pinned fetch down to\nplaintext or onto another host, and the pinned exchange must be an `https://` URL too. The one\nexception is an exchange on **this machine**, where nothing leaves the box: plain `http://` is\naccepted for a loopback *literal* (`127.0.0.1`, `::1`, and any spelling of them), but **not** for\n`localhost`, which a hosts entry or poisoned lookup could point elsewhere. Use the\nliteral. Registration checks that the pinned exchange\nanswers `/health` and `/jwks` as the pinned issuer. It also checks that the broker refuses a\nbare connect; that refusal is the pass. The bundle's sentinel credentials are written to a private (0600) file\nunder the entry's root; the registry itself never carries the secret.\n\n**Without required TLS**, an overlay address is **refused unless you accept the dependency\nexplicitly**, with `--allow-unencrypted-overlay`. The address is not the guarantee: it is protected\nwhile the tunnel is up, and if the tunnel is down that range is ordinary carrier-grade NAT and\nwhoever answers the dial receives your credentials. Only you can know which it is, so the command\nasks you to say so. Your acceptance is recorded on the mesh entry rather than printed and\nforgotten, and the guided form asks the same question instead of taking the flag.\n\n**With required TLS** (`--tls`, or a `tls://` URL) that consent is no longer asked for, and the\nflag is not needed: the handshake is what protects the connection, so the acceptance it stood in\nfor has been replaced by proof rather than promise. `cotal meshes add <space> --server\nnats://100.64.0.1 --tls` registers an overlay address with no prompt, no flag and no recorded\nacceptance. This is the \"the flag disappears once the broker can be served over TLS\" case, and it\nhas now arrived.\n\nThis gate is on **registration**. `cotal join --creds --server <url>` deliberately takes an\nexplicit connection at face value and does not consult the registry, so it is not covered. Join\nthat way only to an address you would have registered.\n\nThe connection is still probed first, with the same second try at the longer budget the registry\npreflight uses, so a slow link reads as a connect that did not finish within that budget and a\nrefused port reads as a broker that is not running.\n\nRecords added this way are removed only by something that names them. A failed liveness probe\ndoes not delete any record: an unreachable broker, local or registered by hand, is shown as\n`offline` in `cotal meshes`. A bare command does not count that offline record as running;\nname it with `--space` to restart it. `cotal down` / `cotal clean all` still drop an `up` record for the\nproject they are tearing down, and they leave a hand-registered one alone even when `--root`\npointed at that project. A `cotal up` for that space refuses outright unless it is that same\nendpoint: finding a broker already answering there is a refresh that starts nothing and leaves the\nrecord's provenance alone, while actually starting the broker for that space, server and root\nmakes this machine the one running it, so the record becomes an ordinary local one that\n`cotal down` clears. The refusal names `cotal supervise --space <s> --server <url>` (plus `cotal\ndeliver`) when the registered broker is on another host, and `cotal meshes rm` when it is local.\n`cotal meshes rm` drops it and re-registering with `--force` replaces it. `rm` only forgets a\nmesh. To stop one running here, use `cotal down`.\n\n## Watching\n\n`cotal console` is the terminal view (TUI on a real terminal, plain line stream when\npiped); `cotal web` is the browser dashboard. Both are read-only observers; the\nwalkthrough is [Watch a mesh](watch-a-mesh.md).\n\n## History\n\nRetained history is operator-owned. `cotal clean history --force` purges a space's\nretained channel history; `--dms` also purges DMs (`cotal history clear` is an alias).\nIt is deliberately **not** an agent tool: agents cannot wipe the record\n([identity & auth](identity-and-auth.md)). For a **stopped** mesh, `cotal clean store\n--force` deletes the on-disk JetStream store outright, and `cotal clean all --force`\nalso resets the space identity ([CLI reference](cli.md#clean)).\n\n## Offline backup\n\nFor a coherent durable cut, preserve the whole stack first, then create the artifact while it stays\ndown:\n\n```bash\ncotal down --preserve-state\ncotal backup create ./space-backup # full by default\n# later: deliberately resume the unchanged source\ncotal up --detach\n# or, from another preserved cut, restore before the normal listener opens\ncotal up --restore ./space-backup --detach\n```\n\nA refused cut leaves the mesh running and unfenced: fix what the refusal names and run\n`cotal down --preserve-state` again.\n\nUse `--store-dir` on both preservation and backup for a custom JetStream store. A store cap set\nwith `cotal up --max-file-store <bytes>` travels with the preserved state, and the resume renders it\nagain. nats-server reads the cap once at start and refuses a config reload that changes it, so a new\ncap always needs a restart. The cut records the chat stream's frontier per retained seat, so a\nresumed seat catches up from there instead of replaying its channels. `registry` is the\nonly partial selection (`backup create ... --only registry`; `up --restore ... --restore-only\nregistry`). Backup never stops or restarts a mesh implicitly, never opens the original store, and\ndoes not contain credentials or trust secrets. Backup/restore in every auth mode, open included,\nuses isolated, operation-specific maintenance logins; normal agent credentials cannot enter that\nlistener. Full\nrestore requires the same space and exact current local trust continuity, recreates conservative\nconsumer checkpoints bound to their snapshot stream sequence state, and resumes retained agents under\ntheir original principals. The trust commitment includes the cryptographically validated full\noperator/system/data-account root chain as well as static/user authority state. A registry-only\nrestore completes canonical empty infrastructure but leaves retained agents stopped because their\nDM/DLV/TASK/ACL state is outside that selection. Authenticated restore validates the complete space\ntrust bundle before staging or changing the preserved store. Interrupted ordinary resume retries the\nsame durable attempt after its prior listener is stopped. Restore re-entry can recover a surviving normal listener\nonly when its attempt nonce, NATS server name, process owner, endpoint, and target-store identity all\nmatch the fsynced proof. A provably dead uncommitted owner is retired under lock and replaced with a\nfresh attempt-bound listener; an occupied foreign listener or ambiguous owner is never adopted. The\nmanager commit validates while retained cleanup is still suppressed; the CLI durably records its\nattempt-bound 64-hex token in `manager-committed` / `resume-committed` before `finalizeResume` can\nrelease suppression. A retry from either committed state goes straight to exact-token finalization;\nfailure preserves the committed gate and retained cleanup suppression. Missing commit evidence,\ninterrupted finalization, a live recorded endpoint despite missing pidfiles, or ambiguous proof fails closed. See the [CLI\nbackup and restore contract](cli.md#backups) for artifact, checkpoint, fallback,\ndisaster-consent, and degraded-recovery details.\n\n## Personas from the CLI\n\n`cotal personas` manages the local catalog offline: `list` (`--running` overlays live\nmarkers), `show <name>`, `edit <name>` (re-validates on save), `new <name>`, `rm <name>\n--force`. The runtime write is `cotal_persona`; the runtime read is `cotal_personas`\n(list / show), both over the wire with the manager's ownership checks. Fields: [agent files](agent-files.md).\n\n## Gate recovery\n\nA manager that dies mid-registration leaves its issuance gate *frozen* under that registration\nop. The freeze is correct: it stops two incarnations serving at once. The successor now completes\nthat dead op on boot, using the same guard as [`cotal reconcile-gate`](cli.md#reconcile-gate): it\nacts only when the freeze-holder is affirmatively gone under a complete CONNZ sweep (`gone` and\n`sweepComplete=true`). If the dead op's spec write committed, it finishes that same freeze\n(promote and reopen at the committed registration revision). If the spec did not advance, it\nabort-reopens the gate (generation+1, processEpoch unchanged) and continues the normal takeover.\nBoot heal and the following re-registration use separate one-shot executor windows, so a large\npredecessor family cannot spend the takeover's credential lifetime. If that later registration\nstill crosses a connection lifetime, it retries the same frozen operation with fresh authority\nand resumes verified-holder progress instead of freezing a new generation.\nA live holder, an incomplete sweep, or an unreachable delivery daemon still\nrefuses. Silence is never evidence of death, and there is no TTL. If holder verification is\ninterrupted, the frozen operation resumes from its durable, operation-and-gate-revision-bound\nprogress after liveness is checked again. A later freeze cannot reuse that progress: the cursor\nbinds the exact op, gate revision, and holder set. Use `cotal reconcile-gate` when the boot path cannot run\n(daemon down, a non-manager endpoint, or you want to lift the freeze without starting a manager). A spawn that hits the same frozen gate names that verb in the refusal\n(`blockedOp=registration`, the holding `opId`, `remedy=cotal reconcile-gate`) instead of a\nwait-timeout: the facts were always in the manager log; they now reach the spawn caller too.\n\nGive reconciliation a **quiet manager**. Suspend systemd restart policies, watchdogs, health-check\nrestart loops, and any other automation that can start or kill `cotal supervise` while boot healing or\n`cotal reconcile-gate` is running. Leave one recovery attempt in control until it finishes.\nRestarting the manager during the walk interrupts the current authority window. Durable progress makes\nthat interruption resumable, but a quiet manager is still the fastest and safest incident procedure.\n\n### Last-resort JetStream store replacement\n\nStore replacement is not normal gate recovery, is never automatic, and is destructive to mesh history.\nUse it only after the retained store cannot be reconciled and after deciding that losing its durable\ncontents is acceptable.\n\n1. Stop every actor touching the space: supervisor, watchdog, manager, delivery daemon, and broker.\n Confirm that no Cotal or NATS process still has the store open.\n2. Preserve the stopped store before changing anything. Move `.cotal/nats` aside to a dated backup and\n archive both `nats` and `auth`. Do not delete the only copy.\n3. Understand the loss: replacing the store removes JetStream message and control history and durable\n consumer state. Agent session files stored outside JetStream remain, but the mesh history they\n referenced does not.\n4. Start the broker against a new empty store, then start one manager. Wait until it reports\n serving successfully.\n5. Repopulate the mesh only after that manager is healthy. Re-enable supervisors, watchdogs, and other\n restart automation last.\n\nKeep the preserved store until the incident is reviewed and any required forensic or manual recovery is\ncomplete. Restoring it later restores the old durable state, including the fault that led to this last\nresort, so do not swap it back into a live mesh casually.\n\n## When something looks absent\n\nPermission denials are **loud, never silent**: an over-tight ACL rejects the endpoint call it\nrefuses instead of returning an empty or incomplete result that looks successful, and a denial no\ncall is waiting on, such as a refused subscription, shows up as a logged denial on the endpoint. Check\n`.cotal/manager.<key>.log`, `.cotal/delivery.<key>.log` (one pair per space, keyed as\n[Config](config.md#project-files) describes), and `.cotal/nats.log`; `cotal status` shows\nwhat is actually running. Those files live under the **project** `.cotal/`, not `~/.cotal`,\nunless the mesh root is the home directory. `cotal up --detach` redirects delivery and manager\nstdio onto those files, so an operator-created systemd unit around that launcher does not put\nthe child logs in that unit's journal. `journalctl -u <unit>` can be empty while the crash\nreason is already in the project log. Manager log lines start with the UTC time they were\nwritten. The access rules are collected in\n[Channels & permissions](channels-and-permissions.md).\n"
62750
+ "body": "# Run a mesh\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nDay-to-day operation of a local mesh: what `cotal up` actually runs, how spawning\nresolves personas, harnesses, and models, how to reach a mesh from any directory, and the\noperator-only maintenance verbs. Every command's full flag set is in the\n[CLI reference](cli.md).\n\n## The stack\n\n`cotal up` brings up the whole local stack and bare `cotal down` stops it. Managed\nagents stay running as unmanaged OS processes; pass `--with-agents` to take them\nwith the stack. Seats of the built-in pty runtime run inside the manager process, so\nthey stop with the manager either way. Ctrl-C on a foreground `up` stops the manager through the\nsame stop as bare down and prints the same report; when that stop is refused, for example because\nthe manager cannot prove it can spare, Ctrl-C leaves the stack running, and you end it with\n`cotal down --with-agents`. A current manager records what its stop does with its seats before\nbare down signals it. A pre-pin legacy manager instead receives a reduced-guarantee\nwarning and is signalled according to the documented upgrade contract. Its running binary\nmay still carry the older destructive SIGTERM handler, so the CLI does not claim its\npre-signal agent inventory was spared; those agents may have been reaped.\n\n- **Broker**: a local `nats-server` (logs to `.cotal/nats.log`).\n- **Delivery daemon**: the durable backstop, auth mode only\n ([what it does](delivery-daemon.md)).\n- **Manager**: a detached supervisor answering the control plane, so\n `cotal spawn --detach` and the `cotal_spawn` tool work right after `up`.\n\nCotal creates the presence bucket in memory storage. Its records are liveness that every endpoint\nrewrites each heartbeat, so nothing is lost when a broker restart empties it, and nats-server's file\nstore write latch cannot reach it. A broker stop removes the memory stream itself, so every `cotal up`,\nincluding the resume after `cotal down --preserve-state`, creates it again before any daemon starts.\nJetStream fixes a stream's storage class when it is created, so a presence bucket created file-backed\nby an older cotal stays file-backed until that stream is recreated.\n\nA file-backed presence bucket can remain open and watchable while refusing every write. A bound\nendpoint reports this as `presence-write-stuck` after one full presence TTL of consecutive failures.\nThe roster is last-known while that condition is active. Restarting the broker clears nats-server's\nin-memory store latch and preserves the JetStream root. Current credentials split the required stream\nauthority: the `cotal up` provisioner can create the presence stream but cannot delete it, while the\nteardown credential can delete it but cannot recreate it. Cotal therefore reports the condition but\ndoes not attempt an unsafe partial delete-and-recreate. Stop and restart the broker to recover.\nA broker below nats-server 2.14.5 carries the latch (nats-server fixed it in 2.14.5). When `cotal up` starts or finds such a broker and the space's presence bucket is file-backed, it says so. A memory-backed bucket gets no warning. A broker below the SPEC \xA713.12 floor of 2.12 is refused at connect with the floor sentence.\n\nThree modes:\n\n- **Default (static auth).** JWT-authed, on by default: sender authenticity and per-agent\n ACLs, enforced by the broker ([how](identity-and-auth.md)).\n- **`--user-auth --idp <url>`.** Per-user auth: people `cotal login` once, the operator\n grants their agents on the actor ledger, and every connect is authorized live against\n that grant. Starts the space's auth service alongside the broker\n ([how](identity-and-auth.md)).\n- **`--open`.** An unauthenticated, live-only dev mesh (no auth, no delivery daemon). For\n quick local experiments.\n\nThe broker and local services bind **loopback** by default. `--host 0.0.0.0` widens the broker\nbind independently of the auth mode, so \"network-reachable\" never silently means\n\"unauthenticated\". With no explicit `--server`, `cotal up` auto-selects a free local port when\nthe default address is already held by another project; an explicit `--server` fails loud on\ncollision.\n\n`--host` is a boot flag, not a live rebind. A fresh `cotal up` writes the generated\n`.cotal/auth/server.conf` (project-local, not `~/.cotal`) with that bind and starts nats against\nit. If anything is already answering at the mesh URL, `up` refreshes the recorded mesh and\nleaves the running nats listener alone, so passing `--host 0.0.0.0` on a live or orphaned\nbroker does not change who can connect. To change the bind: `cotal down`, then `cotal up --host\n<addr>` against a stopped broker so the generated file is rewritten. Do not edit `server.conf`\nby hand; the next real boot overwrites it.\n\nOn a stopped shared broker, `up` renders every persisted space account and every enabled\nspace's auth-callout account into the resolver preload, regardless of which space starts\nthe broker. A missing callout account for an enabled space stops the boot rather than\nstarting with a reduced resolver. An already-running broker is refreshed without rewriting\nits config.\n\nA broker-only host is a first-class `up` mode. `cotal up --no-manager` boots the broker and, in\nauth mode, the delivery daemon, and no local manager, so the broker host never has a manager to\nstop and never leaves a manager slot stale. A refresh under the flag of a mesh whose manager is\nlive refuses rather than keeping or stopping it: `cotal down manager` first. Without the flag,\nauth-mode `up` still starts nats, the delivery daemon, and a\nlocal manager. A space may run more than one manager, addressed by instance id\n([control surface](control-surface.md#instance-routing)); putting no manager on the broker host\nis a topology choice, not a singleton invariant. A manager whose boot inventory has no\navailable connector does not take unpinned `spawn`/`launch` on the class rail, so a sibling\nthat can launch the harness can. `describe` still rides the class rail, so an unpinned spawn\ncan bind-fence when that skip member answered describe; re-issue, or pin `--on`. Pin one\ninstance with `--on` when a partial inventory still answers with a harness refusal. The\nsupported split is:\n\n```bash\n# broker host (project root that owns the generated conf, pidfiles, and logs)\ncotal up --detach --host 0.0.0.0 --space main --no-manager\n# no local manager starts: the summary lists nats-server + delivery daemon, and there is no\n# `.cotal/manager.<spaceKey>.log` to wait for on this host\n\n# manager host (registered remote mesh, same space)\ncotal meshes add --server nats://broker.example:4222 --root ~/meshes/main\ncotal supervise --space main --server nats://broker.example:4222\n```\n\nWait for `\u2713 manager up` in `.cotal/manager.<spaceKey>.log` on the manager host before spawning\nagents. On a broker host started without `--no-manager`, `cotal up --detach` prints `\u2713 running in\nthe background:` with `manager` listed once the manager pidfile is live; stop that local manager\nonly after the `\u2713 manager up` line. A host started WITH `--no-manager` never runs one, so neither\nthe wait nor the stop applies there. That detach stdout is not a safe teardown boundary: it is\npidfile liveness, not `\u2713 manager up`. `\u2713 manager up` is supervise's post-start line after\n`await mgr.start()`. `cotal down manager` after only the detach line can still default-terminate\nthe child during registration after it has taken the governance slot. Stopping before that\npost-start log line can leave the endpoint governance slot held until the holder's gate\nreopens past the stamp (the successor's boot heal, or\n[`cotal reconcile-gate`](cli.md#reconcile-gate) when that boot cannot run). See\n[Gate recovery](#gate-recovery).\n\nStandalone `cotal deliver --creds` is not a repair for that split. Production renewal needs\nthe manager and the daemon to address one credential store. The manager renews its own service\ncredential inside that credential's own window and re-dials its service connection with the\nrenewed credential; if the connection closes and cannot be restored within about forty seconds\nit releases its lease and exits so a restart can serve, while a broker that is briefly gone is\nwaited out. Separate host filesystems still\nleave manager root A writing and the daemon reloading root B; that composition is refused\nwhile the daemon stays up. Before every remint the manager challenges the delivery daemon's\nstore identity, and the answer must come from the process holding the delivery lease: the\nreply names the answering endpoint and the manager reads the lease row itself under its own\ncredential, so a non-holder answering on the queue-grouped admin rail is refused instead of\ncounting as the daemon's store. A rail that reports no responder is also settled from the\nlease row, so a live holder on record makes that outcome a refusal rather than an absent\ndaemon. Keep delivery on the broker host under `up`, and share one store\nonly when you are composing a hosted pair ([embedding](embedding.md#supervisor-signing-authority)).\nOn the `--no-manager` split above, the manager host's manager stays off the daemon-credential\nrenewal lease once its store check finds the daemon on another store. A filesystem store is named\nby its root and by a random id in `.cotal/store.id`, which the copied `.cotal/auth` does not carry,\nso this holds when both hosts use the same root path. `cotal doctor auth --fix` on\nthe broker host then renews the daemon credentials once they pass their renewal point.\n\n### Split host bind\n\nA remote manager cannot reach a loopback broker. After changing `--host`, confirm the\ngenerated `host:` in `.cotal/auth/server.conf` and that nats is listening on that address\nbefore registering the mesh on the manager host. Detached child logs stay under the **project**\n`.cotal/` that `up` ran in (see [When something looks absent](#when-something-looks-absent));\nthey are not `~/.cotal` unless that directory is the mesh root.\n\nA user-auth mesh can expose only its credential exchange through an operator-owned HTTPS reverse\nproxy while leaving the existing local exchange untouched:\n\n```bash\ncotal up --user-auth --idp https://idp.example/api/auth \\\n --exchange-public-port 7443 \\\n --exchange-public-url https://auth.example\n```\n\nThe public listener itself still binds `127.0.0.1:7443`; configure the proxy to terminate TLS and\nforward to it. It serves only `/health`, `/jwks`, `/exchange`, `/manager-service-authority`, and\n`/.well-known/cotal-mesh` with the documented methods. It needs no local file capability: the\nsigned IdP JWT or managed-agent actor token is the proof, while the original loopback listener\nremains capability-gated. Add\n`--exchange-trusted-proxy` only when that listener is reachable exclusively through your trusted\nproxy; it keys failure throttling by the last `X-Forwarded-For` hop instead of the socket address.\nThe well-known bundle includes IdP pins and a deny-all sentinel credential, so fetch it only from\nthe configured HTTPS origin. To change these listener flags, stop and restart the mesh; a refresh\nof an already-running service does not replace its bind or proxy policy. See\n[Identity & auth](identity-and-auth.md#per-user-authentication) for the trust boundary.\n\n### Remote supervised seats by enrollment\n\nA remote seat does not need to run `cotal login` when the mesh owner pre-mints a single-use\nenrollment for it. Mount the enrollment URL as a private file, place the seat persona on the remote\nmachine, and launch the foreground seat:\n\n```bash\nCOTAL_ENROLLMENT_FILE=/run/secrets/cotal-enrollment \\\n cotal spawn --config ./worker.md --space main\n```\n\nThe URL is redeemed once with an unauthenticated GET. Redirects, off-machine plain HTTP, retries,\nand login fallback are refused. If the seat has no mesh record yet, the enrollment response's stock\nuser-bundle fields register it before the launch. The returned actor token then uses the same remote\nauth-service exchange as a login-provisioned agent. The enrollment URL and file path do not enter the\npreflight or harness environment. A failed or reused enrollment leaves no actor material on disk; ask the owner\nfor a fresh enrollment. When the foreground seat exits, this machine's credential files are removed\nand the mesh-side grant stays until the mesh operator revokes it; the launch line says so. The exact\nserver contract is in\n[Enrollment redeem](identity-and-auth.md#enrollment-redeem).\n\n`cotal status` prints the detailed setup, process, registry, and live mesh status. Its Machine\nsection names the running CLI's source checkout, installed package root, or npx package root beside\nthe version. It has one row per installed connector, which reports whether the executables that\nconnector declares in `requires` are on PATH. Status, setup and the manager's preflight resolve them\nthe same way: an entry written as a path is checked as given, and a directory never counts as the\nexecutable. A connector whose setup provider reports health adds its\nown rows above those. The Claude Code connector reports its plugin and its skills plugin, and a stale\nskills row names the installed and CLI versions it compared. `cotal\nsetup` (after the first run) prints the compact card.\n\nBefore reporting ready, the manager resolves every installed connector's declared harness\nbinaries against its own environment. A missing binary does not stop unrelated manager work: boot\ncontinues, but prints a named `connector <name> unavailable` line and records that reason in the\nmanager's `status` response. Available connector rows record the absolute paths boot resolved.\nA spawned seat and a seat resumed after `cotal down --preserve-state` both launch from those paths,\nand both are refused with the recorded reason when their connector's row is unavailable.\n`cotal models` takes the same rule and reports that reason in place of the catalog, so it agrees\nwith a launch about a harness installed or removed after boot. The manager looks again only when it\nrestarts. A connector registered after boot has no row, so spawn, resume and `cotal models` check\nits binaries on PATH when they run.\n\nOn an authenticated manager start, unfinished static lifecycle rows reconcile while the control\nendpoint is already serving. The manager `status` response reports\nthe `staticReconciliation` state, the last sweep counts, and each failed alias with its durable\nphase and literal disposition. `cotal status --components` reports the state and per-alias failure\ndetails. A sweep that stops before it plans any alias, for example on a slot row carrying a DEL or\nPURGE marker, reports the state `failed` with the reason in the last sweep's `error`, and\n`cotal status --components` prints it as `static sweep error=<reason>`. The next manager start\ncollects the rows again. A failed exact terminal is retried in the same process after 1, 5,\nand 30 seconds. Each attempt re-reads the durable slot and re-enters the same deterministic terminal\noperation; the delays only schedule work and never release the lifecycle fence. The terminal's\ncleanup removes the lifecycle's credential file and its broker durables and read-ACL row as separate\nsteps. A file that cannot be removed does not leave the broker footprint behind, and its failure\nkeeps the alias held for the next attempt.\n\nOn shutdown, the manager fences new reconciliation work and waits for an exact terminal that already\nstarted. The current serial sweep stops before its next alias, and startup cannot publish the manager\nservice after `stop()` completes.\n\nThe four-attempt budget is per manager process. An exhausted row stays held and reports\n`retry-exhausted` with the remedy to restart the manager. The next process derives a fresh budget\nfrom the still-authoritative durable row. A `recovered` row remains visible until the next static\nreconciliation sweep, then clears. This component reports reconciliation outcomes. It does not say\nwhether footprint cleanup completed independently of the terminal result; that separate durable\nprojection remains tracked by #1274.\n\n`cotal service install` is the supported way to run the manager as a user service\n([CLI reference](cli.md#service)): a systemd user unit on Linux, a launchd agent on macOS, one\nper mesh, surviving logout and reboot. On Linux that needs user lingering: install refuses while\nit is off and prints the root command that enables it. It installs only\nthe manager; the units below remain the process models for every other component, and they are\nstill **examples of process models** for those: copy them only after you decide which processes\nthe unit should own.\n\n### Supervising the detached stack\n\n`cotal up --detach` is a launcher: it starts the broker, delivery daemon, and manager, reports what\nstarted, then exits. Do not wrap it in a systemd service with `Type=oneshot` and\n`RemainAfterExit=yes` and treat `systemctl is-active` as stack health. That unit becomes `active\n(exited)` when the launcher exits successfully and stays active even if every detached process dies.\nWhen `up --detach` can identify that exact unit shape, it prints a warning but keeps the requested\nstartup behavior.\n\nFor a single-host stack, keep `cotal up` itself in the foreground so systemd tracks a long-running\nprocess and restarts the stack if that process fails:\n\n```ini\n[Service]\nType=simple\nWorkingDirectory=/srv/cotal-mesh\nExecStart=/usr/bin/cotal up --space main --host 0.0.0.0\nRestart=on-failure\nRestartSec=5s\n```\n\nAn active unit then proves the foreground launcher and broker are still running, but it still does\nnot prove that every child component serves. Pair it with the component check below. Also remember\nthat `cotal up` starts a local manager as well as the broker and delivery daemon; run\n`cotal up --no-manager` (add the flag to the unit's `ExecStart` too) on a host intended to be\nbroker-only, so the unit and the host agree.\n\nSeats spawned by the built-in `pty` runtime run with `oom_score_adj` 500, so under memory\npressure the kernel prefers a seat over the broker, manager and delivery daemon, which are left as\nthey were started; the extension runtimes do not own the seat's process and get no preference.\n\nThat `Type=simple` shape puts nats in the unit's cgroup with the foreground `up` process. A\n`Restart=always` (or `on-failure`) of **this** unit therefore restarts nats as well, so remote\nmanagers drop for the time it takes the broker to come back. Wrapping `cotal up --detach` in\n`Type=oneshot` with `RemainAfterExit=yes` does not move nats out of that cgroup. Detached\nspawn starts a new process group, not a new systemd cgroup, and the default\n`KillMode=control-group` still signals every process left in the service cgroup on stop or\nrestart, including the nats PID. Escaping that cgroup needs an explicit unit setting such as\n`KillMode=process`, or a separate nats unit; this CLI does not ship that escape. The\n`Type=oneshot` unit below is a `cotal status --components` liveness check, not a\n`--detach` launcher. Neither trade is universal from\n`Type=simple` alone; it follows from which processes the unit actually owns. `cotal service\ninstall` covers only the manager, so for the broker and its siblings pick the example that\nmatches the ownership you want, and treat\n`systemctl is-active` as unit health, not mesh health.\n\nA broker that crashes under that foreground `up` keeps its mesh record and exits non-zero, so the\nunit's restart takes the repair path against the recorded store rather than starting a second one.\n\nIf the deployment deliberately uses `cotal up --detach` as a boot action, monitor observed state\ninstead of the launcher's exit:\n\n```ini\n[Unit]\nDescription=Check Cotal component liveness\n\n[Service]\nType=oneshot\nWorkingDirectory=/srv/cotal-mesh\nExecStart=/usr/bin/cotal status --components --space main\n```\n\nRun that check from a systemd timer or another monitor and alert on a nonzero exit. The command\ndistinguishes `absent`, `not-serving`, and `refused` components and never treats a sibling's health as\nproof. Its delivery-process check is local to the broker host, so run it there. On a split topology,\nalso probe the broker URL from the manager host and monitor the manager's own service there. A remote\nmanager cannot observe the broker host's delivery PID, and an `active` unit on either host says\nnothing about the other host.\n\nStop one part without tearing down the mesh by naming its registered component: `cotal down\nmanager`, `cotal down delivery`, or `cotal down web`. Component names from installed extensions\njoin the same surface; `cotal down` with no names retains whole-stack behavior and\nleaves managed agents running as unmanaged OS processes, except pty seats, which stop with the\nmanager. `cotal down --with-agents` is the previous reap. If a pinned manager has no\nspare-capability record, stop its managed agents explicitly before running that whole-stack\ncommand. A current manager always publishes the record, so it is absent only for an older manager,\nwhich may not understand the reap request.\n\n## Remote supervised agents\n\nOn a remote user-auth mesh, foreground `cotal spawn` remains the default participant path. A\nparticipant can run detached agents only after the host advertises and operates the remote manager\nauthority service, and the participant's actor-ledger row includes `supervise`. This is not implied\nby `spawn` or `admin`.\n\nThe participant's loopback/operator exchange obtains one closed `manager-service` view for its\nordinary derived owner, a fixed server-selected manager actor, and one opaque manager instance.\nThe host, not the participant, issues the public-nkey JWT material via the replay-safe,\nlifecycle-bound prepare \u2192 activate \u2192 renew exchange, plus a one-shot target-pinned retirement\nrequest for a host-managed terminal. It never exports the space signer, a static\nprovisioner credential, or generic storage authority. Remote registration publishes its service\nstatus at the registered revision and current process epoch, so manager-caller selection can find it.\n\nStock participant supervision asks its host to enroll a detached agent and to prepare its terminal\nretirement, over the same manager-authority transport. The stock auth service answers both when it\nruns with a public exchange face: it grants the agent under the participant's owner at a lifecycle\nUID it picks, bounded by the participant actor's own grant, provisions that UID's durables, and on\nretirement releases them and revokes the grant before the manager's terminal rail. It refuses a\nsecond enrollment of a name whose grant still stands until that agent's retirement is prepared. A\nhost platform that keeps these writers in its own storage intercepts both requests on its own route\ninstead. Copying host secrets or actor-ledger files to a participant is not supported. Foreground\nspawning and operator-local hosted managers use their existing paths.\n\nThe remote manager that `cotal supervise` starts can host workflow runs through its host: the host\nadmits each run and signs only the run's own driver, mediator and operator credentials. A logged-in\nuser's `cotal run start` against it is admitted: the auth callout issues the user's manager\nconnection, and the host binds each run to the owner who registered the manager. The run spawns\nagents that user owns, enrolled by the host like any detached spawn, with the reach the user's own\nrow grants when the spawn runs. A spawn may be placed on that manager and on no other instance. The\nhost's own manager refuses user-auth runs by name.\n[User-auth run start](design/user-auth-run-start.md)\nrecords the path.\n\nThe registry entry decides the broker URL `supervise` dials, so a mesh published over `wss://` is\ndialed as a websocket. The manager-authority registration it runs first also takes its TLS\nrequirement from that entry, so the prepare credential is not exchanged over a plaintext\nconnection the record did not describe. `cotal meshes add` records both.\n\nWhen the authority service, login, or renewal is unavailable, the remote manager degrades\nfail-closed: it refuses new agents, restarts, and credential replacement rather than pretending\nlocal authority exists. Existing agents remain live only while their independent credentials are\nvalid. A hosted composition must revoke the managed grant and finish its resumable release before it\nrequests terminal retirement. Deleting DM or delivery consumers is not retirement and must not reset\na resumable lifecycle's frontier or pending state. The alias remains held until the terminal barrier\nconfirms. Restore service and renew successfully before asking it to recover an agent. See\n[Identity & auth](identity-and-auth.md#remote-manager-authority) and the [CLI\nreference](cli.md#supervise).\n\n## Spawning agents\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn reviewer --detach # supervised: the manager runs it in a PTY\ncotal attach --name reviewer # watch/type into a detached agent (Ctrl-] detaches)\ncotal ps # what the manager is running\ncotal stop --name reviewer # stop one\n```\n\nHow a spawn resolves:\n\n- **Persona.** A bare `cotal spawn` uses `.cotal/agents/default.md`; a positional name\n picks `.cotal/agents/<name>.md`; `--config` takes an explicit ref or path. Set\n `COTAL_DEFAULT_PERSONA=<name-or-path>` to change the fallback. Fields and format:\n [agent files](agent-files.md).\n- **Harness.** Resolution order is an explicit `--agent` or `cotal_spawn` `agent` argument,\n then the persona file's `agent:` pin, then the invoking caller's `COTAL_DEFAULT_AGENT`,\n then the manager's `COTAL_DEFAULT_AGENT`, then the product default (Claude). Compared in\n [Connectors](connectors.md); per-connector guides:\n [Claude](connect-claude.md) \xB7 [OpenCode](connect-opencode.md) \xB7\n [Hermes](connect-hermes.md) \xB7 [pi](connect-pi.md).\n- **Model.** `--model` overrides the persona file's `model:` (Claude: `opus` / `sonnet` or\n a full id; OpenCode: `provider/model`). Connectors that expose a catalog report it via\n `cotal models --agent opencode`: model ids plus available variants; pick one with\n `--model provider/model --variant high`.\n- **Tools.** A spawned Claude Code agent gets the cotal tools plus the MCP servers the cotal\n config shares, which first-run `cotal setup` fills with your own; narrow them per spawn with\n `--share-tools` ([config](config.md)).\n- **Launch options.** `--opt key=value` (repeatable) passes a native harness flag straight\n through; a persona or manifest `launchOptions:` mapping does the same declaratively (a\n `--opt` wins per key). It is a **raw passthrough**, with no allow/deny list: Claude renders\n each as `--key value` (a bare `--key` for an empty value), OpenCode merges them into its\n agent config, and Hermes has no option surface so it fails loud. The trust boundary is the\n `spawn` capability itself, not the flag set, so granting `spawn` is host-launch authority\n ([security](security.md)). A key must be a plain flag name; malformed or prototype-polluting\n keys are refused.\n\nDetach from an attached PTY with **Ctrl-]** (the agent keeps running); rebind it with\n`COTAL_DETACH_KEY=ctrl-<char>` when it clashes with a keybinding inside the agent's TUI.\n\n**Runtimes.** The manager spawns into a **pty** by default. It spawns the PTY in-process on\nevery platform, so replacing the manager worker closes its seats and the pty runtime gives no hot\nupdate. Any manager stop, bare `cotal down` included, stops and deprovisions those seats. A stopping\nmanager refuses new spawns and first waits for the ones it already accepted, so their seats stop too. On Linux\nit can still adopt and reap seats that an earlier manager launched under a detached per-seat\ncustodian, so those seats drain under the new manager; it starts no new custodian. A custodian whose agent has exited exits a few seconds later on its own. `cotal seats`\nlists the custodians left on the machine, and `cotal seats --drain` retires the ones whose agent\nhas exited while keeping every seat whose agent still runs ([cli.md](cli.md#seats)). When a pty\nagent exits on its own, in-process or under a custodian, the manager logs a `seat reaped:` line\nwith the exit code and, for a signalled child, the signal number. The line ends with the last line\nthe child printed that starts with a connector's `[cotal-<name>]` or `[cotal-<name>/<part>]`\nprefix, cut to 240 characters, when it printed one. A custodian keeps the same record beside the\nseat's custody record, so a later reap of that seat, including one by a\nsuccessor manager, reports how the child ended. When the custodian cannot write that record, it\nsays why in the seat's `custodian.log`, and a later reap of a child that ended on its own reports\nthe record as missing or unreadable. Optional runtimes are installed\nthrough the extension surface, for example `cotal ext add @cotal-ai/orca`, then selected with\n`--runtime orca` (similarly `@cotal-ai/tmux`, `@cotal-ai/cmux`, and `@cotal-ai/herdr`). They put teammates in native\nterminal surfaces rather than manager-owned PTYs. Those surfaces stream no exit, so the manager\nasks the runtime every five seconds whether a seat's process has ended, and frees a seat that ended\non its own once the runtime proves the exit. Its `seat reaped:` line says the exit detail is\nunavailable from that runtime. While a launch waits for readiness the manager asks every second and\nonce more when the readiness window closes, so a seat whose process ends before it joins the mesh is\nreported as exited on launch, with no last output, rather than as uncertain. A runtime that has not\nproved that last exit a second after the window closes leaves the launch uncertain. Runtime names are\nopen-ended and resolved from the registry; a missing provider or app throws, never silently falls back\n([architecture](architecture.md)).\n\n## Mesh registry\n\n`cotal up` records each running mesh in a machine-local registry\n(`~/.cotal/meshes/space.<key>.json`, named by a case-safe hex encoding of the space: broker URL, the project root holding its creds and\npersonas, and its mode). So a bare `cotal spawn <persona>` from *any* directory joins the\nrunning mesh with the right credentials instead of mistaking the cwd for a space:\n\n- `cotal use <name>` sets the default from every directory, including inside another mesh's\n project. `--space <name>` overrides it for one command.\n- When one broker has records for several spaces, `cotal up --space <name>` refreshes that named\n space.\n- A refresh rewrites only what that command decided: the server, root and mode, the user-auth\n endpoints, and an explicit `--host` or `--max-sessions`. Every other field, such as the TLS\n requirement, is kept as the record stands when the refresh writes it, so a change another\n command made during the refresh survives. If the record was removed during the refresh, `up`\n fails instead of writing it back.\n- With no live selected default, a project with its own `.cotal/` resolves to that project's\n mesh; otherwise one running mesh is used automatically and several are an error.\n- `cotal meshes` lists them (a `*` marks the default); `cotal down` removes the entry.\n\nThe registry stores a *path*, never a secret; trust material stays in each project's\n`.cotal/auth`. If the mesh is down or won't take your creds, spawn fails with one\nsentence, never a raw NATS trace.\n\n### Meshes you did not start here\n\nA mesh running on another machine has no `cotal up` on this one, so register it by hand:\n\n```bash\ncotal meshes add # guided: asks for the broker, probes it, offers what it finds\ncotal meshes add optiplex --server nats://100.90.12.34:4222 --root ~/meshes/optiplex \\\n --allow-unencrypted-overlay # see below: an overlay address needs this\ncotal meshes rm optiplex\n```\n\nOn a terminal, a bare `cotal meshes add` walks you through it: it probes the broker you name and\nreports whether it is open or requires credentials, offers the spaces the folder already holds\ncredentials for, and shows the record before writing it. Scripts and agents keep the flag form -\nwithout a terminal nothing prompts.\n\n`--root` is the local folder holding that mesh's `.cotal/auth` and `.cotal/agents` (its personas);\nthe mode is inferred from what that folder holds.\n\nThe instance identities of the manager and the user-auth service are not part of that folder.\nEach root keeps its own in `.cotal/space.<hex>/`, so `cotal supervise` or `cotal up --user-auth` in\nthe root you copied the folder to starts an instance of its own. A root last run by an older Cotal\nstill holds them in `.cotal/auth`, as `manager-instance.<hex>.json`, `manager-siblings.<hex>.json`\nand `space.<hex>/.cotal/auth/auth-instance.<hex>.json`. Delete those files from a copy of such a\nfolder before the first `cotal supervise` or `cotal up` there. On the root itself, the first start\nmoves each record to `.cotal/space.<hex>/`. It refuses and keeps both entries when either place\nholds a symlink or anything else that is not a regular file, or when the two places hold different\nrecords.\n\n**Know what you are copying.** For an authenticated mesh that folder carries the space's account\n**signing seed**, which is the authority to mint any identity in the space. A machine holding it\nis a certificate authority for the mesh rather than a client of it: anyone who reads it can\nimpersonate any agent, read every retained channel and DM, change ACLs, and keep issuing\nthemselves credentials. There is no per-machine revocation; undoing it means rotating the signing\nkey and re-minting every credential in the space. Copy it only to machines you would trust with\nthe whole mesh. `cotal mint` on its own does not substitute here: registering an `auth` mesh needs\nsigning material that composes, which a minted user credential is not. The\nbroker is probed before the record is written, so a bad address or a credential that mesh will not\naccept fails at registration rather than at your first `spawn` (`--force` records it without verifying,\nuseful when the mesh is simply down right now).\n\n#### Which addresses you may register\n\nRegistering a mesh is how this machine starts sending agent credentials to a broker it does not\nrun. NATS announces itself in plaintext before anyone authenticates, so an attacker on the path\ncan pose as the broker and read the credential out of the connect unless the connection\n**requires TLS**, which is recorded on the entry and enforced on every dial through it.\n\nWhat the record will require decides what you may register:\n\n- **Without required TLS**, the address is the gate: **loopback** (`127.0.0.0/8`, `::1`), or\n **your private overlay** (`100.64.0.0/10`, `fd7a:115c:a1e0::/48`) with\n `--allow-unencrypted-overlay`. The tunnel provides the protection, and this command cannot check\n its state. Hostnames are refused because the lookup would choose which machine receives your\n credentials.\n- **With required TLS**, set `--tls` or use a `tls://` URL. The recorded scheme enforces the TLS\n requirement. A **hostname or public address** is accepted because the certificate chain and\n hostname check identify the peer. A registration whose broker cannot complete the handshake\n fails unless you pass `--force`, which records the entry without verification.\n\nOrdinary private ranges like `10.x` and `192.168.x` are refused in **both** modes. A caf\xE9's wifi\nis private but does not belong to you, and no public CA issues certificates for those ranges. An\naddress spelling changes nothing: `[::ffff:192.168.1.10]`, `3232235786`, `0300.0250.01.012`, and\n`192.168.257` all resolve to private addresses and receive the same refusal as the dotted form.\n`--force` exists for a mesh that is down. It never permits an unsafe credential destination.\n\n#### Registering a hosted user-auth mesh\n\nA user-auth space's IdP pins are established where the mesh runs and are never guessed. Register\none from **supplied** trust: `--user-auth-file bundle.json` (exported on the mesh's machine), or\n`--from https://auth.example`, which asks before it contacts the address at all, fetches the\ndiscovery document at `/.well-known/cotal-mesh` under that address over HTTPS, shows you the pins,\nand asks again before adopting them. A URL that already ends in `/.well-known/cotal-mesh` is\nfetched as given. Redirects are refused because a 302 can walk a pinned fetch down to\nplaintext or onto another host, and the pinned exchange must be an `https://` URL too. The one\nexception is an exchange on **this machine**, where nothing leaves the box: plain `http://` is\naccepted for a loopback *literal* (`127.0.0.1`, `::1`, and any spelling of them), but **not** for\n`localhost`, which a hosts entry or poisoned lookup could point elsewhere. Use the\nliteral. Registration checks that the pinned exchange\nanswers `/health` and `/jwks` as the pinned issuer. It also checks that the broker refuses a\nbare connect; that refusal is the pass. The bundle's sentinel credentials are written to a private (0600) file\nunder the entry's root; the registry itself never carries the secret.\n\n**Without required TLS**, an overlay address is **refused unless you accept the dependency\nexplicitly**, with `--allow-unencrypted-overlay`. The address is not the guarantee: it is protected\nwhile the tunnel is up, and if the tunnel is down that range is ordinary carrier-grade NAT and\nwhoever answers the dial receives your credentials. Only you can know which it is, so the command\nasks you to say so. Your acceptance is recorded on the mesh entry rather than printed and\nforgotten, and the guided form asks the same question instead of taking the flag.\n\n**With required TLS** (`--tls`, or a `tls://` URL) that consent is no longer asked for, and the\nflag is not needed: the handshake is what protects the connection, so the acceptance it stood in\nfor has been replaced by proof rather than promise. `cotal meshes add <space> --server\nnats://100.64.0.1 --tls` registers an overlay address with no prompt, no flag and no recorded\nacceptance. This is the \"the flag disappears once the broker can be served over TLS\" case, and it\nhas now arrived.\n\nThis gate is on **registration**. `cotal join --creds --server <url>` deliberately takes an\nexplicit connection at face value and does not consult the registry, so it is not covered. Join\nthat way only to an address you would have registered.\n\nThe connection is still probed first, with the same second try at the longer budget the registry\npreflight uses, so a slow link reads as a connect that did not finish within that budget and a\nrefused port reads as a broker that is not running.\n\nRecords added this way are removed only by something that names them. A failed liveness probe\ndoes not delete any record: an unreachable broker, local or registered by hand, is shown as\n`offline` in `cotal meshes`. A bare command does not count that offline record as running;\nname it with `--space` to restart it. `cotal down` / `cotal clean all` still drop an `up` record for the\nproject they are tearing down, and they leave a hand-registered one alone even when `--root`\npointed at that project. A `cotal up` for that space refuses outright unless it is that same\nendpoint: finding a broker already answering there is a refresh that starts nothing and leaves the\nrecord's provenance alone, while actually starting the broker for that space, server and root\nmakes this machine the one running it, so the record becomes an ordinary local one that\n`cotal down` clears. The refusal names `cotal supervise --space <s> --server <url>` (plus `cotal\ndeliver`) when the registered broker is on another host, and `cotal meshes rm` when it is local.\n`cotal meshes rm` drops it and re-registering with `--force` replaces it. `rm` only forgets a\nmesh. To stop one running here, use `cotal down`.\n\n## Watching\n\n`cotal console` is the terminal view (TUI on a real terminal, plain line stream when\npiped); `cotal web` is the browser dashboard. Both are read-only observers; the\nwalkthrough is [Watch a mesh](watch-a-mesh.md).\n\n## History\n\nRetained history is operator-owned. `cotal clean history --force` purges a space's\nretained channel history; `--dms` also purges DMs (`cotal history clear` is an alias).\nIt is deliberately **not** an agent tool: agents cannot wipe the record\n([identity & auth](identity-and-auth.md)). For a **stopped** mesh, `cotal clean store\n--force` deletes the on-disk JetStream store outright, and `cotal clean all --force`\nalso resets the space identity ([CLI reference](cli.md#clean)).\n\n## Offline backup\n\nFor a coherent durable cut, preserve the whole stack first, then create the artifact while it stays\ndown:\n\n```bash\ncotal down --preserve-state\ncotal backup create ./space-backup # full by default\n# later: deliberately resume the unchanged source\ncotal up --detach\n# or, from another preserved cut, restore before the normal listener opens\ncotal up --restore ./space-backup --detach\n```\n\nA refused cut leaves the mesh running and unfenced: fix what the refusal names and run\n`cotal down --preserve-state` again.\n\nUse `--store-dir` on both preservation and backup for a custom JetStream store. A store cap set\nwith `cotal up --max-file-store <bytes>` travels with the preserved state, and the resume renders it\nagain. nats-server reads the cap once at start and refuses a config reload that changes it, so a new\ncap always needs a restart. The cut records the chat stream's frontier per retained seat, so a\nresumed seat catches up from there instead of replaying its channels. `registry` is the\nonly partial selection (`backup create ... --only registry`; `up --restore ... --restore-only\nregistry`). Backup never stops or restarts a mesh implicitly, never opens the original store, and\ndoes not contain credentials or trust secrets. Backup/restore in every auth mode, open included,\nuses isolated, operation-specific maintenance logins; normal agent credentials cannot enter that\nlistener. Full\nrestore requires the same space and exact current local trust continuity, recreates conservative\nconsumer checkpoints bound to their snapshot stream sequence state, and resumes retained agents under\ntheir original principals. The trust commitment includes the cryptographically validated full\noperator/system/data-account root chain as well as static/user authority state. A registry-only\nrestore completes canonical empty infrastructure but leaves retained agents stopped because their\nDM/DLV/TASK/ACL state is outside that selection. Authenticated restore validates the complete space\ntrust bundle before staging or changing the preserved store. Interrupted ordinary resume retries the\nsame durable attempt after its prior listener is stopped. Restore re-entry can recover a surviving normal listener\nonly when its attempt nonce, NATS server name, process owner, endpoint, and target-store identity all\nmatch the fsynced proof. A provably dead uncommitted owner is retired under lock and replaced with a\nfresh attempt-bound listener; an occupied foreign listener or ambiguous owner is never adopted. The\nmanager commit validates while retained cleanup is still suppressed; the CLI durably records its\nattempt-bound 64-hex token in `manager-committed` / `resume-committed` before `finalizeResume` can\nrelease suppression. A retry from either committed state goes straight to exact-token finalization;\nfailure preserves the committed gate and retained cleanup suppression. Missing commit evidence,\ninterrupted finalization, a live recorded endpoint despite missing pidfiles, or ambiguous proof fails closed. See the [CLI\nbackup and restore contract](cli.md#backups) for artifact, checkpoint, fallback,\ndisaster-consent, and degraded-recovery details.\n\n## Personas from the CLI\n\n`cotal personas` manages the local catalog offline: `list` (`--running` overlays live\nmarkers), `show <name>`, `edit <name>` (re-validates on save), `new <name>`, `rm <name>\n--force`. The runtime write is `cotal_persona`; the runtime read is `cotal_personas`\n(list / show), both over the wire with the manager's ownership checks. Fields: [agent files](agent-files.md).\n\n## Gate recovery\n\nA manager that dies mid-registration leaves its issuance gate *frozen* under that registration\nop. The freeze is correct: it stops two incarnations serving at once. The successor now completes\nthat dead op on boot, using the same guard as [`cotal reconcile-gate`](cli.md#reconcile-gate): it\nacts only when the freeze-holder is affirmatively gone under a complete CONNZ sweep (`gone` and\n`sweepComplete=true`). If the dead op's spec write committed, it finishes that same freeze\n(promote and reopen at the committed registration revision). If the spec did not advance, it\nabort-reopens the gate (generation+1, processEpoch unchanged) and continues the normal takeover.\nBoot heal and the following re-registration use separate one-shot executor windows, so a large\npredecessor family cannot spend the takeover's credential lifetime. If that later registration\nstill crosses a connection lifetime, it retries the same frozen operation with fresh authority\nand resumes verified-holder progress instead of freezing a new generation.\nA live holder, an incomplete sweep, or an unreachable delivery daemon still\nrefuses. Silence is never evidence of death, and there is no TTL. If holder verification is\ninterrupted, the frozen operation resumes from its durable, operation-and-gate-revision-bound\nprogress after liveness is checked again. A later freeze cannot reuse that progress: the cursor\nbinds the exact op, gate revision, and holder set. Use `cotal reconcile-gate` when the boot path cannot run\n(daemon down, a non-manager endpoint, or you want to lift the freeze without starting a manager). A spawn that hits the same frozen gate names that verb in the refusal\n(`blockedOp=registration`, the holding `opId`, `remedy=cotal reconcile-gate`) instead of a\nwait-timeout: the facts were always in the manager log; they now reach the spawn caller too.\n\nGive reconciliation a **quiet manager**. Suspend systemd restart policies, watchdogs, health-check\nrestart loops, and any other automation that can start or kill `cotal supervise` while boot healing or\n`cotal reconcile-gate` is running. Leave one recovery attempt in control until it finishes.\nRestarting the manager during the walk interrupts the current authority window. Durable progress makes\nthat interruption resumable, but a quiet manager is still the fastest and safest incident procedure.\n\n### Last-resort JetStream store replacement\n\nStore replacement is not normal gate recovery, is never automatic, and is destructive to mesh history.\nUse it only after the retained store cannot be reconciled and after deciding that losing its durable\ncontents is acceptable.\n\n1. Stop every actor touching the space: supervisor, watchdog, manager, delivery daemon, and broker.\n Confirm that no Cotal or NATS process still has the store open.\n2. Preserve the stopped store before changing anything. Move `.cotal/nats` aside to a dated backup and\n archive both `nats` and `auth`. Do not delete the only copy.\n3. Understand the loss: replacing the store removes JetStream message and control history and durable\n consumer state. Agent session files stored outside JetStream remain, but the mesh history they\n referenced does not.\n4. Start the broker against a new empty store, then start one manager. Wait until it reports\n serving successfully.\n5. Repopulate the mesh only after that manager is healthy. Re-enable supervisors, watchdogs, and other\n restart automation last.\n\nKeep the preserved store until the incident is reviewed and any required forensic or manual recovery is\ncomplete. Restoring it later restores the old durable state, including the fault that led to this last\nresort, so do not swap it back into a live mesh casually.\n\n## When something looks absent\n\nPermission denials are **loud, never silent**: an over-tight ACL rejects the endpoint call it\nrefuses instead of returning an empty or incomplete result that looks successful, and a denial no\ncall is waiting on, such as a refused subscription, shows up as a logged denial on the endpoint. Check\n`.cotal/manager.<key>.log`, `.cotal/delivery.<key>.log` (one pair per space, keyed as\n[Config](config.md#project-files) describes), and `.cotal/nats.log`; `cotal status` shows\nwhat is actually running. Those files live under the **project** `.cotal/`, not `~/.cotal`,\nunless the mesh root is the home directory. `cotal up --detach` redirects delivery and manager\nstdio onto those files, so an operator-created systemd unit around that launcher does not put\nthe child logs in that unit's journal. `journalctl -u <unit>` can be empty while the crash\nreason is already in the project log. Manager log lines start with the UTC time they were\nwritten. The access rules are collected in\n[Channels & permissions](channels-and-permissions.md).\n"
62691
62751
  },
62692
62752
  {
62693
62753
  "slug": "security",
@@ -62729,21 +62789,21 @@ function loadDocsBundle() {
62729
62789
  "title": "Upgrading a running deployment",
62730
62790
  "kind": "Guide (informative)",
62731
62791
  "summary": "Substrate stability tells you what the version numbers promise.",
62732
- "body": "# Upgrading a running deployment\n\n> **Guide** (informative) \xB7 **For:** operators upgrading a mesh that already exists \xB7 **See also:** [Substrate stability](stability.md), [Run a mesh](run-a-mesh.md), [Identity and auth](identity-and-auth.md)\n\n[Substrate stability](stability.md) tells you what the version numbers promise. This page is the\nother half: what to actually do when the deployment already exists, has credentials in it, and\ncannot simply be recreated. Every release that breaks a running deployment gets a section here,\nnaming what migrates on its own, what does not, and the order to move the pieces in.\n\n## The pre-1.0 upgrade contract\n\nThe packages are pre-1.0, so a minor bump may break an API or an on-disk expectation. Four\ncommitments make that survivable for someone with a fleet:\n\n- **Pin an exact version.** `0.N.P`, never `^0.N.P`. A range can pull a breaking minor in during an\n unrelated reinstall.\n- **Every break that touches a running deployment gets a section on this page**, written in terms of\n what an operator does, not in terms of which module changed.\n- **Read the section before you start, not halfway through.** A section names the work up front\n precisely so the operation does not change shape once it is underway.\n- **A break that cannot be made automatic says so.** Where credentials or state must be recreated by\n hand, the section says which ones and when, rather than leaving you to discover it at the moment\n the first one stops working.\n- **A change to the shape of a credential, or to who may renew one, is breaking whatever the commit\n marker says.** This rule is stated because the marker is a judgement made while writing the code\n and the consequence is felt by someone running it a day later. A fleet that keeps authenticating\n looks compatible and is not, if nothing in it can renew. Any automated check of this rule would\n read commit markers, so a break recorded as a feature is the one case it could not see, which is\n why the rule is written for people first. **The marker held for this release: the 0.49.0 change\n that caused all of this, `36d177951 feat(core)!`, did carry its `!`.** The rule exists for the\n next one that does not.\n\nWhat this page does not promise is a rolling upgrade. Nothing in the current line dual-serves two\nauthority versions, so where broker and manager run separately there is a window in which the mesh\nis down. The sections below give that window's shape so it can be scheduled rather than endured.\n\n## Token verifier clock tolerance in 0.77.0\n\n`validateUserToken`, and the `IdpConfig` that `verifyIdpToken` and `createIdpBridge` take, from\n`@cotal-ai/auth` no longer accept `clockToleranceSec`. Both verifiers allow a fixed 5 seconds of\nclock skew on `exp`, `nbf` and `iat`, the default they already applied. Nothing in the repository\npassed the option, so a running mesh behaves as before. Only code of your own that passes it is\naffected.\n\n### What stops working\n\nA call or `IdpConfig` that passes `clockToleranceSec` in an object literal no longer compiles. Plain\nJavaScript that keeps it still runs, and the value is ignored. A `NaN` value, such as `Number()` of\nan unset environment variable, used to turn off the expiry, not-before and issued-at checks in both\nverifiers, so an expired or post-dated token was accepted. A large value widened those checks by\nthat many seconds. Both kinds of token are now refused.\n\n### Before the upgrade\n\nRemove `clockToleranceSec` from each call and each `IdpConfig`. A host whose clock is more than 5\nseconds off the issuer's needs its time synchronized.\n\n## Auth context closure in 0.71.0 (unreleased)\n\nExisting deployments need no credential migration or restart for these additive APIs. Embedded\nhosts can now inspect `handle.connections()` and await `handle.closed` after `close()` or `drain()`\nto prove every owned transport ended, including the callout, replaced readiness readers and\nshort-lived clients. The inventory is a detached snapshot.\n\nA transport close failure now rejects with its connection label. The terminal signal stays pending\nwhile any connection remains live. Repair the failure and retry `close()` before awaiting\n`handle.closed`. Closing one hosted context does not close another account's context.\n\nRead a space's claim with `readPlaneClaim(kv, space)` on that account's leader-only auth bucket.\nAn unclaimed space returns `undefined`; held and released rows retain their claim identity.\nDeleted, malformed and foreign-space rows refuse. `PlaneClaimRow` and `PLANE_CLAIM_KEY` are exported.\n\nUse `observeAccountLivenessWithCreds({ servers, observerCreds, accountId, options })` with the\naccount-scoped membership-observer credential to list that account's connections. It never widens\ncredentials or evicts connections. Zero rows prove absence only with a complete sweep and the\nsingle-server proof. An embedded endpoint's trusted composition can retain transport custody\nthrough `EndpointOptions.onConnection`.\n\n## Unreleased\n\nOn a per-user-auth mesh, a spawn-scoped caller can arm the event plane of a child under its own\nowner without `admin`. This fixes owned spawns in spaces whose registration policy requires the\nplane. Upgrade the manager to pick up the admission change. No credential or state migration is\nneeded. Cross-owner arming still requires `admin`, and the child's own-channel rule and ledger\nenvelope are unchanged. A silent non-owner caller in a space without the policy still has the\nplane disarmed, with a notice if provisioning succeeds.\n\n## Hosted auth plane identity in the store (unreleased)\n\nA hosted context started through `startAuthService` keeps its auth plane instance identity in the\ninjected `SecretStore` under `authInstanceKey(space)`, with the other auth secret kinds. It used to\nsit under `stateDir` at `.cotal/space.<hex>/auth-instance.json`, though `stateDir` holds non-secret\nstate and the record holds the plane's private serve seed. The first start of an upgraded context\nputs that record into the store, removes the file and keeps the instance. A CLI root is unchanged.\n\n### What stops working\n\nA start refuses when the store and `stateDir` hold different instance identities, and names both. A\nstore that refuses a put of the new key fails the start.\n\n### Before the upgrade\n\nLet the store accept a put of `authInstanceKey(space)`. A copy of `stateDir` taken before the upgrade\nstill holds the serve seed, so delete it or protect it as secret material.\n\n## Injected SecretStore naming in 0.76.0\n\nThe manager and the delivery daemon name an injected `SecretStore` through\n`injectedSecretStoreIdentity` from `@cotal-ai/core`. The rule is unchanged: the store's declared\nidentity, else the coordinate in `COTAL_SECRET_STORE`, else a refusal. A running mesh needs nothing.\n\n### What stops working\n\n`reloadStoreIdentityOf` from `@cotal-ai/delivery` takes `{ injected: true, store }` or\n`{ injected: false, identity }`. A call that passes `injected: true` with no store fails to compile,\nand plain JavaScript gets a `TypeError`. Both processes refuse an unnamed injected store with one\nmessage, which starts `an injected SecretStore must declare its identity`, so a log match on either\nold message no longer matches.\n\n### Before the upgrade\n\nPass the injected store to `reloadStoreIdentityOf`, or call `injectedSecretStoreIdentity(store)`.\n\n## Manager-service authority policy flag in 0.75.0\n\n`handleManagerServiceAuthority` from `@cotal-ai/auth` no longer reads `allowManagerAuthority` from\nits policy argument, and the field is removed. Both exchange faces set it to `true`, so it never\nrefused anything. A running mesh needs nothing: the public listener serves\n`POST /manager-service-authority` as it did before, and the docs now list that route.\n\n### What stops working\n\nA host that serves the route itself and passes a policy object literal with\n`allowManagerAuthority` fails to compile with TS2353. Plain JavaScript that set it to `false` got a\n403 from the handler, and its requests now reach the capability and IdP checks.\n\n### Before the upgrade\n\nRemove `allowManagerAuthority` from the policy you pass. Where you set it to `false`, leave the\nroute out of that listener's route table instead.\n\n## Hermes model from the environment in 0.68.0\n\nA connector now launches on the model and variant its launcher resolved (the `--model` or\n`--variant` flag, else the agent file's `model:` or `variant:`) and no longer reads them again from\nthe agent file. The Hermes connector also no longer takes a model from `HERMES_MODEL` in the\nenvironment of the process that spawns the seat, including when `spawn.env` lists it.\n\n### What stops working\n\nA Hermes spawn whose only model was `HERMES_MODEL` in the spawning environment is refused at launch,\nand the refusal names both ways to set a model. Spawns that set `--model` or `model:` are unchanged,\non every connector.\n\nCode that calls a connector's `buildLaunch` directly with only `configPath` now gets no model or\nvariant from that file. Pass them as `model` and `variant`.\n\n### Before the upgrade\n\nMove each Hermes seat's model from `HERMES_MODEL` onto its spawn with `--model`, or into its\npersona's `model:`.\n\n## Run answers on a participant manager in 0.68.0\n\nA participant manager now asks its issuing host for an answering credential by naming the run and\nstep it answers. The host reads the pause's token off that run's journal and no longer accepts a\ntoken from the manager. Runs on a mesh with no participant manager are unaffected.\n\n### What stops working\n\nWhile a participant manager and its issuing host run different sides of this release, the host\nrefuses every `cotal run answer` and every amendment that manager serves, because each side refuses\nthe other's request shape. Starting, resuming and reading runs is unchanged. A pause stays waiting\nthrough the window, or follows its timeout if it has one.\n\n### Before the upgrade\n\nUpgrade the auth service and every participant manager registered with it in the same window, then\nanswer the pauses that waited.\n\n## Headless OpenCode handshake in 0.69.0\n\nWith `COTAL_SERVE_HEADLESS=1`, the OpenCode launcher's `[cotal-serve]` line on stdout now carries\nonly `port` and `session`. The server password no longer appears in it, and the 1.x TUI no longer\nreceives the password on its command line.\n\n### What stops working\n\nA headless host that read `password` from that line has no password, and the server refuses its\nrequests. Seats with a TUI, and headless seats that no host drives, are unaffected.\n\n### Before the upgrade\n\nHave each headless host mint a password and pass it to the launcher as `OPENCODE_SERVER_PASSWORD`,\nthen use it for basic auth as before. Without that variable the launcher mints its own.\n\n## Filesystem store identity in 0.69.0\n\nThe delivery daemon's answer to the manager's store check now names a filesystem store by its root\nand by a random `id` that the store records once in `store.id` inside its own directory:\n`.cotal/store.id` for a workspace root, or the directory of the file for `cotal deliver --creds\n<file>`. A manager no longer counts the daemon's store as its own because the two roots have the\nsame path. On a split whose broker host and manager host use one root path, the manager host now\nstays off the daemon-credential renewal lease, so `cotal doctor auth --fix` on the broker host can\nrenew the daemon credentials.\n\n### What stops working\n\nA manager and a delivery daemon on different sides of this release refuse each other's answer to\nthe store check. The manager then remints no daemon credential, and a manager that is booting does\nnot start. This is read from the code and was not measured across two releases. A\n`cotal deliver --creds <file>` whose directory is a read-only mount and holds no `store.id` stops at\nstart. So does a `--creds` file that is its directory's `store.id` under any name, and a `store.id`\nthat is a symbolic link or holds anything but a lowercase UUID.\n\n### Before the upgrade\n\nUpgrade the broker host and every manager host of a space in the same window. For a `--creds` file\non a read-only mount, add a regular `store.id` file beside it that holds a new lowercase UUID and no newline,\nas `node -e 'process.stdout.write(crypto.randomUUID())' > store.id` writes. Move a `--creds` file\nnamed or linked as `store.id` to a file of its own.\n\n## Detached spawns with `--share-tools` in 0.69.0\n\nThe manager's `spawn` operation now takes `shareTools` as a list of MCP server names. The CLI parses\n`--share-tools` into that list before it sends the request, and the manager cluster document moves\nto revision 22. A cut taken with `cotal down --preserve-state` before the upgrade still resumes: the\nmanager reads its `cotal-manager-resume/v1` inventory and writes new cuts as\n`cotal-manager-resume/v2`.\n\n### What stops working\n\nA CLI and a manager on different sides of this release refuse a detached spawn that passes\n`--share-tools`, because the CLI checks each request against the contract the manager serves. This\nis read from the code and was not measured across two releases. A detached spawn without the flag,\na foreground spawn and a roster entry are unaffected. A manager older than this release cannot\nresume a cut that this release took.\n\n### Before the upgrade\n\nUpgrade the CLI on every host that runs `cotal spawn --detach` in the same window as the managers\nit reaches.\n\n## Shared MCP server checks in 0.69.0\n\nThe cotal config reader now checks each server under `connectors.<name>.mcpServers` when it reads\nthe file, and refuses one that cannot launch as written, naming the file and the field. The rules\nare in [the config file](config.md#the-config-file).\n\n### What stops working\n\nA config file that holds such a server refuses every Claude spawn that reads it, including one with\n`--share-tools none`. Before, a field of the wrong type failed each Claude spawn that shared the\nserver with a `TypeError` that named neither the file nor the server, a spawn that did not share it\nlaunched, and a server with no `command` or `url` was passed to `claude`, which never started it.\nRead from the code and not measured: spawns on other connectors, a manager resume and the step of\n`cotal setup` that records the shared list read the same files, so each stops at the same refusal.\n\n### Before the upgrade\n\nCheck `connectors.<name>.mcpServers` in the operator-level config file and in each space's\n`.cotal/config.json`. Give each server a string `command`, or a `type` of `http`, `sse` or `ws` with\na string `url`. Write `args` as a list of strings and `env` and `headers` as objects of strings, or\nremove the server.\n\n## Remote manager family eviction in 0.69.0\n\nA remote manager registered through its host now asks the host to evict up to 256 holders of its\ncredential family in one maintenance request, and the host reads the family once for the whole set.\nBefore, a restart sent one request per holder and the host read the whole family for each one.\nMeshes with no remote manager are unaffected.\n\n### What stops working\n\nWhile a remote manager and its issuing host run different sides of this release, each side refuses\nthe other's eviction request shape. A restart whose credential family already has holders then fails\nat its eviction step and leaves the manager's registration gate frozen. A first start, a clean stop\nand the host's reconciliation of a foreign slot holder are unchanged.\n\n### Before the upgrade\n\nUpgrade the auth service and every remote manager registered with it in the same window. A manager\nthat restarted inside the window resumes its frozen registration on its next start once both sides\nrun this release.\n\n## AG-UI emitter holder hooks in 0.69.0\n\n`AguiEmitterHolder` from `@cotal-ai/connector-core` now takes its hooks as one named object after\nthe emitter factory: `new AguiEmitterHolder(startEmitter, { onError, onRunClosed, waitLive, runMeta })`.\nOnly `onError` is required. Nothing about a running mesh changes, and every shipped connector passes\nits hooks by name. Only a connector of your own that builds a holder is affected.\n\n### What stops working\n\nA holder built with positional hooks, such as `new AguiEmitterHolder(start, onError, onRunClosed)`,\nno longer compiles, because the constructor takes two arguments. Plain JavaScript that keeps the\npositional form still runs, but the holder calls none of its hooks, so a failure never reaches\n`onError`.\n\n### Before the upgrade\n\nPass each hook by name, for example `new AguiEmitterHolder(start, { onError, onRunClosed })`, and\ndrop any `undefined` that filled an earlier slot to reach a later hook.\n\n## Worker run failure type in 0.70.0\n\n`WorkerRunFailed`, the failed result of `runInWorker` in `@cotal-ai/lang`, is now a union on\n`class`: `released`, `held`, `effect`, `too-large`, `rejected` or `error`. A running mesh needs\nnothing, because the runtime host and the engine thread ship in the same install. A run on the\ncompiled engine whose program throws an object with `code: \"L5012\"` or `code: \"L5025\"` used to end\nreleased and now ends failed, as it does on the walker.\n\n### What stops working\n\nTypeScript code that reads `code`, `reason`, `step`, `pending`, `kind`, `detail` or `tooLarge` on a\n`WorkerRunFailed` it has not narrowed fails with TS2339. A released, held, too-large or rejected\nresult no longer carries `code`, so JavaScript that branched on `L5012`, `L5025`, `L5006` or\n`L5010` stops matching with no error. `tooLarge` is gone.\n\n### Before the upgrade\n\nBranch on `class` where such code read `code`: `released` for L5012, `held` for L5025, `too-large`\nfor L5006 and `rejected` for L5010. An `effect` or `error` result keeps its `code`. Once narrowed to\n`too-large`, a result carries the `stepKey`, `bytes` and `bound` that `tooLarge` held.\n\n## Remote manager request builder in 0.70.0\n\n`remoteManagerClient.remoteManagerAuthorityRequest` from `@cotal-ai/manager` now takes an\noperation's coordinates as one object, and `remoteManagerRegistrationProof` from `@cotal-ai/core`\ncomputes the proof from the manager's identity state instead of a request. Nothing about a running\nmesh changes: the proof digest and the request on the wire are the same, so a manager and a host on\ndifferent sides of this release still accept each other. Only code that builds remote manager\nrequests itself is affected, in TypeScript and in plain JavaScript.\n\n### What stops working\n\nA call that passes the registration proof, contract artifacts, session, retirement or transfer\nreader as positional arguments after the operation no longer compiles. A call that passes a request\nto `remoteManagerRegistrationProof` no longer compiles either, because the second argument now names\nthe lifecycle `lifecycleUid`, as the identity state does.\n\nPlain JavaScript runs both old calls without an error. The builder drops the positional coordinates,\nso the host refuses the request with `requires a sha256 registrationProof`. A proof computed from a\nrequest leaves out the lifecycle, so the host refuses a request that carries it as a proof mismatch.\n\n### Before the upgrade\n\nName the coordinates, for example\n`remoteManagerAuthorityRequest(state, \"cli\", \"retire\", { registrationProof, retirement })`.\nCompute the proof as `remoteManagerRegistrationProof(owner, state)`, adding the contract artifacts\nas a third argument for activation only. A host that recomputes the proof from a received request\npasses `{ space, instanceId, lifecycleUid: managerLifecycleUid, identities }` from that request.\n\n## Bearer validator lifetime cap in 0.70.0\n\n`validateUserToken` from `@cotal-ai/auth` no longer takes `maxTtlSec`. It caps a bearer's lifetime\nat the cap of the bearer's view, the same cap the issuer applies when it mints: 900 seconds, or 300\nfor a `transfer-writer` bearer. The auth callout never passed the option, so a running mesh behaves\nas before. Only code of your own that calls the validator with `maxTtlSec` is affected.\n\n### What stops working\n\nA call that passes `maxTtlSec` in an object literal no longer compiles. Plain JavaScript that keeps\nit still runs, and the value is ignored. A `NaN` value, such as `Number()` of an unset environment\nvariable, used to turn the lifetime check off and accept a bearer of any lifetime. That bearer is\nnow refused at its view's cap.\n\n### Before the upgrade\n\nRemove `maxTtlSec` from each call. A test that needs a bearer to expire sooner mints one with a\nshorter lifetime.\n\n## Persisted identity records in 0.70.0\n\nThe manager instance identity, the manager sibling identities, the auth plane instance identity and\na participant manager's remote authority state now share one reader and one first mint in\n`@cotal-ai/workspace`, exported as `claimIdentityRecord` with the nkey check `identityOf`. Each\nrecord is read as a regular file, must hold non-empty nkeys and is created exclusively, so\nconcurrent first starts of a participant manager on one root now settle on one identity where each\nused to keep its own. `saveManagerInstanceIdentity` and `saveAuthInstanceIdentity` are gone. A\nrunning mesh whose records are plain files needs nothing.\n\n### What stops working\n\nA manager instance, auth instance or remote authority record that is a symlink, a directory or any\nother non-regular entry is refused where it used to be followed. The manager, the auth plane and a\nparticipant manager fail to start on it, and `cotal reconcile-gate` and `cotal deregister-instance`\nrefuse it. Retirement already refused it. A remote authority record with an empty nkey id or seed\nis refused too. A first mint that loses its race and cannot read the winner now refuses with\n`identity-record-create-lost` in place of `manager-instance-identity-create-lost` or\n`auth-instance-identity-create-lost`. Code that imports either `save` function no longer compiles.\n\n### Before the upgrade\n\nReplace a symlinked identity record with a copy of the file it points to. Code that wrote a record\nwith a `save` function plants it with `createManagerInstanceIdentity` or\n`createAuthInstanceIdentity`, which create the record when it is absent and otherwise return the\nstored one unchanged. Nothing replaces an overwrite of a stored identity.\n\n## Manager instance in user credentials in 0.70.0\n\n`AuthProvider.userCredentials` from `@cotal-ai/core` no longer returns `managerInstanceId`. A\n`manager-caller` credential's manager instance is the signed `act.managerInstanceId` claim in its\nbearer, which the broker verifies and the CLI already used. The reference provider in\n`@cotal-ai/auth` stops copying the exchange response's field into its result, where nothing\ncompared it with the bearer. The exchange still answers with the field, so a running mesh behaves as\nbefore.\n\n### What stops working\n\nCode of your own that reads `managerInstanceId` from a `userCredentials` result no longer compiles,\nand plain JavaScript reads `undefined` there.\n\n### Before the upgrade\n\nRead the instance from the bearer's `act.managerInstanceId` claim.\n\n## Auth plane identity location in 0.70.0\n\nThe user-auth service keeps its instance identity in the root's `.cotal/space.<hex>/auth-instance.json`,\nbeside the manager's. It used to sit inside `.cotal/auth`, at\n`space.<hex>/.cotal/auth/auth-instance.<hex>.json`, so a copy of that folder carried it. The first\nstart of an upgraded root moves the record and keeps the instance. A hosted context started through\n`startAuthService` has its record moved the same way inside its `stateDir`.\n\n### What stops working\n\nCode that calls `openAuthAuthorityPlane` without the new `identityRoot` option no longer compiles. A\nstart that finds a record both in `.cotal/space.<hex>/` and at its older place refuses and names the\ntwo files. A start also refuses when the older place of the auth or manager identity holds a symlink,\na directory or anything else that is not a regular file. The manager used to skip a dangling symlink\nthere and mint a new identity.\n\n### Before the upgrade\n\nPass `identityRoot` to `openAuthAuthorityPlane`. When `dir` is a workspace root's user-auth state\ndir, `<root>/.cotal/auth/space.<hex>`, pass that root. A plane with no workspace root, as\n`startAuthService` runs, passes `dir` itself. Either keeps the identity the plane already has: on the\nfirst start it moves from `<dir>/.cotal/auth/` to `<identityRoot>/.cotal/space.<hex>/`. Never pass a\ndirectory inside `.cotal/auth`: the record would land in the folder an operator copies and travel\nwith it again.\n\nA copy of `.cotal/auth` taken from a root last run by an older Cotal carries that root's record.\nDelete `.cotal/auth/space.<hex>/.cotal/auth/auth-instance.<hex>.json` from the root you copied it to\nbefore the first `cotal up --user-auth` there.\n\n## Per-seat `COTAL_` names in `spawn.env` in 0.71.0\n\n`spawn.env` in the cotal config no longer forwards a `COTAL_` name the launcher sets for each seat,\nsuch as `COTAL_ROLE`, `COTAL_MODEL` or `COTAL_SUBSCRIBE`. Before, a seat launched with no value of\nits own took the spawning process's value and ran under that role, model or read set. The\nmachine-wide knobs a seat already receives, such as `COTAL_HOME`, may still be listed.\n\n### What stops working\n\nEvery spawn and resume under a config whose `spawn.env` lists such a name is refused before\nlaunch, and the refusal names the entry. Code that calls `launchEnv` from `@cotal-ai/connector-core`\nwith such a name in `envAllow` gets the same error.\n\n### Before the upgrade\n\nRemove those names from `spawn.env`. Give each seat its role, model and channels with `--role`,\n`--model` and `--subscribe`, or in its persona's `role:`, `model:` and `subscribe:`.\n\n## Role addresses in 0.71.0\n\nA role must be one `[A-Za-z0-9_-]` token. Before 0.71.0 any other spelling was rewritten into one:\n` probe ` reached the `probe` queue and `pro.be` reached `pro_be`, while the message kept the\nspelling sent. An anycast to `*` was accepted and stored where no holder reads it.\n\n### What stops working\n\nAn agent whose role is outside the token set no longer starts, however it is launched:\n`cotal join --role`, `cotal spawn --role`, an agent file's `role:`, `COTAL_ROLE` and an embedded\nendpoint's `card.role` are all refused before the agent joins.\n\nA send to such a role, or to `*`, through `cotal send ask`, `/anycast` or `cotal_anycast` is refused,\nand nothing is stored.\n\n`routeToken` is no longer exported from `@cotal-ai/core`. A role routes as spelled, so code that\nused it to name a role's queue uses the role itself, and `assertValidRole` checks one.\n\n### What migrates on its own\n\nEvery task queue. A `svc_<role>` durable was always named from the rewritten token, so its pending\nrequests and its holders carry over.\n\n### Before the upgrade\n\nRename each role outside the token set to the token it already routed to: remove the surrounding\nspaces and replace every other character outside the set with `_`. Rename it where the holder is\nlaunched and in every script or prompt that sends to it.\n\n## IdP URLs on `localhost` in 0.73.0\n\nThe IdP URL that `cotal login --idp` and `cotal up --user-auth --idp` take, the JWKS URL the auth\nservice pins from it, the space-catalog link and `cotal sync` now share one rule: `https://`, or\n`http://` on a loopback IP literal. `localhost` is a name, so it no longer counts: a hosts entry\nwould choose the IdP, and with it the keys the callout trusts. Every loopback literal now passes, so\n`http://127.0.0.2/api/auth` and `http://[::ffff:127.0.0.1]/api/auth` are accepted where they were\nrefused before. A JWKS URL with any scheme other than `https:` or `http:` is refused. A mesh whose\nIdP uses HTTPS is unaffected.\n\n### What stops working\n\nOn a mesh whose IdP is pinned at `http://localhost:<port>/...`, the auth service builds its key\nresolver from the pinned JWKS URL at start, and that resolver now refuses it with\n`JWKS origin must be https (or http on a loopback IP literal for dev)`.\n\n`cotal login` and `cotal logout` with `--idp http://localhost:<port>/...` are refused with\n`idp url must be https (or http on a loopback IP literal such as 127.0.0.1 for local dev)`, and so is\nevery command that reads a session cached under that URL.\n\n### Before the upgrade\n\nUse `127.0.0.1` (or `::1`) in place of `localhost`. In the space's `idp.json` under the mesh's\n`.cotal/auth`, change `url` and `jwksUri` to the literal spelling and leave `issuer` and `audience`\nas they are. Owners derive from the issuer, so existing grants keep matching. Change a manifest's\n`broker.idp` the same way, because `cotal up` refuses an `--idp` that differs from the pin. Then\nhave each person run `cotal login --idp http://127.0.0.1:<port>/api/auth` again, because sessions\nare cached under the URL.\n\n## Terminal layout `Pane.confirm` in 0.73.0\n\n`Pane.confirm` is removed from `@cotal-ai/core`, and the tmux and cmux terminal-layout providers no\nlonger press Enter in the panes they open. The flag carried no prompt text, so both providers\npressed Enter five times, one second apart: the first dialog a pane showed was answered with its\ndefault, and a prompt that never appeared went unreported. Nothing in Cotal sets it, so a running\nmesh needs nothing. Spawned agents are unaffected, because their runtimes match the startup prompt\nby its text.\n\n### What stops working\n\nA `Pane` object literal that sets `confirm` fails to compile with TS2353. Plain JavaScript that sets\nit runs without an error, and the pane stays at its prompt.\n\n### Before the upgrade\n\nRemove `confirm` from each `Pane`. Start a command that shows a startup prompt with the option that\nskips it, or answer the prompt in the pane.\n\n## Carrying a resumed Claude session to another host in 0.67.0\n\n`cotal spawn --resume <id> --detach --on <instance>` now carries a Claude session held on the\noperator's host to the target manager instance. Both sides need this release: an older manager does\nnot serve `transcript-receive`, and the CLI then stops with that manager's refusal instead of\nlaunching. The manager cluster document moves to revision 21, and the `ps` row's `resume` object\ngains `host` and `transferredAt`.\n\nA manager host that runs carried seats needs `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN` or a\ncloud provider selection in its environment, because each carried seat runs in its own Claude home\nwith no stored login. On an authenticated mesh the CLI mints the transfer writer from the space's\nsigning seed, so the carrying host needs that seed, as for any other operator command. On a user-auth\nmesh it exchanges the operator's login for a `transfer-writer` view instead, so the operator's grant\nneeds scope `admin`, and the auth service must run this release. A remote manager receives a carry once\nits host serves the manager-service `transferReader` operation. A seat launched without carrying,\nincluding any `--resume` whose id this host does not hold, is unchanged.\n\n## Lifecycle head type in 0.67.0\n\n`LifecycleMapping`, the type `parseLifecycleHead` returns, is now a union on `state`. Nothing about\na running mesh changes: heads that parsed before parse the same way, and the refusals are\nunchanged. Only TypeScript code that compiles against `@cotal-ai/core` is affected.\n\n### What stops working\n\nAn `interface` that extends `LifecycleMapping` fails with TS2312, because an interface cannot extend\na union. Code that builds a head in memory no longer compiles when the head is `retiring` without\nits `op`, or `active` or `retired` with one. The parser already refused those heads.\n\n### Before the upgrade\n\nDeclare such an interface as an intersection instead, for example\n`type ActiveMapping = LifecycleMapping & { state: \"active\" }`. A reader that has checked\n`state === \"retiring\"` reads `op` without a guard.\n\n## Issuance gate types in 0.67.0\n\n`EpGateRow` and `EndpointGateRow`, which `parseIssuanceGate` and `parseEndpointGate` return, and\n`EpGateState`, which an `EpIssuanceGate` or `EpIssuanceBarrier` returns from `observe`, are now\nunions on `state`. Nothing about a running mesh changes: gates that parsed before parse the same\nway, and the refusals are unchanged. Only TypeScript code that compiles against `@cotal-ai/core`\nis affected.\n\n### What stops working\n\nAn `interface` that extends one of these types fails with TS2312, because an interface cannot\nextend a union. Code that builds a gate in memory, such as a custom barrier's `observe`, no longer\ncompiles when the gate is `frozen` or `retired` without its `op`, or `open` with one. The gate\nparsers already refused those rows.\n\n### Before the upgrade\n\nDeclare such an interface as an intersection instead, for example\n`type CustomGateRow = EpGateRow & { custom: string }`.\n\n## Lifecycle-blocked refusals in 0.66.0\n\nA refusal that carries `ai.cotal.ep.lifecycle-blocked` now reports only the lifecycle state it\nread. Nothing about a running mesh changes. A client that branches on the detail must read the new\nfield.\n\n### What stops working\n\nA refusal raised at the issuance gate used to carry `headState` without reading the head:\n`retiring` for a frozen gate and `retired` for a retired one. It now carries `gateState`\n(`frozen` or `retired`) and no `headState`. A client that treats `headState: \"retired\"` as a\nburned uid, or `headState: \"retiring\"` as a retirement in flight, no longer matches those\nrefusals, and the `[lifecycle ...]` suffix on the error string changes the same way. A custom\nissuance barrier whose `observe` returns a frozen gate without a valid `op` (a string `opId` and\none of the four op kinds) is now refused as `internal` by `registerServiceInstance`.\n\n### Before the upgrade\n\nUpdate such a client to read `gateState` for a gate refusal and `blockedOp` for the operation that\nholds the gate. `headState` is present only when the refusal read the head, for example an\nactivation refused because the head is still retiring.\n\n## Workflow programs that bind `once` in 0.65.0\n\n`once` is now a scope of the workflow language, so it is a reserved name. A program that declares\nits own `once` binding (`const once = ...`, a parameter or a function named `once`) is refused at\nvalidation with L2002. Nothing else about a running mesh changes.\n\n### What stops working\n\nA run whose recorded program binds `once` cannot be resumed after the upgrade, because a resume\nvalidates the recorded program again. A new `cotal run start` of such a program is refused before\nanything is recorded.\n\n### Before the upgrade\n\nList the runs with `cotal run ps` and check each program that is still running or held for a\nbinding named `once`. Let those runs finish on the old version before you upgrade the manager, and\nrename the binding in the program before you start it again.\n\n## From 0.58.0 to 0.59.0\n\nEvery connector now publishes a failed run's `RUN_ERROR` on `events.<owner>.<actor>` with the fixed\nmessage `run failed` and no `code` or `rawEvent`. The error text and error kind a harness reports\ncan echo a prompt, a peer message or tool output, and that channel has a different read ACL. A\nreader that showed the message or branched on `code` gets neither after the upgrade. Where a\nconnector reports the error kind as the agent's presence condition, that is unchanged.\n\n### Settle pending event frames before the upgrade\n\nEach session's events are frozen in its event write-ahead log before they are published. A session\nrestarted on 0.59.0 whose log still holds an unacknowledged frame with an older `RUN_ERROR` does not\nrepublish it: its event emitter halts with `egress-run-error` and publishes nothing further for that\nsession. The broker may or may not already hold that frame, so the halt cannot settle it.\n\n1. Stop the seats cleanly on 0.58.0, with the broker still up.\n2. List the logs that still hold a pending frame. The logs live under the events state root\n (`COTAL_WORKSPACE_ROOT`). Empty output means there is nothing to settle.\n\n ```sh\n find \"$COTAL_WORKSPACE_ROOT/.cotal/events\" -name wal.json \\\n -exec jq -r 'select(.pending != null) | input_filename' {} +\n ```\n\n3. For each session listed, start it again on 0.58.0 while the broker is reachable, let it recover,\n stop it, and run step 2 again. Recovery publishes the frame as 0.58.0 would have, error text\n included, so it only finishes what 0.58.0 had already started.\n\n If that start halts with `cas-loss` instead, the agent's subject is no longer at the sequence this\n log expects, and no restart settles that log, on 0.58.0 or later. A lost acknowledgement is one\n cause: the broker stored the frame, so it and its error text are already on the channel, and every\n retry halts the same way because the stream checks the frozen expectation before it deduplicates.\n The halt message names the other causes, such as a second emitter for the same agent under a\n different state root, a restored stream or frontier record, or a purged channel. With those the\n pending frame may never have reached the broker, so a `cas-loss` does not tell you whether it\n landed. Find and stop any second writer and rule out a restored state first. Clearing the halt\n then means purging the agent's event channel and removing the agent's directory under the events\n state root whole (see [Event plane](connect-claude.md#event-plane)). That abandons the pending\n frame whether or not the broker has it, and the purge also drops the earlier frames of every\n session of that agent.\n4. Upgrade once step 2 prints nothing.\n\nIf a session halts with `egress-run-error` after the upgrade, go back to step 3 for that session on\n0.58.0. Do not edit or delete `wal.json` on its own to get past either halt: clearing the pending\nframe abandons that epoch, an event the broker never received is lost, and removing part of the\ndirectory leaves a state the next start refuses.\n\n## Explicit actor grants in 0.59.0\n\n`cotal actor grant` no longer fills an omitted ACL flag with its wide default. A grant names\n`--scope`, `--allow-subscribe` and `--allow-publish`, or passes `--full` to give the ones it leaves\noff their wide defaults (`spawn,role:default`, `>` read, `>` post). Any other grant is refused. The\nbreak is in the CLI on the machine that holds the actor ledger, the one that ran\n`cotal up --user-auth --idp <url>`. No stored row, credential or wire message changes.\n\n### What keeps working\n\nExisting actor ledger rows keep the authority they were granted, and their users and agents connect\nas before. `actor revoke`, `actor list` and a `grant` that names all three ACL flags behave as they\ndid on 0.58.0. Nothing on disk is converted.\n\n### What stops working\n\nA grant that leaves off any of the three flags without `--full` exits 1 with\n`refusing to grant \"<actor>\" with --scope, --allow-subscribe, --allow-publish left off`, naming the\nflags it is missing, and then prints both accepted forms. It writes no row and does not retire the\nactor's current lifecycle. An existing row stays as it was, and an actor granted for the first time\nstays out until the grant is run again. This includes the bare grant printed on 0.58.0 by\n`cotal login`, `cotal status`, `actor list` and the not-granted refusal. Look for it in provisioning\nscripts, onboarding runbooks and anything that pastes those hints.\n\n### Upgrade order\n\nChange the scripts before the ledger machine is upgraded, and make each grant name all three flags.\n0.58.0 and 0.59.0 both accept that form. To keep a wide row, write its defaults out:\n\n```sh\ncotal actor grant <actor> --sub <IdP subject> \\\n --scope spawn,role:default --allow-subscribe '>' --allow-publish '>'\n```\n\nSwitch to `--full` only once the ledger machine runs 0.59.0. 0.58.0 refuses it with\n`Unknown option '--full'` before it reads the ledger. Brokers, managers and participant machines\nneed nothing for this break, so their order is the one the section above gives.\n\n### The window\n\nThis break has no outage. No process restarts for it, and a refused grant changes nothing. The\nexposure is a grant script that runs against 0.59.0 before it was changed: it fails and grants\nnothing.\n\n### Snapshot this first\n\nNothing is rewritten, so this break has no state to back up. On the ledger machine, save the output\nof `cotal actor list` to compare rows after the changed scripts run, and list the scripts that call\n`cotal actor grant`.\n\n### The upgrade end to end\n\n```sh\n# on the ledger machine, still on 0.58.0\ncotal actor list > actors-before.txt\ngrep -rn 'actor grant' <your provisioning scripts>\n# make every grant name --scope, --allow-subscribe and --allow-publish, run them, then upgrade\nnpm i -g cotal-ai@0.59.0\ncotal actor list | diff actors-before.txt -\n```\n\nBoth refusals quoted here were run on 0.58.0 and on the 0.59.0 code. That brokers, managers and stored\nrows need nothing is read from the change, which touches only the CLI and its hints, and was not run\non a live split deployment.\n\n## Repeated flags refused in 0.59.0\n\nA `cotal` flag given more than once is now a usage error unless the command declares it repeatable.\nOn 0.58.0 the last value won with no message, so `cotal down web --space a --space b` acted on `b`\nwhile a wrapper that checked the first `--space` verified `a`. The break is in the command-line\nparser on the machine that runs the command, including commands added with `cotal ext add`. No\nstored state, credential or wire message changes.\n\n### What keeps working\n\nA command line that gives each flag once parses as it did on 0.58.0, in any order and in the\n`--flag=value` form. Flags whose help says repeatable, such as `--opt` and `down --session-store`,\nstill collect every value. A flag-shaped word after `--` is still a positional. The daemons, units\nand agents that `cotal` starts for itself are given each flag once, so a fleet driven only by `cotal`\ncommands typed by hand needs no action.\n\n### What stops working\n\nA command line that repeats any other flag exits 1 before the command runs. It prints\n`Option '--space' cannot be repeated`, or `Option '-f, --file' cannot be repeated` for a flag with a\nshort form, followed by the command's help. `-f` and `--file` count as the same flag. Look for it in\nscripts, aliases and wrappers that append a flag to override one set earlier, such as a fixed\n`--space` followed by `\"$@\"`.\n\n### Upgrade order\n\nChange those scripts first so each flag is given once. 0.58.0 and 0.59.0 both accept that form.\nBrokers, managers and participant machines need nothing for this break, and each machine's CLI\napplies it when that machine is upgraded, so their order is the one the sections above give.\n\n### The window\n\nThis break has no outage. No process restarts for it, and a refused command does nothing. The\nexposure is a script that still repeats a flag when it runs on 0.59.0: it exits 1 instead of acting on\nthe last value.\n\n### Snapshot this first\n\nNothing is rewritten, so this break has no state to back up. List the scripts, aliases and wrappers\nthat call `cotal` so each one can be checked.\n\n### The upgrade end to end\n\n```sh\n# still on 0.58.0\ngrep -rn 'cotal ' <your scripts and wrappers>\n# give each non-repeatable flag once, then upgrade\nnpm i -g cotal-ai@0.59.0\n# run each changed script; a repeat left behind exits 1 with the usage error and does nothing\n```\n\nThe refusal and its messages were run against the 0.59.0 parser and `cotal topology view`. That the\nargument lists `cotal` builds for its own processes give each flag once is read from the code, and\nwas not run on a live split deployment.\n\n## Detached spawns from a seat's shell in 0.62.0\n\nOn a static or open mesh, `cotal spawn --detach` run inside a managed seat's shell now launches as\nthat seat. On 0.61.0 it minted a one-shot operator instrument, so the manager recorded that\ninstrument as the spawner and the seat's own `cotal_despawn` of the child was refused with\n`not authorized: <seat> was not spawned by <caller> (admin tier required)`. The break is in the CLI\non the machine where the seats run. No stored state, credential or wire message changes.\n\n### What keeps working\n\n`cotal spawn --detach` from an operator terminal or from a script outside any seat launches as\nbefore, and so does any call with `--creds`, one aimed at a space other than the seat's own, or a raw\nopen target named with `--server` and an unregistered `--space`. A user-auth mesh is unchanged. A seat with\n`capabilities: [spawn]` still spawns from its shell, and can now stop that child with\n`cotal_despawn`. `--on <instance>` from a seat's shell still lands on that manager instance, now as\nthe seat.\n\n### What stops working\n\n- On a static mesh, a seat without `capabilities: [spawn]` can no longer spawn from its shell. Its\n own credential holds no spawn subject, so the broker refuses the request and the command exits 1.\n- A child launched from a seat's shell is now that seat's child, so the manager stops it when the\n seat exits, as it does for a `cotal_spawn` child. A child that has to outlive the seat that\n started it now goes with the seat.\n- A seat launched without `COTAL_SPACE` is placed by its static credential. Every connector sets\n that variable, so this only reaches a hand-built launch: from such a seat's shell, a spawn aimed at\n a static space that holds no credential for the seat is refused instead of running as the operator.\n\n### Upgrade order\n\nOnly the CLI that seats run from their shell changes, which is the one installed on the host where\nthe seats run. Brokers and managers need nothing for this break, so their order is the one the\nsections above give.\n\n### The window\n\nThis break has no outage. No process restarts for it. A child already running when you upgrade\nkeeps the spawner the manager recorded at its launch.\n\n### Snapshot this first\n\nNothing is rewritten, so this break has no state to back up. List the agent files whose seats run\n`cotal spawn --detach` from their shell, note which of them lack `capabilities: [spawn]`, and note\nwhich of their children must outlive the seat.\n\n### The upgrade end to end\n\n```sh\n# still on 0.61.0: find the seats that spawn from their shell\ngrep -rln 'cotal spawn' .cotal/agents\n# add `capabilities: [spawn]` to each of those agent files that lacks it, and launch any child\n# that must outlive its seat from an operator terminal instead\nnpm i -g cotal-ai@0.62.0\n```\n\nThe attribution, the despawn, the refusal of a seat without `spawn`, the stop on seat exit and a\nseat's `--on` spawn were run on a local static mesh, and the attribution and the despawn on a local\nopen mesh.\n\n## From 0.53.0 to 0.54.0\n\nManager calls now borrow an instance-bound `manager-caller` credential. Followed mutations require\n`manager.goal-result` on the selected manager, so a compatible issuer, manager and client must be\nloaded together. An older manager is refused before a followed mutation; upgrading an installed\nbinary alone does not replace code in a running manager, connector or embedded client.\n\n### Preserve state before changing processes\n\nSnapshot the broker's durable storage using its supported backup procedure, the host authority and\nactor ledgers, and each participant's manager identity, runtime custody records, credentials and\nsaved sessions. Include the embedding application's database and configuration under its supported\nbackup procedure. Record the loaded package versions and the CLI path used by bearer helpers.\nKeep these copies private. Do not change the IdP issuer, regenerate manager identities, rotate agent\ncredentials or recreate tenant storage to make the upgrade pass.\n\nNo ledger, goal-history or session conversion is required for this change. Existing ordinary\nmessaging credentials retain their normal expiry rules. New manager-caller credentials are obtained\non demand from the current grant; old manager-call credentials do not gain the new view automatically.\nExisting accepted goals remain durable and must not be submitted again merely because observation\nwas interrupted. Fresh remote registration publishes its service status at the current revision and\nepoch; do not seed that status manually.\n\n### Upgrade the split deployment\n\n1. Stage one pinned 0.54.0 package set for the host and participants, including the embedding SDKs.\n Pause new manager mutations and let accepted work settle where possible before reloading processes.\n2. Upgrade the host issuer and embedding first. Keep the broker, its account identities and durable\n storage in place. Then load the matching manager release on each participating machine.\n3. Preserve active seats through the runtime's supported update path. A Linux custodial runtime may\n release and re-adopt seats within its 600-second unattended window; verify the actual runtime,\n custody records and process identities before relying on it. A legacy PTY runtime without release\n support cannot preserve active seats through a generic manager restart. Drain it at an approved\n idle window instead of signalling the manager or replacing conversations.\n4. Reload the clients and connectors through their session-preserving host controls. Refresh any\n bearer helper captured from an older immutable CLI path. A transport-only reconnect does not reload\n JavaScript. Verify authenticated instance selection, a read-only manager command and canonical\n result recovery before allowing new followed mutations.\n\nTreat the interval from issuer reload through compatible manager/client reload as a manager-control\noutage. Mixed versions can refuse discovery or commands; there is no promised rolling transition.\nOrdinary agent sessions survive only where their runtime and credentials permit it. If verification\nfails, keep mutations paused and repair forward from the preserved state rather than resetting it.\nThis release does not add host-backed enrollment or terminal release for stock participant detached\nagents; see [Remote supervised agents](run-a-mesh.md#remote-supervised-agents).\n\n## From 0.48.2 to 0.49.0\n\n0.49.0 changes how a credential's authority is recorded. A credential is no longer only a signed\nfile: it is an *issuance*, with a generation the issuer chose and durable evidence of the ceiling it\nwas granted under. The important consequence for a running deployment is not at connect time. It is\nat renewal time.\n\n### What keeps working without any action\n\n- **Existing agent credentials keep authenticating.** A credential minted under 0.48.2 is not\n revoked and is not rejected at connect. Nothing needs to be re-issued to bring the fleet back up\n after the upgrade.\n- **The channel registry survives.** Channels, their replay settings, descriptions, and usage text\n are ordinary durable state and are not rewritten by the upgrade.\n- **`cotal deliver` is still a standalone command.** Running the delivery daemon as its own process\n remains supported; it is not restricted to being a child of `cotal up`.\n- **`cotal join` keeps its flags.** In particular `--lifecycle-uid` is not new in 0.49.0. It has\n been required alongside `--creds` since well before this release, and the pairing rule did not\n change here. A scripted external join that worked under 0.48.2 works unchanged.\n\n### What does not migrate\n\n**A credential minted before 0.49.0 cannot be renewed.** Managed agent credentials carry a\n24-hour lifetime and the manager re-signs one once it passes **75%** of its life, ticking every\nquarter of the TTL so a tick always lands inside that window. When the manager reaches a credential\nthat carries no issuance, it refuses to renew it and logs the agent by name:\n\n```\n! managed cred renewal <agent>: renewManagedStaticCred: <agent> carries no issuance;\n a static credential minted before SPEC 13.15 is not renewed under an unbound generation\n - respawn the agent\n - the agent dies loud at this cred's expiry unless it is reminted\n```\n\nSo the fleet comes up fine, runs normally, and then each agent stops at its own credential's\nexpiry, within roughly a day of the upgrade, one at a time rather than together. The refusal is\ndeliberate: the renewal would otherwise have to invent a generation nobody issued, which is the\nstate the release exists to remove.\n\n**Respawn the managed agents as the last step of the upgrade.** For this particular upgrade the\nrespawn is not optional: stopping a 0.48.2 manager ends its agent processes whichever CLI you use,\nfor the reason given under the outage window below. The respawn is how they come back, and it is\nalso what mints each credential as an issuance so it renews from then on. One planned pass over the\nfleet is the whole job. Skipping it leaves agents stopped and, for any credential that survived\ninto 0.49.0 unminted, brings the renewal cliff above a day later, one agent at a time.\n\n### Credentials you minted yourself\n\n**A credential you minted with `cotal mint` is a different case, and it very likely needs\nnothing.** The distinction that matters is not the word \"static\", which covers both. It is **what\nminted the credential and who owns its renewal**. A credential the **manager** minted for an agent\nit spawned carries a lifetime and is renewed by the manager, so it is the subject of everything\nabove. A credential **you** minted with `cotal mint` and handed to an external peer is issued with\n**no expiry at all**, and no manager renews it: it is not in the sweep, so there is no renewal to\nfail. It keeps working after the upgrade, and re-minting it would mean coordinating with a third\nparty for no gain.\n\nThe manager says which one it is holding. Where a credential has no expiry to reach, the sweep\nnames it and moves on rather than refusing:\n\n```\n! managed cred renewal <agent>: credential is unbounded - not renewed\n (a pre-TTL credential stays as minted until respawn)\n```\n\nRe-mint an external peer's credential only if you want it to carry a lifetime, and at a time you\nchoose.\n\n### How to read the boot log\n\nA 0.49.0 manager starting over an existing space may print lines like:\n\n```\n verified evicted: <holder-key> (3/12)\n already verified (durable): <holder-key>\n\u2713 boot self-heal: manager/<id> registration gate reopened at generation <n>\n```\n\nThese are **not** a credential migration, and reading them as one is the most likely way to\nconclude the fleet is fine when it is not. They come from the manager repairing **one** endpoint\nregistration gate that a previous restart left frozen, and they enumerate that single gate's\ncredential-family holders as it verifies each one evicted. `already verified (durable)` on a later\nstart is the repair cursor resuming, not a credential that became durable. The repair is real and\nuseful (it is what previously needed `cotal reconcile-gate` by hand), but it says nothing about\nwhether your agent credentials carry issuances. The renewal refusal above is the signal that does.\n\n### Which side to upgrade first in a split topology\n\nMove the manager first.\n\nThe stores 0.49.0 introduces are created by the **manager** at its own boot, not by the broker.\nThey are create-or-verify and idempotent, so a 0.49.0 manager brings the space's authority stores\nup to the new shape itself, and it does so against whichever broker is answering.\n\nBeing honest about the evidence behind each direction, because they are not equally established:\n\n- **Broker-first was measured on a live 30-agent deployment** (issue #1578). Upgrading the broker\n first locks the old manager out immediately: `cotal up` re-renders the broker's generated config\n from the trust record, and after the restart the still-0.48.2 manager is refused on every\n connection with an `authentication error` naming the Nkey, continuously. That text comes from the\n broker process, not from a Cotal command, so match on its shape rather than on an exact string.\n `cotal ps` reports zero agents while\n the agent processes are still alive, because the manager has lost its view of them, not because\n they died. Upgrading the manager clears it immediately.\n- **Manager-first is reasoned from where the new stores are provisioned**, not from a measured\n fleet upgrade. It is the recommended order because the manager is the component that creates what\n 0.49.0 adds, but it has not been run end to end on a production split topology at the time of\n writing. Treat it as the better-supported order rather than a guaranteed one, and keep the\n rollback below ready either way.\n\nWhichever order you pick, **this is not a rolling upgrade**. Between the two steps the mesh is down\nand the manager cannot see its agents. Go straight through rather than pausing between them, and\nschedule it as an outage window.\n\n### What the window looks like\n\n- **The managed agent processes do not survive step 1, in either order.** This is the one place\n where the obvious reordering does not rescue you, so it is worth understanding rather than\n working around. Sparing agents on a bare manager stop is a **handshake**: a 0.49.0 manager\n publishes a capability file proving it can release its agents, and a 0.49.0 `cotal down` refuses\n the stop unless it finds one. **A 0.48.2 manager never publishes that file**, because the\n mechanism ships in the release you are installing. So the old CLI against the old manager sends a\n plain stop and takes every seat with it, and the new CLI against the old manager either refuses\n (leaving `--with-agents`, which reaps deliberately) or falls to the legacy path, warns that it\n cannot verify the manager can spare its agents, and signals it anyway.\n- **You can confirm which side you are on in one command, without stopping anything.** The flag that\n marks the newer behaviour is absent from the older CLI, and its summary line makes the difference\n plain:\n\n ```\n $ cotal down --help # on 0.48.2\n cotal down - stop the whole local stack, or name only the components to stop\n\n $ cotal down --help # on 0.49.0\n cotal down - stop the whole local stack (managed agents stay running unless --with-agents), ...\n ```\n\n If your `cotal down --help` does not mention `--with-agents`, stopping the manager stops the\n agents with it.\n- **Therefore the respawn in step 5 is mandatory recovery for this upgrade, not an optional pass.**\n It is also the step that re-mints credentials as issuances, so it is the same action either way.\n Plan the window to include it rather than treating it as cleanup.\n- The **manager's view** of them is lost while the two sides disagree, so `cotal ps` reports zero\n and control commands do not reach seats.\n- **Messages are not delivered** while the mesh is down.\n- The window is as long as it takes to restart the second component, plus the manager's own start.\n It is minutes, not hours, provided you do not stop between the steps.\n- **Nothing self-heals if you stop halfway.** The refusal is continuous until both sides match.\n\n### Snapshot this before you start\n\nTake these while the deployment is still on 0.48.2. The two `cotal` reads are live reads and must\nhappen before anything stops.\n\n- **A filesystem or volume snapshot of both containers**, if your platform offers one. This is the\n only rollback that covers every case, and it is what the reporting deployment used.\n- **`cotal backup create <dir>`**, for the durable space state, **but read the next paragraph before\n you rely on it**: on a split broker and manager topology it is very likely unavailable to you, and\n the volume snapshot above is your actual rollback.\n- **The trust records and credential directory** under `.cotal/auth` on the manager host, including\n the per-space material directory. These are what a re-mint would otherwise have to replace.\n- **A copy of the channel registry**, so you can verify it came back rather than assuming it did:\n `cotal channels list` before and after.\n- **The output of `cotal ps`**, so you know how many seats you expect to see afterwards and can tell\n a lost view from a lost agent.\n\n#### `cotal backup` on a split topology\n\n**`cotal backup create` cannot read a running stack.** It requires a completed cut, and only\n`cotal down --preserve-state` publishes one:\n\n```\n$ cotal backup create ./backup.0482\n\u2717 backup requires a completed cut; run `cotal down --preserve-state` first\n```\n\n**And `cotal down --preserve-state` requires a manager alive on the host you run it from.** It uses\nthat manager to attest that every retained child stopped, and the check is deliberately fail-closed:\na manager that is dead or merely uncertain refuses rather than preserving an unproven cut. The check\nreads a local pidfile, so a **remote** manager does not satisfy it. On a split topology the broker\nhost has no local manager, which means the documented durable-backup path is not available there.\n\n**Measured rather than assumed, at 0.48.2**: the backup refusal above is executed output. The\npreservation requirement is read from `down.ts` at the same tag, where the preserve path asks a\nmanager to prepare an inventory and then requires that manager to be locally alive before it\ncommits. The part not executed end to end is a genuine two-host split, which needs two real hosts.\n\n**What to do instead.** Use the filesystem or volume snapshot of both containers. That is the\nrollback the reporting deployment actually used, it covers the broker's durable state and the\nmanager's credential material together, and it does not depend on either component being able to\nattest for the other. If you want `cotal backup` as well, take it from a host that does have a live\nlocal manager, and understand it is a second copy rather than the primary rollback.\n\n**This looks like a product limitation rather than a documentation gap**, and it is written here as\none so an operator is not left thinking they mis-typed a command. The upgrade path for the exact\ntopology this page is addressed to cannot use the documented backup command.\n\n### The upgrade end to end\n\n```bash\n# 0. on 0.48.2, STILL RUNNING: record what you expect to see afterwards.\n# These two are live reads, so they must happen before anything stops.\ncotal channels list > channels.before\ncotal ps > ps.before\n\n# 1. manager host. READ THE NOTE BELOW THE BLOCK FIRST: this step ends the\n# managed agent processes whichever order you choose, and the respawn in\n# step 5 is how they come back. It is recovery, not tidying.\n#\n# STOP THE MANAGER WITH THE 0.48.2 CLI, BEFORE INSTALLING 0.49.0. The\n# order matters and it is not recoverable once you install: a 0.49.0\n# `down manager` REFUSES to stop a 0.48.2 manager whose pid record carries\n# a start token, which is every manager on a platform that can read one\n# (Linux can):\n# refusing bare manager stop: ... does not prove this manager can detach\n# its agents; use --with-agents or stop the agents explicitly\n# The refusal names two remedies and NEITHER clears it for this case. The\n# check reads a capability file that only a 0.49.0 manager writes; it never\n# counts agents, so stopping them first changes nothing. And `--with-agents`\n# is whole-stack only, so `down manager --with-agents` is refused by its own\n# flag rule. See #1592.\ncotal down manager # the 0.48.2 CLI, still installed.\n # 0.48.2 has no --with-agents; this\n # is the whole route. On a host that\n # runs the whole stack, the 0.49.0\n # `cotal down --with-agents` after\n # installing is the alternative.\nnpm install -g cotal-ai@0.49.0 # ONLY after the stop above\n# `supervise` RUNS IN THE FOREGROUND and holds the terminal until you stop\n# it. There is no --detach on this command. Start it under whatever keeps\n# your manager alive normally (systemd unit, container entrypoint, or a\n# second terminal), and run the remaining steps from another shell.\ncotal supervise --space <space> --server nats://<broker>:4222\n\n# 2. broker host: stop the stack.\n# NOT `--preserve-state` on a split topology: it needs a manager alive on\n# THIS host to attest its children stopped, and yours is on the other one.\n# Your rollback is the volume snapshot from \"Snapshot this before you\n# start\", not `cotal backup`.\n# See \"cotal backup on a split topology\" above.\ncotal down\n\n# 3. broker host: install 0.49.0 and start it again\nnpm install -g cotal-ai@0.49.0\n# Record the manager log's size BEFORE starting, so step 3a can tell THIS\n# boot's output from every earlier one. It must be captured here, ahead of\n# the start: taken afterwards it sits past the new line and the wait hangs.\n# `<spaceKey>` is NOT the space name. It is lowercase hex of the name's\n# UTF-8 bytes, so space `prod` is `manager.70726f64.log`. Do not guess it:\n# `cotal up` prints the real path on its launch line. Substituting the\n# plain name points at a file that does not exist, and the wait below then\n# burns its full timeout before telling you.\nLOG=.cotal/manager.<spaceKey>.log\nOFF=$( [ -f \"$LOG\" ] && wc -c < \"$LOG\" || echo 0 )\ncotal up --detach --host 0.0.0.0 --space <space> --no-manager\n\n# 3a. SPLIT TOPOLOGY ONLY: `--no-manager` above boots the broker (and the\n# delivery daemon) with NO local manager on the broker host, so there is\n# no wait-and-stop step on a current cotal-ai. The rest of this step is\n# the OLDER-host recipe, kept because the flag is refused there and that\n# refusal is your signal you are on it: without the flag the `up` also\n# starts a local manager, and you must wait for the log to show it is up,\n# then stop it, or you finish the upgrade with two managers and the one\n# you did not intend is the one nobody is watching.\n# A bare `grep -q` does NOT wait: it reads once and exits 1 immediately\n# if the line has not been written yet. Bound the wait instead, so a\n# manager that never comes up fails loudly rather than reading as ready.\n# The log is opened APPEND-ONLY, so on any host that has run a manager\n# before, this file ALREADY carries a `manager up` line from an earlier\n# boot. Grepping the whole file therefore matches instantly and waits for\n# nothing. Read only what THIS boot appended, using the $OFF captured in\n# step 3 above (before the start, which is the only point it is correct):\ntimeout 60 bash -c \\\n \"until tail -c +$((OFF+1)) \\\"$LOG\\\" | grep -q '. manager up'; do sleep 1; done\"\n# exit 0 = THIS boot logged it; exit 124 = it never did, so STOP and look.\n# This manager is 0.49.0 and publishes its own spare-capability file, so\n# the bare stop below is NOT the refusal case from step 1.\ncotal down manager # broker + delivery remain\n# On a current cotal-ai the two commands above are unnecessary (nothing\n# to wait for, nothing to stop) and `cotal down manager` simply reports\n# no manager to stop.\n\n# 4. verify the mesh is whole again before touching the fleet.\n# Do NOT compare `cotal ps` against ps.before yet: step 1 ended the agent\n# processes, so at this point it is EXPECTED to be empty, and an empty\n# `ps` is also the signature of the broker/manager mismatch described\n# above. The two are indistinguishable here, so compare what the mesh\n# itself should have carried across instead:\ncotal channels list # compare against channels.before: this SHOULD match now\ncotal ps # expect it to be EMPTY here; ps.before is the target for\n # step 5, not for this step\n\n# 5. the step that is easy to skip: respawn the managed agents so their\n# credentials are re-minted as issuances and can renew. Persona is a\n# POSITIONAL argument here, unlike `cotal stop`, which requires --name.\n# One call per agent:\ncotal spawn <persona> --detach --name <n> --space <space>\n# then the comparison step 4 could not make:\ncotal ps # NOW compare against ps.before: seat count should match\n```\n\nThe mesh is down from step 2 until step 3 finishes. That is the window. On a split topology there is\nno cut and no backup inside it, so the window is the stop, the install and the restart, nothing more.\n\n## Adding a section for a future release\n\n**Every changeset marked breaking adds a section to this page.** A release that changes what an\noperator must do, in what order, or what stops working, is not finished until the section exists.\n`scripts/upgrade-section-gate.mjs` grades a commit range for this: run it as\n`pnpm upgrade-section-gate --base <ref>` and it reds when the range carries a breaking change and\nadds no new release section. CI runs its self-test and, as a step of the `attribution` job, grades\neach pull request's own range as `HEAD^1..HEAD` over the merge snapshot it checked out. That job is\nthe only context in the branch protection rule set, so a red gate FAILS A REQUIRED CHECK AND BLOCKS\nTHE MERGE. The section is not optional and a reviewer cannot wave it through without an\nadministrator overriding branch protection. Be precise about what the check proves either\nway, because one trusted past its evidence is worse than none. It proves a section for a release\n**was written here**. It cannot prove the section is **correct**, or that it describes the break\nthat actually landed, and it cannot see a breaking change that carries no marker at all. Reviewing\nthe words remains a person's job.\n\n**Mark the break, or the gate cannot see it.** Any one of these is enough, and they are the only\nthings it reads:\n\n- a `!` before the colon in the commit subject, as in `feat(core)!: bind hosted runs to the caller`\n- a `BREAKING CHANGE:` footer in the commit body\n- a changeset in `.changeset/` declaring a `major` bump for any package\n\nThe marker must survive the squash. A `!` that lives only in a commit you squash away is not in the\nrange the gate grades, so put it in the subject that lands on `main`.\n\n**The heading is a `##` and names the release**, like `## From 0.48.2 to 0.49.0`. Both matter, and\nneither is a style preference. Coverage is claimed by a heading, so a heading that names\nno release claims every release and distinguishes none: `## Notes` with a sentence under it would\notherwise satisfy the rule. Naming the release also makes the section the one an operator upgrading\nthat release will search for. Use `###` freely for detail inside a section. Subsections belong to\ntheir release rather than counting as separate coverage.\n\nName the release that first carries the change: the next version Changesets publishes, which\n`pnpm changeset status --verbose` lists. `bin/package.json` on `main` still reads the release already\npublished. If a release is cut while the change is open, the change ships in the release after it,\nso move the heading before merging. The gate accepts any version in a heading, so before merging a\nrelease pull request, check every heading added since the previous tag against the version it\npublishes.\n\nA section is written for the operator, not for the reviewer. It answers, in this order:\n\n1. What keeps working with no action at all.\n2. What does **not** migrate, and when that becomes visible. Name the log line if there is one.\n3. The order to move components in for a split topology, and why that order.\n4. What the outage window looks like, including what survives it.\n5. What to snapshot before starting.\n6. The commands, end to end.\n\n**Where an answer was not measured, say so in the document rather than guessing.** An operator who\nknows which half of a recommendation is reasoned and which is measured can plan around it; one who\nfinds out afterwards cannot.\n"
62792
+ "body": "# Upgrading a running deployment\n\n> **Guide** (informative) \xB7 **For:** operators upgrading a mesh that already exists \xB7 **See also:** [Substrate stability](stability.md), [Run a mesh](run-a-mesh.md), [Identity and auth](identity-and-auth.md)\n\n[Substrate stability](stability.md) tells you what the version numbers promise. This page is the\nother half: what to actually do when the deployment already exists, has credentials in it, and\ncannot simply be recreated. Every release that breaks a running deployment gets a section here,\nnaming what migrates on its own, what does not, and the order to move the pieces in.\n\n## The pre-1.0 upgrade contract\n\nThe packages are pre-1.0, so a minor bump may break an API or an on-disk expectation. Four\ncommitments make that survivable for someone with a fleet:\n\n- **Pin an exact version.** `0.N.P`, never `^0.N.P`. A range can pull a breaking minor in during an\n unrelated reinstall.\n- **Every break that touches a running deployment gets a section on this page**, written in terms of\n what an operator does, not in terms of which module changed.\n- **Read the section before you start, not halfway through.** A section names the work up front\n precisely so the operation does not change shape once it is underway.\n- **A break that cannot be made automatic says so.** Where credentials or state must be recreated by\n hand, the section says which ones and when, rather than leaving you to discover it at the moment\n the first one stops working.\n- **A change to the shape of a credential, or to who may renew one, is breaking whatever the commit\n marker says.** This rule is stated because the marker is a judgement made while writing the code\n and the consequence is felt by someone running it a day later. A fleet that keeps authenticating\n looks compatible and is not, if nothing in it can renew. Any automated check of this rule would\n read commit markers, so a break recorded as a feature is the one case it could not see, which is\n why the rule is written for people first. **The marker held for this release: the 0.49.0 change\n that caused all of this, `36d177951 feat(core)!`, did carry its `!`.** The rule exists for the\n next one that does not.\n\nWhat this page does not promise is a rolling upgrade. Nothing in the current line dual-serves two\nauthority versions, so where broker and manager run separately there is a window in which the mesh\nis down. The sections below give that window's shape so it can be scheduled rather than endured.\n\n## SecretStore create-only write in 0.78.0\n\n`SecretStore` from `@cotal-ai/core` has a fourth method, `create(key, value)`. It stores the value\nonly while the key is absent and resolves `true`, or resolves `false` and leaves the stored value in\nplace. The auth secret kinds made once per space now write their first value through it: the callout\naccount, the issuer keys, the owner secret and a hosted auth plane's instance identity. Before, two\nfirst calls on one space each made a value, the later `put` replaced the earlier one, and the earlier\ncaller kept using a value the store no longer held. The filesystem store implements `create` with an\nexclusive create, so a CLI root needs nothing, and stored values do not change.\n\n### What stops working\n\nA store of your own without `create` no longer compiles as a `SecretStore`. Plain JavaScript gets a\n`TypeError` at the first call that makes one of those kinds, such as `prepareServer` on a new space\nor the first `startAuthService` of a hosted context.\n\n### Before the upgrade\n\nAdd `create` to your store, decided in one atomic step by the backend, such as a conditional create\nor an insert that fails on an existing key. A read followed by a write brings the race back.\n\n## Manager-service authority refusal budgets in 0.78.0\n\n`POST /manager-service-authority` now counts its refusals against the exchange face's budgets, as\n`POST /exchange` does. On the loopback face a missing or wrong capability counts in the\ninvalid-capability window, and a refused IdP token or request counts in the refused-exchange window.\nOn the public face a refusal counts in the peer's bucket. A running mesh needs nothing, and a\nrequest that succeeds is never throttled.\n\n### What stops working\n\n`ManagerServiceAuthorityCtx` from `@cotal-ai/auth` now requires `failures` and `badCaps`, so a host\nthat builds it in an object literal without them fails to compile. The handler now calls the\n`peerKey`, `throttled` and `recordFailure` of the policy it is given. After 30 refusals in a minute\non one face, a further refusal on either route answers 429 without its reason.\n\n### Before the upgrade\n\nPass the `failures` and `badCaps` arrays your `/exchange` route counts in, so both routes share one\nbudget per face. A host that serves only this route can pass new empty arrays.\n\n## Primitive scope kind in 0.78.0\n\n`PrimitiveSpec` from `@cotal-ai/lang` replaces `opensScope: boolean` with `scope: ScopeKind | null`,\nthe scope kind a primitive opens, in the way `kind` names the effect it journals. Both engines now\nread that kind from the table. Before, they cast the primitive's name to `ScopeKind`, so a scope row\nmissing from the union compiled and reached the journal as a kind its readers did not know. A\nrunning mesh and its journals need nothing.\n\n### What stops working\n\nCode that reads `opensScope` from `PRIMITIVES` or a `PrimitiveSpec` fails to compile, and plain\nJavaScript reads `undefined` there, so a check on it never holds.\n\n### Before the upgrade\n\nRead `spec.scope !== null` where you read `spec.opensScope`, or `spec.scope` when you need the kind.\n\n## Token verifier clock tolerance in 0.77.0\n\n`validateUserToken`, and the `IdpConfig` that `verifyIdpToken` and `createIdpBridge` take, from\n`@cotal-ai/auth` no longer accept `clockToleranceSec`. Both verifiers allow a fixed 5 seconds of\nclock skew on `exp`, `nbf` and `iat`, the default they already applied. Nothing in the repository\npassed the option, so a running mesh behaves as before. Only code of your own that passes it is\naffected.\n\n### What stops working\n\nA call or `IdpConfig` that passes `clockToleranceSec` in an object literal no longer compiles. Plain\nJavaScript that keeps it still runs, and the value is ignored. A `NaN` value, such as `Number()` of\nan unset environment variable, used to turn off the expiry, not-before and issued-at checks in both\nverifiers, so an expired or post-dated token was accepted. A large value widened those checks by\nthat many seconds. Both kinds of token are now refused.\n\n### Before the upgrade\n\nRemove `clockToleranceSec` from each call and each `IdpConfig`. A host whose clock is more than 5\nseconds off the issuer's needs its time synchronized.\n\n## Auth context closure in 0.71.0 (unreleased)\n\nExisting deployments need no credential migration or restart for these additive APIs. Embedded\nhosts can now inspect `handle.connections()` and await `handle.closed` after `close()` or `drain()`\nto prove every owned transport ended, including the callout, replaced readiness readers and\nshort-lived clients. The inventory is a detached snapshot.\n\nA transport close failure now rejects with its connection label. The terminal signal stays pending\nwhile any connection remains live. Repair the failure and retry `close()` before awaiting\n`handle.closed`. Closing one hosted context does not close another account's context.\n\nRead a space's claim with `readPlaneClaim(kv, space)` on that account's leader-only auth bucket.\nAn unclaimed space returns `undefined`; held and released rows retain their claim identity.\nDeleted, malformed and foreign-space rows refuse. `PlaneClaimRow` and `PLANE_CLAIM_KEY` are exported.\n\nUse `observeAccountLivenessWithCreds({ servers, observerCreds, accountId, options })` with the\naccount-scoped membership-observer credential to list that account's connections. It never widens\ncredentials or evicts connections. Zero rows prove absence only with a complete sweep and the\nsingle-server proof. An embedded endpoint's trusted composition can retain transport custody\nthrough `EndpointOptions.onConnection`.\n\n## Unreleased\n\nOn a per-user-auth mesh, a spawn-scoped caller can arm the event plane of a child under its own\nowner without `admin`. This fixes owned spawns in spaces whose registration policy requires the\nplane. Upgrade the manager to pick up the admission change. No credential or state migration is\nneeded. Cross-owner arming still requires `admin`, and the child's own-channel rule and ledger\nenvelope are unchanged. A silent non-owner caller in a space without the policy still has the\nplane disarmed, with a notice if provisioning succeeds.\n\n## Hosted auth plane identity in the store (unreleased)\n\nA hosted context started through `startAuthService` keeps its auth plane instance identity in the\ninjected `SecretStore` under `authInstanceKey(space)`, with the other auth secret kinds. It used to\nsit under `stateDir` at `.cotal/space.<hex>/auth-instance.json`, though `stateDir` holds non-secret\nstate and the record holds the plane's private serve seed. The first start of an upgraded context\nputs that record into the store, removes the file and keeps the instance. A CLI root is unchanged.\n\n### What stops working\n\nA start refuses when the store and `stateDir` hold different instance identities, and names both. A\nstore that refuses a put of the new key fails the start.\n\n### Before the upgrade\n\nLet the store accept a put of `authInstanceKey(space)`. A copy of `stateDir` taken before the upgrade\nstill holds the serve seed, so delete it or protect it as secret material.\n\n## Injected SecretStore naming in 0.76.0\n\nThe manager and the delivery daemon name an injected `SecretStore` through\n`injectedSecretStoreIdentity` from `@cotal-ai/core`. The rule is unchanged: the store's declared\nidentity, else the coordinate in `COTAL_SECRET_STORE`, else a refusal. A running mesh needs nothing.\n\n### What stops working\n\n`reloadStoreIdentityOf` from `@cotal-ai/delivery` takes `{ injected: true, store }` or\n`{ injected: false, identity }`. A call that passes `injected: true` with no store fails to compile,\nand plain JavaScript gets a `TypeError`. Both processes refuse an unnamed injected store with one\nmessage, which starts `an injected SecretStore must declare its identity`, so a log match on either\nold message no longer matches.\n\n### Before the upgrade\n\nPass the injected store to `reloadStoreIdentityOf`, or call `injectedSecretStoreIdentity(store)`.\n\n## Manager-service authority policy flag in 0.75.0\n\n`handleManagerServiceAuthority` from `@cotal-ai/auth` no longer reads `allowManagerAuthority` from\nits policy argument, and the field is removed. Both exchange faces set it to `true`, so it never\nrefused anything. A running mesh needs nothing: the public listener serves\n`POST /manager-service-authority` as it did before, and the docs now list that route.\n\n### What stops working\n\nA host that serves the route itself and passes a policy object literal with\n`allowManagerAuthority` fails to compile with TS2353. Plain JavaScript that set it to `false` got a\n403 from the handler, and its requests now reach the capability and IdP checks.\n\n### Before the upgrade\n\nRemove `allowManagerAuthority` from the policy you pass. Where you set it to `false`, leave the\nroute out of that listener's route table instead.\n\n## Hermes model from the environment in 0.68.0\n\nA connector now launches on the model and variant its launcher resolved (the `--model` or\n`--variant` flag, else the agent file's `model:` or `variant:`) and no longer reads them again from\nthe agent file. The Hermes connector also no longer takes a model from `HERMES_MODEL` in the\nenvironment of the process that spawns the seat, including when `spawn.env` lists it.\n\n### What stops working\n\nA Hermes spawn whose only model was `HERMES_MODEL` in the spawning environment is refused at launch,\nand the refusal names both ways to set a model. Spawns that set `--model` or `model:` are unchanged,\non every connector.\n\nCode that calls a connector's `buildLaunch` directly with only `configPath` now gets no model or\nvariant from that file. Pass them as `model` and `variant`.\n\n### Before the upgrade\n\nMove each Hermes seat's model from `HERMES_MODEL` onto its spawn with `--model`, or into its\npersona's `model:`.\n\n## Run answers on a participant manager in 0.68.0\n\nA participant manager now asks its issuing host for an answering credential by naming the run and\nstep it answers. The host reads the pause's token off that run's journal and no longer accepts a\ntoken from the manager. Runs on a mesh with no participant manager are unaffected.\n\n### What stops working\n\nWhile a participant manager and its issuing host run different sides of this release, the host\nrefuses every `cotal run answer` and every amendment that manager serves, because each side refuses\nthe other's request shape. Starting, resuming and reading runs is unchanged. A pause stays waiting\nthrough the window, or follows its timeout if it has one.\n\n### Before the upgrade\n\nUpgrade the auth service and every participant manager registered with it in the same window, then\nanswer the pauses that waited.\n\n## Headless OpenCode handshake in 0.69.0\n\nWith `COTAL_SERVE_HEADLESS=1`, the OpenCode launcher's `[cotal-serve]` line on stdout now carries\nonly `port` and `session`. The server password no longer appears in it, and the 1.x TUI no longer\nreceives the password on its command line.\n\n### What stops working\n\nA headless host that read `password` from that line has no password, and the server refuses its\nrequests. Seats with a TUI, and headless seats that no host drives, are unaffected.\n\n### Before the upgrade\n\nHave each headless host mint a password and pass it to the launcher as `OPENCODE_SERVER_PASSWORD`,\nthen use it for basic auth as before. Without that variable the launcher mints its own.\n\n## Filesystem store identity in 0.69.0\n\nThe delivery daemon's answer to the manager's store check now names a filesystem store by its root\nand by a random `id` that the store records once in `store.id` inside its own directory:\n`.cotal/store.id` for a workspace root, or the directory of the file for `cotal deliver --creds\n<file>`. A manager no longer counts the daemon's store as its own because the two roots have the\nsame path. On a split whose broker host and manager host use one root path, the manager host now\nstays off the daemon-credential renewal lease, so `cotal doctor auth --fix` on the broker host can\nrenew the daemon credentials.\n\n### What stops working\n\nA manager and a delivery daemon on different sides of this release refuse each other's answer to\nthe store check. The manager then remints no daemon credential, and a manager that is booting does\nnot start. This is read from the code and was not measured across two releases. A\n`cotal deliver --creds <file>` whose directory is a read-only mount and holds no `store.id` stops at\nstart. So does a `--creds` file that is its directory's `store.id` under any name, and a `store.id`\nthat is a symbolic link or holds anything but a lowercase UUID.\n\n### Before the upgrade\n\nUpgrade the broker host and every manager host of a space in the same window. For a `--creds` file\non a read-only mount, add a regular `store.id` file beside it that holds a new lowercase UUID and no newline,\nas `node -e 'process.stdout.write(crypto.randomUUID())' > store.id` writes. Move a `--creds` file\nnamed or linked as `store.id` to a file of its own.\n\n## Detached spawns with `--share-tools` in 0.69.0\n\nThe manager's `spawn` operation now takes `shareTools` as a list of MCP server names. The CLI parses\n`--share-tools` into that list before it sends the request, and the manager cluster document moves\nto revision 22. A cut taken with `cotal down --preserve-state` before the upgrade still resumes: the\nmanager reads its `cotal-manager-resume/v1` inventory and writes new cuts as\n`cotal-manager-resume/v2`.\n\n### What stops working\n\nA CLI and a manager on different sides of this release refuse a detached spawn that passes\n`--share-tools`, because the CLI checks each request against the contract the manager serves. This\nis read from the code and was not measured across two releases. A detached spawn without the flag,\na foreground spawn and a roster entry are unaffected. A manager older than this release cannot\nresume a cut that this release took.\n\n### Before the upgrade\n\nUpgrade the CLI on every host that runs `cotal spawn --detach` in the same window as the managers\nit reaches.\n\n## Shared MCP server checks in 0.69.0\n\nThe cotal config reader now checks each server under `connectors.<name>.mcpServers` when it reads\nthe file, and refuses one that cannot launch as written, naming the file and the field. The rules\nare in [the config file](config.md#the-config-file).\n\n### What stops working\n\nA config file that holds such a server refuses every Claude spawn that reads it, including one with\n`--share-tools none`. Before, a field of the wrong type failed each Claude spawn that shared the\nserver with a `TypeError` that named neither the file nor the server, a spawn that did not share it\nlaunched, and a server with no `command` or `url` was passed to `claude`, which never started it.\nRead from the code and not measured: spawns on other connectors, a manager resume and the step of\n`cotal setup` that records the shared list read the same files, so each stops at the same refusal.\n\n### Before the upgrade\n\nCheck `connectors.<name>.mcpServers` in the operator-level config file and in each space's\n`.cotal/config.json`. Give each server a string `command`, or a `type` of `http`, `sse` or `ws` with\na string `url`. Write `args` as a list of strings and `env` and `headers` as objects of strings, or\nremove the server.\n\n## Remote manager family eviction in 0.69.0\n\nA remote manager registered through its host now asks the host to evict up to 256 holders of its\ncredential family in one maintenance request, and the host reads the family once for the whole set.\nBefore, a restart sent one request per holder and the host read the whole family for each one.\nMeshes with no remote manager are unaffected.\n\n### What stops working\n\nWhile a remote manager and its issuing host run different sides of this release, each side refuses\nthe other's eviction request shape. A restart whose credential family already has holders then fails\nat its eviction step and leaves the manager's registration gate frozen. A first start, a clean stop\nand the host's reconciliation of a foreign slot holder are unchanged.\n\n### Before the upgrade\n\nUpgrade the auth service and every remote manager registered with it in the same window. A manager\nthat restarted inside the window resumes its frozen registration on its next start once both sides\nrun this release.\n\n## AG-UI emitter holder hooks in 0.69.0\n\n`AguiEmitterHolder` from `@cotal-ai/connector-core` now takes its hooks as one named object after\nthe emitter factory: `new AguiEmitterHolder(startEmitter, { onError, onRunClosed, waitLive, runMeta })`.\nOnly `onError` is required. Nothing about a running mesh changes, and every shipped connector passes\nits hooks by name. Only a connector of your own that builds a holder is affected.\n\n### What stops working\n\nA holder built with positional hooks, such as `new AguiEmitterHolder(start, onError, onRunClosed)`,\nno longer compiles, because the constructor takes two arguments. Plain JavaScript that keeps the\npositional form still runs, but the holder calls none of its hooks, so a failure never reaches\n`onError`.\n\n### Before the upgrade\n\nPass each hook by name, for example `new AguiEmitterHolder(start, { onError, onRunClosed })`, and\ndrop any `undefined` that filled an earlier slot to reach a later hook.\n\n## Worker run failure type in 0.70.0\n\n`WorkerRunFailed`, the failed result of `runInWorker` in `@cotal-ai/lang`, is now a union on\n`class`: `released`, `held`, `effect`, `too-large`, `rejected` or `error`. A running mesh needs\nnothing, because the runtime host and the engine thread ship in the same install. A run on the\ncompiled engine whose program throws an object with `code: \"L5012\"` or `code: \"L5025\"` used to end\nreleased and now ends failed, as it does on the walker.\n\n### What stops working\n\nTypeScript code that reads `code`, `reason`, `step`, `pending`, `kind`, `detail` or `tooLarge` on a\n`WorkerRunFailed` it has not narrowed fails with TS2339. A released, held, too-large or rejected\nresult no longer carries `code`, so JavaScript that branched on `L5012`, `L5025`, `L5006` or\n`L5010` stops matching with no error. `tooLarge` is gone.\n\n### Before the upgrade\n\nBranch on `class` where such code read `code`: `released` for L5012, `held` for L5025, `too-large`\nfor L5006 and `rejected` for L5010. An `effect` or `error` result keeps its `code`. Once narrowed to\n`too-large`, a result carries the `stepKey`, `bytes` and `bound` that `tooLarge` held.\n\n## Remote manager request builder in 0.70.0\n\n`remoteManagerClient.remoteManagerAuthorityRequest` from `@cotal-ai/manager` now takes an\noperation's coordinates as one object, and `remoteManagerRegistrationProof` from `@cotal-ai/core`\ncomputes the proof from the manager's identity state instead of a request. Nothing about a running\nmesh changes: the proof digest and the request on the wire are the same, so a manager and a host on\ndifferent sides of this release still accept each other. Only code that builds remote manager\nrequests itself is affected, in TypeScript and in plain JavaScript.\n\n### What stops working\n\nA call that passes the registration proof, contract artifacts, session, retirement or transfer\nreader as positional arguments after the operation no longer compiles. A call that passes a request\nto `remoteManagerRegistrationProof` no longer compiles either, because the second argument now names\nthe lifecycle `lifecycleUid`, as the identity state does.\n\nPlain JavaScript runs both old calls without an error. The builder drops the positional coordinates,\nso the host refuses the request with `requires a sha256 registrationProof`. A proof computed from a\nrequest leaves out the lifecycle, so the host refuses a request that carries it as a proof mismatch.\n\n### Before the upgrade\n\nName the coordinates, for example\n`remoteManagerAuthorityRequest(state, \"cli\", \"retire\", { registrationProof, retirement })`.\nCompute the proof as `remoteManagerRegistrationProof(owner, state)`, adding the contract artifacts\nas a third argument for activation only. A host that recomputes the proof from a received request\npasses `{ space, instanceId, lifecycleUid: managerLifecycleUid, identities }` from that request.\n\n## Bearer validator lifetime cap in 0.70.0\n\n`validateUserToken` from `@cotal-ai/auth` no longer takes `maxTtlSec`. It caps a bearer's lifetime\nat the cap of the bearer's view, the same cap the issuer applies when it mints: 900 seconds, or 300\nfor a `transfer-writer` bearer. The auth callout never passed the option, so a running mesh behaves\nas before. Only code of your own that calls the validator with `maxTtlSec` is affected.\n\n### What stops working\n\nA call that passes `maxTtlSec` in an object literal no longer compiles. Plain JavaScript that keeps\nit still runs, and the value is ignored. A `NaN` value, such as `Number()` of an unset environment\nvariable, used to turn the lifetime check off and accept a bearer of any lifetime. That bearer is\nnow refused at its view's cap.\n\n### Before the upgrade\n\nRemove `maxTtlSec` from each call. A test that needs a bearer to expire sooner mints one with a\nshorter lifetime.\n\n## Persisted identity records in 0.70.0\n\nThe manager instance identity, the manager sibling identities, the auth plane instance identity and\na participant manager's remote authority state now share one reader and one first mint in\n`@cotal-ai/workspace`, exported as `claimIdentityRecord` with the nkey check `identityOf`. Each\nrecord is read as a regular file, must hold non-empty nkeys and is created exclusively, so\nconcurrent first starts of a participant manager on one root now settle on one identity where each\nused to keep its own. `saveManagerInstanceIdentity` and `saveAuthInstanceIdentity` are gone. A\nrunning mesh whose records are plain files needs nothing.\n\n### What stops working\n\nA manager instance, auth instance or remote authority record that is a symlink, a directory or any\nother non-regular entry is refused where it used to be followed. The manager, the auth plane and a\nparticipant manager fail to start on it, and `cotal reconcile-gate` and `cotal deregister-instance`\nrefuse it. Retirement already refused it. A remote authority record with an empty nkey id or seed\nis refused too. A first mint that loses its race and cannot read the winner now refuses with\n`identity-record-create-lost` in place of `manager-instance-identity-create-lost` or\n`auth-instance-identity-create-lost`. Code that imports either `save` function no longer compiles.\n\n### Before the upgrade\n\nReplace a symlinked identity record with a copy of the file it points to. Code that wrote a record\nwith a `save` function plants it with `createManagerInstanceIdentity` or\n`createAuthInstanceIdentity`, which create the record when it is absent and otherwise return the\nstored one unchanged. Nothing replaces an overwrite of a stored identity.\n\n## Manager instance in user credentials in 0.70.0\n\n`AuthProvider.userCredentials` from `@cotal-ai/core` no longer returns `managerInstanceId`. A\n`manager-caller` credential's manager instance is the signed `act.managerInstanceId` claim in its\nbearer, which the broker verifies and the CLI already used. The reference provider in\n`@cotal-ai/auth` stops copying the exchange response's field into its result, where nothing\ncompared it with the bearer. The exchange still answers with the field, so a running mesh behaves as\nbefore.\n\n### What stops working\n\nCode of your own that reads `managerInstanceId` from a `userCredentials` result no longer compiles,\nand plain JavaScript reads `undefined` there.\n\n### Before the upgrade\n\nRead the instance from the bearer's `act.managerInstanceId` claim.\n\n## Auth plane identity location in 0.70.0\n\nThe user-auth service keeps its instance identity in the root's `.cotal/space.<hex>/auth-instance.json`,\nbeside the manager's. It used to sit inside `.cotal/auth`, at\n`space.<hex>/.cotal/auth/auth-instance.<hex>.json`, so a copy of that folder carried it. The first\nstart of an upgraded root moves the record and keeps the instance. A hosted context started through\n`startAuthService` has its record moved the same way inside its `stateDir`.\n\n### What stops working\n\nCode that calls `openAuthAuthorityPlane` without the new `identityRoot` option no longer compiles. A\nstart that finds a record both in `.cotal/space.<hex>/` and at its older place refuses and names the\ntwo files. A start also refuses when the older place of the auth or manager identity holds a symlink,\na directory or anything else that is not a regular file. The manager used to skip a dangling symlink\nthere and mint a new identity.\n\n### Before the upgrade\n\nPass `identityRoot` to `openAuthAuthorityPlane`. When `dir` is a workspace root's user-auth state\ndir, `<root>/.cotal/auth/space.<hex>`, pass that root. A plane with no workspace root, as\n`startAuthService` runs, passes `dir` itself. Either keeps the identity the plane already has: on the\nfirst start it moves from `<dir>/.cotal/auth/` to `<identityRoot>/.cotal/space.<hex>/`. Never pass a\ndirectory inside `.cotal/auth`: the record would land in the folder an operator copies and travel\nwith it again.\n\nA copy of `.cotal/auth` taken from a root last run by an older Cotal carries that root's record.\nDelete `.cotal/auth/space.<hex>/.cotal/auth/auth-instance.<hex>.json` from the root you copied it to\nbefore the first `cotal up --user-auth` there.\n\n## Per-seat `COTAL_` names in `spawn.env` in 0.71.0\n\n`spawn.env` in the cotal config no longer forwards a `COTAL_` name the launcher sets for each seat,\nsuch as `COTAL_ROLE`, `COTAL_MODEL` or `COTAL_SUBSCRIBE`. Before, a seat launched with no value of\nits own took the spawning process's value and ran under that role, model or read set. The\nmachine-wide knobs a seat already receives, such as `COTAL_HOME`, may still be listed.\n\n### What stops working\n\nEvery spawn and resume under a config whose `spawn.env` lists such a name is refused before\nlaunch, and the refusal names the entry. Code that calls `launchEnv` from `@cotal-ai/connector-core`\nwith such a name in `envAllow` gets the same error.\n\n### Before the upgrade\n\nRemove those names from `spawn.env`. Give each seat its role, model and channels with `--role`,\n`--model` and `--subscribe`, or in its persona's `role:`, `model:` and `subscribe:`.\n\n## Role addresses in 0.71.0\n\nA role must be one `[A-Za-z0-9_-]` token. Before 0.71.0 any other spelling was rewritten into one:\n` probe ` reached the `probe` queue and `pro.be` reached `pro_be`, while the message kept the\nspelling sent. An anycast to `*` was accepted and stored where no holder reads it.\n\n### What stops working\n\nAn agent whose role is outside the token set no longer starts, however it is launched:\n`cotal join --role`, `cotal spawn --role`, an agent file's `role:`, `COTAL_ROLE` and an embedded\nendpoint's `card.role` are all refused before the agent joins.\n\nA send to such a role, or to `*`, through `cotal send ask`, `/anycast` or `cotal_anycast` is refused,\nand nothing is stored.\n\n`routeToken` is no longer exported from `@cotal-ai/core`. A role routes as spelled, so code that\nused it to name a role's queue uses the role itself, and `assertValidRole` checks one.\n\n### What migrates on its own\n\nEvery task queue. A `svc_<role>` durable was always named from the rewritten token, so its pending\nrequests and its holders carry over.\n\n### Before the upgrade\n\nRename each role outside the token set to the token it already routed to: remove the surrounding\nspaces and replace every other character outside the set with `_`. Rename it where the holder is\nlaunched and in every script or prompt that sends to it.\n\n## IdP URLs on `localhost` in 0.73.0\n\nThe IdP URL that `cotal login --idp` and `cotal up --user-auth --idp` take, the JWKS URL the auth\nservice pins from it, the space-catalog link and `cotal sync` now share one rule: `https://`, or\n`http://` on a loopback IP literal. `localhost` is a name, so it no longer counts: a hosts entry\nwould choose the IdP, and with it the keys the callout trusts. Every loopback literal now passes, so\n`http://127.0.0.2/api/auth` and `http://[::ffff:127.0.0.1]/api/auth` are accepted where they were\nrefused before. A JWKS URL with any scheme other than `https:` or `http:` is refused. A mesh whose\nIdP uses HTTPS is unaffected.\n\n### What stops working\n\nOn a mesh whose IdP is pinned at `http://localhost:<port>/...`, the auth service builds its key\nresolver from the pinned JWKS URL at start, and that resolver now refuses it with\n`JWKS origin must be https (or http on a loopback IP literal for dev)`.\n\n`cotal login` and `cotal logout` with `--idp http://localhost:<port>/...` are refused with\n`idp url must be https (or http on a loopback IP literal such as 127.0.0.1 for local dev)`, and so is\nevery command that reads a session cached under that URL.\n\n### Before the upgrade\n\nUse `127.0.0.1` (or `::1`) in place of `localhost`. In the space's `idp.json` under the mesh's\n`.cotal/auth`, change `url` and `jwksUri` to the literal spelling and leave `issuer` and `audience`\nas they are. Owners derive from the issuer, so existing grants keep matching. Change a manifest's\n`broker.idp` the same way, because `cotal up` refuses an `--idp` that differs from the pin. Then\nhave each person run `cotal login --idp http://127.0.0.1:<port>/api/auth` again, because sessions\nare cached under the URL.\n\n## Terminal layout `Pane.confirm` in 0.73.0\n\n`Pane.confirm` is removed from `@cotal-ai/core`, and the tmux and cmux terminal-layout providers no\nlonger press Enter in the panes they open. The flag carried no prompt text, so both providers\npressed Enter five times, one second apart: the first dialog a pane showed was answered with its\ndefault, and a prompt that never appeared went unreported. Nothing in Cotal sets it, so a running\nmesh needs nothing. Spawned agents are unaffected, because their runtimes match the startup prompt\nby its text.\n\n### What stops working\n\nA `Pane` object literal that sets `confirm` fails to compile with TS2353. Plain JavaScript that sets\nit runs without an error, and the pane stays at its prompt.\n\n### Before the upgrade\n\nRemove `confirm` from each `Pane`. Start a command that shows a startup prompt with the option that\nskips it, or answer the prompt in the pane.\n\n## Carrying a resumed Claude session to another host in 0.67.0\n\n`cotal spawn --resume <id> --detach --on <instance>` now carries a Claude session held on the\noperator's host to the target manager instance. Both sides need this release: an older manager does\nnot serve `transcript-receive`, and the CLI then stops with that manager's refusal instead of\nlaunching. The manager cluster document moves to revision 21, and the `ps` row's `resume` object\ngains `host` and `transferredAt`.\n\nA manager host that runs carried seats needs `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN` or a\ncloud provider selection in its environment, because each carried seat runs in its own Claude home\nwith no stored login. On an authenticated mesh the CLI mints the transfer writer from the space's\nsigning seed, so the carrying host needs that seed, as for any other operator command. On a user-auth\nmesh it exchanges the operator's login for a `transfer-writer` view instead, so the operator's grant\nneeds scope `admin`, and the auth service must run this release. A remote manager receives a carry once\nits host serves the manager-service `transferReader` operation. A seat launched without carrying,\nincluding any `--resume` whose id this host does not hold, is unchanged.\n\n## Lifecycle head type in 0.67.0\n\n`LifecycleMapping`, the type `parseLifecycleHead` returns, is now a union on `state`. Nothing about\na running mesh changes: heads that parsed before parse the same way, and the refusals are\nunchanged. Only TypeScript code that compiles against `@cotal-ai/core` is affected.\n\n### What stops working\n\nAn `interface` that extends `LifecycleMapping` fails with TS2312, because an interface cannot extend\na union. Code that builds a head in memory no longer compiles when the head is `retiring` without\nits `op`, or `active` or `retired` with one. The parser already refused those heads.\n\n### Before the upgrade\n\nDeclare such an interface as an intersection instead, for example\n`type ActiveMapping = LifecycleMapping & { state: \"active\" }`. A reader that has checked\n`state === \"retiring\"` reads `op` without a guard.\n\n## Issuance gate types in 0.67.0\n\n`EpGateRow` and `EndpointGateRow`, which `parseIssuanceGate` and `parseEndpointGate` return, and\n`EpGateState`, which an `EpIssuanceGate` or `EpIssuanceBarrier` returns from `observe`, are now\nunions on `state`. Nothing about a running mesh changes: gates that parsed before parse the same\nway, and the refusals are unchanged. Only TypeScript code that compiles against `@cotal-ai/core`\nis affected.\n\n### What stops working\n\nAn `interface` that extends one of these types fails with TS2312, because an interface cannot\nextend a union. Code that builds a gate in memory, such as a custom barrier's `observe`, no longer\ncompiles when the gate is `frozen` or `retired` without its `op`, or `open` with one. The gate\nparsers already refused those rows.\n\n### Before the upgrade\n\nDeclare such an interface as an intersection instead, for example\n`type CustomGateRow = EpGateRow & { custom: string }`.\n\n## Lifecycle-blocked refusals in 0.66.0\n\nA refusal that carries `ai.cotal.ep.lifecycle-blocked` now reports only the lifecycle state it\nread. Nothing about a running mesh changes. A client that branches on the detail must read the new\nfield.\n\n### What stops working\n\nA refusal raised at the issuance gate used to carry `headState` without reading the head:\n`retiring` for a frozen gate and `retired` for a retired one. It now carries `gateState`\n(`frozen` or `retired`) and no `headState`. A client that treats `headState: \"retired\"` as a\nburned uid, or `headState: \"retiring\"` as a retirement in flight, no longer matches those\nrefusals, and the `[lifecycle ...]` suffix on the error string changes the same way. A custom\nissuance barrier whose `observe` returns a frozen gate without a valid `op` (a string `opId` and\none of the four op kinds) is now refused as `internal` by `registerServiceInstance`.\n\n### Before the upgrade\n\nUpdate such a client to read `gateState` for a gate refusal and `blockedOp` for the operation that\nholds the gate. `headState` is present only when the refusal read the head, for example an\nactivation refused because the head is still retiring.\n\n## Workflow programs that bind `once` in 0.65.0\n\n`once` is now a scope of the workflow language, so it is a reserved name. A program that declares\nits own `once` binding (`const once = ...`, a parameter or a function named `once`) is refused at\nvalidation with L2002. Nothing else about a running mesh changes.\n\n### What stops working\n\nA run whose recorded program binds `once` cannot be resumed after the upgrade, because a resume\nvalidates the recorded program again. A new `cotal run start` of such a program is refused before\nanything is recorded.\n\n### Before the upgrade\n\nList the runs with `cotal run ps` and check each program that is still running or held for a\nbinding named `once`. Let those runs finish on the old version before you upgrade the manager, and\nrename the binding in the program before you start it again.\n\n## From 0.58.0 to 0.59.0\n\nEvery connector now publishes a failed run's `RUN_ERROR` on `events.<owner>.<actor>` with the fixed\nmessage `run failed` and no `code` or `rawEvent`. The error text and error kind a harness reports\ncan echo a prompt, a peer message or tool output, and that channel has a different read ACL. A\nreader that showed the message or branched on `code` gets neither after the upgrade. Where a\nconnector reports the error kind as the agent's presence condition, that is unchanged.\n\n### Settle pending event frames before the upgrade\n\nEach session's events are frozen in its event write-ahead log before they are published. A session\nrestarted on 0.59.0 whose log still holds an unacknowledged frame with an older `RUN_ERROR` does not\nrepublish it: its event emitter halts with `egress-run-error` and publishes nothing further for that\nsession. The broker may or may not already hold that frame, so the halt cannot settle it.\n\n1. Stop the seats cleanly on 0.58.0, with the broker still up.\n2. List the logs that still hold a pending frame. The logs live under the events state root\n (`COTAL_WORKSPACE_ROOT`). Empty output means there is nothing to settle.\n\n ```sh\n find \"$COTAL_WORKSPACE_ROOT/.cotal/events\" -name wal.json \\\n -exec jq -r 'select(.pending != null) | input_filename' {} +\n ```\n\n3. For each session listed, start it again on 0.58.0 while the broker is reachable, let it recover,\n stop it, and run step 2 again. Recovery publishes the frame as 0.58.0 would have, error text\n included, so it only finishes what 0.58.0 had already started.\n\n If that start halts with `cas-loss` instead, the agent's subject is no longer at the sequence this\n log expects, and no restart settles that log, on 0.58.0 or later. A lost acknowledgement is one\n cause: the broker stored the frame, so it and its error text are already on the channel, and every\n retry halts the same way because the stream checks the frozen expectation before it deduplicates.\n The halt message names the other causes, such as a second emitter for the same agent under a\n different state root, a restored stream or frontier record, or a purged channel. With those the\n pending frame may never have reached the broker, so a `cas-loss` does not tell you whether it\n landed. Find and stop any second writer and rule out a restored state first. Clearing the halt\n then means purging the agent's event channel and removing the agent's directory under the events\n state root whole (see [Event plane](connect-claude.md#event-plane)). That abandons the pending\n frame whether or not the broker has it, and the purge also drops the earlier frames of every\n session of that agent.\n4. Upgrade once step 2 prints nothing.\n\nIf a session halts with `egress-run-error` after the upgrade, go back to step 3 for that session on\n0.58.0. Do not edit or delete `wal.json` on its own to get past either halt: clearing the pending\nframe abandons that epoch, an event the broker never received is lost, and removing part of the\ndirectory leaves a state the next start refuses.\n\n## Explicit actor grants in 0.59.0\n\n`cotal actor grant` no longer fills an omitted ACL flag with its wide default. A grant names\n`--scope`, `--allow-subscribe` and `--allow-publish`, or passes `--full` to give the ones it leaves\noff their wide defaults (`spawn,role:default`, `>` read, `>` post). Any other grant is refused. The\nbreak is in the CLI on the machine that holds the actor ledger, the one that ran\n`cotal up --user-auth --idp <url>`. No stored row, credential or wire message changes.\n\n### What keeps working\n\nExisting actor ledger rows keep the authority they were granted, and their users and agents connect\nas before. `actor revoke`, `actor list` and a `grant` that names all three ACL flags behave as they\ndid on 0.58.0. Nothing on disk is converted.\n\n### What stops working\n\nA grant that leaves off any of the three flags without `--full` exits 1 with\n`refusing to grant \"<actor>\" with --scope, --allow-subscribe, --allow-publish left off`, naming the\nflags it is missing, and then prints both accepted forms. It writes no row and does not retire the\nactor's current lifecycle. An existing row stays as it was, and an actor granted for the first time\nstays out until the grant is run again. This includes the bare grant printed on 0.58.0 by\n`cotal login`, `cotal status`, `actor list` and the not-granted refusal. Look for it in provisioning\nscripts, onboarding runbooks and anything that pastes those hints.\n\n### Upgrade order\n\nChange the scripts before the ledger machine is upgraded, and make each grant name all three flags.\n0.58.0 and 0.59.0 both accept that form. To keep a wide row, write its defaults out:\n\n```sh\ncotal actor grant <actor> --sub <IdP subject> \\\n --scope spawn,role:default --allow-subscribe '>' --allow-publish '>'\n```\n\nSwitch to `--full` only once the ledger machine runs 0.59.0. 0.58.0 refuses it with\n`Unknown option '--full'` before it reads the ledger. Brokers, managers and participant machines\nneed nothing for this break, so their order is the one the section above gives.\n\n### The window\n\nThis break has no outage. No process restarts for it, and a refused grant changes nothing. The\nexposure is a grant script that runs against 0.59.0 before it was changed: it fails and grants\nnothing.\n\n### Snapshot this first\n\nNothing is rewritten, so this break has no state to back up. On the ledger machine, save the output\nof `cotal actor list` to compare rows after the changed scripts run, and list the scripts that call\n`cotal actor grant`.\n\n### The upgrade end to end\n\n```sh\n# on the ledger machine, still on 0.58.0\ncotal actor list > actors-before.txt\ngrep -rn 'actor grant' <your provisioning scripts>\n# make every grant name --scope, --allow-subscribe and --allow-publish, run them, then upgrade\nnpm i -g cotal-ai@0.59.0\ncotal actor list | diff actors-before.txt -\n```\n\nBoth refusals quoted here were run on 0.58.0 and on the 0.59.0 code. That brokers, managers and stored\nrows need nothing is read from the change, which touches only the CLI and its hints, and was not run\non a live split deployment.\n\n## Repeated flags refused in 0.59.0\n\nA `cotal` flag given more than once is now a usage error unless the command declares it repeatable.\nOn 0.58.0 the last value won with no message, so `cotal down web --space a --space b` acted on `b`\nwhile a wrapper that checked the first `--space` verified `a`. The break is in the command-line\nparser on the machine that runs the command, including commands added with `cotal ext add`. No\nstored state, credential or wire message changes.\n\n### What keeps working\n\nA command line that gives each flag once parses as it did on 0.58.0, in any order and in the\n`--flag=value` form. Flags whose help says repeatable, such as `--opt` and `down --session-store`,\nstill collect every value. A flag-shaped word after `--` is still a positional. The daemons, units\nand agents that `cotal` starts for itself are given each flag once, so a fleet driven only by `cotal`\ncommands typed by hand needs no action.\n\n### What stops working\n\nA command line that repeats any other flag exits 1 before the command runs. It prints\n`Option '--space' cannot be repeated`, or `Option '-f, --file' cannot be repeated` for a flag with a\nshort form, followed by the command's help. `-f` and `--file` count as the same flag. Look for it in\nscripts, aliases and wrappers that append a flag to override one set earlier, such as a fixed\n`--space` followed by `\"$@\"`.\n\n### Upgrade order\n\nChange those scripts first so each flag is given once. 0.58.0 and 0.59.0 both accept that form.\nBrokers, managers and participant machines need nothing for this break, and each machine's CLI\napplies it when that machine is upgraded, so their order is the one the sections above give.\n\n### The window\n\nThis break has no outage. No process restarts for it, and a refused command does nothing. The\nexposure is a script that still repeats a flag when it runs on 0.59.0: it exits 1 instead of acting on\nthe last value.\n\n### Snapshot this first\n\nNothing is rewritten, so this break has no state to back up. List the scripts, aliases and wrappers\nthat call `cotal` so each one can be checked.\n\n### The upgrade end to end\n\n```sh\n# still on 0.58.0\ngrep -rn 'cotal ' <your scripts and wrappers>\n# give each non-repeatable flag once, then upgrade\nnpm i -g cotal-ai@0.59.0\n# run each changed script; a repeat left behind exits 1 with the usage error and does nothing\n```\n\nThe refusal and its messages were run against the 0.59.0 parser and `cotal topology view`. That the\nargument lists `cotal` builds for its own processes give each flag once is read from the code, and\nwas not run on a live split deployment.\n\n## Detached spawns from a seat's shell in 0.62.0\n\nOn a static or open mesh, `cotal spawn --detach` run inside a managed seat's shell now launches as\nthat seat. On 0.61.0 it minted a one-shot operator instrument, so the manager recorded that\ninstrument as the spawner and the seat's own `cotal_despawn` of the child was refused with\n`not authorized: <seat> was not spawned by <caller> (admin tier required)`. The break is in the CLI\non the machine where the seats run. No stored state, credential or wire message changes.\n\n### What keeps working\n\n`cotal spawn --detach` from an operator terminal or from a script outside any seat launches as\nbefore, and so does any call with `--creds`, one aimed at a space other than the seat's own, or a raw\nopen target named with `--server` and an unregistered `--space`. A user-auth mesh is unchanged. A seat with\n`capabilities: [spawn]` still spawns from its shell, and can now stop that child with\n`cotal_despawn`. `--on <instance>` from a seat's shell still lands on that manager instance, now as\nthe seat.\n\n### What stops working\n\n- On a static mesh, a seat without `capabilities: [spawn]` can no longer spawn from its shell. Its\n own credential holds no spawn subject, so the broker refuses the request and the command exits 1.\n- A child launched from a seat's shell is now that seat's child, so the manager stops it when the\n seat exits, as it does for a `cotal_spawn` child. A child that has to outlive the seat that\n started it now goes with the seat.\n- A seat launched without `COTAL_SPACE` is placed by its static credential. Every connector sets\n that variable, so this only reaches a hand-built launch: from such a seat's shell, a spawn aimed at\n a static space that holds no credential for the seat is refused instead of running as the operator.\n\n### Upgrade order\n\nOnly the CLI that seats run from their shell changes, which is the one installed on the host where\nthe seats run. Brokers and managers need nothing for this break, so their order is the one the\nsections above give.\n\n### The window\n\nThis break has no outage. No process restarts for it. A child already running when you upgrade\nkeeps the spawner the manager recorded at its launch.\n\n### Snapshot this first\n\nNothing is rewritten, so this break has no state to back up. List the agent files whose seats run\n`cotal spawn --detach` from their shell, note which of them lack `capabilities: [spawn]`, and note\nwhich of their children must outlive the seat.\n\n### The upgrade end to end\n\n```sh\n# still on 0.61.0: find the seats that spawn from their shell\ngrep -rln 'cotal spawn' .cotal/agents\n# add `capabilities: [spawn]` to each of those agent files that lacks it, and launch any child\n# that must outlive its seat from an operator terminal instead\nnpm i -g cotal-ai@0.62.0\n```\n\nThe attribution, the despawn, the refusal of a seat without `spawn`, the stop on seat exit and a\nseat's `--on` spawn were run on a local static mesh, and the attribution and the despawn on a local\nopen mesh.\n\n## From 0.53.0 to 0.54.0\n\nManager calls now borrow an instance-bound `manager-caller` credential. Followed mutations require\n`manager.goal-result` on the selected manager, so a compatible issuer, manager and client must be\nloaded together. An older manager is refused before a followed mutation; upgrading an installed\nbinary alone does not replace code in a running manager, connector or embedded client.\n\n### Preserve state before changing processes\n\nSnapshot the broker's durable storage using its supported backup procedure, the host authority and\nactor ledgers, and each participant's manager identity, runtime custody records, credentials and\nsaved sessions. Include the embedding application's database and configuration under its supported\nbackup procedure. Record the loaded package versions and the CLI path used by bearer helpers.\nKeep these copies private. Do not change the IdP issuer, regenerate manager identities, rotate agent\ncredentials or recreate tenant storage to make the upgrade pass.\n\nNo ledger, goal-history or session conversion is required for this change. Existing ordinary\nmessaging credentials retain their normal expiry rules. New manager-caller credentials are obtained\non demand from the current grant; old manager-call credentials do not gain the new view automatically.\nExisting accepted goals remain durable and must not be submitted again merely because observation\nwas interrupted. Fresh remote registration publishes its service status at the current revision and\nepoch; do not seed that status manually.\n\n### Upgrade the split deployment\n\n1. Stage one pinned 0.54.0 package set for the host and participants, including the embedding SDKs.\n Pause new manager mutations and let accepted work settle where possible before reloading processes.\n2. Upgrade the host issuer and embedding first. Keep the broker, its account identities and durable\n storage in place. Then load the matching manager release on each participating machine.\n3. Preserve active seats through the runtime's supported update path. A Linux custodial runtime may\n release and re-adopt seats within its 600-second unattended window; verify the actual runtime,\n custody records and process identities before relying on it. A legacy PTY runtime without release\n support cannot preserve active seats through a generic manager restart. Drain it at an approved\n idle window instead of signalling the manager or replacing conversations.\n4. Reload the clients and connectors through their session-preserving host controls. Refresh any\n bearer helper captured from an older immutable CLI path. A transport-only reconnect does not reload\n JavaScript. Verify authenticated instance selection, a read-only manager command and canonical\n result recovery before allowing new followed mutations.\n\nTreat the interval from issuer reload through compatible manager/client reload as a manager-control\noutage. Mixed versions can refuse discovery or commands; there is no promised rolling transition.\nOrdinary agent sessions survive only where their runtime and credentials permit it. If verification\nfails, keep mutations paused and repair forward from the preserved state rather than resetting it.\nThis release does not add host-backed enrollment or terminal release for stock participant detached\nagents; see [Remote supervised agents](run-a-mesh.md#remote-supervised-agents).\n\n## From 0.48.2 to 0.49.0\n\n0.49.0 changes how a credential's authority is recorded. A credential is no longer only a signed\nfile: it is an *issuance*, with a generation the issuer chose and durable evidence of the ceiling it\nwas granted under. The important consequence for a running deployment is not at connect time. It is\nat renewal time.\n\n### What keeps working without any action\n\n- **Existing agent credentials keep authenticating.** A credential minted under 0.48.2 is not\n revoked and is not rejected at connect. Nothing needs to be re-issued to bring the fleet back up\n after the upgrade.\n- **The channel registry survives.** Channels, their replay settings, descriptions, and usage text\n are ordinary durable state and are not rewritten by the upgrade.\n- **`cotal deliver` is still a standalone command.** Running the delivery daemon as its own process\n remains supported; it is not restricted to being a child of `cotal up`.\n- **`cotal join` keeps its flags.** In particular `--lifecycle-uid` is not new in 0.49.0. It has\n been required alongside `--creds` since well before this release, and the pairing rule did not\n change here. A scripted external join that worked under 0.48.2 works unchanged.\n\n### What does not migrate\n\n**A credential minted before 0.49.0 cannot be renewed.** Managed agent credentials carry a\n24-hour lifetime and the manager re-signs one once it passes **75%** of its life, ticking every\nquarter of the TTL so a tick always lands inside that window. When the manager reaches a credential\nthat carries no issuance, it refuses to renew it and logs the agent by name:\n\n```\n! managed cred renewal <agent>: renewManagedStaticCred: <agent> carries no issuance;\n a static credential minted before SPEC 13.15 is not renewed under an unbound generation\n - respawn the agent\n - the agent dies loud at this cred's expiry unless it is reminted\n```\n\nSo the fleet comes up fine, runs normally, and then each agent stops at its own credential's\nexpiry, within roughly a day of the upgrade, one at a time rather than together. The refusal is\ndeliberate: the renewal would otherwise have to invent a generation nobody issued, which is the\nstate the release exists to remove.\n\n**Respawn the managed agents as the last step of the upgrade.** For this particular upgrade the\nrespawn is not optional: stopping a 0.48.2 manager ends its agent processes whichever CLI you use,\nfor the reason given under the outage window below. The respawn is how they come back, and it is\nalso what mints each credential as an issuance so it renews from then on. One planned pass over the\nfleet is the whole job. Skipping it leaves agents stopped and, for any credential that survived\ninto 0.49.0 unminted, brings the renewal cliff above a day later, one agent at a time.\n\n### Credentials you minted yourself\n\n**A credential you minted with `cotal mint` is a different case, and it very likely needs\nnothing.** The distinction that matters is not the word \"static\", which covers both. It is **what\nminted the credential and who owns its renewal**. A credential the **manager** minted for an agent\nit spawned carries a lifetime and is renewed by the manager, so it is the subject of everything\nabove. A credential **you** minted with `cotal mint` and handed to an external peer is issued with\n**no expiry at all**, and no manager renews it: it is not in the sweep, so there is no renewal to\nfail. It keeps working after the upgrade, and re-minting it would mean coordinating with a third\nparty for no gain.\n\nThe manager says which one it is holding. Where a credential has no expiry to reach, the sweep\nnames it and moves on rather than refusing:\n\n```\n! managed cred renewal <agent>: credential is unbounded - not renewed\n (a pre-TTL credential stays as minted until respawn)\n```\n\nRe-mint an external peer's credential only if you want it to carry a lifetime, and at a time you\nchoose.\n\n### How to read the boot log\n\nA 0.49.0 manager starting over an existing space may print lines like:\n\n```\n verified evicted: <holder-key> (3/12)\n already verified (durable): <holder-key>\n\u2713 boot self-heal: manager/<id> registration gate reopened at generation <n>\n```\n\nThese are **not** a credential migration, and reading them as one is the most likely way to\nconclude the fleet is fine when it is not. They come from the manager repairing **one** endpoint\nregistration gate that a previous restart left frozen, and they enumerate that single gate's\ncredential-family holders as it verifies each one evicted. `already verified (durable)` on a later\nstart is the repair cursor resuming, not a credential that became durable. The repair is real and\nuseful (it is what previously needed `cotal reconcile-gate` by hand), but it says nothing about\nwhether your agent credentials carry issuances. The renewal refusal above is the signal that does.\n\n### Which side to upgrade first in a split topology\n\nMove the manager first.\n\nThe stores 0.49.0 introduces are created by the **manager** at its own boot, not by the broker.\nThey are create-or-verify and idempotent, so a 0.49.0 manager brings the space's authority stores\nup to the new shape itself, and it does so against whichever broker is answering.\n\nBeing honest about the evidence behind each direction, because they are not equally established:\n\n- **Broker-first was measured on a live 30-agent deployment** (issue #1578). Upgrading the broker\n first locks the old manager out immediately: `cotal up` re-renders the broker's generated config\n from the trust record, and after the restart the still-0.48.2 manager is refused on every\n connection with an `authentication error` naming the Nkey, continuously. That text comes from the\n broker process, not from a Cotal command, so match on its shape rather than on an exact string.\n `cotal ps` reports zero agents while\n the agent processes are still alive, because the manager has lost its view of them, not because\n they died. Upgrading the manager clears it immediately.\n- **Manager-first is reasoned from where the new stores are provisioned**, not from a measured\n fleet upgrade. It is the recommended order because the manager is the component that creates what\n 0.49.0 adds, but it has not been run end to end on a production split topology at the time of\n writing. Treat it as the better-supported order rather than a guaranteed one, and keep the\n rollback below ready either way.\n\nWhichever order you pick, **this is not a rolling upgrade**. Between the two steps the mesh is down\nand the manager cannot see its agents. Go straight through rather than pausing between them, and\nschedule it as an outage window.\n\n### What the window looks like\n\n- **The managed agent processes do not survive step 1, in either order.** This is the one place\n where the obvious reordering does not rescue you, so it is worth understanding rather than\n working around. Sparing agents on a bare manager stop is a **handshake**: a 0.49.0 manager\n publishes a capability file proving it can release its agents, and a 0.49.0 `cotal down` refuses\n the stop unless it finds one. **A 0.48.2 manager never publishes that file**, because the\n mechanism ships in the release you are installing. So the old CLI against the old manager sends a\n plain stop and takes every seat with it, and the new CLI against the old manager either refuses\n (leaving `--with-agents`, which reaps deliberately) or falls to the legacy path, warns that it\n cannot verify the manager can spare its agents, and signals it anyway.\n- **You can confirm which side you are on in one command, without stopping anything.** The flag that\n marks the newer behaviour is absent from the older CLI, and its summary line makes the difference\n plain:\n\n ```\n $ cotal down --help # on 0.48.2\n cotal down - stop the whole local stack, or name only the components to stop\n\n $ cotal down --help # on 0.49.0\n cotal down - stop the whole local stack (managed agents stay running unless --with-agents), ...\n ```\n\n If your `cotal down --help` does not mention `--with-agents`, stopping the manager stops the\n agents with it.\n- **Therefore the respawn in step 5 is mandatory recovery for this upgrade, not an optional pass.**\n It is also the step that re-mints credentials as issuances, so it is the same action either way.\n Plan the window to include it rather than treating it as cleanup.\n- The **manager's view** of them is lost while the two sides disagree, so `cotal ps` reports zero\n and control commands do not reach seats.\n- **Messages are not delivered** while the mesh is down.\n- The window is as long as it takes to restart the second component, plus the manager's own start.\n It is minutes, not hours, provided you do not stop between the steps.\n- **Nothing self-heals if you stop halfway.** The refusal is continuous until both sides match.\n\n### Snapshot this before you start\n\nTake these while the deployment is still on 0.48.2. The two `cotal` reads are live reads and must\nhappen before anything stops.\n\n- **A filesystem or volume snapshot of both containers**, if your platform offers one. This is the\n only rollback that covers every case, and it is what the reporting deployment used.\n- **`cotal backup create <dir>`**, for the durable space state, **but read the next paragraph before\n you rely on it**: on a split broker and manager topology it is very likely unavailable to you, and\n the volume snapshot above is your actual rollback.\n- **The trust records and credential directory** under `.cotal/auth` on the manager host, including\n the per-space material directory. These are what a re-mint would otherwise have to replace.\n- **A copy of the channel registry**, so you can verify it came back rather than assuming it did:\n `cotal channels list` before and after.\n- **The output of `cotal ps`**, so you know how many seats you expect to see afterwards and can tell\n a lost view from a lost agent.\n\n#### `cotal backup` on a split topology\n\n**`cotal backup create` cannot read a running stack.** It requires a completed cut, and only\n`cotal down --preserve-state` publishes one:\n\n```\n$ cotal backup create ./backup.0482\n\u2717 backup requires a completed cut; run `cotal down --preserve-state` first\n```\n\n**And `cotal down --preserve-state` requires a manager alive on the host you run it from.** It uses\nthat manager to attest that every retained child stopped, and the check is deliberately fail-closed:\na manager that is dead or merely uncertain refuses rather than preserving an unproven cut. The check\nreads a local pidfile, so a **remote** manager does not satisfy it. On a split topology the broker\nhost has no local manager, which means the documented durable-backup path is not available there.\n\n**Measured rather than assumed, at 0.48.2**: the backup refusal above is executed output. The\npreservation requirement is read from `down.ts` at the same tag, where the preserve path asks a\nmanager to prepare an inventory and then requires that manager to be locally alive before it\ncommits. The part not executed end to end is a genuine two-host split, which needs two real hosts.\n\n**What to do instead.** Use the filesystem or volume snapshot of both containers. That is the\nrollback the reporting deployment actually used, it covers the broker's durable state and the\nmanager's credential material together, and it does not depend on either component being able to\nattest for the other. If you want `cotal backup` as well, take it from a host that does have a live\nlocal manager, and understand it is a second copy rather than the primary rollback.\n\n**This looks like a product limitation rather than a documentation gap**, and it is written here as\none so an operator is not left thinking they mis-typed a command. The upgrade path for the exact\ntopology this page is addressed to cannot use the documented backup command.\n\n### The upgrade end to end\n\n```bash\n# 0. on 0.48.2, STILL RUNNING: record what you expect to see afterwards.\n# These two are live reads, so they must happen before anything stops.\ncotal channels list > channels.before\ncotal ps > ps.before\n\n# 1. manager host. READ THE NOTE BELOW THE BLOCK FIRST: this step ends the\n# managed agent processes whichever order you choose, and the respawn in\n# step 5 is how they come back. It is recovery, not tidying.\n#\n# STOP THE MANAGER WITH THE 0.48.2 CLI, BEFORE INSTALLING 0.49.0. The\n# order matters and it is not recoverable once you install: a 0.49.0\n# `down manager` REFUSES to stop a 0.48.2 manager whose pid record carries\n# a start token, which is every manager on a platform that can read one\n# (Linux can):\n# refusing bare manager stop: ... does not prove this manager can detach\n# its agents; use --with-agents or stop the agents explicitly\n# The refusal names two remedies and NEITHER clears it for this case. The\n# check reads a capability file that only a 0.49.0 manager writes; it never\n# counts agents, so stopping them first changes nothing. And `--with-agents`\n# is whole-stack only, so `down manager --with-agents` is refused by its own\n# flag rule. See #1592.\ncotal down manager # the 0.48.2 CLI, still installed.\n # 0.48.2 has no --with-agents; this\n # is the whole route. On a host that\n # runs the whole stack, the 0.49.0\n # `cotal down --with-agents` after\n # installing is the alternative.\nnpm install -g cotal-ai@0.49.0 # ONLY after the stop above\n# `supervise` RUNS IN THE FOREGROUND and holds the terminal until you stop\n# it. There is no --detach on this command. Start it under whatever keeps\n# your manager alive normally (systemd unit, container entrypoint, or a\n# second terminal), and run the remaining steps from another shell.\ncotal supervise --space <space> --server nats://<broker>:4222\n\n# 2. broker host: stop the stack.\n# NOT `--preserve-state` on a split topology: it needs a manager alive on\n# THIS host to attest its children stopped, and yours is on the other one.\n# Your rollback is the volume snapshot from \"Snapshot this before you\n# start\", not `cotal backup`.\n# See \"cotal backup on a split topology\" above.\ncotal down\n\n# 3. broker host: install 0.49.0 and start it again\nnpm install -g cotal-ai@0.49.0\n# Record the manager log's size BEFORE starting, so step 3a can tell THIS\n# boot's output from every earlier one. It must be captured here, ahead of\n# the start: taken afterwards it sits past the new line and the wait hangs.\n# `<spaceKey>` is NOT the space name. It is lowercase hex of the name's\n# UTF-8 bytes, so space `prod` is `manager.70726f64.log`. Do not guess it:\n# `cotal up` prints the real path on its launch line. Substituting the\n# plain name points at a file that does not exist, and the wait below then\n# burns its full timeout before telling you.\nLOG=.cotal/manager.<spaceKey>.log\nOFF=$( [ -f \"$LOG\" ] && wc -c < \"$LOG\" || echo 0 )\ncotal up --detach --host 0.0.0.0 --space <space> --no-manager\n\n# 3a. SPLIT TOPOLOGY ONLY: `--no-manager` above boots the broker (and the\n# delivery daemon) with NO local manager on the broker host, so there is\n# no wait-and-stop step on a current cotal-ai. The rest of this step is\n# the OLDER-host recipe, kept because the flag is refused there and that\n# refusal is your signal you are on it: without the flag the `up` also\n# starts a local manager, and you must wait for the log to show it is up,\n# then stop it, or you finish the upgrade with two managers and the one\n# you did not intend is the one nobody is watching.\n# A bare `grep -q` does NOT wait: it reads once and exits 1 immediately\n# if the line has not been written yet. Bound the wait instead, so a\n# manager that never comes up fails loudly rather than reading as ready.\n# The log is opened APPEND-ONLY, so on any host that has run a manager\n# before, this file ALREADY carries a `manager up` line from an earlier\n# boot. Grepping the whole file therefore matches instantly and waits for\n# nothing. Read only what THIS boot appended, using the $OFF captured in\n# step 3 above (before the start, which is the only point it is correct):\ntimeout 60 bash -c \\\n \"until tail -c +$((OFF+1)) \\\"$LOG\\\" | grep -q '. manager up'; do sleep 1; done\"\n# exit 0 = THIS boot logged it; exit 124 = it never did, so STOP and look.\n# This manager is 0.49.0 and publishes its own spare-capability file, so\n# the bare stop below is NOT the refusal case from step 1.\ncotal down manager # broker + delivery remain\n# On a current cotal-ai the two commands above are unnecessary (nothing\n# to wait for, nothing to stop) and `cotal down manager` simply reports\n# no manager to stop.\n\n# 4. verify the mesh is whole again before touching the fleet.\n# Do NOT compare `cotal ps` against ps.before yet: step 1 ended the agent\n# processes, so at this point it is EXPECTED to be empty, and an empty\n# `ps` is also the signature of the broker/manager mismatch described\n# above. The two are indistinguishable here, so compare what the mesh\n# itself should have carried across instead:\ncotal channels list # compare against channels.before: this SHOULD match now\ncotal ps # expect it to be EMPTY here; ps.before is the target for\n # step 5, not for this step\n\n# 5. the step that is easy to skip: respawn the managed agents so their\n# credentials are re-minted as issuances and can renew. Persona is a\n# POSITIONAL argument here, unlike `cotal stop`, which requires --name.\n# One call per agent:\ncotal spawn <persona> --detach --name <n> --space <space>\n# then the comparison step 4 could not make:\ncotal ps # NOW compare against ps.before: seat count should match\n```\n\nThe mesh is down from step 2 until step 3 finishes. That is the window. On a split topology there is\nno cut and no backup inside it, so the window is the stop, the install and the restart, nothing more.\n\n## Adding a section for a future release\n\n**Every changeset marked breaking adds a section to this page.** A release that changes what an\noperator must do, in what order, or what stops working, is not finished until the section exists.\n`scripts/upgrade-section-gate.mjs` grades a commit range for this: run it as\n`pnpm upgrade-section-gate --base <ref>` and it reds when the range carries a breaking change and\nadds no new release section. CI runs its self-test and, as a step of the `attribution` job, grades\neach pull request's own range as `HEAD^1..HEAD` over the merge snapshot it checked out. That job is\nthe only context in the branch protection rule set, so a red gate FAILS A REQUIRED CHECK AND BLOCKS\nTHE MERGE. The section is not optional and a reviewer cannot wave it through without an\nadministrator overriding branch protection. Be precise about what the check proves either\nway, because one trusted past its evidence is worse than none. It proves a section for a release\n**was written here**. It cannot prove the section is **correct**, or that it describes the break\nthat actually landed, and it cannot see a breaking change that carries no marker at all. Reviewing\nthe words remains a person's job.\n\n**Mark the break, or the gate cannot see it.** Any one of these is enough, and they are the only\nthings it reads:\n\n- a `!` before the colon in the commit subject, as in `feat(core)!: bind hosted runs to the caller`\n- a `BREAKING CHANGE:` footer in the commit body\n- a changeset in `.changeset/` declaring a `major` bump for any package\n\nThe marker must survive the squash. A `!` that lives only in a commit you squash away is not in the\nrange the gate grades, so put it in the subject that lands on `main`.\n\n**The heading is a `##` and names the release**, like `## From 0.48.2 to 0.49.0`. Both matter, and\nneither is a style preference. Coverage is claimed by a heading, so a heading that names\nno release claims every release and distinguishes none: `## Notes` with a sentence under it would\notherwise satisfy the rule. Naming the release also makes the section the one an operator upgrading\nthat release will search for. Use `###` freely for detail inside a section. Subsections belong to\ntheir release rather than counting as separate coverage.\n\nName the release that first carries the change: the next version Changesets publishes, which\n`pnpm changeset status --verbose` lists. `bin/package.json` on `main` still reads the release already\npublished. If a release is cut while the change is open, the change ships in the release after it,\nso move the heading before merging. The gate accepts any version in a heading, so before merging a\nrelease pull request, check every heading added since the previous tag against the version it\npublishes.\n\nA section is written for the operator, not for the reviewer. It answers, in this order:\n\n1. What keeps working with no action at all.\n2. What does **not** migrate, and when that becomes visible. Name the log line if there is one.\n3. The order to move components in for a split topology, and why that order.\n4. What the outage window looks like, including what survives it.\n5. What to snapshot before starting.\n6. The commands, end to end.\n\n**Where an answer was not measured, say so in the document rather than guessing.** An operator who\nknows which half of a recommendation is reasoned and which is measured can plan around it; one who\nfinds out afterwards cannot.\n"
62733
62793
  },
62734
62794
  {
62735
62795
  "slug": "watch-a-mesh",
62736
62796
  "title": "Watch a mesh",
62737
62797
  "kind": "Guide (informative)",
62738
62798
  "summary": "A running mesh is a stream of live activity: who is present, what they are doing, what they are saying to each other.",
62739
- "body": "# Watch a mesh\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nA running mesh is a stream of live activity: who is present, what they are doing, what they\nare saying to each other. Cotal gives you three read-only surfaces onto one space. All three\nrender the *same* observer model ([`MeshView`](mesh-view.md)); none opens its own connection or\nre-implements the wire. Pick by where you are:\n\n| Surface | Command | Use it to |\n|---|---|---|\n| **web dashboard** | `cotal web` | a god-view browser dashboard: see at a glance what needs a human |\n| **console (TUI)** | `cotal console` | drive it interactively in the terminal: drill into agents, channels, DMs |\n| **stream** | `cotal console --plain`, or any pipe | tail a passive line log: grep it, pipe it, watch it in CI |\n\nThe web dashboard ships inside `cotal-ai` and is seeded automatically; the console ships with\nthe CLI.\n\n## Terminal console\n\n`cotal console` auto-selects its renderer: a real TTY gets the lazygit-style Ink TUI; a pipe or\n`--plain` gets the line stream. Both read from one invisible observer over the space.\n\n```bash\ncotal console --space main # the TUI for one space\ncotal console --plain # the passive line stream (also the default when piped)\ncotal console # no --space on an open mesh \u2192 the admin overview first\n```\n\n![The cotal console: a live roster of agents and their all-activity feed in a terminal TUI](../assets/quickstart.gif)\n\n**Admin overview.** On an open mesh, `cotal console` with **no `--space`** opens a space picker:\nevery space on the server (enumerated from its `CHAT_*` streams and presence buckets) with its\nagents, channels, and message counts. Pick one to drop into its console; `b` returns to the\noverview. `--space X` skips the picker. Under auth a server hosts a single space, so the console\nenters it directly (no overview). `D` deletes the selected space once you type its name. A space this\nhost registered as a static-auth mesh on that server is deleted under a teardown minted from its trust\nmaterial, which names the resume transfer buckets the space holds at that moment; any other space is\ndeleted under the console's own connection, bare on an open mesh or your `--creds` file.\n\n**Lenses and keys** (TUI). The layout is a roster, a live feed, per-channel tabs, a golden-signal\ntiles strip, and toggleable lenses:\n\n| Key | Does |\n|---|---|\n| `1`\u2013`9`, `[` `]` | select a channel tab |\n| `n` | the NEEDS-YOU rail: agents currently blocked or waiting |\n| `d` | the DM lens: per-peer roll-up and threads (god-view only; shows \"DMs hidden\" under chat-only creds) |\n| `t`, then `v` / `1`\u2013`3` | the topology lens: who-talks-to-whom, as a swimlane, a heat matrix, or a ring map, with the broker's own membership overlaid when the delivery daemon serves it (a header pill reads live, stale, traffic-only, or unreadable) |\n| `/` | search / filter the feed |\n| `:` | the command palette |\n| `D` | kill the selected agent (control-gated, below) |\n| arrows / `h` `l` | move focus; select a row for its detail card |\n| `?` \xB7 `b` \xB7 `q` | help \xB7 back to overview \xB7 quit |\n\n**Operator control.** Watching stays read-only, but the console can also drive the\n[manager](architecture.md#manager-agent-supervisor). The observer endpoint never carries\ncontrol: each action is one call through the same per-action path `cotal stop`, `cotal ps`, and\n`cotal spawn --detach` take. It resolves the mesh, mints a one-shot instrument (or connects bare\non an open mesh, or rides your bearer on a user-auth mesh), calls the manager over the endpoint\nrails, and keeps nothing. That gate is `canControl`, decided by whether that path can produce a\ncaller at all (a raw `--creds` file cannot), and it is independent of `canWrite`, which gates\nchat. `D` kills the selected agent behind a confirm (`y` graceful stop, `f` force-kill), and the\n`:` palette adds `spawn <persona> [name]` (waits for the join, like `cotal spawn --detach`),\n`status <agent>`, `ps`, `purge` (type the space name to confirm, like the space delete), and\n`delchan <channel>` (type the channel name to confirm): the web dashboard's channel delete, run\nfrom the console. It is core's `clearChannel` (a filtered history purge plus the registry entry)\nwith the dashboard's per-action authority, a one-shot `channel-purger` credential minted from the\nresolved mesh's seed on a static mesh, a `channel-purger` view on a user mesh, a bare connection\non an open one; no manager is involved. A wildcard is refused, so the one destructive control\nnames one channel. A refusal, from the broker or the manager, lands on the status line. On a\nspace with several managers, `D`, `:status`, and `a` are pinned to the manager hosting the seat and\n`:ps` merges every manager's rows; `:spawn` and `:purge` ride the class queue, as `cotal spawn\n--detach` and `cotal purge` do without `--on`. A manager that does not answer keeps its last known\nrows and is named as silent. A manager that answers with an error is named with that error and its\nold rows are withheld rather than shown as current.\n\n`a` on a roster agent (or `:attach <agent>`) suspends the console and hands the terminal to the\nseat: it is `cotal attach` run in place, the same one-use holder-bound mesh session, the same\nreconnect on a lost link, the same end reasons, with the observer running in the background\nthroughout. `Ctrl-]` (or `COTAL_DETACH_KEY`) returns and repaints the console, and the verdict\nlands on the status line: detached, the seat is gone, or why it could not attach. A bad\n`COTAL_DETACH_KEY` is refused before the screen is handed over. Attach is `canControl`-gated and\nneeds what `cotal attach` needs: a `pty` seat and the space's local seed to redeem the session\ngrant (a static-auth mesh; an open mesh holds no seed, so attach refuses there as it does on the\ncommand line).\n\n**Operator participant mode.** By default the operator is invisible, and a message it sends is\none-way: an agent's DM reply has no peer to land on. On an open mesh, the operator's **first send**\n(`:dm`, `:call`, `:ask`, `:msg`, or a compose) puts the operator on the roster: the console starts\na second, presence-only endpoint carrying the observer's own card (the same id, name, and\n`role: \"operator\"`, so it is the `from` of everything the observer sends), the status bar says\n`on roster` for the rest of the session, and the god-view tap the console already runs shows the\nreplies in the DM lens. The heartbeat survives a broker\nreconnect, and the operator leaves cleanly (an offline record) on exit. This is `canWrite`-gated,\nso a pure-watch session never registers, and a peer the broker refuses stays invisible, blocks that\nsend, and leaves the refusal on the status line so the operator is never told replies can land when\nthey cannot. Concurrent sends wait for the same startup result; the next later send retries after a\nrefusal. Under auth the console does not upgrade: the read-only default cannot\nsend at all, and an agent-grade `--creds` holds no live read of its own DM inbox (DMs ride its\nlifecycle-keyed durable, which the observer does not consume), so a send there is one-way and the\nstatus line says so once. An auth participant needs a credential profile that can publish\npresence and chat and read its own inbox; that profile does not exist yet.\n\nThe stream is line-oriented, so the signals stay out of it; it is just a timestamped log of\npresence changes and messages, ready for `grep`.\n\n## Web dashboard\n\nThe dashboard ships inside `cotal-ai` as the `@cotal-ai/web` extension and is seeded automatically on\nfirst run (like the built-in connectors), so `cotal web` is there out of the box and tracks your CLI\nversion on upgrade. If a seeded copy is damaged, `cotal ext seed --repair` restores it.\n\n![The web dashboard: roster, all-activity feed, golden-signal tiles, and the NEEDS-YOU lane](../assets/dashboard.png)\n\n```bash\ncotal web --space main # opens http://cotal.localhost:7799/\ncotal web --space main --detach # background; stop with cotal down web\ncotal web --space main --port 8080 --no-open\ncotal web --space main --host 192.0.2.10 # explicit remote bind and browser address\ncotal web --space main --creds ./admin.creds # use a cred you minted yourself\n```\n\nFlags: `--space` (default `main`), `--server` (the mesh's broker, resolved from the registry),\n`--host` (HTTP bind and browser host, default `127.0.0.1`), `--port` (1 to 65535, default `7799`), `--detach`\n(run in the background), `--no-open` (skip auto-launching the browser), `--creds` (override the\nself-minted cred). Remote exposure requires an explicit concrete `--host`; wildcard addresses\n`0.0.0.0` and `::` are refused because neither is a browser destination. Detached mode waits for\nthe real HTTP server at the bound host and port before returning, logs to `<mesh-root>/.cotal/web.log`, and is stopped by\n`cotal down web` or bare `cotal down`. On the default host it probes `127.0.0.1`, because a system\nresolver such as WSL2's may not answer the branded `cotal.localhost`. It requires a recorded mesh root; after `cotal up` records the\nmesh, it can be launched from any directory. The branded URL `http://cotal.localhost:7799/` resolves\nto loopback with no DNS setup in Chrome, Firefox, and Edge. Safari and a system resolver such as\nWSL2's may not resolve `*.localhost`, so on this default the launch link is printed again at\n`http://127.0.0.1:7799/` with the same single-use token. A custom `--port` uses the plain loopback address. An explicit\n`--host` is also the advertised address and the only allowed browser Origin for that process.\n\n**The link is single-use, and the surface authenticates the caller.** Starting the dashboard prints a\nURL carrying a one-time token; opening it exchanges the token for a session cookie and the token is\nthen spent. Binding loopback keeps other *hosts* out, but it never kept out other *processes* on your\nmachine, nor a page in your own browser posting to `http://127.0.0.1:7799`, so the token is what\nmakes the session yours. Requests without it are refused with the reason named (`unauthenticated`,\n`launch-token-already-used`, or `cross-origin`) rather than silently returning nothing.\n\nPractical consequences: open the printed link in the browser you want to use it in, because the\ntoken is spent on first use. Re-opening it **in another browser or profile** is refused with\n`launch-token-already-used`. (In the browser that already holds the session, re-opening the link\nstill works: the session is checked before the spent token, so the page loads on the session you\nalready have.) If you lose the line, the link is also written to `<mesh-root>/.cotal/web.session`,\nmode `0600` on every write. The session is bound to the origin you opened, so one started on\n`http://cotal.localhost` does not carry over to `http://127.0.0.1`. Restarting `cotal web` mints a\nfresh link and invalidates every earlier session.\n\n**A god-view, minimal privilege.** The dashboard is always the full god-view; there is no\nread-only viewer mode. In auth mode it self-mints its own **admin** read cred (the scope that lets\nit tap DMs and anycast), then *drops the space signing seed* so a dashboard compromise can't mint\nidentities; it keeps only one narrow cred for its single write path. In open mode it connects bare.\nPass `--creds` to use a cred you minted yourself instead. On a per-user-auth mesh there is nothing\nto mint: the dashboard rides the read-only admin view over your login, and the channel-delete\nwrite path asks for its own channel-purger view per click (both need ledger scope `admin`;\n[identity & auth](identity-and-auth.md)).\n\nThe dashboard is read-only except that one write path: **deleting a channel and its content**\n(a filtered history purge plus the channel-registry key), which is POST-gated and confirm-guarded\nin the UI.\n\n**The views.** Every view keeps the same skeleton: navigation on the left (roster, channels,\nDMs), the selected content in the centre, the NEEDS-YOU lane always on the right.\n\n- **Monitor**: the all-activity feed (two-line messages with a delivery-mode badge, per-mode\n filter chips, and pause), the roster (status as shape *and* colour, role, a one-line activity,\n and the agent's harness: claude / opencode / hermes), and the golden-signal tiles\n (working / waiting / idle / offline / oldest-unattended). The roster groups live peers by the\n machine each one reports as its host, with a count per machine, so placement across machines\n reads at a glance. A peer that reports no host, such as a manager, is listed last under *host\n not reported*. Seats do not report which manager runs them, so the roster has no manager grouping.\n- **Channel view**: one channel's message list, members shown in the header.\n- **Direct messages**: a per-peer roll-up (one row per peer, not the n\xB2 pair list); expand a peer\n for its conversations. Threads key on authenticated ids, and every shown name, role, and status\n comes from the roster by id, never from the message payload.\n- **Agent Detail.** A per-agent drill-down rendered from the peer's card: name, role, the harness\n and model, capabilities, and what it's working on or blocked on.\n- **Graph view** (`/graph`, linked from the Monitor header): the same feed as a live\n force-directed constellation. Channels and agents are both nodes; a wire is drawn per\n **membership** (a spoke to every channel an agent subscribes to) and glows when a message flows.\n Membership is **broker-sourced and authoritative**, reconstructed by the delivery daemon from\n the broker's connection view unioned with the durable-members registry, so *silent* subscribers\n show too. A header pill reports the feed as *live*, *stale*, *traffic-only* (no daemon, e.g.\n open mode, where the graph degrades to traffic-derived spokes), or *unreadable*. The last\n meaning the read itself did not answer, which is a fact about the viewer rather than about the\n mesh, and is kept distinct from *traffic-only* for that reason. A **hide-offline** control\n collapses durable-but-away members. The live feed opens as the page loads rather than after it, so\n the pill reports the connection honestly from the first moment instead of sitting in its down\n state for as long as the first read takes. What the feed says outranks the page's own startup reads: a\n read issued before a live update cannot overwrite it when it lands afterwards, whether it answers\n or refuses, so a slow link cannot make the pill contradict what the feed already reported.\n Broker-sourced membership needs the delivery daemon (auth mode) and is provisioned on a fresh\n `cotal up`.\n\n**When a read does not land.** A poll that fails never blanks the page. The dashboard keeps the\nlast values it actually read and marks them stale in the header, naming which source is stale and\nwhy (`stale: peers, activity`, with the server's own reason on hover); the next successful read\nreplaces the data and clears the mark.\n\n**When the observer itself goes deaf.** Presence liveness is derived from heartbeat timestamps, so\na watch that hears nothing for longer than the TTL used to flip every peer `offline` at once. The\nsidebar is an online-only list, so the page emptied while the browser's connection pill stayed\nlive: that pill is the local SSE link, not the observer's upstream. Whole-bucket silence past\nTTL is now a fact about the *view*: the header says `stale: roster` (`observer presence watch\nsilent since T`) and the last-known online list stays on screen until the watch delivers again.\nA single peer whose own heartbeat lapses while the watch is live still drops out. A stall\nshorter than TCP-level detection never reconnects, which is why this is a freshness gate on the\nwatch rather than a `connection` event.\n\n**How the all-activity page is ordered.** When the page loads, the dashboard reads the newest chat\nmessages in the order the broker stored them and the newest direct messages, orders the two sets\ntogether by `ts`, the time each sender wrote into its message, and keeps the newest of them. Chat\nand direct messages are stored in separate streams with no arrival order in common, so `ts` is the\none key both carry. Where a sender's clock disagrees with the broker, messages can appear in an\norder different from the one they arrived in. Messages with the same `ts` keep the order the broker\nstored them in, and chat comes before direct messages.\n\nThe all-activity read is bounded by an 8000 ms deadline, so on a slow link it can\ncome back SHORT rather than late: the header then says `partial: activity`, and the page reports how\nmany sources answered out of how many were asked and names the ones that did not. Each missing source\nalso carries its reason in the response's `reasons` and in the line the server prints: `the read did\nnot finish within <deadline>ms` when the deadline cut it, or `the read failed:` and the error when it was\nrefused, such as a chat read whose filter list exceeds the broker's `max_payload`. A short page and a\ncomplete one are never the same bytes. On a link too slow to finish anything the honest answer is\nzero sources answered, and you keep looking at the last good data with the marker up. When the\ndeadline wins, Cotal also cancels the unfinished history pulls and removes their ephemeral consumers;\nan abandoned poll does not keep occupying the link and starve the next one.\n\nThe open channel's own history read is bounded by the same deadline. It is a single read, so there\nis no short page to serve: it either produced the messages or it refuses, naming the channel and the\nbound it exceeded, and the view keeps the messages it already had rather than emptying. A sparse\nchannel (fewer messages than the page) is bounded by that channel's own first and last matching\nsequences, not by walking the stream back to sequence 1. Every one of\nthese routes takes an optional `limit`, and a value that is not a whole number is refused outright\nrather than guessed at. The same holds for the channel name in the URL: an escape the decoder cannot\nread is the caller's typo, not a broken server. Either way a malformed request is answered as a bad\nrequest and never as the dashboard having broken.\n\nA refusal names the value it received, and it renders that value so you can read it. Characters that\nwould otherwise be invisible, rearrange the text around them, or mark part of it as an annotation\ncome back as their escape in both the response and the line printed in the terminal, so what you\nread is what was actually sent. Ordinary text, accents and non-Latin scripts included, is left\nalone: a character that renders as itself is left as itself.\n\nA channel name has to be the name the mesh actually uses: dotted segments of letters, digits, `_`\nand `-`, or a `*` or `>` where the mesh reads a whole subtree. Anything else is refused rather than\nquietly rewritten, because the wire rewrites what it cannot use and two different names would then\nbe one channel. That matters most on the delete button: a name that had to be rewritten would have\npurged a channel you did not name, while the answer showed you the name you typed. Delete takes no\nwildcard at all, so the one destructive control names one channel.\n\nThe delete request itself is capped at 8 KiB, which is far more than a channel name can be and far\nless than a machine can spend. A larger body is refused with a `413` naming the limit, the server\nstops reading it rather than taking it all in first and complaining afterwards, and the connection\nthat body arrived on is closed so the rest of it cannot be sent. It is never shortened to fit: a\ntrimmed name is a name you did not type, which is the thing the paragraph above exists to prevent.\nEvery other route takes no request body. It refuses an announced body before reading it with the\nsame `413` and closed connection. A request the gate refuses is closed the same way when it\nannounced a body, so a caller without a session cannot hold the dashboard reading an upload.\nOrdinary bodyless requests keep their connection as usual.\n\n**Message bodies render Markdown** (headings, lists, **bold**, `code`, blockquotes, links) across\nthe Monitor, channel, and DM views, parsed and sanitized client-side. Agent text is untrusted, so\nraw HTML is stripped and only http(s)/mailto links survive. A peer's name, role and activity show\nas plain text, including inside an attribute such as the activity's hover title. Long bodies still\nclamp to a few lines\nwith a per-message *show more*; a channel-wide **expand / collapse all** in the header opens or\ncloses every message at once.\n\nAppend `?demo` (`http://127.0.0.1:7799/?demo`) to render the design reference as a static\nshowcase with no mesh, including forward-looking elements that have no protocol backing yet\n(intent badges, approval requests, task-failed alerts). Live mode renders only what the god-view\ncan actually read.\n\n## What each surface can see\n\nEvery surface is a read-only observer; what it *sees* depends on its credential:\n\n- **console TUI** and **web** self-mint an **admin** god-view cred under auth, so both show the\n whole space: chat, DMs, and anycast (`dmVisible: true`).\n- **`console --plain`** deliberately narrows to the chat subtree, so DMs and anycast stay\n confidential in a line log even under an admin cred.\n- An explicit **`--creds`** limits each surface to the credential's grants; a chat-only\n observer cred hides the DM lens.\n\nSee [identity and auth](identity-and-auth.md) for the observer vs admin scopes, and\n[MeshView](mesh-view.md) for the shared model behind all three surfaces. Normative delivery and\nvisibility rules live in the [SPEC](../SPEC.md).\n"
62799
+ "body": "# Watch a mesh\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nA running mesh is a stream of live activity: who is present, what they are doing, what they\nare saying to each other. Cotal gives you three read-only surfaces onto one space. All three\nrender the *same* observer model ([`MeshView`](mesh-view.md)); none opens its own connection or\nre-implements the wire. Pick by where you are:\n\n| Surface | Command | Use it to |\n|---|---|---|\n| **web dashboard** | `cotal web` | a god-view browser dashboard: see at a glance what needs a human |\n| **console (TUI)** | `cotal console` | drive it interactively in the terminal: drill into agents, channels, DMs |\n| **stream** | `cotal console --plain`, or any pipe | tail a passive line log: grep it, pipe it, watch it in CI |\n\nThe web dashboard ships inside `cotal-ai` and is seeded automatically; the console ships with\nthe CLI.\n\n## Terminal console\n\n`cotal console` auto-selects its renderer: a real TTY gets the lazygit-style Ink TUI; a pipe or\n`--plain` gets the line stream. Both read from one invisible observer over the space.\n\n```bash\ncotal console --space main # the TUI for one space\ncotal console --plain # the passive line stream (also the default when piped)\ncotal console # no --space on an open mesh \u2192 the admin overview first\n```\n\n![The cotal console: a live roster of agents and their all-activity feed in a terminal TUI](../assets/quickstart.gif)\n\n**Admin overview.** On an open mesh, `cotal console` with **no `--space`** opens a space picker:\nevery space on the server (enumerated from its `CHAT_*` streams and presence buckets) with its\nagents, channels, and message counts. Pick one to drop into its console; `b` returns to the\noverview. `--space X` skips the picker. Under auth a server hosts a single space, so the console\nenters it directly (no overview). `D` deletes the selected space once you type its name. A space this\nhost registered as a static-auth mesh on that server is deleted under a teardown minted from its trust\nmaterial, which names the resume transfer buckets the space holds at that moment; any other space is\ndeleted under the console's own connection, bare on an open mesh or your `--creds` file.\n\n**Lenses and keys** (TUI). The layout is a roster, a live feed, per-channel tabs, a golden-signal\ntiles strip, and toggleable lenses:\n\n| Key | Does |\n|---|---|\n| `1`\u2013`9`, `[` `]` | select a channel tab |\n| `n` | the NEEDS-YOU rail: agents currently blocked or waiting |\n| `d` | the DM lens: per-peer roll-up and threads (god-view only; shows \"DMs hidden\" under chat-only creds) |\n| `t`, then `v` / `1`\u2013`3` | the topology lens: who-talks-to-whom, as a swimlane, a heat matrix, or a ring map, with the broker's own membership overlaid when the delivery daemon serves it (a header pill reads live, stale, traffic-only, or unreadable) |\n| `/` | search / filter the feed |\n| `:` | the command palette |\n| `D` | kill the selected agent (control-gated, below) |\n| arrows / `h` `l` | move focus; select a row for its detail card |\n| `?` \xB7 `b` \xB7 `q` | help \xB7 back to overview \xB7 quit |\n\n**Operator control.** Watching stays read-only, but the console can also drive the\n[manager](architecture.md#manager-agent-supervisor). The observer endpoint never carries\ncontrol: each action is one call through the same per-action path `cotal stop`, `cotal ps`, and\n`cotal spawn --detach` take. It resolves the mesh, mints a one-shot instrument (or connects bare\non an open mesh, or rides your bearer on a user-auth mesh), calls the manager over the endpoint\nrails, and keeps nothing. That gate is `canControl`, decided by whether that path can produce a\ncaller at all (a raw `--creds` file cannot), and it is independent of `canWrite`, which gates\nchat. `D` kills the selected agent behind a confirm (`y` graceful stop, `f` force-kill), and the\n`:` palette adds `spawn <persona> [name]` (waits for the join, like `cotal spawn --detach`),\n`status <agent>`, `ps`, `purge` (type the space name to confirm, like the space delete), and\n`delchan <channel>` (type the channel name to confirm): the web dashboard's channel delete, run\nfrom the console. It is core's `clearChannel` (a filtered history purge plus the registry entry)\nwith the dashboard's per-action authority, a one-shot `channel-purger` credential minted from the\nresolved mesh's seed on a static mesh, a `channel-purger` view on a user mesh, a bare connection\non an open one; no manager is involved. A wildcard is refused, so the one destructive control\nnames one channel. A refusal, from the broker or the manager, lands on the status line. On a\nspace with several managers, `D`, `:status`, and `a` are pinned to the manager hosting the seat and\n`:ps` merges every manager's rows; `:spawn` and `:purge` ride the class queue, as `cotal spawn\n--detach` and `cotal purge` do without `--on`. A manager that does not answer keeps its last known\nrows and is named as silent. A manager that answers with an error is named with that error and its\nold rows are withheld rather than shown as current.\n\n`a` on a roster agent (or `:attach <agent>`) suspends the console and hands the terminal to the\nseat: it is `cotal attach` run in place, the same one-use holder-bound mesh session, the same\nreconnect on a lost link, the same end reasons, with the observer running in the background\nthroughout. `Ctrl-]` (or `COTAL_DETACH_KEY`) returns and repaints the console, and the verdict\nlands on the status line: detached, the seat is gone, or why it could not attach. A bad\n`COTAL_DETACH_KEY` is refused before the screen is handed over. Attach is `canControl`-gated and\nneeds what `cotal attach` needs: a `pty` seat and the space's local seed to redeem the session\ngrant (a static-auth mesh; an open mesh holds no seed, so attach refuses there as it does on the\ncommand line).\n\n**Operator participant mode.** By default the operator is invisible, and a message it sends is\none-way: an agent's DM reply has no peer to land on. On an open mesh, the operator's **first send**\n(`:dm`, `:call`, `:ask`, `:msg`, or a compose) puts the operator on the roster: the console starts\na second, presence-only endpoint carrying the observer's own card (the same id, name, and\n`role: \"operator\"`, so it is the `from` of everything the observer sends), the status bar says\n`on roster` for the rest of the session, and the god-view tap the console already runs shows the\nreplies in the DM lens. The heartbeat survives a broker\nreconnect, and the operator leaves cleanly (an offline record) on exit. This is `canWrite`-gated,\nso a pure-watch session never registers, and a peer the broker refuses stays invisible, blocks that\nsend, and leaves the refusal on the status line so the operator is never told replies can land when\nthey cannot. Concurrent sends wait for the same startup result; the next later send retries after a\nrefusal. Under auth the console does not upgrade: the read-only default cannot\nsend at all, and an agent-grade `--creds` holds no live read of its own DM inbox (DMs ride its\nlifecycle-keyed durable, which the observer does not consume), so a send there is one-way and the\nstatus line says so once. An auth participant needs a credential profile that can publish\npresence and chat and read its own inbox; that profile does not exist yet.\n\nThe stream is line-oriented, so the signals stay out of it; it is just a timestamped log of\npresence changes and messages, ready for `grep`.\n\n## Web dashboard\n\nThe dashboard ships inside `cotal-ai` as the `@cotal-ai/web` extension and is seeded automatically on\nfirst run (like the built-in connectors), so `cotal web` is there out of the box and tracks your CLI\nversion on upgrade. If a seeded copy is damaged, `cotal ext seed --repair` restores it.\n\n![The web dashboard: roster, all-activity feed, golden-signal tiles, and the NEEDS-YOU lane](../assets/dashboard.png)\n\n```bash\ncotal web --space main # opens http://cotal.localhost:7799/\ncotal web --space main --detach # background; stop with cotal down web\ncotal web --space main --port 8080 --no-open\ncotal web --space main --host 192.0.2.10 # explicit remote bind and browser address\ncotal web --space main --creds ./admin.creds # use a cred you minted yourself\n```\n\nFlags: `--space` (default `main`), `--server` (the mesh's broker, resolved from the registry),\n`--host` (HTTP bind and browser host, default `127.0.0.1`), `--port` (1 to 65535, default `7799`), `--detach`\n(run in the background), `--no-open` (skip auto-launching the browser), `--creds` (override the\nself-minted cred). Remote exposure requires an explicit concrete `--host`; wildcard addresses\n`0.0.0.0` and `::` are refused because neither is a browser destination. Detached mode waits for\nthe real HTTP server at the bound host and port before returning, logs to `<mesh-root>/.cotal/web.log`, and is stopped by\n`cotal down web` or bare `cotal down`. If the server is not ready before the deadline, the timeout error ends with `; last probe <url>: <outcome>`, where `<url>` is the bound probe address (`http://127.0.0.1:<port>/api/meta` on the default host), not the browser launch URL, and the outcome is a transport error such as `ECONNREFUSED`, an HTTP refusal status such as `HTTP 401`, or `answered space <space>, pid <pid>` when another process on the port answered with a different space or pid. A mismatched space is cut to 40 characters and a pid to 20 (a number or boolean too long for that is named by its type, and a value that is neither text nor a number is named by its type), and the `<url>: <outcome>` text is capped at 300 bytes, counting the `...` that marks a cut. On the default host it probes `127.0.0.1`, because a system\nresolver such as WSL2's may not answer the branded `cotal.localhost`. It requires a recorded mesh root; after `cotal up` records the\nmesh, it can be launched from any directory. The branded URL `http://cotal.localhost:7799/` resolves\nto loopback with no DNS setup in Chrome, Firefox, and Edge. Safari and a system resolver such as\nWSL2's may not resolve `*.localhost`, so on this default the launch link is printed again at\n`http://127.0.0.1:7799/` with the same single-use token. A custom `--port` uses the plain loopback address. An explicit\n`--host` is also the advertised address and the only allowed browser Origin for that process.\n\n**The link is single-use, and the surface authenticates the caller.** Starting the dashboard prints a\nURL carrying a one-time token; opening it exchanges the token for a session cookie and the token is\nthen spent. Binding loopback keeps other *hosts* out, but it never kept out other *processes* on your\nmachine, nor a page in your own browser posting to `http://127.0.0.1:7799`, so the token is what\nmakes the session yours. Requests without it are refused with the reason named (`unauthenticated`,\n`launch-token-already-used`, or `cross-origin`) rather than silently returning nothing.\n\nPractical consequences: open the printed link in the browser you want to use it in, because the\ntoken is spent on first use. Re-opening it **in another browser or profile** is refused with\n`launch-token-already-used`. (In the browser that already holds the session, re-opening the link\nstill works: the session is checked before the spent token, so the page loads on the session you\nalready have.) If you lose the line, the link is also written to `<mesh-root>/.cotal/web.session`,\nmode `0600` on every write. The session is bound to the origin you opened, so one started on\n`http://cotal.localhost` does not carry over to `http://127.0.0.1`. Restarting `cotal web` mints a\nfresh link and invalidates every earlier session.\n\n**A god-view, minimal privilege.** The dashboard is always the full god-view; there is no\nread-only viewer mode. In auth mode it self-mints its own **admin** read cred (the scope that lets\nit tap DMs and anycast), then *drops the space signing seed* so a dashboard compromise can't mint\nidentities; it keeps only one narrow cred for its single write path. In open mode it connects bare.\nPass `--creds` to use a cred you minted yourself instead. On a per-user-auth mesh there is nothing\nto mint: the dashboard rides the read-only admin view over your login, and the channel-delete\nwrite path asks for its own channel-purger view per click (both need ledger scope `admin`;\n[identity & auth](identity-and-auth.md)).\n\nThe dashboard is read-only except that one write path: **deleting a channel and its content**\n(a filtered history purge plus the channel-registry key), which is POST-gated and confirm-guarded\nin the UI.\n\n**The views.** Every view keeps the same skeleton: navigation on the left (roster, channels,\nDMs), the selected content in the centre, the NEEDS-YOU lane always on the right.\n\n- **Monitor**: the all-activity feed (two-line messages with a delivery-mode badge, per-mode\n filter chips, and pause), the roster (status as shape *and* colour, role, a one-line activity,\n and the agent's harness: claude / opencode / hermes), and the golden-signal tiles\n (working / waiting / idle / offline / oldest-unattended). The roster groups live peers by the\n machine each one reports as its host, with a count per machine, so placement across machines\n reads at a glance. A peer that reports no host, such as a manager, is listed last under *host\n not reported*. Seats do not report which manager runs them, so the roster has no manager grouping.\n- **Channel view**: one channel's message list, members shown in the header.\n- **Direct messages**: a per-peer roll-up (one row per peer, not the n\xB2 pair list); expand a peer\n for its conversations. Threads key on authenticated ids, and every shown name, role, and status\n comes from the roster by id, never from the message payload.\n- **Agent Detail.** A per-agent drill-down rendered from the peer's card: name, role, the harness\n and model, capabilities, and what it's working on or blocked on.\n- **Graph view** (`/graph`, linked from the Monitor header): the same feed as a live\n force-directed constellation. Channels and agents are both nodes; a wire is drawn per\n **membership** (a spoke to every channel an agent subscribes to) and glows when a message flows.\n Membership is **broker-sourced and authoritative**, reconstructed by the delivery daemon from\n the broker's connection view unioned with the durable-members registry, so *silent* subscribers\n show too. A header pill reports the feed as *live*, *stale*, *traffic-only* (no daemon, e.g.\n open mode, where the graph degrades to traffic-derived spokes), or *unreadable*. The last\n meaning the read itself did not answer, which is a fact about the viewer rather than about the\n mesh, and is kept distinct from *traffic-only* for that reason. A **hide-offline** control\n collapses durable-but-away members. The live feed opens as the page loads rather than after it, so\n the pill reports the connection honestly from the first moment instead of sitting in its down\n state for as long as the first read takes. What the feed says outranks the page's own startup reads: a\n read issued before a live update cannot overwrite it when it lands afterwards, whether it answers\n or refuses, so a slow link cannot make the pill contradict what the feed already reported.\n Broker-sourced membership needs the delivery daemon (auth mode) and is provisioned on a fresh\n `cotal up`.\n\n**When a read does not land.** A poll that fails never blanks the page. The dashboard keeps the\nlast values it actually read and marks them stale in the header, naming which source is stale and\nwhy (`stale: peers, activity`, with the server's own reason on hover); the next successful read\nreplaces the data and clears the mark.\n\n**When the observer itself goes deaf.** Presence liveness is derived from heartbeat timestamps, so\na watch that hears nothing for longer than the TTL used to flip every peer `offline` at once. The\nsidebar is an online-only list, so the page emptied while the browser's connection pill stayed\nlive: that pill is the local SSE link, not the observer's upstream. Whole-bucket silence past\nTTL is now a fact about the *view*: the header says `stale: roster` (`observer presence watch\nsilent since T`) and the last-known online list stays on screen until the watch delivers again.\nA single peer whose own heartbeat lapses while the watch is live still drops out. A stall\nshorter than TCP-level detection never reconnects, which is why this is a freshness gate on the\nwatch rather than a `connection` event.\n\n**How the all-activity page is ordered.** When the page loads, the dashboard reads the newest chat\nmessages in the order the broker stored them and the newest direct messages, orders the two sets\ntogether by `ts`, the time each sender wrote into its message, and keeps the newest of them. Chat\nand direct messages are stored in separate streams with no arrival order in common, so `ts` is the\none key both carry. Where a sender's clock disagrees with the broker, messages can appear in an\norder different from the one they arrived in. Messages with the same `ts` keep the order the broker\nstored them in, and chat comes before direct messages.\n\nThe all-activity read is bounded by an 8000 ms deadline, so on a slow link it can\ncome back SHORT rather than late: the header then says `partial: activity`, and the page reports how\nmany sources answered out of how many were asked and names the ones that did not. Each missing source\nalso carries its reason in the response's `reasons` and in the line the server prints: `the read did\nnot finish within <deadline>ms` when the deadline cut it, or `the read failed:` and the error when it was\nrefused, such as a chat read whose filter list exceeds the broker's `max_payload`. A short page and a\ncomplete one are never the same bytes. On a link too slow to finish anything the honest answer is\nzero sources answered, and you keep looking at the last good data with the marker up. When the\ndeadline wins, Cotal also cancels the unfinished history pulls and removes their ephemeral consumers;\nan abandoned poll does not keep occupying the link and starve the next one.\n\nThe open channel's own history read is bounded by the same deadline. It is a single read, so there\nis no short page to serve: it either produced the messages or it refuses, naming the channel and the\nbound it exceeded, and the view keeps the messages it already had rather than emptying. A sparse\nchannel (fewer messages than the page) is bounded by that channel's own first and last matching\nsequences, not by walking the stream back to sequence 1. Every one of\nthese routes takes an optional `limit`, and a value that is not a whole number is refused outright\nrather than guessed at. The same holds for the channel name in the URL: an escape the decoder cannot\nread is the caller's typo, not a broken server. Either way a malformed request is answered as a bad\nrequest and never as the dashboard having broken.\n\nA refusal names the value it received, and it renders that value so you can read it. Characters that\nwould otherwise be invisible, rearrange the text around them, or mark part of it as an annotation\ncome back as their escape in both the response and the line printed in the terminal, so what you\nread is what was actually sent. Ordinary text, accents and non-Latin scripts included, is left\nalone: a character that renders as itself is left as itself.\n\nA channel name has to be the name the mesh actually uses: dotted segments of letters, digits, `_`\nand `-`, or a `*` or `>` where the mesh reads a whole subtree. Anything else is refused rather than\nquietly rewritten, because the wire rewrites what it cannot use and two different names would then\nbe one channel. That matters most on the delete button: a name that had to be rewritten would have\npurged a channel you did not name, while the answer showed you the name you typed. Delete takes no\nwildcard at all, so the one destructive control names one channel.\n\nThe delete request itself is capped at 8 KiB, which is far more than a channel name can be and far\nless than a machine can spend. A larger body is refused with a `413` naming the limit, the server\nstops reading it rather than taking it all in first and complaining afterwards, and the connection\nthat body arrived on is closed so the rest of it cannot be sent. It is never shortened to fit: a\ntrimmed name is a name you did not type, which is the thing the paragraph above exists to prevent.\nEvery other route takes no request body. It refuses an announced body before reading it with the\nsame `413` and closed connection. A request the gate refuses is closed the same way when it\nannounced a body, so a caller without a session cannot hold the dashboard reading an upload.\nOrdinary bodyless requests keep their connection as usual.\n\n**Message bodies render Markdown** (headings, lists, **bold**, `code`, blockquotes, links) across\nthe Monitor, channel, and DM views, parsed and sanitized client-side. Agent text is untrusted, so\nraw HTML is stripped and only http(s)/mailto links survive. A peer's name, role and activity show\nas plain text, including inside an attribute such as the activity's hover title. Long bodies still\nclamp to a few lines\nwith a per-message *show more*; a channel-wide **expand / collapse all** in the header opens or\ncloses every message at once.\n\nAppend `?demo` (`http://127.0.0.1:7799/?demo`) to render the design reference as a static\nshowcase with no mesh, including forward-looking elements that have no protocol backing yet\n(intent badges, approval requests, task-failed alerts). Live mode renders only what the god-view\ncan actually read.\n\n## What each surface can see\n\nEvery surface is a read-only observer; what it *sees* depends on its credential:\n\n- **console TUI** and **web** self-mint an **admin** god-view cred under auth, so both show the\n whole space: chat, DMs, and anycast (`dmVisible: true`).\n- **`console --plain`** deliberately narrows to the chat subtree, so DMs and anycast stay\n confidential in a line log even under an admin cred.\n- An explicit **`--creds`** limits each surface to the credential's grants; a chat-only\n observer cred hides the DM lens.\n\nSee [identity and auth](identity-and-auth.md) for the observer vs admin scopes, and\n[MeshView](mesh-view.md) for the shared model behind all three surfaces. Normative delivery and\nvisibility rules live in the [SPEC](../SPEC.md).\n"
62740
62800
  },
62741
62801
  {
62742
62802
  "slug": "workflows",
62743
62803
  "title": "Workflow runs",
62744
62804
  "kind": "Concept (informative)",
62745
62805
  "summary": "A workflow run is a program that coordinates agents over hours or days and survives the process that started it.",
62746
- "body": "# Workflow runs\n\n> **Concept** (informative) \xB7 **For:** people writing a durable multi-agent workflow, and implementers hosting one \xB7 **Normative:** [SPEC \xA714](../SPEC.md#14-workflow-runs-v05) and the language reference [`spec/cotal-lang.md`](../spec/cotal-lang.md)\n\nA **workflow run** is a program that coordinates agents over hours or days and survives the\nprocess that started it. The program is written in **Cotal Lang**, a small subset of JavaScript in\nwhich every interaction with the world is one of a dozen **effects** (`spawn`, `turn`, `ask`,\n`checkpoint`, `sleep`, `wait`, `notify`, `monitor`, and the four concurrency scopes) and everything\nelse is ordinary, pure JavaScript. Every effect is written into the run's **step journal** before\nit is performed and settled after, keyed by where in the program it happened rather than by when,\nso a run that dies is resumed on any host by **re-running the program from the top** with recorded\neffects returning their recorded results. Nothing about the interpreter is ever serialized: the\njournal and the program are the whole state.\n\n## A first program\n\n```js\nconst planner = await spawn(\"planner\")\nconst builder = await spawn(\"builder\", { worktree: \"wt-1\" })\n\nconst plan = await ask(planner, { name: \"plan\", schema: { steps: \"array\" } })\nconst ok = await checkpoint(\"approve-plan\", \"Approve the plan?\", { timeout: \"4h\", onExpiry: \"proceed\" })\nif (ok.status !== \"resolved\") {\n await notify([planner], { decision: \"approve-plan\", outcome: \"expired\" })\n}\n\nconst r = await turn(builder, { name: \"build\", deadline: \"30m\" })\nif (r.status === \"blocked\") {\n await turn(planner, { name: \"unblock\" })\n}\n\nconst outcome = await race({\n reply: () => wait(replied(builder), { timeout: \"20m\" }),\n giveUp: () => sleep(\"1h\"),\n}, { name: \"await-or-move-on\" })\nlog(\"outcome\", outcome.index)\n```\n\nRead it as the flowchart it is. `spawn` brings agents in; `ask` is the narrow case where the\nprogram itself needs a value (`schema` is a record the program hands the handler unchanged; the\nlanguage hashes it and gives it no meaning, and the handlers in this repository enforce it as the\nshorthand of the language reference \xA76.5);\n`checkpoint` is a durable pause a human resolves from anywhere, raced against a durable timer; `turn`\nwakes an agent for one turn and returns how it yielded; `race` runs two branches and keeps the one\nwhose recorded clock is earliest. Agents talk to each other in channels as they always do; the\nprogram never speaks in a channel, and the one thing it can put in front of an agent (`notify`) is a\nbounded decision record, not prose.\n\n## The mental model\n\n- **Pure code is JavaScript.** Loops, records, arrays, closures, template literals, destructuring,\n `try`/`catch`, arithmetic, `switch`, compound assignment, optional chaining, spread and rest: what\n you would write anyway, with the parts that hide effects or make meaning depend on the host removed\n (`class`, `this`, `new`, `for...in`, `==`, labels, regex literals, `Math`/`Date`/`JSON`, promises,\n generators). Every refusal names its code and the edit that fixes it. The builtins are a short list\n (`keys`, `map`, `sort`, `json.stringify`, `now()`, `random()`), and arrays, strings and numbers\n answer their usual methods (`xs.map`, `s.trim()`, `n.toFixed()`) and nothing outside that table.\n Records and arrays you build are yours to change until they cross an effect boundary; a member you\n do not own, a host prototype, or a value another branch built is refused with a code, never a\n surprise.\n- **Every effect is journalled and hashed.** A step is keyed `(scope path, kind, name, occurrence)`\n and its inputs are hashed. Reorder your program, add a step, rename a variable: recorded steps\n still match. Change what a step asks (a checkpoint's prompt, a sleep's duration, a turn's\n deadline) and the resume stops with a **divergence** naming the step, rather than replaying an\n answer to a question the program no longer asks.\n- **Concurrency is visible.** `parallel`, `race`, `fanOut` and `conclave` are the only ways to do\n two things at once, each branch gets its own journal namespace, and the scope writes its own\n entry saying how it settled: which arm won a race is a recorded fact, decided by the arms'\n recorded clocks and declaration order, never by a scheduler. A failure the program itself caused\n inside the scope settles the entry under its own catalog code (`fanOut` without a stable key is\n `L3021`, kind `runtime`), so a resume reads the same code the live run threw; a plain failure\n from the handler records the generic `L4000` `scope-fault`. A value the program throws inside a\n branch is recorded with that fault, so `catch` receives the same value on resume, and in a\n migration's dry walk, as it did live.\n A branch may not write to anything declared outside it; return the value and read it out of the\n scope's result.\n- **Time and randomness are tamed.** `now()` is the branch's run clock, the end of the last effect\n it awaited; `random()` is a seeded stream derived per scope. Both replay identically.\n- **Values freeze at the boundary.** What crossed into or out of an effect is what the journal\n recorded, and it cannot change afterwards; build a new value.\n- **The journal is the debugger.** Every entry carries its key, its inputs' hash, its outcome and\n its timing, and every error is in the program's own coordinates. A run can be **simulated** with a\n scripted handler and **dry-run** to a plan before it touches an agent. The simulator is\n discrete-event: timed effects park at their wake times and are delivered in wake order on one\n virtual clock, so concurrent branches accumulate the durations they wrote and a simulated `race`\n is decided by the same rule a live handler produces (least recorded clock, ties by declaration\n order). A `sleep(\"1m\")` arm beats a `sleep(\"1h\")` arm whatever their declaration order. Behind the\n worker bridge the simulator delivers its next wake only after the thread reports it has reacted\n to the last one, so a bridged simulation settles a race the same way, also when the dry run's\n recorder wraps the simulator.\n\nFull rules, with every code: [`spec/cotal-lang.md`](../spec/cotal-lang.md).\n\n## Continuing a run\n\n**Resume** is re-execution: the driver replays the journal, the program runs from the top, recorded\nsteps return instantly, and the first unrecorded step is performed live. It refuses a journal that\nbelongs to another run, a pin that differs from the recorded ones, and a different language version.\n\nA failed journal entry replays its error, while a pending entry lets the handler recover the\nexternal work it bound before the interruption.\n\nA step that writes to a far side that honors no idempotency key (posting a comment, sending a mail)\nbelongs in `once`. A resume that finds a step inside `once` begun and never settled does not\ndispatch it again: it opens a hold, a checkpoint under a token derived from the step's recorded\nrequest id, whose prompt names that id. Answer it with\n`cotal run answer <run> <step-key> --value <json>`, and the value becomes the step's result. An\nexpired hold fails the step with the catchable L4027. Only `ask` runs inside `once`, so wrap just\nthe step that writes: a host restart while it is in flight costs a settle.\n\n```js\nconst publisher = await spawn(\"publisher\")\nconst res = await once(async () => {\n return await ask(publisher, { name: \"publish\", schema: { commentId: \"number\" } })\n}, { name: \"publish-360\" })\n```\n\n**Migrate** moves a run onto edited source. A dry walk of the new program over the recorded journal\nfinds every recorded step the edit changed (a divergence) and every one it no longer reaches (an\norphan), and the orphan table says what each means: a removed `sleep` is nothing, a removed `turn`\nalready happened, a removed `spawn` is a live agent you must adopt or release, a removed resolved\n`checkpoint` is a human decision you must explicitly discard. The decision is filed as a\n`migration` record with the actor's name on it. An adopted seat (`--adopt <name>#<uid>`) goes to\nthe edited program's next `spawn` of that persona, which returns the recorded handle and mints\nnothing, so the agent keeps its identity, its worktree and its turn history across the edit. A\nreleased seat (`--release <name>#<uid>`) is despawned when the migration commits, through the same\ndischarge a cancelled branch's seat leaves by, so the record never claims a release nothing did.\nThe spawn that adopts a seat binds the orphaned spawn's goal as its own, so a resume of that step\nreads the same seat back and a cancellation of it despawns the seat it holds.\n\n**Fork** starts a new run from a named step of an old one, copying the prefix under the parent's\npins (seed included, so the copied history's pure draws are the same draws). The child is a new run\nunder a new id whose record names the parent and the cut step (`forkedFrom`); the parent is\nuntouched. A spawn inside the copied prefix is honoured by its `onFork`: `\"adopt\"` copies it, and\nthe child shares the parent's agent (the manager shows that seat one turn at a time across both\nruns); `\"respawn\"`, the default, would mint a fresh identity the copied turns do not address, so\nthis host refuses that cut (L5019) rather than rewriting the parent's history.\n\nFork planning and migration inspection use the recorded language version. Version-1 history uses\nthe interpreter. Version-2 history is inspected inside a locked-down worker with a read-only\njournal, without a live effect handler or durable store. Inspection stops before the fork's cut\nstep or any effect that needs new work. Program catch and finally blocks cannot extend the cut.\nThe recorded pins are preserved.\n\n## From an agent session\n\nFresh `cotal setup` defaults declare `capabilities: [spawn, run]`. On an authenticated mesh,\nthat exposes `cotal_run` alongside the teammate tools. The selected manager must support execution.\nRead `cotal_docs` pages `lang-card` and `workflows`, then try:\n\n```json\n{\n \"verb\": \"start\",\n \"source\": \"await sleep(\\\"1s\\\", { name: \\\"first-run\\\" });\",\n \"file\": \"first-run.cotal.js\"\n}\n```\n\nPass this object to `cotal_run`. `source` contains the program; `file` only labels diagnostics\nand reads nothing from disk. The response returns a run ID before execution finishes. Call\n`cotal_run` with `verb: \"status\"` and that `runId` to inspect the state and step journal.\nA completed timer records its sleep step as `ok`.\n\n### If `cotal_run` is missing\n\n1. Call `cotal_orientation` and check the connector version, capabilities and tool list.\n Upgrade an older installation using the [upgrade guide](https://github.com/Cotal-AI/Cotal/blob/main/docs/UPGRADING.md).\n2. Have the operator add `run` to the persona's existing `capabilities` list, for example\n `capabilities: [spawn, run]`. `spawn` alone does not expose `cotal_run`. Setup leaves existing\n personas unchanged except for its [byte-exact legacy migration](getting-started.md).\n Peer persona-definition tools cannot grant capabilities.\n3. Relaunch the agent through the manager from the updated persona so it receives newly issued\n credentials and a fresh connector configuration. Editing the file or reconnecting with the\n old credential does not grant new broker permissions. If the launch sets `COTAL_CAPABILITIES`,\n update that override too; it takes precedence over the file.\n4. Check `cotal_orientation` again, then call `cotal_run` with `verb: \"ps\"` before starting work.\n\nTool visibility alone does not establish execution support. Hosted runs require issued caller\nauthority. Static authentication issues it to its credentials. On a user-auth mesh, a registered\nparticipant manager hosts authorized runs. A managed agent invokes `cotal_run` through its own\nlauncher-provided bearer command and immutable manager instance selection. Its current managed\ngrant and its direct parent's current grant must both carry `run`. A removed grant, stale lifecycle\nor wrong manager selection refuses rather than borrowing a signed-in user's authority.\n\nOpen meshes may expose the tool but refuse hosted runs. A local user-mode host without remote\nrun authority and an `execution: \"none\"` control manager also refuse the family. A legacy\ncredential without issued authority must be replaced through the current issuance path.\n\n## Operating a run\n\nThe manager hosts runs. `cotal run start` hands the program to the manager of the resolved mesh\n(the usual `--space` / `--server` / `--creds` flags), which validates it, mints the run id, drives\nit in its own process, and answers with the id once the run is recorded. The terminal is free the\nmoment the id prints; the run continues on the manager through every pause, and a manager restart\ntakes back every run it had recorded running, from the journal, under the next epoch. `resume`\nnames a run the manager recorded and is refused while the manager is already driving it. `ps` and\n`journal` read; `answer` resolves an open checkpoint, or an open `ask` attempt, from any terminal\nor agent that holds the `run` capability.\n\n`journal` prints an open pause's question under its step. Once a checkpoint or `ask` settles with an\naccepted answer, it instead prints the answer value as JSON, who answered, the artifact when one was\ncited, the recorded time, and the accepted answer id. Expired pauses and ordinary steps print no\nanswer line.\n\nA settled step is never answered twice, so a participant who changes their mind uses `amend`. It\nfiles a new answer beside the accepted one, naming the answer it supersedes, and `journal` prints\neach amendment under the step as an `amended` line, in the order the store committed them. The last\nline is the current position, whatever clock each amender's `at` came from. The pause stays settled\nand the run keeps the answer it acted on. A step that is still open, or that settled with no answer,\nrefuses an amend. The manager records the amender from the credential, as for an answer, and a\nspawned seat may amend only an answer recorded under its own name.\n\n```bash\ncotal run start --file build.cotal.js # the manager starts it; the minted id is printed\ncotal run ps # list run records: state, holder, lineage\ncotal run journal run-3f2a90c41b7e0d5a6c884e19b02df4a1 # print the durable step journal\ncotal run resume run-3f2a90c41b7e0d5a6c884e19b02df4a1 # the manager takes the run back\ncotal run answer run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"yes\"'\ncotal run amend run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"no\"' # record a changed position\ncotal run migrate run-3f2a90c41b7e0d5a6c884e19b02df4a1 --local --file build-v2.cotal.js # check an edited program against the journal\n```\n\nA program that does not validate is refused before anything is recorded, with every problem in the\nanswer as the validator would print it. The driver records the program beside the run, so `resume`\ntakes the run id alone and the manager reads the source back; an edited program is a `migrate` or a\n`fork`, never a resume. `cotal run migrate <runId> --local --file <program>` is that check: it\nreplays the run's journal and walks the edited program over it, prints whether the migration is\nadmissible, how many journal rows the walk accounted for, every orphaned step with its verdict and\ncode, and exits 0 on admissible and non-zero on not. It reads only, under the same credential\n`journal` reads on, and the commit side is not reachable yet: the report itself says what a commit\nwould file and that this invocation filed nothing. An answer is recorded under the answerer the\nmanager knows from the caller's credential: a managed agent by its name, anyone else by their\nprincipal. The request\ncarries no name. An agent with `capabilities: [run]` has the same five verbs as the `cotal_run`\ntool ([MCP tools](mcp-tools.md)), so a program can be written and started from inside a session.\nA `start` or `resume` answers once the run's record is written, within a bounded wait; a manager\nthat is still taking back a predecessor's runs at boot refuses both with `unavailable`, and a\nretry a moment later is the whole remedy.\n\n`--local` drives the run in this process instead: `start`, `resume` and `answer` exit when the\ndrive settles, `--by <who>` names the answerer, and `cotal run resume <runId> --local --file\n<program>` is how a run with no recorded program is continued. On a static mesh the local drive\nmints the run's own credential from the folder's trust material, so it runs from the mesh's\nproject folder. A local start also names the run's channel ceiling itself:\n`--admit-read <channels> --admit-publish <channels>`, comma-separated patterns or `none`, both\nrequired. The record it writes says an operator admitted the run and why, and the host checks\nit the same way it checks a hosted admission. A registered remote user-auth manager can host a\nrun through its issuing host. The host resolves a versioned caller against live issuance,\nadmits the run, and signs only the run's fixed driver, mediator and one-shot operator credentials\nfor manager-held nkeys. Renewal checks the activated attempt; the manager holds no signer.\nLocal user-auth runs remain unavailable because a user bearer holds no run rows. An open mesh\nhosts none either, since it issues no caller authority to admit a run under.\n\nA logged-in user starts runs on that remote manager with `cotal run start`. The auth callout issues\nthe user's manager connection against the user's actor-ledger row, the CLI reads the generation\nback from the connection's accepted row, and every `run` verb rides the versioned rail. The issuing\nhost admits a run only for the owner who registered the manager, and on every resume and answer it\nchecks that owner and that the caller's issuance is still live. It also watches the start, resume\nand answer requests on the broker itself, and admits or issues for one the manager forwards only if\nit saw that request, and only once. A resume or answer gets issuance only for the run, step, endpoint\nand amendment that request named. An answer's\ncredential reaches one pause, and the host reads which one off the run's own journal, for a run\nadmitted on that manager's instance. A request bound to another manager instance or epoch gets nothing, and so does one whose class or pinned contract is not the one the manager registered. Another user's start, answer or resume is refused, and so is a revoked actor's.\nSuch a run spawns, turns and despawns agents owned by that user. A spawn has the reach of the user\nwho started the run, as their actor-ledger row reads at that moment, and the host enrolls each agent\nthrough its managed-agent enrollment. A spawn may be placed on the manager that hosts the run and on\nno other instance. [User-auth run start](design/user-auth-run-start.md)\nrecords the path.\n\nA hosted run is **admitted** under the caller that started it. The caller's credential is an\nissuance ([identity and auth](identity-and-auth.md#issued-authority)): its requests ride a\nversioned rail that carries the credential's generation, and the manager resolves that\ngeneration's recorded permission ceiling and writes it beside the run before the driver starts.\nThat ceiling, the caller's own channel scope as it was issued, is what the run may read and post\nin channels; the manager's own reach never stands in for it. A request from a credential minted\nwithout an issuance is refused with `permission-denied` and a detail naming the caller. A run\nwhose caller had no channels can still sleep, checkpoint and turn agents; its `wait` on a channel\nis refused at the effect.\n\n`cotal run revoke <runId> --local --by <who> --reason <text>` writes the run's revocation marker\nfrom the project folder. An empty `--by` or `--reason` is refused before anything is written. The\nadmission itself is never rewritten. Every host reads the marker\nbefore its next channel effect, so an open `wait` refuses at its next poll, and no resume,\ntakeover or manager restart continues the run. Revoking twice is not an error, and the first\nreason stands. A run whose admission is missing or revoked is left parked by the manager's boot\nreconcile, named in its log.\n\n`run ps`, hosted or `--local`, reads the marker beside each run record and prints `revoked` for a\nrun that carries one, whatever state the record itself holds, with the revoker and the reason\nunder the table. The record is display only here: a revoke writes no terminal state, because no host drove\nthe run to one and the journal owns the facts. A marker the listing cannot read, whether the store\nis unreachable or the marker has a version or shape it does not know, prints `unchecked` in the\n`STATE` column. The reason and the state the record carries go to stderr, and the command exits 1\nonce every row is printed. The hosted `run-ps` rows carry the marker as `revoked` (`by` and\n`reason`) or a failed read as `revocationUnreadable`, beside the record's own `state`.\n\nA run whose step was refused (L5016) stays held; a\nresume on a host that can perform the step performs it live and continues from there.\n`journal` prints what an open pause asks beneath its step key, which is the address `answer` takes\nback. Checkpoint expiry rides the mediated timer writer, which the delivery daemon pumps on a live\nmesh; on a bare broker a pause still resolves, it just cannot expire.\n\n## What is on the wire\n\nThe run's wire footprint is [SPEC \xA714](../SPEC.md#14-workflow-runs-v05):\n\n| Thing | Where | What it is |\n| --- | --- | --- |\n| the run | `run.<endpoint>.<runId>` record | the resolved **pins** (seed, logical epoch, budgets, language version) on the immutable half; holder, lease and `journalHigh` on the status half |\n| the program | `program.<endpoint>.<runId>` record | the source the run was started from, verbatim, written once by the driver that pinned the run; what a resume reads and what a migration is measured against |\n| the step journal | `WFJ_<space>` stream, one subject per run | append-only, no age eviction, no Direct Get; every append fenced by the run subject's own sequence; takeover is replay-then-activate |\n| a checkpoint answer | `answer.<endpoint>.<token>.<answerId>` | the payload beside the one-use settle fact; the settle names the answer it accepted |\n| a notice | `notice.<endpoint>.<runId>.<addresseeId>.<noticeId>` | one bounded decision told to one agent, rendered ahead of its next turn |\n| a migration | `migration.<endpoint>.<runId>.<migrationId>` | the report and who applied it, keyed by the report's own digest |\n| the admission | `admission.v1.<endpoint>.<runId>` in `cotal_admission_<space>` | the caller the run was admitted for, its channel ceiling and its provenance; written once before the driver starts, and the store refuses a second write on the key |\n| a revocation | `revoked.v1.<endpoint>.<runId>` in the same store | who revoked the run and why; create-only, idempotent, permanent at the broker, read by every host before its next channel effect |\n\nThe driver writes `journalHigh` at activation and again after each journal append, before the\nprogram acts on the entry. A successor whose replay ends below it refuses the run with\n`RunJournalTailTruncated`. That holds for records appended since the last activation too.\n\nA run's **driver** connects on a `run-driver` credential minted for one run and takeover\nattempt. It can append to its journal, use its replay durable, and write its own `run`, `program`,\n`notice` and `migration` records. It has no store point reads, checkpoint writes, chat consumers,\nor channel and membership registry grants.\n\nThe hosting process keeps a separate `run-mediator` connection for effects and reads. The driver\nreceives methods and data from that host; it never receives the mediator credential or connection.\nThe host checks the journal's current activation and step identity before dispatch, and checks\npause and wait authority again at each operation. Cancellation cleanup also admits losing steps\nnamed by a settled parent whose `cancel.issued` is still false. That permission allows cleanup;\nit cannot mint or rearm a cancelled pause. Wait acknowledgements consume host-held delivery\nreceipts. A recorded match can be reread only at its bound sequence and channel. A conclave's\nrecorded ownership flag must match its step-derived channel before registry writes or cleanup.\n\nThe mediator retains endpoint-wide checkpoint rights and stream-wide leader reads as trusted\nhost authority. Record reads exposed to the driver are restricted to its own run's keys. Reads\nthat decide writes remain leader-served. A read of the journal uses the run's filtered replay\ndurable, including the diagnostic for a journal with no run record. That durable is named after\nthe takeover, and an attempt reads it many times, so reads under one takeover run one at a time in\nthe hosting process and a replay removes a durable of its own name that an interrupted earlier read\nleft behind. A durable that survives a replay's own delete belongs to a reader the process cannot\naccount for, and reading its tail is refused. A drive handles that refusal as a takeover does: the\nreads behind its steps, and the diagnostic for a journal with no run record, replay up to three\ntimes before the refusal is raised. An operator read runs under a takeover minted for that read and\nreports the refusal on its first read.\n\nA served read uses a one-shot `run-operator` credential. An answer uses a read to find the open\npause, then a second credential pinned to that token for the answer and settlement.\n`cotal run --local` uses the same driver/mediator split. On an authenticated mesh it needs the\nlocally recorded space signer to mint both credentials; a single `--creds` file is refused.\nDirect library users supplying broker clients to `MeshHandler` are constructing a trusted effect\nhost. A hosted driver receives its closed effect interface instead.\n\nThis split confines broker credentials; it is not process isolation for injected host code.\nThe runtime and its effect host share the manager process. A run's channel reach is the admitted\nceiling ([SPEC \xA714.8](../SPEC.md#148-run-admission)): the starting caller's issued channel scope,\nrecorded once in `cotal_admission_<space>` under a per-run `run-admitter` credential the driver\nnever holds, and re-read by the host before every channel effect. Spawn and turn keep their own\ndelegated checks; `notify` writes agent-addressed notices and is not channel publication. Treat\n`run` as program-execution authority bounded by that ceiling, not as sandboxing of the program.\n\nA version-1 fork can replay its settled parent history through the host. Inherited checkpoint\nidentifiers carry no authority to read, rearm or claim the parent's pauses. New child effects use\nchild-derived identifiers. A fork is a new run and takes a new admission under the caller who\nforks it; the parent's ceiling is not inherited.\n\n\n## What ships today\n\nThe language, its validator, interpreter, simulator and dry run are `@cotal-ai/lang`\n(`packages/lang`), usable in-process with your own effect handler and with no broker: `validate(src)`,\nthen `run(src, { runId, handler })`, and `resume(src, journal, { runId, pins, handler })` to pick a\nrun up from its journal (the package README has the snippet, with `SimHandler` as the handler). That\nis the in-process route, yours to drive with your own handler; a run the driver starts executes on\nthe compiled engine, as the engine paragraph below says. The wire\nsubstrate of \xA714 (the `WFJ_<space>` stream, the five record kinds, the activation barrier, the\nper-run grants) is in `@cotal-ai/core`, and the run driver, journal store, migrate and fork are\n`@cotal-ai/runtime` (`implementations/runtime`). The migrate check is reachable as\n`cotal run migrate <runId> --local --file <program>`; committing a migration it judged admissible\nis not reachable from any surface yet. On the mesh handler, `sleep`, `checkpoint`,\n`wait(message(...))`, `wait(idle(...))`, `wait(down(...))`, `wait(replied(...))`, `notify`,\n`spawn`, `conclave`, `ask`, `monitor` and `turn` are durable.\n`spawn` is\nthe manager's spawn action submitted under the step's own identity: the goal binds under the step's\nrequest id, so a resumed run re-attaches to the same seat instead of allocating a second one, a\nfailed or refused spawn is catchable as L4002 with the manager's recorded reason, and a spawn on a\nrace branch that loses is despawned by the run's own cancellation sweep. A seat belongs to the run\nthat spawned it: when the run completes, it despawns every seat it spawned, including a race\nwinner's and one whose spawn failed while its process stayed up. A spawn marked `onFork: \"adopt\"`\nis the exception: a fork can share that seat and no run can see whether another still uses it, so\nthe seat stays up until you stop it with `cotal stop` once every run sharing it is done. A seat a\nmigration handed to a later spawn follows that spawn's policy, and it stays up if any spawn that held\nit was marked `onFork: \"adopt\"`, because a fork taken before the migration may still share it. In a\nspace with several managers, a despawn counts a seat as already gone only when the manager that\nallocated it says so. A run that\nfails or is released keeps its seats until a resume completes it or you stop them with\n`cotal stop`. Start a seat with `cotal spawn` when it should outlive any run. `permits` are the budgets\nthis host meters: `turns`, how many turns the run may dispatch to the agent, and `wallClock`, a\nduration from the spawn after which no turn is admitted. The turn that would exceed one is the\ncatchable L4001 (kind `permit-turns` or `permit-wall-clock`; a deadline the remaining wall clock\ncannot hold counts as exceeding it), an adopted run counts the turns its journal recorded, and a\nbudget the host has no meter for, such as `tokens` or `spend`, is refused at the spawn rather than\naccepted and ignored. `supervise` is the restart policy this host asks the manager to enforce:\n`restarts`, how many in-window process deaths may come back under the same handle, and `window`,\nthe duration those deaths are counted in (default `10m`). The manager restarts the process in\nplace under the same name, lifecycle uid, persona, worktree and permits; `monitor` does not fire\nfor a restart, and `wait(down)` fires only when the seat is gone for good. Spending the budget\nretires the seat, and the next `turn` is the catchable L4002. A policy this host cannot enforce\n(an unknown key, a user-mode seat, or a runtime that cannot respawn a name in place) is refused\nat the spawn rather than accepted and ignored. `events: false` is the workflow form of\n`cotal spawn --no-events`: the seat starts without its AG-UI event plane. A connector that\npublishes none, such as Hermes, needs it, because an omitted `events` arms the plane and the\nmanager refuses that connector at the spawn. A value that is not a boolean is refused at the\nspawn. `conclave` joins its\nmembers to a real channel as durable membership rows: the channel derives from the step's own\nrequest id when the program names none (a program-named channel is borrowed, never torn down, and\na membership that predates the conclave survives its close), each member handle resolves to its\nprincipal through the seat's own presence row (an absent member is catchable as L4002), and a\nconclave cancelled on a losing branch is released by the same cancellation sweep. `ask` parks one\ncheckpoint-plane pause per attempt, answered through `cotal run answer` as a checkpoint is, and\ntells the agent through the same relay `turn` uses: one relay per attempt under the attempt's own\ntoken, carrying the schema, the attempt count, the deadline and the previous refusal, which the\nseat's connector renders as the record wanted and the hosted command that answers it. The relay\nnames the ask's absolute deadline, so the manager stops serving an attempt at the instant its\npause expires and refuses a relay already past it. When that\nliteral command is run inside the managed seat, the CLI reuses the seat's lifecycle credential and\nissued caller identity rather than minting an operator instrument. The command\ndoes not take `--by`: the manager records the authenticated caller as the answerer. A spawned seat's\nbaseline credential carries only the self-targeted `run-answer` row, and the manager accepts it only\nfor the open ask or escalation relayed to that exact incarnation. It cannot answer another seat's\nask, an unrelayed checkpoint, another run, or start and resume commands. An ask addresses\nan agent the run spawned (anything else refuses before an attempt opens), a resumed attempt tells\nthe seat nothing twice, and a seat gone at the relay is L4002. On the pause itself:\nthe shorthand of the language reference \xA76.5 is enforced (an unreadable schema is L4022), a\nnon-conforming answer costs one attempt and its refusal reason is recorded on the entry for the\nanswerer to read, exhausted attempts (default one) are the catchable L4006, and so is the one\nabsolute deadline for the whole ask passing with no conforming record (its kind is `ask-deadline`),\nincluding one that passes before an attempt's pause is armed.\n`checkpoint` binds what it asks on its own entry, so `cotal run journal` prints the question under\nthe step key an answer is addressed by while the pause is open: the address alone left whoever was\nasked reading the source to find out what \"approve\" meant. The entry also records the open\nattempt's deadline and the `onExpiry` it was armed with, which `run journal --json` reports; what\nan expiry does is still decided from the program's source. An escalation's attempt records its own\ndeadline, one `timeout` after it opens, and a resumed attempt keeps the deadline it recorded. After a checkpoint or `ask` accepts an\nanswer, the journal prints that accepted answer's recorded value and attribution under the settled\nstep. A checkpoint's comes from its frozen result, and the journal never substitutes another filed\nanswer or invents fields that result does not hold. An `ask`'s result is the value alone, so its line\nis read from the answer record its last attempt's settle named, even when that value is a record with\nfields named like a checkpoint's. Amendments print on their own lines after it.\nAn `escalate` addressed to an agent this\nrun spawned is relayed to that seat through the same turn relay an `ask` uses, carrying the prompt\nand the token to answer under, held to the pause's own deadline; a `to` naming anyone else is a\nperson, and their pause stays the one anybody can answer, with the addressee recorded and rendered\nbeside the question.\n`monitor` registers interest in an agent, and the\nregistration is the journal entry itself, carrying the handle it registered: monitoring an agent\nthat is already dead succeeds, and the death is the wait's to observe. `wait(down(...))` observes\na monitored agent, and refuses one the run never performed `monitor` on. It reads the death off presence liveness, the\nsame witness a conclave join resolves members through: the value carries the handle, the reason\n(`lapsed` when nothing live holds the name any more, `superseded` when a live row holds it under\na different incarnation) and the time of observation. Presence liveness skips any value without a\nstring `card.id` and `card.name`, so a participant that publishes a malformed row under its own key\ncannot fail another run's wait, turn or conclave join. A superseded incarnation is down at once. A\nlapsed one is down only after its presence row has stayed gone for 30 seconds, because a seat whose\nconnector stalls past the row's 6-second TTL, under host load or across a reconnect, renews it under\nthe same incarnation and is still working. The 30 seconds count only across presence reads that\neach end within 6 seconds of the previous one starting, so a slow read or a run of failed reads, which\ncould hide a renewal, starts the count over. Even then the run asks the manager first: a seat whose\nprocess the manager still runs is alive however long its row has been gone, so the count starts over.\nOnly the manager that spawned the seat can report it gone, because another manager in the space knows\nonly its own seats, so the run asks again until that one answers. When it does not answer, the presence\nrow is the only witness and the lapse stands. The asking stops at the wait's timeout or cancellation,\nand at a turn's deadline, yield or cancellation, which then decide. A wait that begins\nafter the death resolves once that holds, and a timeout resolves null on one absolute deadline a\nresumed run re-attaches to.\n`turn` wakes one seat for one host turn through the manager as a pull-shaped relay: the run\nsubmits the turn under the step's own identity, the manager holds it as a goal pinned to the\nseat's incarnation, and the seat pulls it under its own reach ahead of its next host turn, so\nnothing is pushed into a session mid-thought. The payload the seat reads names the run and the\nstep and carries the rendered run context, plus any pending notices addressed to it, which the\nturn consumes. The seat yields through `cotal_yield` (`done`, `blocked`, or `handoff` with an\naddressee), and ending its host turn yields `done` for every turn it was shown. A `handoff` names\nanother seat the same run spawned: the next `turn` in the same scope to that seat records the\nlink, a handoff to a name the run never spawned is the catchable L4005, and one to a seat bound\nto a different worktree is L4004. The deadline elapsing before any yield is the catchable L4003:\nthe acceptance names the instant, the manager's goal-bound hold denies at it, and the run arms its\nown pause on that same instant, so either side outliving the other still converges on the same\nanswer. A seat that dies mid-turn is read off its own presence row by the run itself, with the\nsame 30-second confirmation for a lapsed row, and is the catchable L4002, and a death the manager\nmarked on the deadline terminal reads the same way. Two\nturns on one seat, from two branches or from two runs, reach it one at a time: the language\ndispatches the second when the first settles, and the manager shows a seat the oldest unsettled\nturn alone. A turn's goal id belongs to the caller that submitted it, so two callers may each submit\na turn under the same id. A seat yields by goal id alone, so the manager refuses to hand one seat\na second caller's turn under an id it already holds. On an auth mesh the relay needs no extra grant: every spawned seat's baseline\ncredential carries its own pull, yield, and caller-bound answer rows, the run driver's operator instrument carries the\nturn request and the `inspect` read that confirms a lapse, and the manager arms the deadline hold over its own serve grant and expires it\nitself once due. An accept the manager cannot finish is unwound to a failed terminal on the goal\nit bound, and a retry of that submission is refused naming the terminal rather than accepted a\nsecond time.\n`wait(replied(...))` observes those turns from another branch: a completed turn is a reply, and\nthe wait resolves with the observation record (the handle, the yield's status and note, the\nyield's own stamp). It reads as a level, the way `wait(down)` does: a reply that already exists\nresolves the wait at once, and two replies resolve to the latest by the yield's stamp. A denied\nor cancelled turn is never a reply, so an unanswered wait rides its own mediated timeout to\n`null`, and a handle the run never spawned or turned refuses loudly, since only this run's turns\nare observable. A turn the run itself ended without an accepted yield (its deadline, a\ncancellation, a refused handoff) is never a reply, whatever the seat yields to the relay later.\nA cancelled branch also withdraws what it relayed to a seat before its cancellation completes: its\nturn, its ask attempt or its escalation ends `cancelled` through the manager's reserved `cancel`\n(SPEC \xA713.6), so the seat is not shown it after the branch's scope settles and the next turn to\nthat seat does not wait behind it. Only the manager that accepted the relay holds it, so in a\nspace with more than one manager the run sends the cancel again while another manager refuses it,\nuntil the accepting one answers. A cancel the accepting manager refuses is sent again until it\nlands or the relay's deadline passes, so the branch's cancellation does not complete while the\nseat can still be shown the relay. The run reads that deadline from the relay's goal and retries\na failed read, since the relay may still be served until the deadline is known. A goal record\nthat can never yield a deadline, such as one that is not JSON, fails the branch's step at once,\nand so does a refusal that lasts past the deadline.\nA `spawn` may bind its agent to a **logical worktree** (`spawn(\"builder\", { worktree: \"wt-1\" })`):\nthe handle carries the id, and the run enforces the one rule the language states about it: two\nagents never share a worktree concurrently. The validator rejects the literal case up front\n(L3022: two branches of one concurrent scope spawning into one literal worktree, named branch\nfunctions included), and the runtime guards the rest, computed ids included: a spawn claims its\ntree before it submits, so a second spawn into a tree held by a live seat or by a spawn still\nbringing one up is the catchable L4008, a spawn that ends without a handle gives the tree back,\nand the tree is reusable the moment a holder's presence row is gone, so a discharged race loser\nor a crashed seat releases its tree with no bookkeeping. A spawn the endpoint refuses at accept\nis the catchable L4000 (L4001 when the refusal is the endpoint's seat capacity), and one whose\nseat never came up is L4002. A refusal that states the command did not run is answered\nbefore it gets that far. In a space served by more than one manager the resolve and the invoke\nare separate trips through the same anycast queue, so a run's call can reach an instance it did\nnot resolve against, and that instance refuses ahead of any effect. The run drops its resolved\nhandle, re-describes and re-issues, for a bounded number of attempts; after them the refusal\nsurfaces as the effect's own failure and still states that nothing ran. A spawn that names a\n`placement` addresses one instance by name, so a refusal from it is that incarnation answering\nabout itself and is never re-issued. When the spawn also names `cwd`, that manager resolves the\nexisting absolute directory to its canonical host path before accepting the spawn. A missing,\nrelative or non-directory path refuses with no seat and never falls back to the manager workspace.\nOne hosted run may name one placement instance; a program naming several is refused instead of\nwidening one run credential across hosts. Placement is accepted only from an object-literal spawn\noption bag whose `endpoint` and `instanceId` are string literals. A computed option bag, a computed\nplacement field, or a spread in either object refuses at `run-start`. The option argument may be\nabsent, and an object-literal bag without placement is accepted.\nA turn handoff across worktrees is the L4004 described above. Recovery keeps these honest: a resumed run\nreseeds its roster, holders and handoff memos from its own journal, and the driver re-issues any\nrecorded-but-undischarged cancellation at adoption, before the engine performs a new step, so a\nloser a crash left alive does not keep its seat or its tree while the resumed run works on. The\nsame sweep withdraws a cancelled branch's relays and undelivered notices: a notice waits on the run for its\naddressee's next turn, so a decision the run cancelled would otherwise arrive at an agent with\nnothing to distinguish it from one that stood.\n\nEvery effect the language defines performs on the mesh handler; nothing is refused as\nnot-yet-durable any more. The operator surface over the driver is `cotal run`; the section above has the verbs.\n\n**Two engines, and which one runs your program.** The tree-walker is language version `1` and the\ncompiled engine is version `2`, two languages rather than two speeds of one (`spec/cotal-lang.md`\n\xA78.4 lists what differs). The driver hosts both: **every run a driver starts is stamped `2` and\nexecuted by the compiled engine**. The program runs in its own locked-down worker thread with\nnothing in its global scope, while the effects and the durable journal stay in the driver's process,\nbridged over a message port. No socket or credential enters the isolate holding the program,\nand **every version-`1` record keeps replaying on the walker**, which is the walker's job. On either\nengine the driver bounds an effect's `ok` result at the broker's `max_payload` less 4096 bytes: a\nlarger result is refused ahead of the settling append (**L5006**), the step stays pending, and the\nrun is released. The driver serves a declared set of versions, and a record whose version it does\nnot serve is refused by name (**L5023**) with the run left untouched, instead of being replayed by\nwhichever engine happens to be present. Records do not cross between versions in either\ndirection; the repair is to resume on the recorded version, or to fork.\n\n**The engine needs node 22 or newer** and refuses below it as `EngineUnavailable`, which is an\nimplementation limit and not a language error: it carries no `L` code, so there is nothing to look\nup in the catalog. It is a floor rather than a warning because the engine's frame plumbing rests on\n`AsyncLocalStorage`, and 22 is the lowest node it has been measured on. The walker has no such floor.\n"
62806
+ "body": "# Workflow runs\n\n> **Concept** (informative) \xB7 **For:** people writing a durable multi-agent workflow, and implementers hosting one \xB7 **Normative:** [SPEC \xA714](../SPEC.md#14-workflow-runs-v05) and the language reference [`spec/cotal-lang.md`](../spec/cotal-lang.md)\n\nA **workflow run** is a program that coordinates agents over hours or days and survives the\nprocess that started it. The program is written in **Cotal Lang**, a small subset of JavaScript in\nwhich every interaction with the world is one of a dozen **effects** (`spawn`, `turn`, `ask`,\n`checkpoint`, `sleep`, `wait`, `notify`, `monitor`, and the four concurrency scopes) and everything\nelse is ordinary, pure JavaScript. Every effect is written into the run's **step journal** before\nit is performed and settled after, keyed by where in the program it happened rather than by when,\nso a run that dies is resumed on any host by **re-running the program from the top** with recorded\neffects returning their recorded results. Nothing about the interpreter is ever serialized: the\njournal and the program are the whole state.\n\n## A first program\n\n```js\nconst planner = await spawn(\"planner\")\nconst builder = await spawn(\"builder\", { worktree: \"wt-1\" })\n\nconst plan = await ask(planner, { name: \"plan\", schema: { steps: \"array\" } })\nconst ok = await checkpoint(\"approve-plan\", \"Approve the plan?\", { timeout: \"4h\", onExpiry: \"proceed\" })\nif (ok.status !== \"resolved\") {\n await notify([planner], { decision: \"approve-plan\", outcome: \"expired\" })\n}\n\nconst r = await turn(builder, { name: \"build\", deadline: \"30m\" })\nif (r.status === \"blocked\") {\n await turn(planner, { name: \"unblock\" })\n}\n\nconst outcome = await race({\n reply: () => wait(replied(builder), { timeout: \"20m\" }),\n giveUp: () => sleep(\"1h\"),\n}, { name: \"await-or-move-on\" })\nlog(\"outcome\", outcome.index)\n```\n\nRead it as the flowchart it is. `spawn` brings agents in; `ask` is the narrow case where the\nprogram itself needs a value (`schema` is a record the program hands the handler unchanged; the\nlanguage hashes it and gives it no meaning, and the handlers in this repository enforce it as the\nshorthand of the language reference \xA76.5);\n`checkpoint` is a durable pause a human resolves from anywhere, raced against a durable timer; `turn`\nwakes an agent for one turn and returns how it yielded; `race` runs two branches and keeps the one\nwhose recorded clock is earliest. Agents talk to each other in channels as they always do; the\nprogram never speaks in a channel, and the one thing it can put in front of an agent (`notify`) is a\nbounded decision record, not prose.\n\n## The mental model\n\n- **Pure code is JavaScript.** Loops, records, arrays, closures, template literals, destructuring,\n `try`/`catch`, arithmetic, `switch`, compound assignment, optional chaining, spread and rest: what\n you would write anyway, with the parts that hide effects or make meaning depend on the host removed\n (`class`, `this`, `new`, `for...in`, `==`, labels, regex literals, `Math`/`Date`/`JSON`, promises,\n generators). Every refusal names its code and the edit that fixes it. The builtins are a short list\n (`keys`, `map`, `sort`, `json.stringify`, `now()`, `random()`), and arrays, strings and numbers\n answer their usual methods (`xs.map`, `s.trim()`, `n.toFixed()`) and nothing outside that table.\n Records and arrays you build are yours to change until they cross an effect boundary; a member you\n do not own, a host prototype, or a value another branch built is refused with a code, never a\n surprise.\n- **Every effect is journalled and hashed.** A step is keyed `(scope path, kind, name, occurrence)`\n and its inputs are hashed. Reorder your program, add a step, rename a variable: recorded steps\n still match. Change what a step asks (a checkpoint's prompt, a sleep's duration, a turn's\n deadline) and the resume stops with a **divergence** naming the step, rather than replaying an\n answer to a question the program no longer asks.\n- **Concurrency is visible.** `parallel`, `race`, `fanOut` and `conclave` are the only ways to do\n two things at once, each branch gets its own journal namespace, and the scope writes its own\n entry saying how it settled: which arm won a race is a recorded fact, decided by the arms'\n recorded clocks and declaration order, never by a scheduler. A failure the program itself caused\n inside the scope settles the entry under its own catalog code (`fanOut` without a stable key is\n `L3021`, kind `runtime`), so a resume reads the same code the live run threw; a plain failure\n from the handler records the generic `L4000` `scope-fault`. A value the program throws inside a\n branch is recorded with that fault, so `catch` receives the same value on resume, and in a\n migration's dry walk, as it did live.\n A branch may not write to anything declared outside it; return the value and read it out of the\n scope's result.\n- **Time and randomness are tamed.** `now()` is the branch's run clock, the end of the last effect\n it awaited; `random()` is a seeded stream derived per scope. Both replay identically.\n- **Values freeze at the boundary.** What crossed into or out of an effect is what the journal\n recorded, and it cannot change afterwards; build a new value.\n- **The journal is the debugger.** Every entry carries its key, its inputs' hash, its outcome and\n its timing, and every error is in the program's own coordinates. A run can be **simulated** with a\n scripted handler and **dry-run** to a plan before it touches an agent. The simulator is\n discrete-event: timed effects park at their wake times and are delivered in wake order on one\n virtual clock, so concurrent branches accumulate the durations they wrote and a simulated `race`\n is decided by the same rule a live handler produces (least recorded clock, ties by declaration\n order). A `sleep(\"1m\")` arm beats a `sleep(\"1h\")` arm whatever their declaration order. Behind the\n worker bridge the simulator delivers its next wake only after the thread reports it has reacted\n to the last one, so a bridged simulation settles a race the same way, also when the dry run's\n recorder wraps the simulator.\n\nFull rules, with every code: [`spec/cotal-lang.md`](../spec/cotal-lang.md).\n\n## Continuing a run\n\n**Resume** is re-execution: the driver replays the journal, the program runs from the top, recorded\nsteps return instantly, and the first unrecorded step is performed live. It refuses a journal that\nbelongs to another run, a pin that differs from the recorded ones, and a different language version.\n\nA failed journal entry replays its error, while a pending entry lets the handler recover the\nexternal work it bound before the interruption.\n\nA step that writes to a far side that honors no idempotency key (posting a comment, sending a mail)\nbelongs in `once`. A resume that finds a step inside `once` begun and never settled does not\ndispatch it again: it opens a hold, a checkpoint under a token derived from the step's recorded\nrequest id, whose prompt names that id. Answer it with\n`cotal run answer <run> <step-key> --value <json>`, and the value becomes the step's result. An\nexpired hold fails the step with the catchable L4027. Only `ask` runs inside `once`, so wrap just\nthe step that writes: a host restart while it is in flight costs a settle.\n\n```js\nconst publisher = await spawn(\"publisher\")\nconst res = await once(async () => {\n return await ask(publisher, { name: \"publish\", schema: { commentId: \"number\" } })\n}, { name: \"publish-360\" })\n```\n\n**Migrate** moves a run onto edited source. A dry walk of the new program over the recorded journal\nfinds every recorded step the edit changed (a divergence) and every one it no longer reaches (an\norphan), and the orphan table says what each means: a removed `sleep` is nothing, a removed `turn`\nalready happened, a removed `spawn` is a live agent you must adopt or release, a removed resolved\n`checkpoint` is a human decision you must explicitly discard. The decision is filed as a\n`migration` record with the actor's name on it. An adopted seat (`--adopt <name>#<uid>`) goes to\nthe edited program's next `spawn` of that persona, which returns the recorded handle and mints\nnothing, so the agent keeps its identity, its worktree and its turn history across the edit. A\nreleased seat (`--release <name>#<uid>`) is despawned when the migration commits, through the same\ndischarge a cancelled branch's seat leaves by, so the record never claims a release nothing did.\nThe spawn that adopts a seat binds the orphaned spawn's goal as its own, so a resume of that step\nreads the same seat back and a cancellation of it despawns the seat it holds.\n\n**Fork** starts a new run from a named step of an old one, copying the prefix under the parent's\npins (seed included, so the copied history's pure draws are the same draws). The child is a new run\nunder a new id whose record names the parent and the cut step (`forkedFrom`); the parent is\nuntouched. A spawn inside the copied prefix is honoured by its `onFork`: `\"adopt\"` copies it, and\nthe child shares the parent's agent (the manager shows that seat one turn at a time across both\nruns); `\"respawn\"`, the default, would mint a fresh identity the copied turns do not address, so\nthis host refuses that cut (L5019) rather than rewriting the parent's history.\n\nFork planning and migration inspection use the recorded language version. Version-1 history uses\nthe interpreter. Version-2 history is inspected inside a locked-down worker with a read-only\njournal, without a live effect handler or durable store. Inspection stops before the fork's cut\nstep or any effect that needs new work. Program catch and finally blocks cannot extend the cut.\nThe recorded pins are preserved.\n\n## From an agent session\n\nFresh `cotal setup` defaults declare `capabilities: [spawn, run]`. On an authenticated mesh,\nthat exposes `cotal_run` alongside the teammate tools. The selected manager must support execution.\nRead `cotal_docs` pages `lang-card` and `workflows`, then try:\n\n```json\n{\n \"verb\": \"start\",\n \"source\": \"await sleep(\\\"1s\\\", { name: \\\"first-run\\\" });\",\n \"file\": \"first-run.cotal.js\"\n}\n```\n\nPass this object to `cotal_run`. `source` contains the program; `file` only labels diagnostics\nand reads nothing from disk. The response returns a run ID before execution finishes. Call\n`cotal_run` with `verb: \"status\"` and that `runId` to inspect the state and step journal.\nA completed timer records its sleep step as `ok`.\n\n### If `cotal_run` is missing\n\n1. Call `cotal_orientation` and check the connector version, capabilities and tool list.\n Upgrade an older installation using the [upgrade guide](https://github.com/Cotal-AI/Cotal/blob/main/docs/UPGRADING.md).\n2. Have the operator add `run` to the persona's existing `capabilities` list, for example\n `capabilities: [spawn, run]`. `spawn` alone does not expose `cotal_run`. Setup leaves existing\n personas unchanged except for its [byte-exact legacy migration](getting-started.md).\n Peer persona-definition tools cannot grant capabilities.\n3. Relaunch the agent through the manager from the updated persona so it receives newly issued\n credentials and a fresh connector configuration. Editing the file or reconnecting with the\n old credential does not grant new broker permissions. If the launch sets `COTAL_CAPABILITIES`,\n update that override too; it takes precedence over the file.\n4. Check `cotal_orientation` again, then call `cotal_run` with `verb: \"ps\"` before starting work.\n\nTool visibility alone does not establish execution support. Hosted runs require issued caller\nauthority. Static authentication issues it to its credentials. On a user-auth mesh, a registered\nparticipant manager hosts authorized runs. A managed agent invokes `cotal_run` through its own\nlauncher-provided bearer command and immutable manager instance selection. Its current managed\ngrant and its direct parent's current grant must both carry `run`. A removed grant, stale lifecycle\nor wrong manager selection refuses rather than borrowing a signed-in user's authority.\n\nOpen meshes may expose the tool but refuse hosted runs. A local user-mode host without remote\nrun authority and an `execution: \"none\"` control manager also refuse the family. A legacy\ncredential without issued authority must be replaced through the current issuance path.\n\n## Operating a run\n\nThe manager hosts runs. `cotal run start` hands the program to the manager of the resolved mesh\n(the usual `--space` / `--server` / `--creds` flags), which validates it, mints the run id, drives\nit in its own process, and answers with the id once the run is recorded. The terminal is free the\nmoment the id prints; the run continues on the manager through every pause, and a manager restart\ntakes back every run it had recorded running, from the journal, under the next epoch. `resume`\nnames a run the manager recorded and is refused while the manager is already driving it. `ps` and\n`journal` read; `answer` resolves an open checkpoint, or an open `ask` attempt, from any terminal\nor agent that holds the `run` capability.\n\n`journal` prints an open pause's question under its step. Once a checkpoint or `ask` settles with an\naccepted answer, it instead prints the answer value as JSON, who answered, the artifact when one was\ncited, the recorded time, and the accepted answer id. Expired pauses and ordinary steps print no\nanswer line.\n\nA settled step is never answered twice, so a participant who changes their mind uses `amend`. It\nfiles a new answer beside the accepted one, naming the answer it supersedes, and `journal` prints\neach amendment under the step as an `amended` line, in the order the store committed them. The last\nline is the current position, whatever clock each amender's `at` came from. The pause stays settled\nand the run keeps the answer it acted on. A step that is still open, or that settled with no answer,\nrefuses an amend. The manager records the amender from the credential, as for an answer, and a\nspawned seat may amend only an answer recorded under its own name.\n\n```bash\ncotal run start --file build.cotal.js # the manager starts it; the minted id is printed\ncotal run ps # list run records: state, holder, lineage\ncotal run journal run-3f2a90c41b7e0d5a6c884e19b02df4a1 # print the durable step journal\ncotal run resume run-3f2a90c41b7e0d5a6c884e19b02df4a1 # the manager takes the run back\ncotal run answer run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"yes\"'\ncotal run amend run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"no\"' # record a changed position\ncotal run migrate run-3f2a90c41b7e0d5a6c884e19b02df4a1 --local --file build-v2.cotal.js # check an edited program against the journal\n```\n\nA program that does not validate is refused before anything is recorded, with every problem in the\nanswer as the validator would print it. The driver records the program beside the run, so `resume`\ntakes the run id alone and the manager reads the source back; an edited program is a `migrate` or a\n`fork`, never a resume. `cotal run migrate <runId> --local --file <program>` is that check: it\nreplays the run's journal and walks the edited program over it, prints whether the migration is\nadmissible, how many journal rows the walk accounted for, every orphaned step with its verdict and\ncode, and exits 0 on admissible and non-zero on not. It reads only, under the same credential\n`journal` reads on, and the commit side is not reachable yet: the report itself says what a commit\nwould file and that this invocation filed nothing. An answer is recorded under the answerer the\nmanager knows from the caller's credential: a managed agent by its name, anyone else by their\nprincipal. The request\ncarries no name. An agent with `capabilities: [run]` has the same five verbs as the `cotal_run`\ntool ([MCP tools](mcp-tools.md)), so a program can be written and started from inside a session.\nA `start` or `resume` answers once the run's record is written, within a bounded wait; a manager\nthat is still taking back a predecessor's runs at boot refuses both with `unavailable`, and a\nretry a moment later is the whole remedy.\n\n`--local` drives the run in this process instead: `start`, `resume` and `answer` exit when the\ndrive settles, `--by <who>` names the answerer, and `cotal run resume <runId> --local --file\n<program>` is how a run with no recorded program is continued. On a static mesh the local drive\nmints the run's own credential from the folder's trust material, so it runs from the mesh's\nproject folder. A local start also names the run's channel ceiling itself:\n`--admit-read <channels> --admit-publish <channels>`, comma-separated patterns or `none`, both\nrequired. The record it writes says an operator admitted the run and why, and the host checks\nit the same way it checks a hosted admission. A registered remote user-auth manager can host a\nrun through its issuing host. The host resolves a versioned caller against live issuance,\nadmits the run, and signs only the run's fixed driver, mediator and one-shot operator credentials\nfor manager-held nkeys. Renewal checks the activated attempt; the manager holds no signer.\nLocal user-auth runs remain unavailable because a user bearer holds no run rows. An open mesh\nhosts none either, since it issues no caller authority to admit a run under.\n\nA logged-in user starts runs on that remote manager with `cotal run start`. The auth callout issues\nthe user's manager connection against the user's actor-ledger row, the CLI reads the generation\nback from the connection's accepted row, and every `run` verb rides the versioned rail. The issuing\nhost admits a run only for the owner who registered the manager, and on every resume and answer it\nchecks that owner and that the caller's issuance is still live. It also watches the start, resume\nand answer requests on the broker itself, and admits or issues for one the manager forwards only if\nit saw that request, and only once. A resume or answer gets issuance only for the run, step, endpoint\nand amendment that request named. An answer's\ncredential reaches one pause, and the host reads which one off the run's own journal, for a run\nadmitted on that manager's instance. A request bound to another manager instance or epoch gets nothing, and so does one whose class or pinned contract is not the one the manager registered. Another user's start, answer or resume is refused, and so is a revoked actor's.\nSuch a run spawns, turns and despawns agents owned by that user. A spawn has the reach of the user\nwho started the run, as their actor-ledger row reads at that moment, and the host enrolls each agent\nthrough its managed-agent enrollment. A spawn may be placed on the manager that hosts the run and on\nno other instance. [User-auth run start](design/user-auth-run-start.md)\nrecords the path.\n\nA hosted run is **admitted** under the caller that started it. The caller's credential is an\nissuance ([identity and auth](identity-and-auth.md#issued-authority)): its requests ride a\nversioned rail that carries the credential's generation, and the manager resolves that\ngeneration's recorded permission ceiling and writes it beside the run before the driver starts.\nThat ceiling, the caller's own channel scope as it was issued, is what the run may read and post\nin channels; the manager's own reach never stands in for it. A request from a credential minted\nwithout an issuance is refused with `permission-denied` and a detail naming the caller. A run\nwhose caller had no channels can still sleep, checkpoint and turn agents; its `wait` on a channel\nis refused at the effect.\n\n`cotal run revoke <runId> --local --by <who> --reason <text>` writes the run's revocation marker\nfrom the project folder. An empty `--by` or `--reason` is refused before anything is written. The\nadmission itself is never rewritten. Every host reads the marker\nbefore its next channel effect, so an open `wait` refuses at its next poll, and no resume,\ntakeover or manager restart continues the run. Revoking twice is not an error, and the first\nreason stands. A run whose admission is missing or revoked is left parked by the manager's boot\nreconcile, named in its log.\n\n`run ps`, hosted or `--local`, reads the marker beside each run record and prints `revoked` for a\nrun that carries one, whatever state the record itself holds, with the revoker and the reason\nunder the table. The record is display only here: a revoke writes no terminal state, because no host drove\nthe run to one and the journal owns the facts. A marker the listing cannot read, whether the store\nis unreachable or the marker has a version or shape it does not know, prints `unchecked` in the\n`STATE` column. The reason and the state the record carries go to stderr, and the command exits 1\nonce every row is printed. The hosted `run-ps` rows carry the marker as `revoked` (`by` and\n`reason`) or a failed read as `revocationUnreadable`, beside the record's own `state`.\n\nA run whose step was refused (L5016) stays held; a\nresume on a host that can perform the step performs it live and continues from there.\n`journal` prints what an open pause asks beneath its step key, which is the address `answer` takes\nback. Checkpoint expiry rides the mediated timer writer, which the delivery daemon pumps on a live\nmesh; on a bare broker a pause still resolves, it just cannot expire.\n\n## What is on the wire\n\nThe run's wire footprint is [SPEC \xA714](../SPEC.md#14-workflow-runs-v05):\n\n| Thing | Where | What it is |\n| --- | --- | --- |\n| the run | `run.<endpoint>.<runId>` record | the resolved **pins** (seed, logical epoch, budgets, language version) on the immutable half; holder, lease and `journalHigh` on the status half |\n| the program | `program.<endpoint>.<runId>` record | the source the run was started from, verbatim, written once by the driver that pinned the run; what a resume reads and what a migration is measured against |\n| the step journal | `WFJ_<space>` stream, one subject per run | append-only, no age eviction, no Direct Get; every append fenced by the run subject's own sequence; takeover is replay-then-activate |\n| a checkpoint answer | `answer.<endpoint>.<token>.<answerId>` | the payload beside the one-use settle fact; the settle names the answer it accepted |\n| a notice | `notice.<endpoint>.<runId>.<addresseeId>.<noticeId>` | one bounded decision told to one agent, rendered ahead of its next turn |\n| a migration | `migration.<endpoint>.<runId>.<migrationId>` | the report and who applied it, keyed by the report's own digest |\n| the admission | `admission.v1.<endpoint>.<runId>` in `cotal_admission_<space>` | the caller the run was admitted for, its channel ceiling and its provenance; written once before the driver starts, and the store refuses a second write on the key |\n| a revocation | `revoked.v1.<endpoint>.<runId>` in the same store | who revoked the run and why; create-only, idempotent, permanent at the broker, read by every host before its next channel effect |\n\nThe driver writes `journalHigh` at activation and again after each journal append, before the\nprogram acts on the entry. A successor whose replay ends below it refuses the run with\n`RunJournalTailTruncated`. That holds for records appended since the last activation too.\n\nA run's **driver** connects on a `run-driver` credential minted for one run and takeover\nattempt. It can append to its journal, use its replay durable, and write its own `run`, `program`,\n`notice` and `migration` records. It has no store point reads, checkpoint writes, chat consumers,\nor channel and membership registry grants.\n\nThe hosting process keeps a separate `run-mediator` connection for effects and reads. The driver\nreceives methods and data from that host; it never receives the mediator credential or connection.\nThe host checks the journal's current activation and step identity before dispatch, and checks\npause and wait authority again at each operation. Cancellation cleanup also admits losing steps\nnamed by a settled parent whose `cancel.issued` is still false. That permission allows cleanup;\nit cannot mint or rearm a cancelled pause. Wait acknowledgements consume host-held delivery\nreceipts. A recorded match can be reread only at its bound sequence and channel. A conclave's\nrecorded ownership flag must match its step-derived channel before registry writes or cleanup.\n\nThe mediator retains endpoint-wide checkpoint rights and stream-wide leader reads as trusted\nhost authority. Record reads exposed to the driver are restricted to its own run's keys. Reads\nthat decide writes remain leader-served. A read of the journal uses the run's filtered replay\ndurable, including the diagnostic for a journal with no run record. That durable is named after\nthe takeover, and an attempt reads it many times, so reads under one takeover run one at a time in\nthe hosting process and a replay removes a durable of its own name that an interrupted earlier read\nleft behind. A durable that survives a replay's own delete belongs to a reader the process cannot\naccount for, and reading its tail is refused. A drive handles that refusal as a takeover does: the\nreads behind its steps, and the diagnostic for a journal with no run record, replay up to three\ntimes before the refusal is raised. An operator read runs under a takeover minted for that read and\nreports the refusal on its first read.\n\nA served read uses a one-shot `run-operator` credential. An answer uses a read to find the open\npause, then a second credential pinned to that token for the answer and settlement.\n`cotal run --local` uses the same driver/mediator split. On an authenticated mesh it needs the\nlocally recorded space signer to mint both credentials; a single `--creds` file is refused.\nDirect library users supplying broker clients to `MeshHandler` are constructing a trusted effect\nhost. A hosted driver receives its closed effect interface instead.\n\nThis split confines broker credentials; it is not process isolation for injected host code.\nThe runtime and its effect host share the manager process. A run's channel reach is the admitted\nceiling ([SPEC \xA714.8](../SPEC.md#148-run-admission)): the starting caller's issued channel scope,\nrecorded once in `cotal_admission_<space>` under a per-run `run-admitter` credential the driver\nnever holds, and re-read by the host before every channel effect. Spawn and turn keep their own\ndelegated checks; `notify` writes agent-addressed notices and is not channel publication. Treat\n`run` as program-execution authority bounded by that ceiling, not as sandboxing of the program.\n\nA version-1 fork can replay its settled parent history through the host. Inherited checkpoint\nidentifiers carry no authority to read, rearm or claim the parent's pauses. New child effects use\nchild-derived identifiers. A fork is a new run and takes a new admission under the caller who\nforks it; the parent's ceiling is not inherited.\n\n\n## What ships today\n\nThe language, its validator, interpreter, simulator and dry run are `@cotal-ai/lang`\n(`packages/lang`), usable in-process with your own effect handler and with no broker: `validate(src)`,\nthen `run(src, { runId, handler })`, and `resume(src, journal, { runId, pins, handler })` to pick a\nrun up from its journal (the package README has the snippet, with `SimHandler` as the handler). That\nis the in-process route, yours to drive with your own handler; a run the driver starts executes on\nthe compiled engine, as the engine paragraph below says. The wire\nsubstrate of \xA714 (the `WFJ_<space>` stream, the five record kinds, the activation barrier, the\nper-run grants) is in `@cotal-ai/core`, and the run driver, journal store, migrate and fork are\n`@cotal-ai/runtime` (`implementations/runtime`). The migrate check is reachable as\n`cotal run migrate <runId> --local --file <program>`; committing a migration it judged admissible\nis not reachable from any surface yet. On the mesh handler, `sleep`, `checkpoint`,\n`wait(message(...))`, `wait(idle(...))`, `wait(down(...))`, `wait(replied(...))`, `notify`,\n`spawn`, `conclave`, `ask`, `monitor` and `turn` are durable.\n`spawn` is\nthe manager's spawn action submitted under the step's own identity: the goal binds under the step's\nrequest id, so a resumed run re-attaches to the same seat instead of allocating a second one, a\nfailed or refused spawn is catchable as L4002 with the manager's recorded reason, and a spawn on a\nrace branch that loses is despawned by the run's own cancellation sweep. A seat belongs to the run\nthat spawned it: when the run completes, it despawns every seat it spawned, including a race\nwinner's and one whose spawn failed while its process stayed up. A spawn marked `onFork: \"adopt\"`\nis the exception: a fork can share that seat and no run can see whether another still uses it, so\nthe seat stays up until you stop it with `cotal stop` once every run sharing it is done. A seat a\nmigration handed to a later spawn follows that spawn's policy, and it stays up if any spawn that held\nit was marked `onFork: \"adopt\"`, because a fork taken before the migration may still share it. In a\nspace with several managers, a despawn counts a seat as already gone only when the manager that\nallocated it says so. A run that\nfails or is released keeps its seats until a resume completes it or you stop them with\n`cotal stop`. Start a seat with `cotal spawn` when it should outlive any run. `permits` are the budgets\nthis host meters: `turns`, how many turns the run may dispatch to the agent, and `wallClock`, a\nduration from the spawn after which no turn is admitted. The turn that would exceed one is the\ncatchable L4001 (kind `permit-turns` or `permit-wall-clock`; a deadline the remaining wall clock\ncannot hold counts as exceeding it), an adopted run counts the turns its journal recorded, and a\nbudget the host has no meter for, such as `tokens` or `spend`, is refused at the spawn rather than\naccepted and ignored. `supervise` is the restart policy this host asks the manager to enforce:\n`restarts`, how many in-window process deaths may come back under the same handle, and `window`,\nthe duration those deaths are counted in (default `10m`). The manager restarts the process in\nplace under the same name, lifecycle uid, persona, worktree and permits; `monitor` does not fire\nfor a restart, and `wait(down)` fires only when the seat is gone for good. Spending the budget\nretires the seat, and the next `turn` is the catchable L4002. A policy this host cannot enforce\n(an unknown key, a user-mode seat, or a runtime that cannot respawn a name in place) is refused\nat the spawn rather than accepted and ignored. `events: false` is the workflow form of\n`cotal spawn --no-events`: the seat starts without its AG-UI event plane. A connector that\npublishes none, such as Hermes, needs it, because an omitted `events` arms the plane and the\nmanager refuses that connector at the spawn. A value that is not a boolean is refused at the\nspawn. `conclave` joins its\nmembers to a real channel as durable membership rows: the channel derives from the step's own\nrequest id when the program names none (a program-named channel is borrowed, never torn down, and\na membership that predates the conclave survives its close), each member handle resolves to its\nprincipal through the seat's own presence row (an absent member is catchable as L4002), and a\nconclave cancelled on a losing branch is released by the same cancellation sweep. `ask` parks one\ncheckpoint-plane pause per attempt, answered through `cotal run answer` as a checkpoint is, and\ntells the agent through the same relay `turn` uses: one relay per attempt under the attempt's own\ntoken, carrying the schema, the attempt count, the deadline and the previous refusal, which the\nseat's connector renders as the record wanted and the hosted command that answers it. The relay\nnames the ask's absolute deadline, so the manager stops serving an attempt at the instant its\npause expires and refuses a relay already past it. When that\nliteral command is run inside the managed seat, the CLI reuses the seat's lifecycle credential and\nissued caller identity rather than minting an operator instrument. The command\ndoes not take `--by`: the manager records the authenticated caller as the answerer. A spawned seat's\nbaseline credential carries only the self-targeted `run-answer` row, and the manager accepts it only\nfor the open ask or escalation relayed to that exact incarnation. It cannot answer another seat's\nask, an unrelayed checkpoint, another run, or start and resume commands. An ask addresses\nan agent the run spawned (anything else refuses before an attempt opens), a resumed attempt tells\nthe seat nothing twice, and a seat gone at the relay is L4002. On the pause itself:\nthe shorthand of the language reference \xA76.5 is enforced (an unreadable schema is L4022), a\nnon-conforming answer costs one attempt and its refusal reason is recorded on the entry for the\nanswerer to read, exhausted attempts (default one) are the catchable L4006, and so is the one\nabsolute deadline for the whole ask passing with no conforming record (its kind is `ask-deadline`),\nincluding one that passes before an attempt's pause is armed.\n`checkpoint` binds what it asks on its own entry, so `cotal run journal` prints the question under\nthe step key an answer is addressed by while the pause is open: the address alone left whoever was\nasked reading the source to find out what \"approve\" meant. The entry also records the open\nattempt's deadline and the `onExpiry` it was armed with, which `run journal --json` reports; what\nan expiry does is still decided from the program's source. An escalation's attempt records its own\ndeadline, one `timeout` after it opens, and a resumed attempt keeps the deadline it recorded. After a checkpoint or `ask` accepts an\nanswer, the journal prints that accepted answer's recorded value and attribution under the settled\nstep. A checkpoint's comes from its frozen result, and the journal never substitutes another filed\nanswer or invents fields that result does not hold. An `ask`'s result is the value alone, so its line\nis read from the answer record its last attempt's settle named, even when that value is a record with\nfields named like a checkpoint's. Amendments print on their own lines after it.\nAn `escalate` addressed to an agent this\nrun spawned is relayed to that seat through the same turn relay an `ask` uses, carrying the prompt\nand the token to answer under, held to the pause's own deadline; a `to` naming anyone else is a\nperson, and their pause stays the one anybody can answer, with the addressee recorded and rendered\nbeside the question.\n`monitor` registers interest in an agent, and the\nregistration is the journal entry itself, carrying the handle it registered: monitoring an agent\nthat is already dead succeeds, and the death is the wait's to observe. `wait(down(...))` observes\na monitored agent, and refuses one the run never performed `monitor` on. It reads the death off presence liveness, the\nsame witness a conclave join resolves members through: the value carries the handle, the reason\n(`lapsed` when nothing live holds the name any more, `superseded` when a live row holds it under\na different incarnation) and the time of observation. Presence liveness skips any value without a\nstring `card.id` and `card.name`, so a participant that publishes a malformed row under its own key\ncannot fail another run's wait, turn or conclave join. A superseded incarnation is down at once. A\nlapsed one is down only after its presence row has stayed gone for 30 seconds, because a seat whose\nconnector stalls past the row's 6-second TTL, under host load or across a reconnect, renews it under\nthe same incarnation and is still working. The 30 seconds count only across presence reads that\neach end within 6 seconds of the previous one starting, so a slow read or a run of failed reads, which\ncould hide a renewal, starts the count over. Even then the run asks the manager first: a seat whose\nprocess the manager still runs is alive however long its row has been gone, so the count starts over.\nOnly the manager that spawned the seat can report it gone, because another manager in the space knows\nonly its own seats, so the run asks again until that one answers. When it does not answer, the presence\nrow is the only witness and the lapse stands. The asking stops at the wait's timeout or cancellation,\nand at a turn's deadline, yield or cancellation, which then decide. A wait that begins\nafter the death resolves once that holds, and a timeout resolves null on one absolute deadline a\nresumed run re-attaches to.\n`turn` wakes one seat for one host turn through the manager as a pull-shaped relay: the run\nsubmits the turn under the step's own identity, the manager holds it as a goal pinned to the\nseat's incarnation, and the seat pulls it under its own reach ahead of its next host turn, so\nnothing is pushed into a session mid-thought. The payload the seat reads names the run and the\nstep and carries the rendered run context, plus any pending notices addressed to it, which the\nturn consumes. The seat yields through `cotal_yield` (`done`, `blocked`, or `handoff` with an\naddressee), and ending its host turn yields `done` for every turn it was shown. A `handoff` names\nanother seat the same run spawned: the next `turn` in the same scope to that seat records the\nlink, a handoff to a name the run never spawned is the catchable L4005, and one to a seat bound\nto a different worktree is L4004. The deadline elapsing before any yield is the catchable L4003:\nthe acceptance names the instant, the manager's goal-bound hold denies at it, and the run arms its\nown pause on that same instant, so either side outliving the other still converges on the same\nanswer. A seat that pulled the turn earlier stops showing it to its session at that same\ninstant. A seat that dies mid-turn is read off its own presence row by the run itself, with the\nsame 30-second confirmation for a lapsed row, and is the catchable L4002, and a death the manager\nmarked on the deadline terminal reads the same way. Two\nturns on one seat, from two branches or from two runs, reach it one at a time: the language\ndispatches the second when the first settles, and the manager shows a seat the oldest unsettled\nturn alone. A turn's goal id belongs to the caller that submitted it, so two callers may each submit\na turn under the same id. A seat yields by goal id alone, so the manager refuses to hand one seat\na second caller's turn under an id it already holds. On an auth mesh the relay needs no extra grant: every spawned seat's baseline\ncredential carries its own pull, yield, and caller-bound answer rows, the run driver's operator instrument carries the\nturn request and the `inspect` read that confirms a lapse, and the manager arms the deadline hold over its own serve grant and expires it\nitself once due. An accept the manager cannot finish is unwound to a failed terminal on the goal\nit bound, and a retry of that submission is refused naming the terminal rather than accepted a\nsecond time.\n`wait(replied(...))` observes those turns from another branch: a completed turn is a reply, and\nthe wait resolves with the observation record (the handle, the yield's status and note, the\nyield's own stamp). It reads as a level, the way `wait(down)` does: a reply that already exists\nresolves the wait at once, and two replies resolve to the latest by the yield's stamp. A denied\nor cancelled turn is never a reply, so an unanswered wait rides its own mediated timeout to\n`null`, and a handle the run never spawned or turned refuses loudly, since only this run's turns\nare observable. A turn the run itself ended without an accepted yield (its deadline, a\ncancellation, a refused handoff) is never a reply, whatever the seat yields to the relay later.\nA cancelled branch also withdraws what it relayed to a seat before its cancellation completes: its\nturn, its ask attempt or its escalation ends `cancelled` through the manager's reserved `cancel`\n(SPEC \xA713.6), so the seat is not shown it after the branch's scope settles and the next turn to\nthat seat does not wait behind it. Only the manager that accepted the relay holds it, so in a\nspace with more than one manager the run sends the cancel again while another manager refuses it,\nuntil the accepting one answers. A cancel the accepting manager refuses is sent again until it\nlands or the relay's deadline passes, so the branch's cancellation does not complete while the\nseat can still be shown the relay. The run reads that deadline from the relay's goal and retries\na failed read, since the relay may still be served until the deadline is known. A goal record\nthat can never yield a deadline, such as one that is not JSON, fails the branch's step at once,\nand so does a refusal that lasts past the deadline.\nA `spawn` may bind its agent to a **logical worktree** (`spawn(\"builder\", { worktree: \"wt-1\" })`):\nthe handle carries the id, and the run enforces the one rule the language states about it: two\nagents never share a worktree concurrently. The validator rejects the literal case up front\n(L3022: two branches of one concurrent scope spawning into one literal worktree, named branch\nfunctions included), and the runtime guards the rest, computed ids included: a spawn claims its\ntree before it submits, so a second spawn into a tree held by a live seat or by a spawn still\nbringing one up is the catchable L4008, a spawn that ends without a handle gives the tree back,\nand the tree is reusable the moment a holder's presence row is gone, so a discharged race loser\nor a crashed seat releases its tree with no bookkeeping. A spawn the endpoint refuses at accept\nis the catchable L4000 (L4001 when the refusal is the endpoint's seat capacity), and one whose\nseat never came up is L4002. A refusal that states the command did not run is answered\nbefore it gets that far. In a space served by more than one manager the resolve and the invoke\nare separate trips through the same anycast queue, so a run's call can reach an instance it did\nnot resolve against, and that instance refuses ahead of any effect. The run drops its resolved\nhandle, re-describes and re-issues, for a bounded number of attempts; after them the refusal\nsurfaces as the effect's own failure and still states that nothing ran. A spawn that names a\n`placement` addresses one instance by name, so a refusal from it is that incarnation answering\nabout itself and is never re-issued. When the spawn also names `cwd`, that manager resolves the\nexisting absolute directory to its canonical host path before accepting the spawn. A missing,\nrelative or non-directory path refuses with no seat and never falls back to the manager workspace.\nOne hosted run may name one placement instance; a program naming several is refused instead of\nwidening one run credential across hosts. Placement is accepted only from an object-literal spawn\noption bag whose `endpoint` and `instanceId` are string literals. A computed option bag, a computed\nplacement field, or a spread in either object refuses at `run-start`. The option argument may be\nabsent, and an object-literal bag without placement is accepted.\nA turn handoff across worktrees is the L4004 described above. Recovery keeps these honest: a resumed run\nreseeds its roster, holders and handoff memos from its own journal, and the driver re-issues any\nrecorded-but-undischarged cancellation at adoption, before the engine performs a new step, so a\nloser a crash left alive does not keep its seat or its tree while the resumed run works on. The\nsame sweep withdraws a cancelled branch's relays and undelivered notices: a notice waits on the run for its\naddressee's next turn, so a decision the run cancelled would otherwise arrive at an agent with\nnothing to distinguish it from one that stood.\n\nEvery effect the language defines performs on the mesh handler; nothing is refused as\nnot-yet-durable any more. The operator surface over the driver is `cotal run`; the section above has the verbs.\n\n**Two engines, and which one runs your program.** The tree-walker is language version `1` and the\ncompiled engine is version `2`, two languages rather than two speeds of one (`spec/cotal-lang.md`\n\xA78.4 lists what differs). The driver hosts both: **every run a driver starts is stamped `2` and\nexecuted by the compiled engine**. The program runs in its own locked-down worker thread with\nnothing in its global scope, while the effects and the durable journal stay in the driver's process,\nbridged over a message port. No socket or credential enters the isolate holding the program,\nand **every version-`1` record keeps replaying on the walker**, which is the walker's job. On either\nengine the driver bounds an effect's `ok` result at the broker's `max_payload` less 4096 bytes: a\nlarger result is refused ahead of the settling append (**L5006**), the step stays pending, and the\nrun is released. The driver serves a declared set of versions, and a record whose version it does\nnot serve is refused by name (**L5023**) with the run left untouched, instead of being replayed by\nwhichever engine happens to be present. Records do not cross between versions in either\ndirection; the repair is to resume on the recorded version, or to fork.\n\n**The engine needs node 22 or newer** and refuses below it as `EngineUnavailable`, which is an\nimplementation limit and not a language error: it carries no `L` code, so there is nothing to look\nup in the catalog. It is a floor rather than a warning because the engine's frame plumbing rests on\n`AsyncLocalStorage`, and 22 is the lowest node it has been measured on. The walker has no such floor.\n"
62747
62807
  }
62748
62808
  ],
62749
62809
  "spec": {