Cotal
DocsAPI Reference
Build integrationsEmbedding reference

Manager embedding authority

Interactive manager primary

A trusted composition can pass { interactiveManagerSelection: "local-primary" } as the third argument of runAuthService(args, store, options). AuthServiceRuntimeOptions is exported by @cotal-ai/auth. The third argument still accepts the native daemon lifecycle hooks. No CLI flag or exchange field sets the policy, and unsupported runtime values refuse before startup effects. openAuthAuthorityPlane accepts the same option beside its fresh localManager reader; OpenAuthAuthorityPlaneOptions is exported too.

The opt-in chooses the genuine local Manager up front for an unpinned human manager-caller exchange. It reads the persisted local identity and requires the existing open gate, serve principal, registration revision and status epoch checks. An absent, unreadable or noncandidate local Manager refuses without scanning or substituting remote Managers. Explicit selectors win, and managed-secret exchanges and launching or issued instance bindings keep their existing rules. The option adds no scopes, registry reads or inventory rows.

This routes all unpinned interactive Manager control, not only ps: slot reads, stop, attach, input, spawn, models, hosted run verbs and generic Manager describe/invoke share the view. It chooses local even when the person has a live remote Manager. A host must decide whether that routing is appropriate; a local Manager without execution cannot forward a spawn or run to the person's remote Manager. The person selects that Manager explicitly for those operations.

Absent the option, selection is unchanged. startAuthService still has no local Manager and keeps its remote-only composition. Supplying this programmatic option does not enable it in stock up, restart or recovery paths; a host must compose those paths separately after adopting the published API. ps still lists only the selected Manager's managed seats, not HTTP-hosted sessions.

Supervisor signing authority

@cotal-ai/manager exports the Manager class; there is no runSupervise(opts) runner. The private CLI runManager also does broker-reachability checks, space/default resolution, roster/launch parsing and materialization, installed-extension resolution, signal handling, staged pre-spawn, and the forever wait. A host composes that lifecycle itself around Manager:

import { Manager } from "@cotal-ai/manager";
const mgr = new Manager({ space, servers: brokerUrl, workspaceRoot });
await mgr.start();          // then wire your own SIGINT/SIGTERM -> mgr.stop()

stop() runs once. A later call joins the stop in progress and settles with it, and a call that asks for a different withAgents than the running stop is refused.

start() refuses with the exported ManagerInstanceLeaseHeld when this instance's lease is held by another live process on this root, or by a holder on another root. Its held field is the lease row (runtime, pid, root, holder, since) and sameRoot says whether that root is this one. A restart whose predecessor on this root is dead, including this process's own earlier start, waits for the old key to expire instead. A lease that could not be read or written is a plain Error.

Signer custody

Unlike delivery, the manager is not a pre-minted-scoped-cred daemon (auth-service is also a signer: it holds fewer artifacts than the full trust bundle, but its data-account signing seed still grants complete data-account mint authority on compromise, so this is not least-privilege). On start() the manager reads its space's full trust chain through its secretStore (getSpaceAuth(this.secrets, this.space), composed from auth/broker.json + auth/account.<key>.json; a container may instead mount a stripped signer bundle at the legacy auth/auth.json key) and self-mints its supervisor cred and renewals from the data-account signing seed. In static mode it also mints every per-agent cred from that seed; in user mode agents instead receive callout-minted bearers, but the manager still holds the signing seed for its own creds and renewal. So a hosted supervisor is a trusted per-tenant account-signer process, not a least-privilege connect client. It additionally requires a ~/.cotal/meshes/space.<key>.json registry record and the workspace user-auth marker to start in user mode. ManagerOptions.secretStore injects the one SecretStore the manager uses for the signer itself (the split trust records), daemon-credential renewal (remintDaemonCreds), and per-agent secrets, defaulting to the workspace filesystem store; pass the delivery daemon the same store for end-to-end hosted renewal.

Daemon renewal ownership

The store declares the same identity on both processes, or both set COTAL_SECRET_STORE to the same coordinate. The manager remints no daemon credential when the daemon names a different store, including a daemon that binds after start; it keeps running and serving its own agents, so one space can carry a manager on more than one workspace root. That manager also stays off the space's renewal lease, so a manager or a cotal doctor auth --fix on the daemon's store can still take it. Pointing several managers at one coordinate is safe: the store identity alone cannot pick an owner (it carries no holder and no tiebreak, so every manager sharing the store matches), so the manager that also holds the space's renewal lease is the one that remints and the rest skip it. Without that lease two owners would remint on independent timers with no ordering between them, and one write would land between the other's re-sign and its fingerprint-only reloadCreds. cotal doctor auth --fix takes the same lease before it re-signs, so a live manager and a local repair never race each other either. The signer IS now injectable: a hosted composition injects a KMS/Vault store and no signing seed lands on the hosted disk. What remains is signer isolation. The seed is decrypted in-process at the manager's uid. That issue needs an OS sandbox or remote signer; it is no longer a custody problem. The other knobs are workspaceRoot and the process-global COTAL_HOME.

Rejection handling

A store, runtime, extension or host callback the manager calls may reject with any value, including null. The manager logs or refuses with that value's message, or with the value itself as text when it has none, and keeps serving. A value it cannot read reports as an unreadable rejection.

Scope note: the static-auth operator paths (cotal spawn/join/status/web, via mesh-target → connect/preflight) still read the signer from the local split records (sync loadSpaceAuth). That is the single-machine composition, where the signer is on local disk by the static-auth model; multi-tenant hosting runs user mode, which never mints from on-disk trust. The store-injectable signer path is the hosted-server set: the manager, remintDaemonCreds, and delivery.

Managed lifecycle retirement

The typed remote-manager authority contract includes a one-shot terminal phase. A host implements remoteAuthority.prepareAgentRetirement to revoke the managed grant and finish its resumable release while preserving the UID, then remoteAuthority.mintRetirementRequester returns the host-signed JWT for a fresh participant-owned nkey. The credential is pinned to the authenticated owner, server-derived manager serve principal, current instance epoch, and exact target lifecycle. The manager then uses the existing auth retireLifecycle rail with the operation id derived by managedRetirementOpId(target.lifecycleUid). This derivation is the reference remote-Manager composition's closed contract, not a rule for every retirement entry point; interactive retirement keeps its existing operation identity and remains compatible. The retireLifecycle rail independently recomputes the managed id from its broker-pinned target before any gate, head, intent, or barrier access, so mint-time validation is not the terminal boundary. A failure keeps the alias held. This does not expose the auth barrier or give the participant signer authority.

If the participant disappears after prepare, the host finishes the retirement itself on the auth service's loopback face: POST /managed-lifecycle/retire (exported as MANAGED_RETIRE_PATH from @cotal-ai/auth) with the Bearer <cap> from auth-service.json and only { owner, actor, lifecycleUid }. It has the interactive door's guards (POST only, no Origin, JSON, capability, closed body) and is never served on the public face. The managed grant must already be revoked at that uid, or it answers 409. It runs the same managedRetirementOpId(uid) operation as the rail, and the rail and the door share one in-process flight, so a late participant request and the host call converge on one barrier.

lifecycle headanswer
absent, or retired at another uid200 { retired: false, lifecycleUid, notStarted: true }
active/retiring at another uid409
retired at this uid200 { retired: true, lifecycleUid, alreadyRetired: true }
active/retiring at this uidthe barrier runs, then 200 { retired: true, lifecycleUid }

Durable cleanup results

Deprovisioning 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 and channel families. The manager fills that list from the launch's concrete read channels and the delivery daemon's read-only lifecycleMemberships admin verb. Before it reads that verb, it calls fenceLifecycleAcl under a deprovisioner credential for the same lifecycle with no member channels. That narrows the lifecycle's read-ACL row to no reads, and a durable join the inventory does not see then removes its own row. 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 exact.

A separate host activates one exact managed lifecycle through the same full account auth service: POST /managed-lifecycle/activate (exported as MANAGED_ACTIVATE_PATH) with the Bearer <cap> from that account's auth-service.json and the closed body { accountPublicKey, owner, actor, lifecycleUid }. This route is present on an ordinary separately hosted full auth service. It does not require platformControl; that option adds the equivalent in-process handle for a composition sharing the process. A callout-only replica has no HTTP listener or authority plane and cannot serve it.

The repeated account key prevents a host routing error from asking one account authority to act on another account's target. It must equal the service's assigned data account before lifecycle work. The route is POST-only, refuses Origin, requires JSON and the per-start capability, rejects unknown fields, and is absent from the public exchange face. Parser and authority-selection refusals state outcome: "not-executed". After dispatch, the native activation code, details and outcome are returned. A non-native failure reports outcome: "unknown", never activation success.

The operation calls the authority plane's existing fence before activateLifecycleAtUid. Success returns { accountPublicKey, owner, actor, lifecycleUid, activated: true } only after the head is active at that UID and its issuance gate is open. The native lifecycle contract remains unchanged:

lifecycle stateanswer
absent, or retired at another UIDactivate this UID
active at this UID with an open gateidempotent success
active at another UIDalready-exists refusal
retiringlifecycle-blocked failed-precondition
this UID frozen by retirement, or terminally retiredlifecycle-blocked refusal
authority fenced or unavailableunavailable; no success result

Retained managed agents

A host that resumes retained managed actors also implements remoteAuthority.validateRetainedAgent. The participant sends back the actor token and sentinel it already holds, plus the nextRegistrationProof returned by the activation response. That proof is host-issued after registration and binds the manager owner, actor, lifecycle, identity nkeys, current registration revision, and serving epoch. The host checks it against the current open manager gate, validates the retained secrets against its current managed row, and returns only the non-secret authority shape. The manager binds every result coordinate and the returned authority back to its inventory before use. Do not copy the provider's issuer.json or callout.json into the participant store. Both contain private signing or exchange authority.

The same composition supplies remoteAuthority.agentBearerExchangeUrl, the pinned public auth-service base used by retained children. Remote adoption launches agent-bearer --exchange-url <base>; it must not select the local --dir arm, which depends on a host-only auth-service process record.

Fresh managed agents

A host that lets a remote participant spawn FRESH managed agents implements remoteAuthority.enrollManagedAgent. The participant generates the standing actor token, writes it at mode 0600, and passes only its SHA-256 digest with the requested actor, label, role, capabilities, and channel lists, so the plaintext secret never leaves the participant machine. There is deliberately no lifecycleUid input: the host selects the UID, because only the host sees the retirement tombstones that make a UID permanently unusable, and a participant-chosen UID could aim a fresh grant at a dead incarnation. The host authors the ledger grant, pre-creates the lifecycle-keyed durables, clamps the requested lists to what the spawning owner already holds, and returns the owner, actor, chosen lifecycleUid, the space sentinel credentials, the effective lists, and agentBearerExchangeUrl. The manager binds every returned coordinate, re-keys the secret family onto the returned UID, and launches agent-bearer --exchange-url <base>. When the hook is absent a signerless manager refuses the user-mode spawn rather than authoring a local grant the host knows nothing about.

Managed-agent authority transport

Both managed-agent operations ride the one verified POST /manager-service-authority transport as kind: "manager-managed-agent-enrollment" and kind: "manager-managed-agent-prepare-retirement". Stock cotal auth-service answers both itself, because it owns the actor ledger and the space's provisioning authority. An enrollment writes the managed grant at a fresh UID with the supervising actor as its parent, provisions that UID's durables, and returns the daemon's public exchange URL as agentBearerExchangeUrl; a daemon started without --exchange-public-port refuses enrollment. A retry with the same token digest answers the same UID while the supervising actor's current grant covers it, and a fresh enrollment's refusal otherwise. While the agent's grant stands, an enrollment with another digest is refused with conflict until that lifecycle's retirement is prepared. A prepare-retirement releases the target UID's broker footprint and then revokes its grant, so the manager's terminal rail finds the grant gone. The footprint includes every durable membership row of that UID, found as the manager's own teardown finds them: the grant's concrete channels, plus the delivery daemon's lifecycleMemberships inventory read after the ACL fence, which adds family rows and rows on channels a wildcard grants. When that inventory cannot be read, the prepare removes what the grant names, fails, and keeps the grant for a retry. A platform that keeps these writers in its own storage intercepts both kinds instead. It terminates its own public route, authenticates the human there, and asks the auth service for the decision at POST /manager-service-authority/verify-enrollment (exported as VERIFY_ENROLLMENT_PATH from @cotal-ai/auth) with the Bearer <cap> from auth-service.json and only { owner, request }. That door has the managed retirement door's guards, derives the caller's scope from the local ledger rather than the body, checks the manager gate and registration proof in-process, and answers { authorized: true, owner, actor, instanceId, serveEpoch }. authorizeRemoteManagedAgentEnrollment and authorizeRemoteManagedAgentPrepareRetirement are exported too, for a host that composes the decision without the HTTP hop. Both require ledger scope supervise; spawn and admin do not imply it.

A host that runs managed agents on its own hosted runtime adds two more kinds on the same transport. kind: "manager-managed-agent-runtime-create" asks the host to create the runtime for one agent it already enrolled, and kind: "manager-managed-agent-runtime-status" reads that runtime's state. Both carry the manager envelope plus target: { owner, actor, lifecycleUid }, the coordinate the enrollment returned. Both schemas are closed. An unknown top-level or target field, including providerRef, handle, or name, is refused as bad-request, because the host alone issues and holds provider references. There is no stop, adopt, or probe kind: stop goes through prepare-retirement. Stock dispatch refuses both kinds with unimplemented, and the verify-enrollment door decides them. authorizeRemoteManagedAgentRuntimeCreate and authorizeRemoteManagedAgentRuntimeStatus apply the enrollment door's checks: host space, a target owner equal to the authenticated owner, the manager actor's own ledger row with supervise, the open gate, the current serve epoch, and the registration proof. Each returns only { owner, instanceId, actor, target }. The door touches no provider and writes nothing. The host matches the decision to its own intent record and performs the create afterwards. The host answers with state (reserved, creating, bound, create-unknown, closing, or closed), readiness (ready, bound-not-ready, or none), and an optional retirementPhase. A manager builds requests with remoteManagerClient.remoteManagedAgentRuntimeRequest and binds the answer with remoteManagedAgentRuntimeState.

Host runtime contracts

An enrollment result may also carry runtimeIntent: { state: "reserved" } when the host reserved a hosted runtime for the agent. It is display-only. Older hosts omit it, the manager binds both shapes to the same material, and nothing reads it as authority.

Delegated user intent decisions

No stock door lets a platform control holder launch or retire an agent for a signed-in user. The managed-agent kinds above act only under the authenticated owner and refuse a caller that is not that user, so a platform could only run a user's agent by holding the user's login or by enrolling the agent under its own owner. Both are refused. The delegated user launch intent design and SPEC §13.16 define the smallest addition. The user admits one launch or one retirement on the host's authenticated route. The holder consumes that intent once, from its current registration, epoch and lifecycle. The host then enrolls the agent under the user's u_ owner with the user's own actor as its ledger parent, so the envelope walk, membership and channel lists match what the user's own manager would produce. Retirement keeps the prepare, provider closure and terminal barrier order, and the host finishes it when the holder is gone. A launch the host had to undo keeps its agent name held until the host process that ran it confirms it has stopped, and while the name is held the host also refuses it to the user's own manager. @cotal-ai/auth ships the two decisions, authorizeDelegatedUserIntentAdmission and authorizeDelegatedUserIntentExecution, for a host that owns an intent store and those writers to compose on its own routes. Both read the holder's gate through the handle's observeManagerGate, present with platformControl, which reads over the context's own connection, so the host opens no second data-account connection. It is an observation for the decision: the consuming CAS and the writers still apply their own checks. The consuming CAS pins the incarnation of the host process that runs the flight as the executor, never the auth plane's, because the two can restart independently.

Host registration artifacts

platformControl.host names that process's reverse-DNS endpoint, the closure digest of its §13.7 cluster and the contract artifacts registration reads, and the auth plane self-authorizes that one name. Registration reads the closure manifest { v: 1, root, members } at clusterDigest, then the cluster document at the manifest's root, and verifies each against its digest. artifacts therefore carries both, and clusterDigest is the digest of the manifest. members stays empty: SPEC §13.7 lists every reachable artifact there, but this implementation registers single-document clusters only and refuses a manifest that lists members. A manifest with any field beyond v, root and members is refused too, as the contract store refuses it. singleDocumentClosure(document) returns that manifest and its closure digest. The instance id is a lifecycle token, [a-z0-9]{26,32}. The minimal construction below has one command over the void schema. A host copies it, replaces document with its real cluster, and passes host as platformControl: { observeAssignment, host }.

import { mintLifecycleUid, singleDocumentClosure, VOID_SCHEMA_DIGEST } from "@cotal-ai/core";

const document = {
  urn: "com.example.host",
  revision: 1,
  attributes: [],
  events: [],
  commands: [{
    name: "ping", class: "ephemeral", targeted: false, capability: "host.ping",
    inputDigest: VOID_SCHEMA_DIGEST, outputDigest: VOID_SCHEMA_DIGEST,
  }],
};
const { manifest, closureDigest } = singleDocumentClosure(document);
const host = { endpoint: "com.example.host", clusterDigest: closureDigest, artifacts: [document, manifest] };
const instanceId = mintLifecycleUid(); // first start only; later starts reuse the persisted id

Host incarnation fencing

registerHostIncarnation(instanceId) publishes the artifacts, registers that instance through the ceremony the plane runs for itself, and returns { instanceId, processEpoch } with the epoch that registration committed. The host calls it at every start with its persisted instance id, before it admits or recovers any flight, so a restart fences its predecessor. The first registration of an instance commits epoch 0, which is open and serving like any later epoch, and each later start of that instance commits the previous epoch plus one. A consumer compares epochs for equality and never reads 0 as absent or not ready. A start whose confirming read of the gate finds that a later start of the same instance registered is refused with conflict. The returned epoch is a committed coordinate and stays current only until the next start registers, which can happen before the call returns. observeHostGate(instanceId) is a point-in-time read of an executor's gate, and answers null for an absent gate; a sweeper decides on it. awaitHostFence(instanceId, processEpoch) resolves with the gate once it is no longer open at that epoch, and with null once it is absent. It takes any non-negative safe integer epoch, 0 included, and refuses any other value with bad-request. The host arms it with its returned incarnation before it admits or recovers any flight, and stops serving when it resolves. It polls the gate, because no runtime credential may watch the auth bucket.

Delegated intent execution

The host's launch writer first activates the agent's lifecycle at the pinned UID through the handle's activateManagedLifecycle, before any ledger row or durable, and its compensation runs the same call before the terminal barrier, so a launch whose agent never exchanged its bearer still reaches the terminal barrier at that UID. The launched agent exchanges its bearer on the context's public face, so the host starts it with publicFace, and its retirement writer ends at POST /managed-lifecycle/retire with the handle's cap. Stock dispatch refuses both kinds as unimplemented. The holder's composition passes remoteAuthority.executeDelegatedUserIntent, which posts the execution request and binds the answer with parseRemoteDelegatedUserIntentExecutionResult. It then starts the agent with startAgent({ ..., delegatedIntent: { intentId, owner, parent } }) and retires it with retireDelegatedAgent(name, intentId), which stops the agent and removes its local secret family only after the host confirms retired: true for its exact target. A delegated agent's stop, exit or failed launch keeps its name held until that retirement confirms, and a secret file the retirement fails to remove keeps it held too.

Remote administration authorization

Remote user-mode managers must also supply remoteAuthority.authorizeAdmin. The manager builds each request only from the caller tuple parsed from the broker-authenticated endpoint subject, then relays that tuple over the current registered manager lifecycle. HTTPS does not separately authenticate the relayed caller. The host authenticates the manager operator, binds the request to the current open manager gate, registration proof, serving epoch, and identity nkeys, then reads the caller's unified authoritative row fresh. It returns only the manager owner and authorized: boolean, with every request coordinate echoed. Missing, revoked, narrowed, foreign-owner, and stale-lifecycle callers all return false; malformed coordinates or corrupt and unavailable authority state fail the operation. The participant never reads or mirrors the host ledger, and the remote branch has no local fallback. The same callback gates all manager.admin handlers, any-mode cross-owner control, and ps or inspect cross-owner visibility. Launch keeps its owner-equality policy.

Maintenance limits

The remote authority's instance executor remains the scoped maintenance credential for clean service deregistration and exact instance registration operations. It carries no records-stream consumer lifecycle authority. The manager's boot goalidx sweep uses the authenticated host operation, which returns parsed goalidx.manager.<owner>.> entries for that owner only. The host keeps the sealed consumer connection and its create/delete rights. The five-minute executor already renews through remoteAuthority.renewExecutor. remoteManagerClient.remoteStandingBundleRenewal builds standing-bundle renewal requests, with adoption bound to the current serve epoch. remoteRunHosting supplies renewRun through the renewRunDriver authority operation. The host validates and adopts the driver and mediator pair before reconnecting, using the current holder and fencing coordinates. The hosted runtime contract records the bounded additions and their ownership; it is not a shipped pooled service.

Agent isolation

Signer isolation needs an OS sandbox. The default pty runtime runs agent children under the same OS uid and the same workspaceRoot, so mode-0600 on the trust records does not stop a hostile same-uid agent from reading their absolute paths. The reference deploy tree does not solve this: it mounts the signer into the agent's own container, so its phase-1 boundary isolates agents from each other, not the signer from the agent. A hosted composition must run the manager/minter that holds the signer in a different uid, container, or mount namespace from the agent children, which mount no signer at all; that split is future hosted-composition work, so until it (or a remote/injected minter) exists, do not run untrusted agents under this manager.

On this page