This is a compact reference for the all-in-one @fastagent-sh/fastagent entry. The same exports are
layered across subpaths by what each costs to import:
| engine-neutral | runtime-neutral | pulls | |
|---|---|---|---|
/core, /session |
yes | yes | nothing |
/node |
yes | no | the Node HTTP bridge and a cron |
/pi |
no | no | the pi runtime |
FastAgent is pre-1.0. The Agent Handler contract is the stable design center; implementation-specific APIs may still tighten before 1.0.
Contract
interface Agent {
invoke(scope: Scope, prompt: Prompt): AsyncIterable<AgentEvent>;
}
interface Scope {
session: string;
}
interface Prompt {
text: string;
images?: ImageRef[];
}
interface ImageRef {
mimeType: string;
data: string; // base64
}AgentEvent:
type AgentEvent =
| { type: "text"; delta: string }
| { type: "thinking"; delta: string }
| { type: "tool_started"; id: string; name: string; args: Json }
| { type: "tool_ended"; id: string; isError: boolean; content: Json }
| { type: "retrying"; attempt: number; maxAttempts: number; delayMs: number; reason: string } // advisory backoff
| { type: "completed"; data?: Json }
| { type: "failed"; details: string; retryable: boolean; code?: string };See Agent Handler SPEC for normative behavior.
collect
function collect(events: AsyncIterable<AgentEvent>): Promise<CollectResult>;
interface CollectResult {
text: string;
data?: Json;
}
class AgentFailure extends Error {
details: string;
retryable: boolean;
code?: string;
}Buffers text deltas until completed. Throws AgentFailure on failed. Throws a regular error if the stream ends without a terminal event.
HTTP/host helpers
function createInvokeHandler(agent: Agent): (req: Request) => Promise<Response>;Fetch-shaped HTTP/SSE handler. Accepts POST JSON:
{ "session": "s1", "text": "hello" }Returns Server-Sent Events with one JSON AgentEvent per data: line. The stream also carries
SSE comment heartbeats (: ping, every 30s) so remote consumers can distinguish a quiet run from
a dead connection — parse per the SSE spec (only data: lines carry events), not line-by-line
JSON.
// From `@fastagent-sh/fastagent/node` — the only runtime-specific entry, and the only one that
// costs a third-party package (the node:http ↔ Fetch bridge).
function nodeListener(handler: ChannelHandler): (req, res) => void;
// `host` is the bind address; unset binds all interfaces (what containers need).
function serveNode(handler: ChannelHandler, options: { port: number; host?: string }): {
listening: Promise<number>;
close(): Promise<void>;
closeAllConnections(): void;
};Route types:
type ChannelHandler = (req: Request) => Response | Promise<Response>;
type Routes = Record<string, ChannelHandler>;Route keys are "/path" (any method) or "METHOD /path", and the path is a literal.
Dispatch is a map lookup on the literal path, so “would these two fight over a request?” is string
equality — a fact about the keys, not a prediction about a matcher. :id and * carry no pattern
meaning here; they are ordinary characters, so a key containing one simply never matches.
Startup refuses only what would cost ANOTHER route: two keys naming the same one ("/x" and
"GET /x"), a route inside a mounted prefix, and any path a URL rewrites (?/#, ./..) —
that request arrives under a different path, so the key never matches AND compares as distinct,
hiding the collision.
Paths are matched as they arrive, without percent-decoding — decoding would undo the normalisation
URL performs, turning %2F..%2F back into /../. HEAD is answered from the GET route without
the content (RFC 9110); writing an explicit HEAD route is allowed and takes precedence.
A path that exists under another method answers 405, an unknown path 404; remote clients read that 404 as version skew rather than as a fault.
A handler owning a whole path prefix (the session control plane is the one) is mounted beside the
routes rather than spelled as a key. createAgentService does that wiring; a route landing inside
such a mount is refused at assembly, because the mount would answer requests aimed at it.
pi assembly
createPiAgent
function createPiAgent(options: CreatePiAgentOptions): Agent;Assemble an agent from typed parts:
createPiAgent({
model: "openai-codex/gpt-5.5",
instructions: "You are a support assistant.",
tools: [lookupOrder],
});Common options:
| Option | Meaning |
|---|---|
model |
Required provider/modelId spec string. |
instructions |
String or function returning the system prompt. The function is evaluated once per invoke, never during construction; a thrown error becomes that turn’s failed event. |
tools |
MountedTool[]: AgentTool with optional native Pi execution context. Serving/chat forward progress updates and context; direct CLI calls are sessionless. |
skills |
Loaded Agent Skills. Pi lists them in the system prompt when read is active. |
sessions |
PiSessionRecordStore. |
env |
ExecutionEnv supplies cwd at L1; at L2 it also reads persona.md and skills/. Project context and tools use the local process directly. This is not a sandbox. |
lease |
Same-session concurrency lease. |
providers |
Extra model providers. |
Tool contexts preserve the original caller session id. FastAgent’s default bash tool also exposes it
as PI_SESSION_ID. When the id contains NUL or unpaired UTF-16 surrogates, the shell receives its JSON
string representation and PI_SESSION_ID_ENCODING=json. Otherwise the value is unchanged and the
encoding marker is unset. Read both variables to distinguish an encoded id from a literal JSON-looking
id; use JSON.parse only when the marker is json.
Caller-created Pi shell tools retain their construction options. Configure their spawnHook with the
same environment encoding policy, or use exposeSessionEnvironment: false when PI_* metadata is
unnecessary. FastAgent cannot retrofit a spawn hook into an existing tool instance.
createPiAgentFromDefinition
function createPiAgentFromDefinition(
dir: string,
options: CreatePiAgentFromDefinitionOptions,
): Promise<{ agent: Agent; definition: LoadedDefinition }>;Load persona.md/skills/ from dir (the agent dir) and assemble the pi prompt. ② project context is sourced via pi’s loadProjectContextFiles({ cwd, agentDir: dir }) — the dir’s own AGENTS.md plus every AGENTS.md walking cwd (option; default dir) up to root. Pass cwd to decouple the workspace (where tools operate, whose repo AGENTS.md is context) from the agent dir — createPiAgentFromDir always passes the resolved workspace, which is the directory fastagent was pointed at (the agent dir’s parent when the agent was found one level inside it, the agent dir itself when you aimed straight at it).
LoadedDefinition carries contextFiles: Array<{ path; content }> (the ② files), persona? (from persona.md, ①), skills, and diagnostics/collisions (SkillDiagnostic[] / SkillCollision[] — both exported).
createAgentService
function createAgentService(
dir: string,
options?: { model?: string; authPath?: string; sessionsDir?: string; signal?: AbortSignal;
onChannelClosed?: (name: string, error?: unknown) => void },
): Promise<{
handler: ChannelHandler; // channels + control plane + health, composed
agent: Agent;
routes: Routes; // what is served, for a startup line
agentDir: string;
workspace: string;
channels: { routes: string[]; longConnections: string[]; builtinInvoke: boolean };
schedules: readonly LoadedSchedule[];
ready: Promise<void>; // settles when long connections are up; rejects if one cannot
control?: { token: string; prefix: string }; // the plane's bearer token, when sessionControl is on
close(): Promise<void>; // stop long connections and schedules; rejects if one fails
// to stop, or does not stop within 5s
}>;The assembly dev/start perform, without the process: no port bound, no signal handlers, no
process.exit. This is the supported way to mount a whole agent inside an app; nodeListener and
serveNode below are how you attach the handler it returns.
createPiAgentFromDir
function createPiAgentFromDir(
dir: string,
options?: { model?: string; sessionsDir?: string; authPath?: string; serving?: boolean },
): Promise<{
agent: Agent;
definition: LoadedDefinition;
config: FastagentConfig;
configPath?: string;
modelSpec: string;
agentDir: string; // where the agent lives
workspace: string; // the agent's cwd — the agent dir's parent
stateRoot: string;
sessionsDir: string;
authPath: string;
toolNames: string[];
toolCollisions: ToolCollision[];
toolFailures: ModuleLoadFailure[];
}>;The same opener used by fastagent dev, invoke, and start: load config, resolve model/tools, pick session storage, and assemble the directory. Set serving: true only for a long-running host that also runs the scheduler; it allows an opted-in workspace to mount its wake tool.
interface FastagentConfig {
tools?: FastagentTool[];
}Every directory-opening workflow (dev, start, invoke, chat, tool, and info) mounts the
complete coding set. Conditional built-ins stay independent: deferred tools may add search_tools,
and selfSchedule may add wake while serving. createPiAgentFromDefinition uses the complete coding
set unless tools replaces it; createPiAgent starts from the passed tools. In both APIs, omitted
coding built-ins cannot be reactivated, while deferred tools may add search_tools.
Tool authoring
function defineTool<I extends z.ZodType>(options: DefineToolOptions<I>): AgentTool;Use the re-exported z:
import { defineTool, z } from "@fastagent-sh/fastagent";
export default defineTool({
description: "Look up an order.",
input: z.object({ orderId: z.string() }),
async execute({ orderId }) {
return await db.find(orderId);
},
});tools/<name>.ts files are discovered by the assembly, and the filename becomes the tool name.
The second execute argument is a ToolContext:
interface ToolContext {
cwd: string;
signal?: AbortSignal;
sessionManager?: ReadonlySessionManager;
tools?: ToolActivation;
}
interface ReadonlySessionManager {
getSessionId(): string;
getHeader(): Promise<{ id: string; timestamp: string }>;
getBranch(): Promise<PiSessionEntry[]>;
}During serving and fastagent chat, sessionManager is FastAgent’s read-only adapter over the current
conversation. It is undefined in a sessionless direct call such as fastagent tool. Current bindings
ride AsyncLocalStorage, not definition closures, because a tool is built once and reused across turns.
The built-in wake tool uses sessionManager.getSessionId() to schedule a follow-up in the same
conversation.
Deferred tools
For tool-heavy agents, defineTool({ ..., deferred: true }) registers a tool without activating it:
its schema stays out of every request (and the model’s sight) until discovered. When any deferred tool
is mounted, fastagent automatically mounts the built-in search_tools loader (an agent’s own tool
named search_tools wins — the author owns the concept then): the model searches by keywords, matching
tools are activated mid-turn, and the activation is recorded in the session, so it survives fastagent’s
per-invoke session rebind for the rest of that conversation.
Costs and behavior to know:
- Discovery rides on the
description— a deferred tool the model never searches for effectively does not exist. Write descriptions with the search in mind. - On models with native deferred tool loading, an activation preserves the provider’s prompt-cache prefix; everywhere else activation still works but may pay a cache miss. The supported-model matrix is pi’s (see its Dynamic Tool Loading docs) and evolves with pi releases — fastagent adds no restriction of its own.
ToolContext.tools({ active(), registered(), activate(names) }) is the activation bridge a custom loader can use;activateis additive and ignores unknown names. A custom loader must also declareexecutionMode: "sequential"(adefineTooloption; pi then serializes the batch — in chat, pi’s own before/after diff around SDK tools would otherwise attribute one activation to two parallel calls). An agent’ssearch_toolsmissing the mode gets it forced, with a warning. Both types are exported:ToolActivation, andFastagentTool(AgentTool+ thedeferredmarker — the typeconfig.toolsand the L1/L2toolsoptions accept, so a raw object literal withdeferred: truetype-checks).- At L1 (
createPiAgent) theinstructionsare verbatim by contract — fastagent does not inject the discovery note the directory path’s base prompt carries. When passing deferred tools at L1, tell the model aboutsearch_toolsin your own instructions (or rely on the loader’s description alone, which is weaker). - An activation is persisted as a dedicated DELTA entry in the session (“this conversation activated
these deferred tools”): on reopen the active set is rebuilt as the initial set (current non-deferred
tools) plus the accumulated deltas. A tool you add to the agent later joins existing
conversations, and a tool you later flip to
deferreddrops out of sessions that never discovered it. fastagent chatemulates deferral like the serving path (what you iterate is what you serve): the session starts with deferred tools inactive, the samesearch_toolsloader discovers and activates them (bridged to chat’s resident session instead of the served one), and the prompt is identical. One divergence: chat activations do not survive/new//resume— pi’s chat session does not record them, so a resumed conversation re-discovers viasearch_tools(on the serving path activations persist in the session for the conversation’s life).
Channel authoring
interface ChannelContext {
agent: Agent;
stateRoot: string; // resolved state root (FASTAGENT_STATE_DIR > <root>/.state), absolute
}
type ChannelModule = (ctx: ChannelContext) => Routes;
interface LongConnection {
ready: Promise<void>; // settles on first usable connection; on a pre-ready abort it still settles (cancellation)
closed: Promise<void>; // resolves after abort-driven shutdown; rejects on terminal failure
}
interface LongConnectionChannelModule {
name: string;
connect(ctx: ChannelContext, signal: AbortSignal): LongConnection;
}An agent channel default-exports either a route ChannelModule or a
LongConnectionChannelModule. Bundled webhook adapters (telegramChannel(opts),
githubChannel(opts), feishuChannel(opts)) return ChannelModule; feishuWebSocketChannel(opts)
and larkWebSocketChannel(opts) return LongConnectionChannelModule. In both forms the channel file
is one expression; a channel persisting durable state derives its home from
ctx.stateRoot (<stateRoot>/channels/<kind>), never process.cwd(). Enabled files end in .ts,
.js, or .mjs; rename one to <name>.ts.disabled to disable it. Serving fails if any enabled channel
cannot load.
Channel adapters can also use:
function readBodyCapped(req: Request, max: number): Promise<{ text: string } | { tooLarge: true }>;
function text(body: string, status: number): Response;
const textHeaders: { readonly "content-type": "text/plain" };See Channel development.
Schedule authoring
interface Schedule {
cron: string; // 5-field cron expression
tz?: string; // IANA timezone (default "UTC")
prompt: string; // the turn's text = the job's instruction
}
function defineSchedule(schedule: Schedule): Schedule;An agent declares time-triggers by dropping schedules/<name>.ts, mirroring tools//channels/;
the filename becomes the schedule name. Each file default-exports defineSchedule({ cron, tz?, prompt }).
// schedules/daily-digest.ts → schedule "daily-digest"
import { defineSchedule } from "@fastagent-sh/fastagent";
export default defineSchedule({
cron: "0 9 * * *",
tz: "America/New_York",
prompt: "Generate today's digest and send it to the team Telegram.",
});The scheduler is a time-trigger (the N axis, clock form): on each cron instant it invokes the agent
with prompt — borrowing the same Agent contract as channels, adding none. It:
- carries no
sessionfield — a session id is runtime conversational context, not a build-time value. It derives a stable per-schedule session (schedule:<name>), so a schedule’s turns share one continuing conversation persisted by the core session store (zero-touch on storage, like the telegram channel deriving a session fromchat.id); - delivers nothing — output is the agent’s tools’ job; the scheduler only fires and logs the outcome;
- catches up an overdue run once — durable
fires.jsonunder<stateRoot>/schedule/records the last fire; a run missed while the process was down fires once on the next start (not per missed slot), claimed before the invoke (at-most-once per slot).
Single-process (like all state today). The scheduler is started by
the serve path (dev/start); fastagent fire <name> runs one schedule’s turn immediately for authoring.
Self-scheduling. Opt in with selfSchedule: true in fastagent.config (off by default — an autonomy
capability, not given to every agent). Then the serving path (dev/start, where the poller runs — not the
one-shot invoke/fire) mounts a built-in wake tool so the agent can schedule itself: wake({ in: "30m", prompt })
records a one-shot wake-up — or wake({ cron: "0 9 * * *", tz?, prompt }) a RECURRING one — persisted under
<stateRoot>/schedule/, polled by the scheduler and fired back into the SAME session, so the agent resumes
the conversation — the woken turn’s prompt is enveloped with the wake-up’s id and origin (“YOUR
self-scheduled turn, not a user message”), so the model can tell its own alarm from the user speaking. It reads the current session through ToolContext.sessionManager; guardrails cap the minimum delay,
the recurring frequency (≥10 min between fires), and the per-session pending count. The agent cancels its own
with unwake({ id }) (session-scoped); the operator with fastagent schedule cancel <id> (schedule list
shows ids).
Config and models
function defineConfig(config: FastagentConfig): FastagentConfig;
function listModels(models: Models): string[];
function resolveModel(models: Models, spec: string): Model;
function createPiModels(options?: CreatePiModelsOptions): Models;
function probeAuthSource(models: Models, spec: string): Promise<string | undefined>;Auth:
const GLOBAL_AUTH_PATH: string; // ~/.fastagent/.secrets/auth.json — the cross-project share target
function fastagentCredentialStore(authPath?: string, options?: FastagentAuthOptions): CredentialStore;fastagent login writes the project-level <agent dir>/.secrets/auth.json by default; GLOBAL_AUTH_PATH
is createPiModels’s default when no authPath is passed, and the explicit one-file share target
(FASTAGENT_AUTH_PATH=~/.fastagent/.secrets/auth.json). Note the two defaults differ: an embedder calling
createPiModels() bare reads the global file, not a project-level login — pass authPath explicitly
to read the project’s credential (the createPiAgentFrom* openers already do).
The directory you name is managed as a secrets directory. Every credential write — including an
OAuth refresh mid-run — creates it if needed and re-applies 0700, so a directory you widen by hand
is silently tightened again on the next token rotation. auth.json itself is 0600, but the
directory’s x bit is what actually keeps another account out, and mkdir’s mode is a no-op on a
directory that already exists. Point authPath at a file inside a directory fastagent may own, not
into a shared one you need others to read. Where the process cannot chmod that directory, the
credential write fails loudly rather than proceeding with it left readable.
Provider injection:
Provider, ProviderAuth and Model are re-exported as TYPES because they appear in our options —
a caller must be able to name them. The factory that builds one is pi’s own: import createProvider
from @earendil-works/pi-ai directly, so its API answers to its own package.
Sessions and leases
interface PiSessionRecordStore {
openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<SessionManager>;
/** Read-only sibling for the observation plane: unknown session → undefined, never created. */
openIfExists(sessionId: string): Promise<SessionManager | undefined>;
}
/** Where a NEW thread starts from. Read only on the create path; an existing session ignores it. */
interface SessionInheritance {
parentSession: string;
branchHints?: string[];
}
function piInMemorySessionRecordStore(options?: { cwd?: string }): PiSessionRecordStore;
function piSessionRecordStore(options: { dir: string; cwd?: string }): PiSessionRecordStore;piSessionRecordStore’s dir is resolved against cwd (which itself defaults to process.cwd()), so
a relative path means “inside the workspace this store serves”. cwd also scopes lookups: two stores
sharing one dir but serving different workspaces never open each other’s sessions.
Session ids are the Caller’s, and arbitrary — a telegram group is -1001234567890, a feishu thread
carries : and /. pi accepts none of those as a record name, so the store encodes them
injectively (-1001234567890 becomes s-1001234567890 on disk, readable enough to tell which room a
file belongs to). A record is published complete: pi buffers a new session until its first assistant
message, which would otherwise lose the user’s question to a crash AND make open-or-create
non-idempotent.
Both backends inherit: the durable one forks the parent’s record, the in-memory one copies its path entry by entry. Inheritance is a property of the contract, not of the medium — a thread must not forget its room because the store happens to be in memory.
Lease:
interface Lease {
tryAcquire(session: string): Release | null;
}
type Release = () => void;
function inProcessLease(): Lease;The lease is the same-session concurrency floor. A failed acquisition yields a retryable failed event.
Session control (observation plane)
The optional serving extension beside invoke
(design): watch and reconnect to invoke-driven runs. Neutral types live
in @fastagent-sh/fastagent/session; the pi implementation in /pi:
import type { SessionControl, SessionEvent } from "@fastagent-sh/fastagent/session";
import { createPiAgent, createPiSessionControl, piInMemorySessionRecordStore } from "@fastagent-sh/fastagent/pi";
const sessions = piInMemorySessionRecordStore();
const { control, observer } = createPiSessionControl({ sessions });
const agent = createPiAgent({ model: "openai-codex/gpt-5.5", sessions, observer });
// This agent has no definition, so `control.commands()` is `[]` — true, not a gap. Over a DIRECTORY
// agent, pass `commands: async () => …` returning one `AgentCommand` per name the definition
// exposes, re-read per call; otherwise the list claims the definition's skills do not exist.
// `createPiAgentFromDir` wires it for you.
// Live events are NOT durable history: a subscription sees only what happens while it iterates,
// so start watching BEFORE (or while) the run is driven — never after it drained.
const watching = (async () => {
for await (const ev of control.sessions.get("s1").events()) {
console.log(ev.type); // run_started, message_delta, tool_started, …
if (ev.type === "run_settled") break; // events() has no natural end — the consumer decides
}
})();
for await (const e of agent.invoke({ session: "s1" }, { text: "hi" })) void e; // the data plane
await watching;
// What a `/` composer LISTS (read live, so a skill added while serving appears at once). A listing
// only — the data plane takes prompts as text, so what typing `/triage` means is the client's.
await control.commands(); // [{ name: "triage", description: "Sort an inbox", source: "skill" }]
// After a disconnect, missed history comes from the durable plane, not the live stream:
const s1 = control.sessions.get("s1"); // a pure binding: an id + the transport, nothing to dispose
const { entries, leafEntryId } = await s1.entries({ since: cursor });
const state = await s1.state(); // { status, name?, activeRunId?, leafEntryId? }invoke stays the only way to start work; the AgentEvent stream is a projection of the rich
SessionEvent stream. A session’s ACTIONS modulate the run an invoke is driving — acceptance is not
outcome (ok: true = admitted; the result arrives as run_settled):
await s1.steer({ text: "use bun, not npm" }); // joins the run
await s1.followUp({ text: "then summarize" }); // FIFO queue
await s1.abort(); // invoke ends failed{code:"aborted"}, run_settled{aborted}With steering/follow-ups the invoke stream terminates at the run’s SETTLE (all queued continuations
drained) — for consumers that never act on a run, a run equals a single turn, byte-identical
behavior. Actions on an idle session reject with no_active_run before acceptance; one that reached
a run but could not take effect (the run raced to settlement) rejects with run_command_failed. Both
are retryable: false — the same call fails again; consult state() before trying again. The race
window applies to all three symmetrically: an
accepted abort can still settle completed, and an accepted steer/follow_up can settle
without its prompt being consumed, when the run finishes inside the window — acceptance is not
outcome; the settlement is the truth.
commands() lists what a / composer completes: { name, description?, source } per named thing
the definition exposes (source: "skill" today). It is a LISTING, not a dispatch surface — the data
plane takes prompts as text and nothing expands /name, so what typing one means is the client’s
choice. It is read live and uncached — the definition’s skills/ is re-read per call (the ②
context walk the full load does is skipped: this answers at composer-open frequency) — so a skill
added while serving appears at once; [] means the agent exposes none. It is also the one read that can REJECT:
a definition the server cannot read at all is a deployment fault with no truthful degraded value, and
the rejection carries no stable code (remotely: an uncoded non-2xx → ControlRequestError). Wrap the
call, and expect no error.code to branch on.
sessions.list() is the deployment’s conversation list — { session, name?, createdAt, updatedAt, messageCount, preview? } per record, with session being the id the CALLER minted (a channel’s
thread key, not a storage name). It is DEPLOYMENT-level: it answers for every session at once, so a
multi-tenant facade in front of one deployment must not expose it (it does not need to — it already
holds its own user→sessions mapping). It is the one read besides commands() that can REJECT, and
unlike that one it carries a stable code: a store that cannot be enumerated answers
sessions_unavailable (remotely: 503 with the code on ControlRequestError.code), because []
already means “no sessions”.
Building that list reads and parses EVERY record, every call — there is no cache (one was tried and
deleted: it missed on every poll of a busy deployment, since each append moves the file). Measured at
20 ms for 100 sessions / 8 MB, and it yields to the event loop between records, so a timer refresh is
fine at human intervals. Drive the OPEN conversation from events() rather than by re-listing.
Writes run between runs, under the SAME lease (session_busy while a run is active, retryable at
idle). A session’s PROPERTIES are one patch — update validates every field before writing any, so a
rejected patch leaves nothing behind, and one event reports the result:
await s1.update({ name: "Deploy notes" }); // the list's label
await s1.update({ model: "anthropic/claude-sonnet-4-5" }); // durable per-session override
await s1.update({ thinkingLevel: "high" });
await s1.update({ leafEntryId: entryId }); // move the leaf → state_changed
await s1.update({ model: "anthropic/claude-opus-4-5", thinkingLevel: "high" }); // one call, one eventA patch is validated as a whole — a rejected one leaves nothing behind, which is what makes
ok: false safe to retry. The writes themselves are separate journal entries, so a failure BETWEEN
them (a full disk) answers partial_update naming what landed, after an event reporting the record
as it now is: read state() before retrying. A field this serve does not know rejects
unsupported_capability — the same code on both planes, naming the field, so a newer client talking
to an older serve knows which one to drop; a wrong value type is invalid_command.
leafEntryId is the write verb for the tree entries() publishes: it moves the session’s active
leaf, so the next turn hangs off it instead of the old one — which is also how sibling branches come
to exist. An id that entries() did not publish rejects invalid_command. Gate each field on
capabilities().updatable.
The rest are whole-record or run-scoped calls:
await s1.compact({ instructions: "keep the decisions" }); // accept-fast: ok on admission; the
// outcome arrives as compaction_finished{summary|error|aborted} (emitted after the lease frees;
// aborted = a deliberate s1.abort() — not a failure)
await control.sessions.fork({ from: "s1", at: entryId, into: "s1-b" }); // copy history into a NEW session
await s1.delete(); // irreversiblefork names its target: into is a Caller id like any other, so the plane invents nothing. It is
IDEMPOTENT — the new record carries where it came from, so repeating the same fork answers ok: true
and writes nothing (a retry after a lost response does not produce a second record), while the same
id holding a different history rejects invalid_command. Cloning is fork at the session’s own
leafEntryId. There is no create — invoke is what brings a session into being. delete ends the
session’s live events() streams; it is guarded by the same bearer token as every other call, which
is the only key the framework owns.
Overrides persist in the session record and every later turn’s fresh session binding applies them on any
serving path, channels included. resolveSessionSettings clamps both recorded thinking levels and
configured defaults to the current model’s capabilities using pi’s own clamp. Defaults apply when the
active path has no valid thinking override, including after navigation removes one. The journal keeps
the recorded preference, which returns when the session moves back to a capable model. state(),
state_changed, and execution use the same resolved level, including after a deployment changes the
configured model. The clamp takes the lowest supported level at or above the requested one and falls
back downward only if nothing above exists; filling a gap can increase reasoning cost.
Writes require an existing session (no_such_session otherwise): sessions are created by invoke or
copied by fork, never minted by an update. Invalid payloads reject invalid_command before acceptance.
capabilities() lists allowedModels (the
deployment’s registry — a static fact) but not thinking LEVELS: which exist depends on the model a
session is running, so they ride state().availableThinkingLevels, and update({ thinkingLevel })
validates against that same set rather than recording an override the run would ignore. Every write
requires the wiring the agent opener provides (sessionControl: true); a hub without it reports an
empty updatable, fork: false, delete: false, and rejects with unsupported_capability.
For agent assembly the store lives inside the opener, so ask the opener to wire the hub:
const { agent, sessionControl } = await createPiAgentFromDir(dir, { sessionControl: true });Remote (HTTP + SSE)
The same contract over the wire — for a Web panel, a desktop app, or fastagent attach. Server
side, mount the bearer-authenticated routes (dev/start do this automatically when the config sets
sessionControl: true, minting a per-boot token into <stateRoot>/control.json — or using
FASTAGENT_CONTROL_TOKEN when the environment sets it, which is how a deployed box gets a token its
callers already know):
import { createAgentService } from "@fastagent-sh/fastagent";
import { connectSessionControl } from "@fastagent-sh/fastagent/core";
// Set `sessionControl: true` in fastagent.config.*; the plane is then mounted on the service's
// handler, owning the /control prefix — routes, preflight, 404/405 and a failing handler all carry
// CORS headers, so a browser client can read every reply. SSE at /control/sessions/{id}/events.
const service = await createAgentService("./my-agent");
// service.control?.token is how you hand a client access
// Client side — the SAME SessionControl interface, isomorphic to local:
const remote = await connectSessionControl({ url: "http://127.0.0.1:8787", token });
for await (const ev of remote.sessions.get("s1").events()) console.log(ev.type);The DATA plane travels the same wire: connectAgent({ url, token }) returns an Agent whose
invoke drives POST /control/invoke (mounted when the serve wires an agent — dev/start do) —
paired with connectSessionControl, a client holds a full remote fastagent instance through the
same two contracts local code uses. Disconnecting the invoke stream cancels the run. The invoke wire is
text-only for now (images fail visibly there); steer/followUp carry full Prompts, images
included — within the action body cap (1 MiB, with base64 inflation counted; oversized bodies get a
413 naming the limit).
The wire is RESTful and mechanical, so a non-TypeScript client is a curl away:
GET /control/capabilities what this deployment allows
GET /control/commands the agent's skills
GET /control/sessions list
PUT /control/sessions/{id} {from, at} — fork (idempotent)
GET /control/sessions/{id} state
PATCH /control/sessions/{id} {name?, model?, thinkingLevel?, leafEntryId?}
DELETE /control/sessions/{id}
GET /control/sessions/{id}/entries ?since=
GET /control/sessions/{id}/events SSE
POST /control/sessions/{id}/actions {type: "steer"|"follow_up"|"abort"|"compact"}
POST /control/invoke the DATA plane: {session, text} — SSE, starts a run{id} is percent-encoded, so a Telegram group is /control/sessions/tg%3A-1001234567890 — session
ids are opaque Caller strings and may contain : and /.
The transport envelope (epoch/seq per SSE message) is consumed inside the client: a sequence
gap — and any mid-stream transport failure, a server restart included — throws from the events
iterator so the consumer’s failure handling owns it (only the consumer’s own detach reads as a
clean end); recovery is the standard reconnect steps. Exposing the port beyond loopback exposes a
remote-control surface — wrap it with real authentication and authorization
(design §14).
Subpath exports
import { type Agent, collect, readBodyCapped } from "@fastagent-sh/fastagent/core";
import type { SessionControl, SessionEvent } from "@fastagent-sh/fastagent/session";
import { createPiAgent, defineTool, z } from "@fastagent-sh/fastagent/pi";
import { githubChannel } from "@fastagent-sh/fastagent/github";
import { telegramChannel } from "@fastagent-sh/fastagent/telegram";
import { slackChannel, slackTransport } from "@fastagent-sh/fastagent/slack";
import { feishuChannel, feishuTransport, type FeishuTransport } from "@fastagent-sh/fastagent/feishu";
import { larkChannel, larkTransport, type LarkTransport } from "@fastagent-sh/fastagent/lark";/core loads no third-party package at all, which is what makes it the right dependency for a
channel package or a second engine. The root entry remains the supported all-in-one. See GitHub channel,
Telegram channel, Slack channel, and the canonical Feishu channel with Lark compatibility.

