This document is the serving-extension design for FastAgent. Phases 0–3 (§15) are implemented (Phase 3’s subprocess adapter stays demand-driven). It is a companion to, not a replacement for, the locked Agent Handler SPEC v0.1.
The whole design reduces to one sentence: invoke is the only data plane; the session control
plane observes and modulates the runs that invoke drives. A client uses invoke to make the
agent work, dispatch to intervene while it works, and events to watch.
The design adapts the useful headless primitives from pi RPC mode (steering, follow-ups, abort, settlement, tool progress) without exposing pi’s TUI control surface, raw RPC protocol, or a second run-starting entry point.
1. Decision: three planes, one execution core
| Plane | Surface | Invariant |
|---|---|---|
| Data | agent.invoke(scope, prompt) |
No run exists without an invoke. Every turn, and every durable conversation write, is driven by some invoke — channel, schedule, and desktop alike. |
| Control | dispatch(session, command) |
Modulates, never initiates. steer/follow_up text reaches the record only through the run an invoke is driving; abort only changes that run’s course. |
| Observation | state / entries / events |
Strictly read-only. Any number of subscribers; disconnecting and resubscribing is lossless with the durable cursor; zero effect on the run. |
| Exclusion | the shared Lease |
Protects writes only. A run holds it for its whole activity window. Boundary mutations (compact, set_model, set_thinking) are the control plane’s only writers and take the same lease. |
flowchart LR
D["FastAgent Definition"] --> A["Shared pi assembly"]
A --> CORE["One execution core<br/>(runs, queue, lease)"]
CORE -->|"data plane: invoke"| CH["HTTP / channels / schedules"]
CORE -->|"data plane: invoke"| UI["Desktop / Web / IDE"]
CORE -->|"control + observation"| UI
CORE --> R["Durable session repository"]There is no session handle, no open/close, and no resident object in the API. Residency is an
internal cache inside the serving process (see §9), never a
prerequisite for calling any method. This is what “residency is an execution optimization, not the
source of continuity” means when taken seriously: the optimization is invisible in the contract.
The control plane MUST NOT change Agent, Scope, Prompt, AgentEvent, or the terminal
semantics in src/agent.ts. It lives behind a separate package subpath so interactive serving does
not grow the minimal handler contract.
2. Goals
- a desktop or Web client that watches a run live and intervenes: steer, queue a follow-up, abort;
- reconnect after a UI or network interruption without losing the conversation;
- live model, thinking, queue, retry, compaction, tool, and usage visibility;
- multiple observers of one session, naturally;
- engine-neutral consumers with capability gating;
- a future remote adapter without making its transport the embedded API.
3. Non-goals
- a second way to start agent work (that is
invoke, onlyinvoke); - a durable task/workflow protocol or a replayable event log;
- a group-chat, account, membership, or deployment control plane;
- exactly-once tool execution;
- a remote shell API;
- a mirror of pi’s TUI commands, editor state, themes, widgets, or window chrome;
- a promise that every engine implements every capability.
Product-level authorization, routing, offline queues, and durable run records belong above FastAgent. A product runner may expose these planes remotely, but the runner owns authentication, policy, idempotency, and device routing.
4. Terms and identity
| Term | Meaning |
|---|---|
| Session | Durable conversation tree identified by an opaque sessionId (the same value as Scope.session). |
| Run | One activity window: an invoke’s accepted prompt until all steering, queued follow-ups, automatic retries, and overflow recovery have settled. |
| Entry | A durable append-only session record with a stable id. |
| Event | Ephemeral live progress on the observation plane. |
Three identifiers, each with an irreducible job:
| ID | Minted by | Lifetime | Job |
|---|---|---|---|
sessionId |
host/product | durable | addresses the conversation; equals Scope.session |
runId |
engine, when an invoke starts a run | one activity window | correlates control-plane acceptance with observed outcome |
entry id |
session repository | durable | the reconnect cursor for entries({ since }) |
There is deliberately no requestId, no runtimeId, and no sequence in the embedded contract.
In-process, the dispatch promise is the correlation, the events iterable is lossless and
ordered, and iterator termination is the epoch signal. Those concerns reappear only on the wire and
belong to the transport envelope (§13).
5. The contract
Pure types under the @fastagent-sh/fastagent/session subpath (src/session.ts); the pi
implementation lives under engines/pi/ (session-control.ts, exported from /pi).
interface SessionControl {
capabilities(): SessionCapabilities;
state(session: string): Promise<SessionState>;
entries(session: string, options?: { since?: string }): Promise<SessionEntries>;
events(session: string): AsyncIterable<SessionEvent>;
dispatch(session: string, command: SessionCommand): Promise<SessionResult>;
}All methods are session-scoped and flat: no lifecycle calls, no stateful client-visible object. Each of the four non-capability methods survives a deletion test:
- delete
state→ a reconnecting client cannot learn whether work is still active (the durable record does not know whether the process died); - delete
entries→ disconnection means amnesia (live streams are not durable); - delete
events→ no observers, no reconnect, no rich vocabulary without pollutingAgentEvent; - delete
dispatch→ the invoke stream is one-way; intervention physically requires a second, upstream channel.
5.1 Commands
Six commands. There is no prompt command: starting work is the data plane’s definition.
type SessionCommand =
| { type: "steer"; prompt: Prompt } // delivered after the current turn's tool calls, before the next model call
| { type: "follow_up"; prompt: Prompt } // FIFO queue, delivered when the run is otherwise idle
| { type: "abort" } // stops the run, queues, retry delay, and cancellable tool work
| { type: "compact"; instructions?: string } // ┐
| { type: "set_model"; model: string } // ├ safe boundaries only; otherwise rejected `session_busy`
| { type: "set_thinking"; level: string }; // ┘- Queued messages are processed FIFO, one at a time. pi’s queue-mode tuning is not exposed.
follow_upis polyfillable (wait forrun_settled, then invoke); it exists because it buys atomicity against competing writers and queue visibility, at near-zero cost since steering needs the queue anyway.steeris not polyfillable — its delivery point is an engine primitive.set_modeltakes a FastAgent model spec, constrained by the assembled definition and host policy. It never accepts provider credentials.set_thinkinguses a string because supported levels are model-dependent; current allowed values are reported in capabilities.- There are no
cycle_*commands: cycling is a TUI input affordance.
5.2 Acceptance is not outcome
type SessionResult =
| { ok: true; runId?: string } // admitted (steer/follow_up: joined this run) or applied (boundary mutations)
| { ok: false; error: { code: string; message: string; retryable: boolean } };ok: true means the command was admitted or applied. It never means the run ultimately succeeded:
run outcomes are reported by run_settled on the observation plane and by the invoke stream’s
terminal event. ok: false is guaranteed to mean rejection before acceptance — the only case
that is safe to blindly retry. Work that fails after acceptance surfaces through events and durable
entries, never as a second result for the same call.
5.3 Capabilities
interface SessionCapabilities {
steering: boolean;
followUp: boolean;
manualCompaction: boolean;
modelSelection: false | { allowedModels: string[] };
thinkingLevel: false | { allowedLevels: string[] };
toolProgress: boolean;
usage: boolean;
}Clients MUST gate controls on capabilities; unsupported commands fail before acceptance with a
stable unsupported_capability code. state, entries, and events are mandatory — they are
the reconnect contract, and an implementation that cannot honor them cannot claim this interface.
Branching (fork/clone/tree projection) and blocking interactions (typed confirm/select/input
gates that suspend a run for user input) are deliberately absent; each can arrive later as one
negotiated capability without changing this contract.
6. Invoke as the data plane
invoke keeps its SPEC v0.1 shape and stays the only way to start a run, on every path.
Settle window. When steering or follow-ups join a run, the invoke stream terminates when the
run settles: steering, queued follow-ups, automatic retries, and overflow compaction have all
finished and nothing will continue automatically. For every existing consumer — channels,
schedules, HTTP — nothing dispatches mid-run, so a run equals a single turn and behavior is
byte-identical to today. SPEC’s “one turn = one invoke” gains a clarifying sentence (“a turn is the
activity window of one invoke”) when the control plane lands; its terminal set {completed, failed} is untouched.
Busy semantics. An invoke against a session with an active run fails with the existing
session_busy code. An interactive client seeing busy chooses steer or follow_up explicitly —
the ambiguity of “send during a run” is resolved by the client’s intent, never guessed.
Projection, not translation. AgentEvent is a narrow projection of the rich event stream:
AgentEvent |
Source SessionEvent |
|---|---|
text { delta } |
message_delta { channel: "text" } |
thinking { delta } |
message_delta { channel: "thinking" } |
tool_started |
tool_started |
tool_ended |
tool_finished |
completed { data? } |
run_settled { status: "completed" } |
failed { details, retryable, code? } |
run_settled { status: "failed" | "aborted" } |
An externally aborted run projects as failed with code: "aborted", so a channel can render
cancellation distinctly from an error. Channels MUST treat it as a settled outcome — durable
turn-intent cleanup included — so an operator’s abort is never replayed as a fresh turn on restart.
Events with no AgentEvent counterpart (queue, compaction, retry, tool progress) are
simply not projected. The implementation translates pi events into SessionEvent once and
derives the invoke stream from it — one translation plus one projection, never two parallel
translations.
7. State and durable recovery
interface SessionState {
status: "idle" | "running" | "compacting";
activeRunId?: string;
model?: string;
thinkingLevel?: string;
pending: { steering: number; followUp: number };
usage?: {
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
cost?: number;
contextTokens?: number;
contextWindow?: number;
};
leafEntryId?: string;
}compacting refers to Phase 2 manual compaction at a session boundary; automatic overflow
compaction happens inside a run’s activity window (before its run_settled) and reports as
running — the observation plane’s “running” window equals the data plane’s lease window, so
state() never says idle while an invoke would still be rejected session_busy.
There is deliberately no failed status. A failed run settles (run_settled { failed }) and the
session returns to idle — the conversation is intact and can continue. A serving-process fault
surfaces as serving_error plus event-iterator termination; recovery is resubscription, not a
sticky state with no defined exit.
entries({ since }) is the durable reconnect primitive:
interface SessionEntries { entries: SessionEntry[]; leafEntryId?: string }
interface SessionEntry {
id: string;
parentId?: string;
timestamp: number;
kind: string; // guaranteed minimum vocabulary: "user" | "assistant" | "tool"; open set beyond
data: Json;
}Entries are append-ordered with stable ids, including pre-compaction records and abandoned branches
where the engine preserves them — parentId exists because branches objectively occur (compaction)
even though branching commands are deferred. The since cursor is an APPEND-ORDER position (“every
record appended after this id”), not a descendant filter: in a branched session it may include
records from other branches, and the client reconstructs the active path via parentId chains from
leafEntryId. The guaranteed kind minimum is what a reconnecting client needs to render a
conversation; engine-specific kinds may appear beyond it and MUST be skippable.
Reconnect is four steps: entries({ since: cursor }) to backfill → state() to learn whether work
is active → resubscribe events() → continue. Live events are not the durable history API; a
product that needs replayable run timelines persists normalized events above FastAgent.
The neutral state never exposes session file paths, working directories, provider base URLs, credential sources, or engine model descriptors.
8. Live event model
Events carry semantics and nothing else:
interface SessionEvent<TType extends string = string, TData extends Json = Json> {
type: TType;
timestamp: number;
runId?: string; // present on run-scoped events
data: TData;
}In-process the stream is lossless and ordered; there is no sequence number to check and no epoch to compare. The vocabulary, grouped by the client maturity level that needs it:
| Level | Events | Purpose |
|---|---|---|
| L0 | run_started, run_settled { status: completed | failed | aborted, error? } |
Run boundaries; exactly one run_settled per run_started while the serving process lives. |
| L0 | message_started, message_delta { channel: "text" | "thinking", delta }, message_finished |
Streaming text. The text/thinking distinction of AgentEvent is preserved; thinking MUST NOT be folded into the answer. |
| L0 | tool_started, tool_progress { partialResult }, tool_finished |
Tool activity. tool_progress uses replace semantics: the accumulated snapshot so far, not a delta. |
| transport | serving_error |
A transport adapter lost the serving process outside a normal run outcome (fail visibly). Not emittable in-process — a dead process has no one left to emit. |
| L1 | queue_changed { steering, followUp } |
Normalized queue depths. |
| L2 | turn_started, turn_finished |
Group tool activity under one assistant turn. |
| L2 | compaction_started/finished |
Manual compaction bounds: between runs, no runId; every started is closed (summary, error, or aborted — a deliberate stop is not a failure). Automatic overflow compaction stays inside its run and does not emit these. |
| L2 | retry_scheduled { operation, attempt, maxAttempts, delayMs, error } |
A transient provider failure scheduled a summarization retry backoff — explains a quiet gap that would otherwise read as a hang. Inside a run (auto-compaction / branch summary, runId) or during manual compaction (no runId). No closing event: the next event is the closure. |
| L2 | state_changed { model?, thinkingLevel? } |
Material state changes. |
Consumers MUST forward or ignore unknown event types; the vocabulary is additive. The contract deliberately excludes editor replacement, themes, widgets, and all other TUI presentation surfaces.
9. Concurrency and residency
- Single writer, run-scoped. All writers — channel invoke, scheduler fire, desktop invoke —
take the same
Lease(the existing injectable port inengines/pi/invoke.ts) for the run’s activity window. A scheduler firing into a session mid-run getssession_busyand defers, the same mechanism and behavior as today. - Boundary mutations take the lease.
compact,set_model, andset_thinkingare the control plane’s only durable writers; they acquire the lease like a run does and are rejectedsession_busywhen they would race one. - Residency is an internal cache. The serving process MAY keep a live engine session per recently-used sessionId. Before starting a run it revalidates against the durable record (leaf entry id) and reloads when stale — so interleaved writers are correct, merely slower. Eviction is a policy (idle/LRU), invisible in the contract.
- Within a run: one run at a time per session; steering and follow-ups are serialized FIFO; tool calls within one turn may run concurrently where the engine permits; cancellation may leave a started tool without a finished event; side-effecting tools remain at-least-once across process failure.
- Process affinity exists only while a run is active. Cross-instance routing of control-plane calls to the process hosting the run belongs to a session router above FastAgent.
10. Definition fidelity
The serving planes must run the same agent that dev, start, and embedded Agent Handler run:
FastAgent prompt assembly, definition-local skills and tools, the same deferred-tool activation,
FastAgent auth (never implicit ~/.pi state), model policy from config, and host-owned working
directory and session repository — never client-provided paths.
The shared builder src/engines/pi/session-builder.ts (Phase 0, extracted from the TUI launcher)
proves this assembly seam: it builds a resident pi AgentSessionRuntime with FastAgent’s prompt,
skills, tools, auth, and workspace boundary; the TUI (chat.ts) is one consumer of it. The
formerly TUI-only ~/.pi auth divergence was eliminated in place, not inherited.
11. Pi capability selection
FastAgent adapts pi’s concepts, never proxies pi --mode rpc unchanged:
| Pi surface | Decision | Reason |
|---|---|---|
prompt |
Map to the data plane (invoke) |
One way to start work. |
steer, follow_up, abort |
Include | Core control plane. |
get_state, session stats |
Normalize into state() |
Reconnect and rendering. |
get_entries(since) |
Include | Durable cursor recovery. |
agent_settled |
Adapt to run_settled + invoke terminal |
Correct settle boundary. |
| tool progress | Include, replace semantics | Live feedback. |
compact, set_model, set_thinking_level |
Include with policy | Explicit client controls. |
cycle_*, queue-mode tuning |
Exclude | TUI affordances; fixed FIFO is deterministic. |
| auto-compaction/retry toggles | Exclude | Deployment policy, not per-client state. |
bash, abort_bash |
Exclude | Unsafe remote-shell bypass; duplicates tools. |
new_session, switch_session by path |
Exclude | Sessions are opaque ids; paths are not portable. |
export_html, session naming |
Exclude | Product presentation concerns. |
| slash/TUI command discovery | Exclude | Conflicts with definition-as-truth. |
| extension UI dialogs | Defer behind a future interactions capability |
Permission/input gates have serving value, but not in the first contract. |
| extension UI presentation | Exclude | TUI chrome. |
fork, clone, get_tree |
Defer behind a future branching capability |
Not required to serve a session. |
12. Storage boundary
PiSessionStore (openOrCreate) stays deliberately small and MUST NOT grow into the interactive
API. The pi implementation may use pi’s richer session repository internally for stable entry ids
and session reconstruction; both views point at the same durable root, and all writers share the
same lease. Engine-specific records (pi JSONL, message classes) never cross the adapter.
13. Transport and envelope
The embedded contract is semantic-only; wire concerns exist only at the transport. As SHIPPED
(HTTP+SSE, controlRoutes/connectSessionControl):
-
Commands ride plain HTTP request/response — the request correlation the design once sketched as a
WireCommand.idis implicit in HTTP itself; the dispatch body is{ session, command }, parsed field by field at the boundary (never cast through). -
Events carry the one explicit envelope:
interface WireEvent { sessionId: string; epoch: string; seq: number; event: SessionEvent }seqdetects loss in transit on one connection — a gap throws in the client (the consumer’s failure budget and diagnostics own it) into the normal reconnect steps (§7).epochis INFORMATIONAL for consumers correlating across connections: within one connection it cannot change, so the client does not compare it — a serving-process restart surfaces as its connections dropping.
The remote adapter consumes the envelope internally and re-exposes the same SessionControl
interface (and connectAgent does the same for the data plane’s Agent). Local and remote
consumers are isomorphic; that is the entire payoff of keeping the envelope out of the API.
14. Security boundary
A remotely exposed control plane MUST be wrapped by a host that enforces: an authenticated
principal and per-session authorization; separated observe and dispatch permissions; allowed model
and thinking-level policy; prompt and attachment size limits; opaque artifact references instead of
filesystem paths; audit records for accepted commands. The control plane does not make local coding tools
safe for untrusted users; ExecutionEnv is still not a complete sandbox boundary
(core design §5).
15. Implementation sequence
| Phase | Work | Done when |
|---|---|---|
| 0 | Done. The definition-aware session builder is extracted (session-builder.ts); the TUI-only ~/.pi auth divergence is eliminated in place; runPiChat is one consumer. |
Chat assembly semantics unchanged; auth source and thinkingLevel deliberately converged to serving; builder independently instantiable. |
| 1 | Done. Observation plane: pi events translate ONCE to SessionEvent inside the invoke path (toSessionEvent), AgentEvent is its projection (projectAgentEvent); the session subpath types + pi createPiSessionControl (state/entries/events over a read-only PiSessionReader); conformance tests for projection fidelity, run boundaries (incl. cancellation → exactly-one run_settled{aborted}), reconnect, and single-writer. |
An L0 client can watch and reconnect to any invoke-driven run. |
| 2a | Done. Run modulation: dispatch routes steer/follow_up/abort to the live run via {@link RunControls} registered with run_started; the settle window spans steered/queued continuations inside one invoke (pi’s agent loop drains both queues within one prompt()); queue_changed + live pending in state; a control-plane abort terminates as failed{code: "aborted"} / run_settled{aborted}; idle-session run commands reject no_active_run before acceptance. |
L1 clients. |
| 2b | Done. Boundary mutations under the lease: set_model/set_thinking append durable session overrides (validated against the registry / pi’s thinking scale; invalid_command before acceptance) and the fresh-harness resolve (resolveHarnessOverrides, the active-tools precedent) applies them on every later turn — a registry change across deploys falls back to the default with a deduped warn instead of bricking the session. compact is ACCEPT-FAST (§5.2 has no exceptions: a summarization is a full model call, so the dispatch answers on admission — lease held, harness built — and the outcome travels as `compaction_finished{summary |
error |
| 3 | Done (HTTP+SSE; subprocess adapter stays demand-driven). One transport serves every remote consumer — Web panel, desktop app, fastagent attach: controlRoutes (engine-neutral, bearer-token REQUIRED) serves /control/capabilities|state|entries|dispatch|events, with the envelope born at the wire ({sessionId, epoch, seq, event} per SSE message; HTTP itself is the request correlation). connectSessionControl re-exposes the SAME SessionControl interface and consumes the envelope internally — a seq gap ends the iterator into the standard reconnect steps; a server restart is covered by the same rule (its connections drop), and epoch is informational for consumers correlating ACROSS connections — within one connection it cannot change, so the client does not compare it. The DATA plane travels too: POST /control/invoke (the standard invoke handler behind the same token, mounted when the serve wires an agent) + connectAgent client-side — a remote consumer holds a full fastagent instance through the same two contracts local code uses. Serve wiring: config.sessionControl: true → dev/start mount the routes, mint a per-boot token, and write <stateRoot>/control.json (0600) for local discovery; product runners still own real authentication, idempotency, event persistence, and routing (§14). |
Remote SessionControl is isomorphic to local (conformance-tested). |
Phase 1 modifies the existing invoke pipeline incrementally (translation + projection); it does not build a parallel runtime that later needs reconciling. Demand-driven follow-ons, explicitly not prerequisites: branching, blocking interactions (typed confirm/select/input gates that suspend a run), definition reload, export, and channel upgrades — a chat channel becoming an events consumer for message-boundary delivery (enabling an opt-in follow_up/steer policy for mid-run messages) or, once interactions exist, an interaction responder (e.g. Telegram inline keyboards). The extension unlocks those options; it does not mandate them.
Considered and rejected — replacing the chat channels’ queued-turn path with steer/follow_up:
the stateful channels persist each accepted turn intent BEFORE the transport ACK (L1, at-least-once,
crash replay); a message folded into a live run as a steer exists only in that run’s in-memory queue,
so a process crash silently loses it. Trading the durability floor for lower latency inverts the
channels’ design center. Any future adoption must first give steered messages the same durable-intent
treatment (which is exactly the opt-in policy sketched above — an events consumer at message
boundaries — not a replacement of the queue).
Shipped from that list — the user-facing stop command: ChannelContext.control? hands channels
the hub for DISPATCH only (observation stays on the data plane — the retrying event precedent),
and the chat channels map an explicit user stop (Telegram /stop; a bare “stop”/“cancel” summon on
Slack/Feishu/Lark) onto abort. Decisions: the hub stays gated by config.sessionControl (no
hub → a visible “not enabled” notice, never a silent ignore); the stop message is a control action,
never a turn (it must not queue behind the run it stops); only the ACTIVE run is aborted — queued
durable turns are independent asks and keep their at-least-once floor.
16. Invariants
Implementation review should reject changes that violate these:
- Agent Handler v0.1 semantics and the frozen terminal set stay unchanged; additive advisory
failed.codeconstants insrc/agent.tsare allowed (theSESSION_BUSY_CODEprecedent —ABORTED_CODEfollowed it). invokeholds no state between calls.- No run exists without an invoke; the control plane modulates, never initiates.
- The observation plane is strictly read-only.
- All durable session writes happen under the shared lease.
- Residency is an invisible cache: no lifecycle in the contract, correctness via durable revalidation.
- Acceptance is not outcome;
ok: falsealways means rejected before acceptance. - The embedded contract is semantic-only; correlation, ordering, and epoch identity live in the transport envelope.
- FastAgent Definition artifacts, not ambient pi globals, determine behavior; pi imports stay
under
src/engines/pi/. - Engine paths, models, messages, and repositories never leak into the neutral contract.
- Live events are ordered but never presented as durable history.
- TUI presentation APIs and remote-shell shortcuts stay out.

