protocol agentproto.shcli cli.agentproto.shpanel /panel
agentproto

AIP-46: AGENT-SESSIONS — agent-session-lifecycle/v1 (long-running multi-turn agent orchestration)

A generic session-management protocol for orchestrating long-running, multi-turn agent CLIs (Hermes, Claude Code, OpenCode, …) on a host. Standardises the lifecycle (spawn → multi-turn → kill), the data model (sessions[] with status / ground / workspace bindings, nested via parentSessionId), the over-the-wire surface (HTTP + SSE + MCP tools), the workspace-resolution pattern that lets a single host serve many bound directories, the per-workspace state partitioning that keeps one workspace's history from evicting another's, and the spawn gate that decides whether a session may delegate to further sessions. Layers over AIP-45 (AGENT-CLI) for the per-adapter spawn semantics and AIP-44 (ACP) for the wire format inside one turn; binds AIP-42 (AGENT) as the session's definition, AIP-38 (POLICY) as the delegation authority, and AIP-27 (REF) as the session's ground.

FieldValue
AIP46
TitleAGENT-SESSIONS — agent-session-lifecycle/v1
StatusDraft
TypeSchema
Domaincli.sh
RequiresAIP-17 (RUNNER), AIP-19 (SECRETS), AIP-27 (REF — the session's ground), AIP-36 (SANDBOX), AIP-38 (POLICY — the delegation authority), AIP-42 (AGENT — the session's definition), AIP-44 (ACP), AIP-45 (AGENT-CLI)
Composes withAIP-3 (skills an agent loads), AIP-39 (the actions a policy grants or denies), AIP-47 (ROLE — org context, via AIP-9 OPERATOR; never the spawn authority — see §Delegation)
Reference Impl@agentproto/runtime

Abstract

agent-session-lifecycle/v1 standardises how a host spawns, drives, observes, and tears down long-running agent CLI sessions on a machine. It sits above AIP-45, which defines how to install and start a single agent binary, by adding the N-sessions-on-one-daemon plumbing every real orchestration scenario needs:

  • many concurrent agents (claude in ~/code/foo, hermes in ~/code/bar, aider in ~/blog)
  • multi-turn continuity per session (don't respawn for every prompt)
  • live observation (UI tails output, operator polls for the answer)
  • workspace-bound spawns (resolve ground from a stable, named AIP-27 ref instead of hand-typing absolute paths) — and the state each bound workspace accumulates, partitioned per workspace rather than pooled (§State partitioning)
  • nested sessions — a session that spawns further sessions, and the gate deciding whether it may (§Delegation)

A daemon implementing this AIP becomes a control plane for local agents. Any host (cloud orchestrator, IDE extension, mobile remote) can drive it through the standard surface — HTTP routes, an SSE stream, and MCP tools — without knowing which adapter is wrapped.

A session is the running instance; what it runs is an AIP-42 AGENT. AIP-46 owns the process boundary and the lifecycle; it does not re-declare the agent's prompt, skills, tools, or permissions — those are AGENT fields, and the permission half is AIP-38 POLICY. This split is what keeps the spawn gate honest: the thing being gated is declared somewhere a human can read and sign, not inside the daemon.

AIP-46 is opinionated about the lifecycle and the data shape; it is not opinionated about the wire format inside a turn (that's AIP-44/ACP via AIP-45's protocol discriminator). Hosts that already support AIP-45 adapters get AIP-46 by adopting the registry + endpoint table below.

Motivation

Reference implementation history: agentproto shipped AIP-45 with one canonical entry point (agentproto run <slug> --prompt "…") — a single, fire-and-forget invocation. This worked for ad-hoc piping but fell apart for the actual product flows:

  1. A guild operator wants claude alive in 4 different folders so it can answer follow-ups without re-pulling each repo's context. The run verb spawns a fresh agent per call — no continuity.
  2. A web UI wants to mirror claude mcp list over HTTP so the user sees what's running on their laptop without ps -aux. There was no enumeration surface.
  3. A cloud orchestrator wants to dispatch a claude turn into the user's workspace through the same MCP connection it already uses for fs/exec. There was no MCP path; the orchestrator had to shell out.

Three different consumers, three workarounds, one missing primitive: persistent, named, observable agent sessions. AIP-46 fills it.

Why delegation is in scope

The original cut treated a session as a leaf: something a human or a cloud host starts. Real orchestration made sessions spawn other sessions — a supervisor that decomposes work across children — and that turned a lifecycle protocol into an authority question the spec had no vocabulary for:

  1. A session spawns further sessions, and nothing says whether it may. Reference implementations grew a private notion — agentproto's role: executor | supervisor — carrying a prompt fragment, a delegation switch, and a privilege level in one struct. Three problems compound:

    • It is unspecified. Two hosts implementing AIP-46 have incompatible, undiscoverable delegation semantics. A consumer cannot ask "may this session spawn?" in a portable way.
    • It re-invents AIP-42. A prompt fragment is an AGENT body; a skill list is skills:; an allowlist of spawnable children is delegates_to:; a permission is AIP-38 POLICY. The struct is those four primitives fused, minus their schemas.
    • It collides with AIP-47. "Role" is already taken: an organizational job template that AIP-47 §Role vs Policy vs Governance says MUST NOT grant permission. A spawn-time privilege tier is precisely what AIP-47 calls an access role and forbids conflating with a role. Naming the delegation switch role inverts a security invariant of a neighbouring AIP.

    This amendment fills the gap without minting a fifth primitive: the spawn request binds an AIP-42 AGENT, the AGENT's delegates_to declares who it may spawn, AIP-38 POLICY decides effect, and AIP-46 specifies only the enforcement point — the one thing that is genuinely session-lifecycle's own.

Why state partitioning is in scope (added 2026-07-17)

This AIP's premise, stated in its own abstract, is "the workspace-resolution pattern that lets a single host serve many bound directories". §Workspaces delivered the addressing for that — a slug resolves to a path — and stopped there. The state those many directories generate stayed pooled in one store, with workspaceSlug demoted to a column:

  1. The multi-workspace premise had no multi-workspace storage. A slug that only ever appears as a field is a label, not a boundary. Three consequences, in ascending order of how hard they are to see:

    • Bounded retention becomes a race. Any host capping how much history it keeps caps it across every workspace at once, so the cap is spent by whoever was busiest. The reference implementation measured this: a 200-descriptor cap against a store holding 201 records from four different slugs, where a workspace contributing 8 of them is one busy afternoon away from losing all 8 — having done nothing to deserve it. The eviction is silent and lands on the quietest workspace.
    • Lifecycle has no unit. "Remove this workspace and everything it accumulated" is a filtered rewrite of a shared file rather than the removal of a subtree, which is why no host offers it.
    • Scoping has nothing to scope to. A host that wants to hand a consumer a view of one workspace has to filter a global store on every read, and every read that forgets is a leak. A partition lets the host not hold what it should not serve.

    This amendment does not mint a new primitive either: it takes the slug §Workspaces already defines and makes it select storage rather than annotate it.

Specification

Session data model

A session is the host's record of one running (or recently exited) agent CLI invocation. Required fields:

interface Session {
  /** Stable id for the lifetime of the session. Caller-opaque. */
  id: string
  /** Adapter slug from the AIP-45 manifest the session wraps. */
  adapterSlug: string
  /** Workspace handle (AIP-46 §workspaces) this session binds to.
   *  "default" when the host has no workspace registry. */
  workspaceSlug: string
  /** The session's ground, as the resolved AIP-27 ref. This is the
   *  value §Delegation's containment check compares against — a
   *  child's ground MUST resolve inside its parent's. */
  ground: string
  /** Absolute path the agent runs in — `ground` resolved against the
   *  host's filesystem. Reported for observability; it is NOT the
   *  containment surface (`ground` is). Hosts MUST NOT reconstruct a
   *  parent's ground by string-matching this. */
  cwd: string
  /** Lifecycle phase. State machine in §status-transitions. */
  status: "starting" | "running" | "exited" | "killed" | "error"
  /** ISO-8601. Time the host called the spawn. */
  startedAt: string
  /** ISO-8601, present when status ∈ {exited, killed, error}. */
  endedAt?: string
  /** ISO-8601, last stdout/stderr or projected event line. */
  lastOutputAt?: string
  /** Process exit code when present. -1 / unset for non-process
   *  sessions or sessions still alive. */
  exitCode?: number
  /** Free-text label the spawner attaches (conversation id, operator
   *  name, …) so consumers can group/filter. */
  label?: string

  /** The AIP-42 AGENT this session runs, when one was bound at spawn.
   *  Absent for a bare adapter spawn (adapter default behaviour).
   *  Persisted: a consumer MUST be able to ask what a live session is
   *  running without replaying its prompt. */
  agentSlug?: string
  /** Set when this session was spawned BY another session. Absent ⇒
   *  a root spawn (a human or an external host started it). This is
   *  the edge the spawn gate keys off — see §Delegation. */
  parentSessionId?: string
  /** Depth in the spawn tree: a root spawn is 0; a session spawned by
   *  a session at depth d is d+1. Present whenever `parentSessionId`
   *  is. Bounds recursion (§Delegation limits). */
  depth?: number
}

The host MAY add fields; consumers MUST ignore unknown fields.

parentSessionId + depth make the registry a forest: roots are sessions with no parent, and a consumer renders the tree by grouping on parentSessionId. A host that supports delegation MUST populate both — a nested session that reports no parent is indistinguishable from a root one, which defeats both the gate and every supervision UI built on top of it.

Status transitions

starting ──→ running ──→ exited
   │           │     ╲──→ killed
   │           ╰──────────→ error
   ╰──→ error  (spawn failed)
  • starting → running fires when the agent's protocol arm reports the session ready for the first turn.
  • running → exited is the normal terminal — the underlying child exited with code 0 (or any code; presence of exitCode is what matters, not its value).
  • running → killed fires when a consumer called kill_session, DELETE /sessions/:id, or the host shut down with a live agent.
  • Interrupting a turn is not a transition. A session whose in-flight turn was cancelled (POST /sessions/:id/interrupt, or a prompt sent with interrupt: true) stays running — the agent is alive, idle, and holding its context. There is no interrupted status, and consumers MUST NOT infer one from a turn ending early.
  • * → error fires on any unhandled spawn / protocol / IO error; lastOutputAt typically points at the corresponding line in the ring buffer.

Workspaces

A host implementing AIP-46 SHOULD persist a workspaces config (reference implementation: ~/.agentproto/workspaces.json) so spawns can reference a named handle instead of an absolute path:

{
  "version": 1,
  "active": "agentik-studio",
  "workspaces": [
    { "slug": "agentik-studio",
      "path": "/Volumes/.../agentik-studio",
      "addedAt": "2026-05-10T15:31:56.664Z",
      "updatedAt": "2026-05-10T15:32:00.123Z",
      "label": "Main monorepo" }
  ]
}

Each entry is addressable as an AIP-27 ref — ws://workspaces/<id> — and that ref, not the path, is what a spawn carries as its ground. The path is the host's private resolution of it. Per AIP-27 §Seams, an AIP-34 workspace id is @<owner>/<slug>, so a fully-qualified ground ref reads ws://workspaces/@acme/agentik-studio; a host whose registry predates AIP-34 ids MAY key by bare slug until it adopts them.

Ground resolution priority for any root spawn that omits an explicit ground:

  1. lookup workspaces[workspaceSlug] → use its ref
  2. fall back to workspaces[active] → use its ref
  3. emit a warning + use the host's process.cwd()

Steps 2 and 3 are root-only. A nested spawn (one whose caller is a session) MUST NOT reach them — it inherits its parent's ground instead. See §Delegation; the active workspace is ambient host state and resolving a child through it silently relocates that child outside the subtree its parent was working in.

Hosts that don't implement workspaces MAY require explicit cwd on every spawn; consumers MUST then pass it.

State partitioning (added 2026-07-17)

§Workspaces makes workspaceSlug a field on a session. That is enough to render a filter and not enough to keep two workspaces out of each other's way: a field distinguishes rows inside one store, and every row in that store still competes for the same bounded resource. This section makes the slug a partition of the host's state.

Layout

A host that persists session state and implements a workspace registry MUST partition that state per workspace. The reference layout:

~/.agentproto/
├── workspaces.json              # the registry (§Workspaces)
└── workspaces/                  # one bucket per workspace
    ├── agentik-studio/
    │   ├── sessions.json        # this workspace's registry snapshot
    │   └── sessions/            # this workspace's transcripts
    │       └── <sessionId>/…
    ├── choisir-service-public-app/
    │   └── …
    └── default/                 # the fallback bucket — see below
        └── …

workspaces.json (the registry, a file) and workspaces/ (the state, a directory) are deliberate siblings: the registry names the workspaces, the directory holds what each one accumulated.

StoreNormative force
the sessions registry snapshotMUST be per-bucket
durably-kept transcripts / per-session outputSHOULD be per-bucket
any other store whose records carry a workspace bindingSHOULD be per-bucket
stores with no workspace binding (host config, pairings, adapter installs)MUST stay global — bucketing them would assert a binding that does not exist

Hosts MAY choose a different root; the partition, not the path, is what this section requires.

Bucket resolution

For any record carrying a workspaceSlug, the host resolves its bucket:

  1. workspaceSlug is non-empty and matches a registered entry in workspaces.json by exact identity → that workspace's bucket.
  2. Otherwise — absent, empty, or unregistered → the default bucket.

A host MUST resolve by exact match against the registry, and MUST NOT derive a bucket directory from a slug that did not come back from that lookup. The registry's slugs are host-sanitised (§Workspaces); a slug on a spawn request is caller input, and a host that paths on it directly has handed the caller directory traversal under its own state root. Membership is the validation: an unregistered slug does not get a bucket of its own, it gets default.

default is a real bucket, not an error state. It receives every record the registry cannot place — one-off cwd spawns, sessions from a workspace the user has since removed, and the pre-partition past. Records in default share a bucket with one another, i.e. they keep exactly the pooled behaviour this section replaces elsewhere. Partitioning is therefore not an isolation mechanism for unregistered work, and hosts MUST NOT present it as one: registering a workspace is what buys separation.

Per-bucket bounds

A host that bounds how many historical descriptors it retains MUST apply that bound per bucket, never across the union.

This is the concrete fairness property the partition exists to buy. A global bound makes retention a race between workspaces: the busiest workspace's records evict the quietest one's, so a workspace can lose its history by having done nothing at all while a neighbour was busy — the eviction is silent, and the workspace that suffers it is the one with the least evidence that anything happened. A per-bucket bound makes retention local: a workspace's history is bounded by its own volume and nothing else's.

Hosts SHOULD size the bound per bucket rather than dividing a global budget among buckets. The bound exists to keep a single store readable; after partitioning, each bucket is a single store.

Migration

A host that previously pooled this state in one global store MUST treat the move to buckets as additive:

  • The host MUST partition the legacy store's records into buckets by the rule above, on first boot after the change.
  • The host MUST NOT delete, truncate, or rewrite the legacy artifact. It is the user's data and their only rollback; the partitioned copy is a derivative until they decide otherwise. A host MUST be able to complete the migration with the legacy store held read-only.
  • The host SHOULD record a durable marker — what was migrated, when, from where, and how many records landed in each bucket — so the split is auditable, and so a later boot does not re-import records the user has since deleted from a bucket.
  • A record whose slug does not resolve MUST land in default. The migration MUST NOT drop a record it cannot place.

Reclaiming the legacy artifact is a separate, human-gated decision. This AIP specifies no retention or garbage collection for any bucket; see §Open Questions.

Delegation and the spawn gate

A nested spawn is a spawn whose caller is itself a session. The host MUST decide, at spawn time, whether the calling session may create the requested child. That decision is the spawn gate, and this section is the whole of what AIP-46 specifies about it: the enforcement point. What is permitted is declared by AIP-42 and decided by AIP-38 — AIP-46 only guarantees the check happens where it cannot be talked past.

The agent binding

A spawn request MAY carry agent: <agent-ref> — a reference to an AIP-42 AGENT, resolved by the host through AIP-42's inline | ref | file pattern. The bound AGENT supplies:

AGENT fieldWhat it supplies to the session
bodythe system prompt / disposition given to the child
skills[]AIP-3 skills the child loads
boundaries[]hard rules surfaced high-priority in the prompt
delegates_to[]the closed allowlist of agents this one may spawn
policyAIP-38 binding — the access decision

A host that resolves an AGENT MUST record it as agentSlug on the session. Absent agent, the host spawns the adapter's default behaviour and the session MUST be treated as declaring no delegates_to — i.e. deny (below).

Who may spawn whom

  1. Deny by default. An agent whose resolved delegates_to is absent or empty MUST NOT be permitted to spawn. This mirrors AIP-38's default: deny and makes the safe case the silent one.
  2. Closed allowlist. When delegates_to is non-empty, the host MUST permit a nested spawn only when the requested child's agent-ref resolves to a member of the caller's delegates_to. Membership is by resolved identity, not by the literal string.
  3. No self-widening. A child MUST NOT be able to alter its own delegates_to, its policy, or the toolset the host granted it — not through its prompt, not by requesting a wider binding at spawn, not by re-spawning itself. The caller's grant is a ceiling; a nested grant MUST be bounded by the parent's.
  4. Role is never the authority. A host MUST NOT derive delegation permission from an AIP-47 ROLE, from an operator's seniority, or from any organizational field. AIP-47 is explicit that a role declares intent and MUST NOT grant access; an agent's job title has no bearing on whether it may spawn a process.

The enforcement point

This is the normative core.

  • The gate MUST be evaluated host-side, at spawn, before any tool injection or process creation. A rejected spawn MUST create no session.
  • The gate MUST bind to the calling session's identity, not to the surface it connects through. The host MUST resolve the caller's grant from the authenticated identity of the calling session and enforce it where the delegating action is invoked. A host MUST NOT derive the grant from anything the caller supplies about itself: a restriction expressed as a URL parameter, a header, a config entry, or any other caller-controlled input is a request, not a gate — the caller omits it and the restriction evaporates.
  • Every surface that can reach a delegation action MUST carry session identity. A surface reachable without identity — an unauthenticated loopback endpoint, a permissive local socket — is an ungated surface, and its existence defeats the gate for every session that can reach it, including sessions the host believes it denied. Co-located children can reach loopback by construction; "local" is not an authentication boundary when the thing being authenticated is a process the host itself spawned.
  • Removing the actions from the surface the host injects is defence in depth, and MUST NOT be the only mechanism. An adapter that carries its own configuration for the host's endpoint will connect to the ungated one; an adapter with a native delegation facility will not use the host's surface at all. A host that gates only the URL it hands out will report a child as denied while that child delegates freely — the worst available outcome, because it is silent and it reads as enforcement.
  • Prompt text is not enforcement. A disposition, a boundaries[] entry, or any instruction telling a child not to delegate is a hint. A conforming host MUST NOT count it as the gate. Prose that restates the gate is permitted and useful; prose in place of the gate is non-conforming.
  • The gate MUST be the single decision point shared by enforcement and introspection. A host exposing "what may this session spawn?" MUST answer from the same predicate that rejects the spawn, so the two can never disagree.

Delegation actions. The verbs that spawn or drive another session — agent_start and agent_prompt in the tool table below — are AIP-39 actions for policy purposes. A host MUST gate them as actions, not as tool-name strings, so a renamed or aliased surface cannot route around the check.

Root spawns. A spawn with no calling session (no parentSessionId) has no parent to gate against; it is authorised by the host's own auth boundary (§Security Considerations), not by this lattice. Hosts MUST NOT treat the absence of a parent as an implicit grant to the child — a root-spawned session is still subject to its own agent's delegates_to for anything it spawns in turn.

A nested spawn inherits its parent's ground

Identity is not the only thing a nested spawn must carry down. A host MUST resolve a nested spawn's ground as:

  1. the request's explicit ground ref, when given
  2. otherwise the parent session's ground

A host MUST NOT resolve a nested spawn through §Workspaces' ambient workspaces[active] fallback. That fallback exists for root spawns, where the human's active workspace is a sensible default. For a nested spawn it is actively wrong: active is a property of the human's session, mutable at any moment by an unrelated workspace use, and nothing about it is chosen by the spawning agent. A child resolved that way runs with its parent's credentials in a directory its parent never named — the parent believed it was delegating inside its own workspace, and the daemon silently placed the child somewhere else entirely.

The general rule: a nested spawn's context is derived from its parent, never from ambient host state. Where the host has no parent value to inherit and the request names none, it MUST reject the spawn rather than substitute an ambient default.

Ground narrows, like authority. A nested spawn's ground MUST resolve inside the parent's own; a host MUST reject one naming ground outside it (cwd_outside_parent). Inheritance alone is only a default, and a default is not a boundary — without this rule a parent hands its child any ground on the machine and the inheritance rule buys nothing.

This is the same non-escalation principle delegates_to applies to authority, applied to ground: a child may be given less than its parent has, never more, on either axis. A subtree is the natural shape — a parent may legitimately want a child confined to a package inside its own tree, and never has cause to send one somewhere it cannot itself reach.

The check is a containment test between two AIP-27 refs, not between two path strings. That is the whole reason ground is a ref: AIP-27 rejects lexical escapes (.., absolute paths) at parse time — §Security 1, "the resolver layer is not the right place to catch this" — so the escape a naive prefix check would miss is not expressible in the first place. A host that reduces refs to strings and prefix-matches those has re-opened what the ref grammar closed.

Two limits, stated because parse-time validity is not containment:

  • Symlinks resolve after parse. A ref that is lexically inside the parent's subtree MAY still land outside it through a symlink. Hosts that treat the subtree as a security boundary MUST verify the resolved real path; hosts that treat it as an orientation default need not. AIP-46 does not decide which it is for you — see the §Delegation boundary note.
  • The host's own fs/exec tools MUST anchor to the session's ground by this same rule. A spawn-time boundary that the session's own tools then ignore is not a boundary.

Limits

Independently of the allowlist, a host MUST bound recursion:

LimitMeaningOn breach
maxDepthgreatest permitted depthreject, create no session
maxChildrengreatest number of concurrently alive children per sessionreject, create no session

An allowlist that names a peer (a delegates to a) is legal and recursive; maxDepth — not the allowlist — is what terminates it.

Errors

A host MUST reject with a stable, machine-readable code. Codes below are normative; hosts MAY add their own.

CodeCause
invalid_agentagent ref does not resolve
delegation_deniedcaller's agent may not spawn the requested child
cwd_outside_parentnested spawn named ground outside its parent's subtree
max_depth_exceededdepth would exceed maxDepth
child_quota_exceededcaller already at maxChildren

A rejection MUST be distinguishable from a spawn failure: the former means no session was permitted, the latter that one was permitted and failed to start.

What this gate cannot reach (informative)

An honest boundary, because implementations have shipped believing otherwise. Everything in §Delegation governs surfaces the host grants. Every adapter also ships its own equivalent of those surfaces, and the host's rules do not reach them:

The host governsThe adapter's ungoverned twin
the delegation actions on the host's own gatewaya native task / sub-agent facility, spawning off-protocol
host fs / exec tools anchored to the session's groundthe CLI's own built-in shell and file tools
what the host injects into the child's configwhat the adapter already has in its config

The pattern is one fact wearing three hats: an adapter can do natively whatever the host can do for it, and the host's version is the only one with rules on it. A native sub-agent spawn has no session id and no parent edge — there is nothing to gate. A native shell reads any path the process can reach — ground is where it starts, not a fence.

Which decides whether ground is a boundary or a default, the question §A nested spawn inherits its parent's ground defers here. Where the child's only reach is the host's own tools, ground is a boundary: the containment check holds, and verifying resolved real paths against symlinks is worth it. Where the child has a native shell — the common case — ground is an orientation default: it says where work starts, not where it can go, and no amount of symlink-checking at spawn changes that. Hosts SHOULD know which of the two they are running and MUST NOT describe the second as the first. Turning ground into a real boundary for an adapter with its own shell is AIP-36's job, not this AIP's.

Consequences a conforming host MUST accept:

  • It MUST NOT report as gated anything it did not actually gate. A child spawned off-protocol is invisible, not denied; a path read through the adapter's own shell is unbounded, not confined.
  • It SHOULD disable the native facility through the adapter's AIP-45 options wherever the adapter exposes a flag, and SHOULD treat the absence of such a flag as a material property of that adapter.
  • It SHOULD treat AIP-36 as the only real backstop. A boundary the child's own tooling can step over is a convention. Where a host needs containment rather than convention, the containment is the sandbox — nothing in AIP-46 substitutes for it.

Stated plainly, because it is the rule most likely to be forgotten: AIP-46 bounds what the host hands out. It is not a sandbox, and no amount of naming discipline inside it becomes one.

HTTP surface

The host MUST expose these routes under a fixed prefix (default /sessions):

MethodPathBodyReturns
GET/sessions{ sessions: Session[] }
POST/sessions/agentSpawnAgentRequestSession (201)
GET/sessions/:idSession
POST/sessions/:id/prompt{ prompt: string }{ ok: true, id }
POST/sessions/:id/interrupt{ ok: true, id, wasBusy: boolean }
POST/sessions/:id/kill{ ok: boolean, id }
DELETE/sessions/:id{ ok: boolean, id } (forget)
GET/sessions/:id/streamSSE — see §sse

Interrupt (added 2026-07-17)

POST /sessions/:id/interrupt cancels the in-flight turn and leaves the session alive and idle, ready for the next prompt with its context intact. It is the wire form of the Ctrl-C a human would press at the agent's own TUI, and hosts SHOULD implement it as exactly that (ACP session/cancel, or an adapter-specific SIGINT for process/PTY-backed adapters).

It is deliberately not a status transition: an interrupted session stays running (see §Status transitions), which is the whole distinction from kill (running → killed, terminal). Consumers that want "stop what you're doing" MUST NOT reach for kill to get it — killing a session to end one turn discards the agent's context and forces a respawn, defeating the multi-turn continuity this AIP exists to provide.

wasBusy reports whether a turn was actually cancelled. Interrupt is idempotent: on an idle or already-terminal session it MUST succeed as a no-op with wasBusy: false rather than erroring — a consumer racing a turn-end should not have to distinguish "stopped it" from "it had already finished", and both leave the session in the state the consumer asked for.

Hosts MUST return 409 when the resolved adapter cannot cancel a turn at all: that is a capability refusal the consumer can act on (fall back to kill, or surface it), not a server fault.

This route is the ONLY way to end a turn without supplying the next one. POST /sessions/:id/prompt with interrupt: true (see §Multi-turn semantics) cancels a turn too, but only as a means of redirecting the session onto a new prompt — it cannot express "stop, and send nothing". Hosts predating this route forced consumers to re-send a throwaway prompt purely to reach the cancel, which pollutes the transcript with a turn the user never asked for.

SpawnAgentRequest

{
  /** AIP-45 adapter slug — WHICH CLI runs. Required. */
  adapter: string
  /** AIP-42 AGENT ref — WHAT it runs (prompt, skills, delegates_to,
   *  policy). Optional; absent ⇒ adapter default + no delegation.
   *  See §Delegation. */
  agent?: string
  /** WHERE it runs — an AIP-27 ref naming the session's ground.
   *  `ws://workspaces/@<owner>/<slug>` or its compact form, optionally
   *  with a subtree body. Root spawns MAY omit it (§Workspaces
   *  resolves a default); nested spawns inherit the parent's when
   *  absent and MUST NOT escape it (§Delegation). */
  ground?: string
  /** DEPRECATED alias for `ground`, accepting a raw absolute path.
   *  Root spawns only — a nested spawn MUST use `ground`, because a
   *  raw path cannot express the containment check §Delegation
   *  requires. Hosts SHOULD reject `cwd` on nested spawns. */
  cwd?: string
  /** Initial prompt — equivalent to start + prompt back-to-back. */
  prompt?: string
  /** Free-text label for the descriptor. */
  label?: string
}

ground replaces the old workspaceSlug + cwd pair. Those were two fields expressing one axis with a precedence rule between them ("cwd wins over workspaceSlug"), which is how a nested spawn ended up resolving through the ambient active workspace: the field that should have carried the parent's ground was optional, and the fallback behind it was global. One field, one ref, no precedence rule to get wrong.

A ref also carries what a slug could not — a subtree (ws://workspaces/@acme/studio/packages/runtime), which is exactly what a parent needs to hand a child less ground than it holds.

adapter and agent are orthogonal and both may be present: the adapter is the runtime (which binary), the agent is the definition (which prompt, skills, and delegation grant). The same reviewer AGENT MAY be run on any conforming adapter; the same adapter MAY run any agent. Hosts MUST NOT infer one from the other.

SSE event format

The /sessions/:id/stream endpoint emits one JSON-encoded event per SSE message:

event: line
data: { "line": "hello world", "stream": "stdout" }

stream is one of "stdout" | "stderr". Hosts MAY emit additional event types (e.g. status for state transitions); consumers MUST ignore unknown event types. Hosts SHOULD send a comment-line keep-alive every 25–30 seconds to defeat proxy idle timeouts.

MCP tools

Hosts that already expose an MCP server (the common case for daemons also serving fs/exec tools) MUST register the following five tools so MCP clients can drive sessions through the same connection:

ToolInputsNotes
agent_start{adapter, agent?, ground?, prompt?, label?}Returns the Session JSON. Subject to §Delegation when the caller is a session.
agent_prompt{sessionId, prompt}Fire-and-forget — call agent_output to read the reply. A delegation action (§Delegation).
agent_sessions_list{onlyAlive?: boolean}Returns {sessions: Session[]}
agent_output{sessionId, lastN?: number}Returns the last N ring-buffer lines
agent_kill{sessionId}Returns {ok, sessionId}

The MCP tools and the HTTP routes target the same underlying registry — calling either family results in the same observable state.

agent_start and agent_prompt are the delegation actions of §Delegation; hosts gate them by action identity, not by tool name.

Multi-turn semantics

A session stays alive after each turn. Subsequent agent_prompt (or POST /sessions/:id/prompt) calls dispatch to the same underlying agent process; the agent retains its in- memory context (previous tool results, file reads, etc.) until the session is killed.

The host MUST reject (HTTP 409 / MCP error) overlapping prompts on the same session — agents are single-turn-at-a-time. Consumers can poll agent_output or watch the SSE stream to know when a turn ended (look for the turn-end projected line).

Redirecting a busy session (documented 2026-07-17). A prompt MAY carry interrupt: true, which replaces that 409 with a redirect: the host cancels the in-flight turn (as §Interrupt describes), waits for it to settle, and then admits the new prompt on the same session, context intact. It is a no-op modifier on an idle session — the prompt is admitted normally, byte-identical to omitting the flag — so a consumer racing a turn-end never has to choose in advance. This flag has shipped in the reference implementation since before this AIP was written; the document simply never described it.

interrupt: true and POST /sessions/:id/interrupt are not alternatives: the flag cancels a turn in order to start another one and cannot express "stop, send nothing"; the route ends a turn and stops there. A consumer wanting the latter MUST use the route.

Output projection

When the underlying adapter speaks AIP-44/ACP (the common case), the host SHOULD project structured events into the ring buffer with human-readable prefixes:

ACP eventProjected line
text-deltathe delta text, joined on \n boundaries
thought[thought] {text} (dim)
tool-call[tool] {toolName} (cyan)
tool-result (error)[tool-error] (red)
agent-prompt[awaiting input] (yellow)
turn-end── turn-end ({reason}) ── (dim)
error[error] {message} + child stderr tail (red)

The exact wire format is non-normative; the goal is that a CLI observer (agentproto sessions --attach <id>) sees a coherent transcript without parsing per-event JSON.

Rationale

Why buckets under the host's state root, and not inside the workspace itself. The obvious alternative to ~/.agentproto/workspaces/<slug>/ is the git-style one: put each workspace's state in the workspace — <repo>/.agentproto/ — where the partition is free, needs no registry lookup, no default fallback, and no migration, because the directory is the identity. It was rejected, and the reason is worth stating because the ergonomics genuinely are better.

Transcripts are not metadata. They contain the full text of agent conversations: every prompt a user typed, every file the agent read back, and every secret that passed through a turn — §Security Considerations already warns that the ring buffer captures whatever the agent printed. Co-location writes that stream into a directory whose defining property is that it is tracked, committed, and pushed. The failure is not hypothetical or gradual: it is one forgotten .gitignore between a private transcript and a public remote, on a path the user did not choose and has no habit of auditing. A host that partitions this way has fixed a fairness bug by manufacturing a disclosure bug, and the disclosure bug ships to everyone who clones the repo.

The registry lookup, the default bucket, and the migration are the price of keeping the state in a directory that exists to hold state. That is the trade this section makes deliberately.

Why membership in the registry is the validation. A slug arrives from a caller; a bucket is a directory name. Every other approach to that gap is a sanitiser, and a sanitiser is a thing you can get subtly wrong forever. Requiring the slug to come back from the registry lookup collapses validation into a step the host already performs: the only strings that ever become directory names are ones the host itself minted and sanitised on the way in. An attacker-supplied slug is not rejected, escaped, or normalised — it simply fails to match, and lands in default like any other unregistered work.

Why default is a bucket and not an error. Refusing to persist a session whose workspace is unregistered would make the partition a breaking change for every one-off cwd spawn, and hosts would respond by auto-registering, which turns a registry of the user's intent into a registry of everything that ever ran. Accepting them into a named, shared bucket keeps the migration additive and keeps the registry meaningful — at the cost, stated plainly above, that default gives its occupants no separation from each other.

Why partitioning is specified without a scoping rule. A reader could reasonably expect this section to also say "and a consumer scoped to workspace X MUST NOT see workspace Y's sessions". It deliberately does not. Partitioning is a storage property; confidentiality is an access-control property, and the two are independent — a host can partition perfectly and still serve every bucket to every caller, which is exactly what a host does the day it lands this section. Specifying the storage boundary first is what makes the access rule expressible later; conflating them would let an implementer read this section, ship the layout, and believe they had shipped isolation. §Security Considerations says so in the negative, and §Open Questions carries the scoping rule as the follow-on work it is.

Reference Implementation

The @agentproto/runtime package implements AIP-46 alongside AIP-45. Daemons started via agentproto serve (or built directly through createGateway()) expose:

  • HTTP routes: runtime/src/http-server.ts — handler under /sessions/*
  • MCP tools: runtime/src/session-tools.ts — registered per-request via mcpServerFactory
  • Registry: runtime/src/sessions.ts — in-memory + persisted (debounced) to the per-workspace snapshot ~/.agentproto/workspaces/<slug>/sessions.json of §State partitioning; carries parentSessionId + depth
  • Workspaces config: runtime/src/workspaces-config.ts — read+write helpers for ~/.agentproto/workspaces.json
  • Bucket resolution + migration: runtime/src/workspace-buckets.ts — the slug→bucket rule, the default fallback, and the additive split of the legacy global sessions.json
  • Spawn gate: runtime/src/session-spawn.ts — the enforcement point of §Delegation

§Delegation is specified ahead of its reference implementation — @agentproto/runtime does not yet conform. Divergences are tracked in that repository's issues rather than here: a spec carrying an implementation's bug list goes stale the moment the bugs close, which is how this document's tool names drifted in the first place.

That gap generalises into a rule worth stating plainly, because the failure is silent and the consequence is credentialed processes: a consumer MUST NOT assume a host enforces the spawn gate merely because the host implements AIP-46. Delegation is the one part of this AIP whose absence is invisible from the outside — a host that never gates looks identical to one that does until something spawns that should not have. Hosts SHOULD advertise conformance to §Delegation explicitly; consumers that rely on the gate SHOULD verify it rather than infer it.

The CLI shell @agentproto/cli adds:

  • agentproto workspace add|list|remove|use for managing the workspaces config
  • agentproto sessions [--watch] [--attach <id>] [--json] for browsing + tailing the registry

Backwards Compatibility

This AIP is Draft and pre-adoption: the reference implementation is its only consumer. Breaking changes are therefore cheap, and this document prefers being right over being compatible with its own earlier drafts. Where the spec and the implementation disagree, the implementation moves. Once a second consumer exists this section gets stricter; until then, treat every rule here as settled by argument rather than by precedent.

The session lifecycle itself is additive over AIP-45: hosts adopt it by registering the registry + routes, and existing agentproto run invocations continue to work unchanged (they bypass the registry, which is fine for one-shot scripting).

Hosts MAY refuse to register the routes when no AIP-45 adapter is installed; they then SHOULD return HTTP 501 from POST /sessions/agent with a clear message pointing at the install command.

There is no role on this surface

Some hosts grew a private role: executor | supervisor field carrying delegation authority. It is not part of this AIP and MUST NOT be introduced: a spawn names an agent, and authority comes from that agent's delegates_to + policy. Hosts carrying the field today should replace it — the delegating tier becomes an AGENT declaring delegates_to, the leaf tier an AGENT declaring none — and delete it rather than alias it. Organizational role belongs on the AIP-9 OPERATOR that owns the agent, never on the spawn; AIP-47 forbids a role granting access at all.

ground replaces workspaceSlug + cwd

The spawn surface previously carried two fields for one axis — workspaceSlug (a handle) and cwd (a raw path that "wins over" it). That precedence rule is how a nested spawn came to resolve through the ambient active workspace: the field that should have carried the parent's ground was optional, and the fallback behind it was global.

ground is one AIP-27 ref and replaces both. workspaceSlug remains on the Session descriptor as a reported binding, but is no longer a spawn input. cwd survives on the spawn request as a deprecated root-only alias accepting a raw path, because a human naming a directory on their own machine is a real case and a ref buys nothing there. On a nested spawn it MUST NOT be honoured: a raw path cannot express the containment check §Delegation requires, and accepting one re-opens exactly the hole the ref closes. New implementations SHOULD NOT offer cwd at all.

The 2026-07-17 state-partitioning amendment

§State partitioning changes where a conforming host keeps state, not what it serves. Compatibility weight sits in three places:

  1. The wire surface is untouched. Session.workspaceSlug keeps its meaning and its field; the HTTP routes, the MCP tools, and the SSE stream are unchanged. A consumer cannot observe the partition, which is the point — this is a host-storage requirement, and a consumer that reads workspaceSlug off a descriptor behaves identically before and after.
  2. Migration is additive by mandate. The legacy global store is read, split, and left intact (§Migration). A host that rolls the change back finds its original artifact where it left it; a host that rolls forward twice is protected by the marker. Nothing about this amendment authorises deleting a user's history — retention is explicitly not specified.
  3. Per-bucket bounds change what survives, in the user's favour. A host moving a global cap to a per-bucket cap retains strictly more history than before (each bucket now gets the whole bound rather than a contested share). Hosts that sized a global cap against a render budget SHOULD re-check it per §Per-bucket bounds rather than dividing it, but a host that keeps its existing number is conforming and simply retains more.

Hosts with no workspace registry are unaffected: §State partitioning binds only hosts that implement §Workspaces, and a host without one has a single implicit workspace, which is already a partition of one.

Security Considerations

  • All sessions execute as the daemon's UID. Hosts SHOULD gate the routes behind their existing auth (bearer / loopback / mTLS), which is the same mechanism protecting the AIP-45 spawn surface.
  • The ring buffer captures stdout/stderr verbatim — secrets the agent printed are visible to anyone with GET /sessions/:id or the SSE stream. Hosts MAY redact known secret patterns, but the AIP does not require it; consumers MUST treat the buffer as potentially sensitive.
  • agent_start accepts ground from the caller. A ground ref is bounded by the registry — it names a workspace the host already knows, so an unregistered location is unnameable. The deprecated raw-path cwd alias has no such property: hosts that still accept it SHOULD reject paths outside their allowed surface (e.g. require the path to be a registered workspace, or under the workspaces' parent directory) when the auth principal is not the local UID. That asymmetry is the argument for retiring the alias.
  • The spawn gate is an authority boundary, not a guardrail. A session that can spawn can spend money, write files, and reach the network as the daemon's UID — recursively. Hosts MUST evaluate the gate before process creation (§Delegation) and MUST NOT rely on the child's cooperation. The failure mode is not a confused agent; it is an unbounded fan-out of credentialed processes.
  • Root authority is the host's auth, and it is total. Because a root spawn has no parent to gate against, whatever authenticates to the spawn surface can request any agent. Hosts MUST NOT expose that surface more broadly than they would a shell. Any surface reachable by a child (a proxied gateway, a forwarded port, a tunnel) that reaches the spawn routes without a parent edge is a privilege escalation: it converts a gated nested spawn into an ungated root one. Hosts MUST ensure a session's onward calls carry their parent edge.
  • Loopback is not an authentication boundary here. The usual reasoning — "only local processes can reach it, and local processes are already trusted" — inverts for this AIP: the local processes are precisely the untrusted party, because they are the agents being gated, and the host spawned them there itself. A host that exempts loopback from auth on any surface exposing the delegation actions has published an ungated gateway to every child it runs. Hosts MUST authenticate the delegation surface per-session even on loopback.
  • The realistic defeat of this gate is not an attacker. It is an adapter shipping its own config entry for the host's own endpoint, or a well-meaning caller handing a child a broader MCP server. Both are ordinary configuration, neither looks like an attack, and both produce a child the host reports as denied while it delegates. This is why §Delegation binds the gate to session identity rather than to surface composition: surface-based reasoning fails against config, and config is the common case.
  • Ring-buffer disclosure crosses the tree. A parent reading a child's output through agent_output sees whatever the child printed, including secrets it was given. Delegation therefore widens the blast radius of §the ring-buffer caveat above by one hop per level.
  • Partitioning is not isolation. §State partitioning bounds what a workspace's state costs other workspaces; it does not bound who may read it. A host that has partitioned its state and changed nothing else still serves every bucket to every authorised caller — the files moved, the access did not. Hosts MUST NOT describe partitioning as an isolation or privacy control, and consumers MUST NOT infer from a partitioned layout that a scoped credential sees only its own workspace. The rule that would make it one does not exist yet (§Open Questions).
  • A bucket name is caller input until the registry says otherwise. workspaceSlug reaches the host on a spawn request and a bucket is a directory under the host's state root. A host that joins the two without the exact-match lookup of §Bucket resolution has given the caller a path-traversal write primitive — ../../ in a slug escapes the state root entirely, as the daemon's UID. The lookup is what makes this safe, which is why it is specified as membership rather than as sanitisation: a host cannot forget to validate a value it never chose.
  • Transcripts are the payload, so where they live is a security decision. They hold prompts, file contents, and any secret that crossed a turn. §Rationale records why they are not co-located inside the user's workspace directory: a state store that lives in a git repository is one missed .gitignore from a public remote. Hosts adding their own stores under a bucket SHOULD apply the same test — the directory must exist to hold state, not to be published.

Open Questions

  • Should sessions expose a pid field even for protocol-arm (ACP-driven) sessions where the host owns the spawn? Consumers asked for pid for kill -9 debugging; the AIP currently leaves it null for agent sessions to discourage host-side process tree manipulation.
  • Cancellation mid-turn — the protocol arms support session.cancel(), but the AIP doesn't expose it as an HTTP/MCP verb yet. Should be added once we have a real consumer demand (the ACP cancel_turn semantics differ across adapters).
  • Resume across daemon restarts — the per-workspace snapshot (§State partitioning) persists descriptors but not the live agent process. A future v2 could specify rehydration (re-spawn + replay context) for adapters whose AIP-45 manifest declares session.mode = "resumable".
  • Workspace-scoped reads. §State partitioning gives a host the boundary but no rule for honouring it: nothing here says a consumer presenting a workspace-scoped credential MUST be served only that workspace's bucket. Writing that rule means first answering what a workspace-scoped credential is — the reference implementation has no such thing today, so the honest state is that pairings and orchestrator scope-tokens are daemon-wide and the partition is a precondition rather than a control (§Security Considerations). The scoping rule and the credential it keys off should land together, or the rule will be written against an authority that does not exist.
  • Retention and reclamation. Buckets grow without bound; this AIP specifies no GC. The obvious question — when may a host delete a transcript, and who decides — is deliberately unanswered, because the failure mode of guessing is deleting a user's only copy of work they care about. A retention rule needs an explicit human policy input, and probably belongs alongside the legacy-artifact reclamation §Migration also leaves open.
  • Should the bound be descriptors, or bytes? §Per-bucket bounds caps record count, inherited from the reference implementation. Count is a poor proxy for the thing that actually hurts (a bucket's transcripts are orders of magnitude larger than its descriptors), so a host can be well under the cap and still unreadable on disk. Revisit if a host reports the count bound as the wrong axis.
  • Privilege levels vs. explicit allowlists. This amendment specifies delegates_to (a closed allowlist) as the only delegation mechanism, and deliberately does not standardise the numeric privilege lattice the reference implementation grew (level, with non-escalation as child.level <= parent.level). The lattice is terser for large casts — N agents need no N² of allowlist entries — but it is implicit: it grants by arithmetic rather than by name, so nobody can read an agent and know what it may spawn. The allowlist is auditable and matches AIP-42's existing field. If a real cast outgrows enumeration, the lattice should return as an AIP-42 concern (a computed delegates_to), not as a second authority in AIP-46. Revisit when a host reports the allowlist as the binding constraint.
  • Fan-in / joins. The gate governs spawn (one parent → many children). Nothing here specifies a child reporting completion back to a parent, or a parent blocking on N children. Reference implementations do this with out-of-band completion policies. If a common shape emerges it may belong in this AIP; it is deliberately out of scope for the amendment.
  • Should agent_prompt across the tree be gated separately from agent_start? Both are delegation actions today, so an agent that may spawn may also drive. A supervisor that should observe children without steering them has no way to say so. Possibly two actions with separate grants; unclear whether the distinction earns its keep.