This is a compact reference for the all-in-one @fastagent-sh/fastagent surface. The same exports are grouped into @fastagent-sh/fastagent/core (engine-neutral) and @fastagent-sh/fastagent/pi (the pi reference implementation).
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.
function nodeListener(handler: (req: Request) => Promise<Response>): (req, res) => void;
function router(routes: Routes): ChannelHandler;
function serveNode(handler: ChannelHandler, options: { port: number }): {
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" or "METHOD /path".
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. |
tools |
Agent tools. |
skills |
Loaded Agent Skills. |
sessions |
PiSessionStore. |
env |
Harness ExecutionEnv. This alone does not sandbox the pi coding tools or project-context loader. |
lease |
Same-session concurrency lease. |
providers |
Extra model providers. |
createPiAgentFromDefinition
function createPiAgentFromDefinition(
dir: string,
options: CreatePiAgentFromDefinitionOptions,
): Promise<{ agent: Agent; definition: LoadedDefinition }>;Load persona.md/skills/ from dir (the agent-definition 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 run/working directory (where tools operate, whose repo AGENTS.md is context) from the definition dir.
LoadedDefinition carries contextFiles: Array<{ path; content }> (the ② files), persona? (from persona.md, ①), skills, and diagnostics/collisions (SkillDiagnostic[] / SkillCollision[] — both exported).
createPiAgentFromWorkspace
function createPiAgentFromWorkspace(
dir: string,
options?: { model?: string; sessionsDir?: string; authPath?: string; serving?: boolean },
): Promise<{
agent: Agent;
definition: LoadedDefinition;
config: FastagentConfig;
configPath?: string;
modelSpec: string;
agentDir: string;
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.
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 with loadTools(dir), 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<SessionTreeEntry[]>;
}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 (a workspace 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 harness rebuild 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). A workspacesearch_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 workspace 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 pi’s session instead of the serving harness), 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 > <dir>/.fastagent), 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;
}
function loadChannels(
dir: string,
ctx: ChannelContext,
): Promise<{
routes: Routes;
longConnections: LoadedLongConnectionChannel[];
routeChannels: string[];
longConnectionChannels: string[];
collisions: ChannelCollision[];
failures: ModuleLoadFailure[];
}>;A workspace 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;
function loadSchedules(dir: string): Promise<{ schedules: LoadedSchedule[]; failures: ModuleLoadFailure[] }>;
function discoverScheduleFiles(dir: string): Promise<string[]>; // existence probe: file basenames, no import
// (deploy preflight's time-trigger detection; prefer loadSchedules to also surface broken files)
function createScheduler(opts: SchedulerOptions): Scheduler; // { start(): void; stop(): void }
function scheduleSession(name: string): string; // the derived stable session idA workspace 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 (scheduleSession(name)=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). createScheduler({ agent, stateRoot, schedules }) 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/auth.json — the cross-project share target
function fastagentCredentialStore(authPath?: string, options?: FastagentAuthOptions): CredentialStore;fastagent login writes the project-level <state root>/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/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).
Provider injection:
function createProvider(...): Provider;createProvider, Provider, ProviderAuth, and Model are re-exported from pi’s model layer so callers do not need to depend on FastAgent internals.
Sessions and leases
interface PiSessionStore {
openOrCreate(sessionId: string): Promise<Session>;
}
/** Read-only sibling for the observation plane: unknown session → undefined, never created. */
interface PiSessionReader {
openIfExists(sessionId: string): Promise<Session | undefined>;
}
function inMemorySessionStore(): PiSessionStore & PiSessionReader;
function jsonlSessionStore(options: { dir: string; cwd?: string }): PiSessionStore & PiSessionReader;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, inMemorySessionStore } from "@fastagent-sh/fastagent/pi";
const sessions = inMemorySessionStore();
const { control, observer } = createPiSessionControl({ sessions });
const agent = createPiAgent({ model: "openai-codex/gpt-5.5", sessions, observer });
// 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.events("s1")) {
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;
// After a disconnect, missed history comes from the durable plane, not the live stream:
const { entries, leafEntryId } = await control.entries("s1", { since: cursor });
const state = await control.state("s1"); // { status, activeRunId?, leafEntryId? }invoke stays the only way to start work; the AgentEvent stream is a projection of the rich
SessionEvent stream. dispatch modulates the run an invoke is driving — acceptance is not
outcome (ok: true = admitted; the result arrives as run_settled):
await control.dispatch("s1", { type: "steer", prompt: { text: "use bun, not npm" } }); // joins the run
await control.dispatch("s1", { type: "follow_up", prompt: { text: "then summarize" } }); // FIFO queue
await control.dispatch("s1", { type: "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 dispatch, a run equals a single turn, byte-identical behavior.
Run commands on an idle session reject with no_active_run before acceptance; a command 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 command as-is fails again; consult
state() before re-dispatching. The race window applies to all three commands 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.
Boundary mutations run between runs, under the SAME lease (session_busy while a run is active,
retryable at idle):
await control.dispatch("s1", { type: "set_model", model: "anthropic/claude-sonnet-4-5" }); // durable per-session override
await control.dispatch("s1", { type: "set_thinking", level: "high" });
await control.dispatch("s1", { type: "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 stop via dispatch(abort) — not a failure)Overrides persist in the session record and every later turn’s fresh harness applies them — on any
serving path, channels included. Boundary mutations require an EXISTING session (no_such_session
otherwise): sessions are created by invoke, never by the control plane. Invalid payloads reject
invalid_command before acceptance;
capabilities() lists allowedModels/allowedLevels. Boundary commands require the wiring the
workspace opener provides (sessionControl: true); a hub without it reports them off and rejects
with unsupported_capability.
For workspace assembly the store lives inside the opener, so ask the opener to wire the hub:
const { agent, sessionControl } = await createPiAgentFromWorkspace(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):
import { controlRoutes, connectSessionControl } from "@fastagent-sh/fastagent/core";
const routes = controlRoutes(sessionControl, { token }); // GET/POST /control/*, SSE at /control/events
// 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.events("s1")) 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/follow_up via dispatch carry full
Prompts, images included — within the dispatch body cap (1 MiB, with base64 inflation counted;
oversized bodies get a 413 naming the limit).
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 } from "@fastagent-sh/fastagent/slack";
import { feishuChannel } from "@fastagent-sh/fastagent/feishu";
import { larkChannel } from "@fastagent-sh/fastagent/lark";core avoids loading the pi reference runtime and is the preferred dependency for engine-neutral
channels. The root entry remains the supported convenience surface. See GitHub channel,
Telegram channel, Slack channel, and the canonical Feishu channel with Lark compatibility.

