Skip to content
Robotadocs

@robota-sdk/agent-framework SPEC

Purpose

@robota-sdk/agent-framework is the assembly layer of the Robota SDK. It composes agent-core, agent-session, agent-tools, agent-executor, agent-roundtable (the turn loop behind group chat), and the agent-interface contracts into a single, provider-neutral SDK surface. InteractiveSession is the primary entry point, preserving observed tool outcomes across live and restored presentation without replaying effects; a createQuery({ host, cwd, provider }) factory covers single-shot prompt use; createAgentRuntime() composes a headless/multi-session runtime. Its main entry is platform-neutral: it runs in a browser, a Web Worker, Node, Deno and Bun, and what only a Node host can provide is on the ./node subpath (./testing is Node-only too).

This package owns config loading, context loading (AGENTS.md/CLAUDE.md discovery), command infrastructure (contracts, registry, sources, common APIs consumed by command modules), permission prompting, edit checkpointing, reversible execution policy, project memory, self-hosting verification planning, skill discovery, background job orchestration, subagent assembly, bundle plugin management, and the SDK-specific type definitions that tie these together.

It does not own provider implementations, the generic session run loop, tool infrastructure, background/subagent lifecycle state machines, permission enforcement, hook execution, or any React/Ink UI.

Boundaries and non-goals

  • Assembly first, not a re-export layer. Every feature composes existing packages; a general-purpose capability (permissions, hooks, tools, session mechanics) belongs in its owning package, never duplicated here. Consumers import general-purpose symbols from their owner (agent-core, agent-session, agent-tools); this package exports only what it owns or narrows behind an SDK facade. A pass-through re-export is allowed only for a symbol a permitted consumer has no other legal import path to (such as IBackgroundTaskRunner from agent-executor, in background-tasks/), so ownership stays visible in every import. A public-API test pins that the general-purpose runtime symbols (permission evaluation, hook running, message helpers) are not re-exported from the root.
  • React-free. No React/Ink dependency, so the SDK stays usable from a CLI, server, worker, or test in any TypeScript context. React and Ink belong to agent-cli.
  • Provider-neutral. The consumer creates the provider and injects it; the SDK never imports a concrete provider package.
  • No public project-path helper. A cwd is provenance only, never filesystem authority. Project content and application state are reachable only through a TWorkspaceProjectAccess decision and facets minted from its opaque authority; omission is an explicit Restricted decision, not a silent fallback to path-based access. Restricted construction instantiates no project reader, store, or writer. The file operations behind those facets are the host's: the trust service takes them as projectFiles and binds them to each authority it mints, behind the brand's module-level maps, so nothing but an authority the service issued reaches them, and a host that has none builds no service and runs Restricted. The brand and its maps live in one module that . and ./node share. Trusted access is accepted only when the real working directory is the trusted root or a descendant of it — cwd and the access decision are independent inputs, so this boundary check keeps the pair fail-closed. A directory spelled another way than the root (through a symlink, or a Windows 8.3 short name) is accepted on its real path and bound to the session as that real path, so every project read starts from a path inside the root; a directory already inside the root as written keeps its spelling. Once accepted, the pair is one value fixed for the session's life and handed by reference, never copied field by field, so no lazy operation can hold a different answer from the session; a move to another directory is a new session, not a mutation of this one.
  • Settings layers and stores never open a path; plugin enablement does. A settings layer is read through IHostSettingsSource.read() and a document is written through ISettingsDocumentStore, so neither opens a settings file. Plugin enablement is the one consumer that works on the file itself: it takes the user source's path and reads and writes it through an IFileSystem (NodeHostPluginSettingsStore), so a user source with no path enables no plugins. Host adapters (NodeHost*) exist for callers that deliberately own a host path, but never satisfy an authority parameter, and command modules must not assemble settings/project paths themselves — they go through host adapters or command-facing common APIs.
  • The platform and the working directory are passed in, never assumed. A session, subagent, runtime or query takes a required host (IFrameworkHost, what the platform lends) and a required absolute cwd. Neither has a default, because a default would be whichever process the framework happens to run in. Every host member is optional and an absent one means that capability is off, so {} is a valid host, except that a trusted project decision is compared on real paths and a host that cannot resolve them refuses it; a Node composition root builds one with createNodeFrameworkHost from the ./node subpath. A missing host throws at construction instead of reading as a host that lends nothing, because an untyped caller would otherwise get sessions without a capability and no explanation.
  • Every user- and project-scoped location is host-supplied, and the framework never infers or falls back to one. Storage roots, project settings and state directories, user settings sources, plugin/skill/agent-definition roots, permission baselines, and task-context directories are all read only from what the host explicitly passes; an omitted discovery root means no discovery, and an omitted storage root fails before any filesystem write — never an ambient default. A restricted (untrusted) project can never read project settings merely because a path was supplied. A user-local storage root that resolves inside the active repository — including through a symlink — is rejected. A project settings writer requires its own separately approved target, and when that guarded write is unavailable, a project-wide permission approval is never silently downgraded to a session-only grant. A later deny rule always takes precedence over a host-supplied permission baseline, even across a live preset change.
  • Edit checkpoint state may live outside the project, and is sealed or plain as its store is told. A store keeps its manifests and pre-images in the trusted project's checkpoint directory unless the host supplies a location of its own, for a run that must leave nothing in the project; the sealing decision is the store's own and is required, never implied by the location. A session asks its checkpoints of IEditCheckpointStore and its stored sessions of IInteractiveSessionStore; the stores that keep them in files, and seal and migrate them through @robota-sdk/agent-session/node, are on the ./node subpath, and a host with no files brings its own. Only where the state is kept moves: restoring still writes the project's files back through the project mutation capability, so a restore can touch nothing it could not before. Sealed state is never trusted for what it says: a file opens only at the place it was written, and a manifest counts only where it agrees with that place. A plain manifest counts only below every sealed one, since a store that seals writes none: that stops one being appended above the sealed ones, but nothing in the state anchors trust, so it cannot stop a program of the same user replacing sealed manifests with plain ones. Making a checkpoint or resuming a branch never depends on opening an earlier checkpoint; a session has one checkpoint writer or restore at a time, across processes (the state host supplies the writer lease), and each checkpoint claims its own place atomically, so concurrent edits and unreadable records do not make rollback restore the wrong pre-image. A host without those guarantees (no writer lease, no atomic directory claim) is refused, and an unreadable checkpoint is reported without hiding the others. A sealed manifest is replaced whole, since a torn one could not be told from a damaged one, and a restore is all or nothing, since a partial restore cannot be told from a finished one. A branch head a rollback through the first checkpoint cleared on purpose is recorded in the session record as a pointer with no checkpoint, so a resumed session starts its next turn on a fresh root line instead of continuing the line that was rolled back.
  • Organization policy is host-located and fail-closed. The policy loader reads only the path selected by its host. An absent or empty path is an error; a missing file at a valid path means no deployed policy. A present but unreadable or malformed file raises a typed error rather than silently disabling enforcement.
  • Recovery instructions are host-owned. Framework errors name the missing provider configuration or invalid settings file without prescribing a product command. A terminal fork-attach refusal carries its resume session id separately so a host can add its own reopen command while neutral consumers still receive an actionable session identifier.
  • User contributions are host-selected. Skill discovery uses only explicitly supplied contribution sources and roots; neutral SDK helpers do not infer the current process home. Discovered metadata does not admit instructions: lazy content remains under the host's current consent and source authority, and its activation belongs to the actual model turn or isolated fork that uses it, including access to its verified supporting bytes. Forks cannot borrow another execution's activations; ending or cancelling an execution closes its activation so a later turn cannot inherit it; the framework owns this lifetime without knowing the source's protocol.
  • Headless shell execution is host-owned. The host supplies the shell adapter for explicit local skill interpolation; the framework does not construct a child-process fallback or treat consent to lazily supplied instructions as authority to execute their shell expressions.
  • Hosts own product identifiers; command modules own product behavior. Attached file references and projected command tools use neutral identifiers unless the host supplies its own, and subagent lifecycle hooks add product environment aliases only when the host supplies their names. Model-facing identifiers remain consistent through prompt execution and child-tool filtering. SDK core ships no user-visible built-in commands; the command modules of @robota-sdk/agent-product/command (and any host's own) contribute behavior through ICommandModule, consuming SDK command contracts and common APIs, and what a module opens to the model is all the model is offered or can run — a per-subcommand declaration is an allowlist enforced before the command runs, so an alias or a later subcommand stays user-only until its owner opens it. The SDK does not know command ids in advance; host-declared inline human controls can withdraw authority while a turn remains active under the same invocation and host policy, and on session shutdown it settles every module's host-scoped work before closing the session, even when another module's shutdown fails.

Architecture position

agent-framework sits above agent-core (engine, provider abstraction, permissions, hooks), agent-session (generic run loop, persistence ports), agent-tools (tool infrastructure and built-ins), and agent-executor (background task / subagent lifecycle primitives), and below agent-cli and other runtime shells. The command modules in agent-product sit beside it, consuming its command contracts without a reverse dependency. Assembly (wiring tools, provider, system prompt) happens inside the SDK; the provider instance itself always comes from the consumer.

Within the package, command-api/ is the stable, render-agnostic layer command modules build against; interactive/ holds the event-driven session wrapper; assembly/ is the internal session factory; config/, context/, memory/, checkpoints/, self-hosting/, subagents/, background-tasks/, and transport-host/ are the SDK-specific feature areas described below.

Guarantees and invariants

These are behaviors a caller cannot infer from a type signature alone.

  • Prompt trace identity has a narrow execution boundary. Every started prompt records a fresh, content-free trace root and its actual outcome even when it has no token usage or ends in failure or interruption; a failure before execution begins has no root. This remains a partial trace: it leaves the process only as a traceparent on that prompt's own provider calls and tool bodies to origins the host trusts, or in the environment of the child-process classes the host enabled — either grant alone is enough, and subagent, worker and background runs never inherit it — and it does not prove final turn settlement. A provider-call child's span ID is derived from core's call ID and a tool child's from core's minted body ID, never invented, so the propagated parent and the exported span are the same span; a tool body reported without that ID is counted as omitted. Valid interval totals preserve measured work when detail is omitted by the bound, while invalid observations remain explicit; queue totals end at selection for admission or refusal and never grant tool ownership. Prompt, response and tool text travels only through the host's separate content channel, only when the host provides one, and only for the owner-typed turns prompt history records — one shared predicate decides both; otherwise the framework copies no text. Tool content is limited to the turn's own calls: ownership comes from the permission and body events on the turn's own bus, never from the shared tool callback, which subagent and background runs also report through. The framework only pre-truncates, renders arguments without walking past the bound, and bounds what one turn holds with room kept for the prompt and response; redaction is the host's.

  • Session persistence is explicit, never implicit. InteractiveSession/createAgentRuntime never construct a project session store from a bare cwd. A host wanting persistence supplies an explicit store (optionally composed from same-authority sessions/session-logs state facets, built with its sealing decision, which is required); an omitted per-session store inherits the runtime store, an explicit store replaces it, and an explicit undefined disables persistence for that one session — all three are distinguishable states, not collapsed into "no store". Saved recovery discovery and conversation history share one Session-owned snapshot, restored before runnable initialization; strict persistence must acknowledge synchronously or refuse the transition. A sealed session whose keys are locked is present, so the project store never rebuilds it from its replay log as it would an absent one, a listing leaves it out while a count says how many are there, and a surface says why it does not open — telling to unlock only when locked keys are why — rather than reporting the session as not found. The project store reads its replay logs with the sealing it keeps its records with, and a log it cannot read is reported the way an unreadable record is — locked, corrupt or unsupported, never missing and never a failed listing — except to the writer of a session that has logged but not yet saved, who is told it is missing: its save writes the snapshot beside the log and destroys nothing. A log carries no authorship, so a session rebuilt from one is never the owner's, and in a store that seals an id a session here runs is never rebuilt from one. A resume — a fork's source included, whose read takes no writer marker so a session another process runs can still be copied — restores only from the read of the claim its session holds on the id for as long as it runs, and only a record it may run or a session never saved: a locked, corrupt or unsupported record is refused, because a session started empty under its id would replace it on the first save after an unlock, and a record a store that seals calls unverified is refused unless it is exactly the bytes the owner accepted, because any program of the user can seal a record that opens. Accepted bytes become the owner's only once the session restored from them and never through a copy, so a refused resume or a fork leaves the record as it was, and what an unverified record says of itself never decides what a run resumes. The claim fences every save of the id and ends however the session ends; the marker check and record mutation share an operation lock with cooperating forced releases, so a session its host stopped waiting for cannot overwrite the next one, and no surface opens a second session on an id that has a writer, in this process or another: a served pool tells its client to try again only for a session of its own that is still closing, and names whoever holds the id otherwise, a fork's subagent included. A save the store refuses is reported once for each reason, in the store's own words, until a save goes through. A session whose file something unverifiable changed stops saving the id and acting on its own and saves its state once as a new record it names, since merging the file would launder it and overwriting it could destroy newer work; a refusal only the locked keys stand behind is decided at the unlock, and a session that ends while either refusal stands saves that copy as it ends and reports a copy it could not write. A project session record is replaced whole, so a failed write can leave either the prior record or the complete replacement; a replay log rebuilds only the session it names.

  • Fork vs. resume is exactly one bit of difference. Resume reuses the source session id; fork generates a fresh id while still restoring the same messages and the parent's assembled system prompt verbatim — a fork inherits the parent's full assembled prompt (including any active output style, since that is just a section of the same string) rather than rebuilding one from current project files. A plain resume is not a copy and always rebuilds, so edits to AGENTS.md between sessions still reach it. Writing a fork's record leaves the source record byte-identical; the two conversations never rejoin. An unresolved recovery conversation cannot be copied to another session or workspace because discovery is bound to its existing owner. Following a running fork or continuing an ended fork keeps its record separate from the parent; the live view is bounded and transient so observing it does not add another persisted conversation copy.

  • Host-action parity. A command's host actions (language change, settings reset, session exit/restart/rename, …) are executed by the session through injected host adapters, so headless and programmatic embeddings get the same command semantics as an attached UI. An embedding with no adapter for a requested action gets an explicit failure naming the missing capability — never a silent no-op. Parity does not extend to enabling remote control: that mints a pairing link, so it runs only for the operator's own command, never for a connected surface or the model, whichever command asked. UI-only intents (opening a picker, a settings screen) are fire-and-forget: with no surface listening they are a defined no-op, and that never affects the host-action half.

  • A command never reads another command's source. Where the host tracks async context, every command sees the source, driver and locality it was started with, however many run at once, and a forked skill's resources are readable only from inside that fork. A host that does not (a browser) stays strictly nested, because one slot read after an await by two overlapping commands would hand each the other's scope: it admits one user or remote command at a time and refuses a second in plain words; it admits a model command only when no user or remote command runs, or when that command has started its own turn, whose model commands all carry the same scope and cannot overwrite each other; and it refuses a forked skill before it starts, naming the missing createAsyncScope.

  • Local peer status is display-only. Host-observed activity, independently verified process liveness and the verified workspace relation never grant authority over the peer or identify a persisted session record, and a passive observer can never keep a request alive after its last answering surface leaves.

  • Prompt/permission settlement is first-wins and fail-closed. InteractiveSession exposes no session-level callback option for permission or ask prompts; it emits transport-neutral request events, and any attached surface settles them through one shared registry. The first settlement wins and emits exactly one resolution event — there is no second settlement path. A callback that rejects must resolve to deny/cancel, never leave the request open.

  • An external event's sender is the grant its verified access token matched. Opening a grant does not itself authorize turns: each event must present a bearer access token that the grant's own verifier admits, and the grant pins exactly one principal, so the grant is the sender. The session builds that verifier from the grant with the one factory its host gave it when the session was built, so opening a grant cannot bring a verifier that checks someone else. Revoking a grant stops its pending turns and refuses its later events for the life of the session, and says so only to a caller whose token the grant's verifier admits: anyone else is refused exactly as a live grant would refuse them. Nothing the carrier or the payload says is identity; a display name in the payload is shown as claimed and never attributed. A token is spent on one event; a restartable carrier persists that spent claim until the token can no longer verify, without persisting the bearer or its raw identifier. Refusals and audit records use the closed refusal words and carry no content, and a refused event never reaches the queue, history or the model; an untrusted submission can never claim another conversation's reserved identity. The model receives only an escaped, bounded envelope — file-reference shorthand in external text stays literal and never reads local context. An admitted external turn is text-only for model-generated actions: it does not expose local tool schemas, and a provider tool call it produces is rejected before execution. External admission is mutually exclusive with bypassPermissions throughout active and already-admitted work. Each accepted event settles from its own turn handle, and an interrupted result never becomes a successful reply. This does not sandbox trusted hooks/plugins or prove which person inside a platform wrote an event, and it is not a remote permission-approval channel.

  • A peer's message is instant messaging, and carries no authority. Text from another session is an opinion from an untrusted third party, not the owner's prompt: it expands no file references, attaches no context reference, and reaches the model marked as a peer's with a per-turn system statement that the model decides for itself whether and how to act. Nothing on a message becomes a tool call, a command, a turn option, a permission mode or an approval answer, and an answer in a peer's name never settles a question — only the owner's surfaces answer. What the model does is decided by the session's ordinary permissions, like its own work; the owner's remote surface, by contrast, is the owner typing and is treated exactly as the local terminal. Each sender's messages may start only a bounded number of turns over time, because a message still runs this owner's model. The answer goes back only through the reply tool, whose target is the sender admission bound to the incoming message and whose thread is that message's own — the model chooses the text, never the recipient, and a message naming a sender other than the admitted one is refused. Such a turn cannot send files; outside one, the model sends a file only through a tool that asks the owner about every file, a question no mode, rule or remembered consent answers.

  • The session names itself once, and only with text. When its host turns naming on, the session titles itself after the first turn that runs, from that turn's own message and with the provider it is using then, so every client sees the same name whichever one drove the turn. A failed turn or a failed title does not use up the naming, so one bad first turn cannot leave a session unnamed for good; once named, it is never renamed. A name it already has is kept, and a rename made while the title is generated wins. The title-generation call always disables tool use, so hosted web tools can never be invoked merely to generate a title.

  • A rename reaches the stored record or says why not. setName writes the name onto the session's stored record. A record this process cannot read (corrupt, unsupported or locked), or a session that can no longer save its record, raises an error, because a rename that appears to succeed while changing nothing on disk is worse than a refusal. A session with nothing saved yet is renamed in memory and the next save writes it.

  • Status changes are pushed. A status change made through any client reaches every client on the session. Context usage is not part of that: it moves every turn and is reported on its own, so counting it would push a status after every turn with nothing changed.

  • Hook executor registration is replace-vs-extend, and the built-ins are seeded first. The session resolves an omitted executor list to the executors the host builds (host.createHookExecutors; on Node, command and http) — an undefined-only fallback, so supplying any executor array at all replaces the built-ins rather than extending them; only an actually-omitted array reactivates them. A host that lends no builder seeds none: the session has no executor of its own, and a declared hook only the host's executors run is refused at assembly. Because the last executor registered for a given hook type wins, the host's built-ins are seeded before SDK-specific ones, so a caller-supplied executor of the same type still overrides the built-in — seeding them last would make the built-ins unoverridable, and merging instead would remove a sandboxed caller's ability to exclude one. The general principle: restriction must be asked for explicitly; extension may be assumed. A declared hook type with no runnable executor is refused before the first turn, naming the type and the missing option, rather than being silently discarded at every tool call.

  • Settings layers merge per key, and the rule is chosen for safety, not uniformity. Layers are read user-first, project-later; the later (lower-trust) layer overrides by default, except: defaultTrustLevel keeps the most restrictive value; every permissions list (allow, deny, ask) is unioned, because an allow list is not the complete permitted set once deny, ask and ceiling rules outrank it, and replacing let a checked-in project file silently drop the user's rules; disabledHooks accumulates; provider/env/plugin/task-context objects merge field by field; and hooks merge per lifecycle event, appending each layer's groups, so a later project layer can only add hooks and can never remove a user's guard by declaring an unrelated one. A hook group may carry an id; a layer may disable only ids declared by later layers, so a user can turn off a project hook but not the reverse.

  • A credential never follows a changed endpoint. When a later layer changes a provider's baseURL, any apiKey/apiKeyEnv inherited from an earlier layer is dropped unless the same layer supplies its own, so a project file cannot redirect a user's key to a different endpoint.

  • Provider resolution order is fixed and total. An explicit settings profile always wins; failing that, env-default synthesis picks the first provider definition (in definition order) whose default API key references an environment variable that is set and non-empty in the environment the host passes (with none, nothing is set and synthesis finds nothing), and which also has a default model; failing that, provider resolution throws a typed configuration error. An existing-but-corrupt settings file is never treated as a missing one — it fails fast with a typed parse error naming the file and the parse message, rather than silently falling back to defaults.

  • Turn error surfacing is layered and never silently drops a partial answer. Provider errors are classified by the provider, humanized once by this package, and rendered per-transport. A failed turn commits any partially streamed answer to history as an interrupted entry before stream state clears; a resumed tool call without a committed result keeps its original identity and receives an interrupted, unknown-outcome receipt rather than being dispatched again unless its saved recovery identity requires an explicit host continuation decision. Errors from outside the turn boundary (background tasks, catalog refresh, uncaught promises) surface through the same humanize path via reportBackgroundError, and the session stays usable.

  • Self-paced loop intent is durable before admission. Loop state transitions are strict session-record writes; a failed or uncertain write can never authorize another iteration, missed wakes are not caught up, and an uncertain running iteration is never replayed. Only a successful, structured, provider-neutral decision can select the next delay or stop; an omitted or denied decision falls back to one bounded delay and then terminates.

  • Default loop prompts are live host content, not stored authority. A loop created without an explicit prompt keeps that intent across resume, and the host resolves the current default before each iteration; a missing or invalid resolver fails visibly rather than running a stale iteration.

  • Tool composition is asymmetric on purpose: replace and append are not interchangeable. defaultTools replaces the host's default tool tier outright; additionalTools only appends and, on a name collision with an already-assembled tool, the earlier entry silently wins and the contributed one is dropped rather than displacing it. That asymmetry is deliberate: the default tier is constructed with session context (the working-directory guard, sandbox client), and an externally-constructed contribution carries none of it, so letting a name collision swap one in would silently disable a security guarantee. A consumer that wants its own tools to fully own the surface passes an empty defaultTools and supplies everything through additionalTools instead — replacement stays fully expressible, just never as a side effect of a naming accident. The same session-context wraps are applied to the final assembled set and to a tool the session adds mid-session, so a contributed Write/Edit is checkpointed exactly like a default one and a tool that became usable later is held to the same policy as one present from the start; a late tool whose name the session already has is dropped by the same rule. Scheduling remains a current host decision through assembly, independent of contributor and permission authority.

  • Deferred-tool residency has a fixed assembly order. A tool may declare itself deferred (withheld until the model searches for it); deduplication preserves the surviving entry's own residency marker rather than the first-seen one's. A configuration where every tool is deferred is refused outright — a session must always keep at least one resident tool — and that check runs over the fully declared tool set before the framework's own search tool is added, so the search tool can never silently satisfy the very check it exists downstream of. The search tool itself is added, resident, only when something in the assembled set is actually deferred, and the system prompt is given a roster of exactly what was withheld — a session with nothing deferred is prompt-identical to one without the feature at all.

  • A model fallback chain moves a request only where another model could serve it, and only before any reply text was shown. It is a decorator in front of the session's one provider rather than a loop in the round logic, because the core cannot build providers and many readers take "the" provider and model; it receives the provider-neutral messages the shared conversion step already produced and delegates them unchanged, so conversation, tool calls, tool results and tool schemas carry across vendors while the primary's prompt cache, vendor-only reasoning and hosted tools do not. It moves on only for a failure the core classifies as switchable, and never once reply text has streamed, since text the user saw cannot be withdrawn; tool calls arrive whole with the reply, and reasoning already shown does not hold it back, because it is not the answer and the next model's reasoning simply follows it. An entry that cannot be built is passed over rather than ending the turn. A run stays on the model that accepted it and the next run starts on the primary, and compaction moves only to a model whose context window is known to be at least the primary's, since its input was sized to the primary and an unknown window on either side proves nothing. The chain belongs to the session, so switching the provider reads it again for the new primary under the same policy, and a turn that ran on more than one model is charged to each model for its own calls. In-process subagents share the decorated provider and so the chain; a child-process subagent does not, because its one bound connection would need a binding per entry.

  • A subagent or a fork-context skill starts under the parent's permission mode and rules as they are when it starts, not as they were when the runtime was built, so a switch to plan binds every one started after it. One already running keeps what it started with.

  • Subagent tool filtering has a fixed order, and subagents cannot spawn subagents. Filtering first unwraps any tool-call-handoff wrapper (so a child session or fork never inherits one even though the parent's own tool list does), then applies the agent definition's denylist, then its allowlist, and finally always removes agent-spawning tools regardless of either list.

  • A session runs the skills and commands of the bundle plugins it may load, behind the same gates as plugin hooks (a bare session loads none; project plugins load only from a folder inside the trusted workspace; a disabled plugin is skipped). The prompt and router share the same admitted snapshot until an explicit user reload replaces both; unreadable settings cannot activate installed code. The session's own skill of the same name wins, then the first plugin to name it.

  • Agent definitions resolve through three precedence tiers, highest to lowest: discovered definitions on disk, then definitions injected by the composition root, then the built-in set (or its full replacement). A composition-root-injected definition can override a framework built-in of the same name, and a consumer's own on-disk definition still overrides that — "the consumer decides" applied uniformly. Because the injected and built-in tiers are concatenated into one array before loading, the loader deduplicates within that combined tier by keeping the first entry for a given name, so a shadowed built-in never produces two roster entries for the same agent name.

  • Tool-call handoff starts the underlying call exactly once and has exactly one declared degradation path. When a policy and a supporting background runner are both present, a wrapped tool call races the real call against a threshold timer; if it settles first the result passes through unchanged and no background task is ever spawned. If the threshold fires first, the call is handed to the background task manager and the wrapper returns immediately — but the underlying call that was already running is the one adopted, never restarted or re-sent. The one declared fallback is: if handing off to the background manager itself fails, the wrapper keeps awaiting the same in-flight call in the foreground and reports the refusal once, rather than retrying or dropping the call.

  • Goal completion is a deterministic signal, never parsed from prose. While a goal is active the model reports status through a dedicated, schema-validated tool; the loop reads only the last such call from the completed turn's structured tool summaries. A missing or malformed signal is always "no signal", never treated as satisfaction — there is no keyword or prose matching anywhere in the loop. Only agent-driven wakeup turns count toward the goal's iteration and no-progress bounds; a user's own interjected message never counts as a goal iteration.

  • The main model's tool list changes mid-session only on an explicit user action. The main model's prompt cache is keyed on its tools, so the list changes only when the user does something that makes a tool usable — signing in to a server — and then only at a turn boundary, accepting one cache miss, never while a turn's rounds are running and never as a side effect of configuration. Whether a session has the Advisor tool is therefore decided once, when it starts; turning the advisor off or pointing it at another model changes only where calls go. Conversation history reaches a destination the main model does not already use — a provider type and endpoint, since one type can front both a local server and a vendor's cloud — only after the user consented to that destination, and never reaches a profile outside the organization's allowlist. The call limits hold for calls issued in parallel, and a request that was sent always counts, since it carried the conversation. Advisor usage is recorded with the session's turn usage, on the advisor's own model. Its answer is framed as guidance to check against the main model's own evidence, because the advisor sees only what the main model was shown and can verify nothing itself.

  • Background wake tracking is cleared on every exit path, not just the happy one. A background task's wake-dedup entry is removed both when its turn completes normally and when it is evicted before completing (session abort, shutdown, or a pending-queue drop) — omitting the eviction path would leave the tracking id stuck forever, silently rejecting every future wake for that task.

  • Streaming is never written into the main session JSON. High-frequency events — streaming text deltas, subagent transcript chunks — are written to append-only JSONL logs and transcript files, not to the primary session snapshot, so debugging data is available mid-stream without any risk of a torn or partial JSON write; the session JSON remains a fast snapshot, and the JSONL replay log is the recovery source when that snapshot is missing.

  • Restriction must be asked for; extension may be assumed is the recurring shape behind several of the guarantees above (hook executors, tool composition, agent-definition tiers): a capability that is silently inferable from the shape of an input (an empty vs. non-empty array, a name collision) is a recurring source of regressions, so every such seam requires an explicit, separately-named option rather than inferring intent from omission.

  • Workspace identity: linked worktrees stay distinct by worktree root, while a nested working directory inside one worktree resolves to the same identity. A repository recreated at the same path never inherits a grant; where the filesystem records a birth time, a grant also holds across what a repository goes through in normal use (git commands that rewrite its config, a volume renumbered by the host). A repository's grant generation only rises, even when another repository held its root in between, because approvals recorded against a generation must not return.

  • Pre-trust source preview: candidate project sources (settings, host-selected skills, agents, detection metadata, context, tasks, state, plugins) are listed from the paths their owners actually load, so the preview cannot drift from what trust would enable. Inspection reads metadata only under a revalidated Git identity, never follows links, never issues a content reader and grants no authority; where a stable no-follow walk is unavailable, names are listed with metadata unavailable.

  • Session-loop first fire: a persisted first-allowed boundary skips earlier calendar-aligned slots without cancelling the loop; a boundary that is invalid or later than the loop's expiry is refused.

  • Memory capture filters likely-sensitive content, heuristically. Automatic capture and /memory add skip candidates whose text matches secret-like wording, card/ID number formats, or secret-shaped values (well-known credential prefixes, key blocks, JWTs, long mixed-case random runs). A skipped candidate is never persisted anywhere, including the pending queue. Approval accepts only a pending candidate and re-runs the check, so nothing flagged reaches durable memory. This is a heuristic, not a secret scanner: a secret that reads like prose can still pass.

  • group-chat's only turn loop is the Roundtable core's. The facade keeps no transcript, turn counter or loop of its own, so conversation orchestration lives in exactly one place.

Error taxonomy (shape, not enumeration)

The package's own typed errors cover configuration (unresolvable provider, corrupt settings file, unparseable org policy), workspace authority and project read limits, command registration conflicts, and turn admission. Errors from lower packages (permission denial, session run failure, background task failure) propagate without being re-wrapped.

There are three failure channels. submit() rejects when a prompt is refused before it runs (initialization failure, shutdown, a stopped wake, setup mode with no provider configured, or a failed pre-run step). A turn handle's completion promise rejects with the error the turn failed on, or with a turn-not-run error that says why a submission never became a turn. Failures during a run are emitted as error events.

Testing philosophy

Feature behavior is verified at the framework level, not through the CLI, because the CLI is considered a thin wrapper that must not be where behavior is proven. A dedicated ./testing subpath (excluded from the runtime bundle) builds a real InteractiveSession — real agent loop, real built-in tools, real persistence and events — over a scripted, cassette-replayed, or recording provider, so functional tests exercise genuine session mechanics without live network calls. A separate lightweight session stub exists only for wiring/type tests that need no real loop, and is intentionally owned by a different package so this one does not have to keep two competing "fake session" concepts. A public-API surface test guards against a lower-package symbol being accidentally re-exported through this package's root.

Extension points

Third parties extend the SDK through a small set of seams rather than subclassing: ICommandModule (commands, command sources, model-visible descriptors, session requirements), ITransportAdapter (attaching a session to HTTP/WebSocket/MCP/etc.), hook executor injection (subject to the seeding and replace-vs-extend rules above), ISandboxClient (routing built-in tool I/O through a sandbox), a subagent runner factory (swapping the default in-process runner for a process- or worktree-backed one), and the guardrails port read by session assembly but implemented by nobody in this package. Each of these is deliberately a narrow, typed seam rather than an escape hatch into internal state, so a third party cannot reach further than the contract it was given.