This document explains the architecture of FastAgent’s pi reference implementation. The normative
protocol is Agent Handler SPEC v0.1; code in src/ is the implementation source of
truth. User behavior belongs in the other docs/ guides, not here.
1. Product boundary
FastAgent serves file-defined agents. Its stable center is the engine-neutral Agent Handler:
agent.invoke(scope, prompt) => AsyncIterable<AgentEvent>The contract separates three things that otherwise form an integration matrix:
| Concern | FastAgent seam |
|---|---|
| Trigger: HTTP, channel, schedule | Calls an Agent |
| Engine/model implementation | Implements Agent |
| Host/runtime | Supplies process, storage, credentials, and deployment |
Pi is the reference implementation. The contract does not require pi, but pi-specific assembly,
sessions, models, and tool types live under src/engines/pi/ and the public /pi subpath.
Engine-neutral consumers use /core.
2. Workspace shape and prompt assembly
A flat workspace uses one root for both the run directory and the agent definition:
workspace/
├── persona.md # optional identity
├── AGENTS.md # optional project context
├── skills/
├── tools/
├── channels/
├── schedules/
├── fastagent.config.mjs
└── .fastagent/ # machine state, gitignoredWhen an existing repository already owns names such as tools/ or has its own build/deploy files,
config.agentDir moves the agent surface into a subdirectory while the repository remains the working
directory:
repo/
├── AGENTS.md
├── fastagent.config.mjs # { agentDir: "./agent" }
└── agent/
├── persona.md
├── skills/
├── tools/
└── channels/The pi reference prompt has four segments:
| Segment | Source |
|---|---|
| ① engine base + identity | piBasePrompt; persona.md replaces its default identity line |
| ② project context | AGENTS.md files loaded by pi from agentDir and the cwd ancestor walk |
| ③ skills listing | definition-local Agent Skills |
| ④ runtime context | cwd only — no date, deliberately: a date line would invalidate the provider prefix cache at every day boundary (mirrors pi ≥0.80.7) |
persona.md and AGENTS.md are deliberately different slots: persona is authored identity;
AGENTS.md is project context. The definition is re-read for every invocation, so persona/context/
skill edits take effect on the next turn. Code modules are reloaded by the dev supervisor instead.
The low-level createPiAgent({ instructions }) path is different on purpose: instructions is the
system prompt verbatim, without the directory prompt assembly.
3. Assembly ladder
The pi reference implementation has three reusable rungs:
| Rung | Function | Responsibility |
|---|---|---|
| L0 | createPiAgentFromHarness |
Adapt a pi harness factory to the Agent Handler stream |
| L1 | createPiAgent |
Assemble from typed model/instructions/tools/ports |
| L2 | createPiAgentFromDefinition |
Load a definition directory and build the prompt |
createPiAgentFromWorkspace sits above L2. It loads config, resolves agentDir, model, auth, tools,
sessions, and state paths. dev, start, invoke, and fire share this assembly rather than carrying
parallel implementations.
Each invocation builds a fresh harness for its session and discards it after the turn. Conversation
continuity comes from PiSessionStore, not a resident harness. Reopening is faithful to the whole
record, not just the messages: pi’s harness writes active-tool changes to the session but never reads
them back (its own TUI harness is resident), so piHarnessFactory resolves the active-tool set itself
(harness.ts resolveHarnessActiveToolNames): the UNION of the initial set (every non-deferred tool;
pi’s all-active default when nothing is deferred) and the session’s accumulated activation DELTAS —
dedicated fastagent:tool-activation custom entries the activation bridge writes, each carrying
exactly the names that call activated. pi’s own active_tools_change entries are full active-set
snapshots and are deliberately ignored: replaying a snapshot would freeze later-added tools out of old
sessions and keep a later-deferred tool active in sessions that never discovered it. The corollary
is a constraint on future writers: NARROWING the active set is not representable in this record — a
capability that needs durable narrowing must change the resolve semantics here first, deliberately.
This per-invoke assembly remains the only data plane. A client that needs mid-run control, live
observation, or reconnectable history uses the optional session control plane:
session-scoped observe/modulate methods beside invoke — never a second way to start work, and
never resident process state as the source of continuity.
4. Event translation and terminal discipline
Pi exposes a promise for the final assistant message and a subscription side channel for streaming
events. src/engines/pi/invoke.ts combines them into one async iterable:
- acquire the per-session lease;
- open/create the session and harness;
- subscribe to pi events and translate text/thinking/tool events;
- run the prompt;
- emit exactly one
completedorfailedterminal; - unsubscribe, abort, and release the lease.
Setup, model, and tool-loop failures become failed events rather than thrown iteration errors.
Consumer cancellation runs generator cleanup and aborts the harness. Cleanup anomalies are logged but
cannot turn an already-terminal stream into a throw.
5. Tools, skills, and execution environment
Definition-local skills are the deployment truth. Runtime loading never scans global skill directories;
fastagent add skill may copy a global or remote skill into skills/, after which the vendored copy is
the source.
Workspace tools are merged in this order:
- pi coding tools (
read,bash,edit,write); config.tools;- discovered
tools/*.ts|js|mjs.
Earlier names win and collisions are reported. Broken discovered tools are reported and skipped.
Reusable integrations export ordinary FastagentTool[] for explicit config.tools mounting; package
origin does not create a second tool runtime.
Every defineTool execution receives the same generic runtime context. Serving adapts its fresh
pi-agent-core Session; chat adapts pi coding agent’s resident SessionManager; both expose the
FastAgent-owned read-only port (getSessionId, getHeader, getBranch). Sessionless direct execution
provides cwd but no manager.
Deferred tools (defineTool({ deferred: true })) are registered but not initially active: their
schemas stay out of the request — and the model’s sight — until the built-in search_tools loader
(auto-mounted whenever a deferred tool exists; an authored search_tools wins, the wake-pair rule)
activates them by keyword mid-turn. The activation runs through a per-turn bridge on the turn context
(ToolActivation: additive setActiveTools, unknown names filtered — pi throws on them), is stamped
on that tool call’s own result as addedToolNames — the load point that lets providers with native
deferred loading add the definitions at the transcript position without invalidating the cached
prompt prefix (the stamp comes from that execute’s own activate() calls, never an active-set
snapshot diff: batch tool calls run in parallel and a diff would misattribute a sibling’s activation)
— and is recorded in the session, which the per-invoke resolve above carries into later turns. The
base prompt lists only non-deferred tools plus a discovery note, computed from the static mounted set,
so activation never rewrites the prompt. The shared session builder (session-builder.ts, which
chat consumes) emulates the same behavior over pi’s AgentSession — the session is narrowed to the
initial active set at build, and the same builtin loader activates through a session-side
ToolActivation bridge (sessionToolActivation) riding the same turn context, so the author debugs
exactly what serves.
ExecutionEnv is a harness assembly seam, not a complete sandbox boundary today. Pi’s cwd-bound coding
tools and loadProjectContextFiles still use the local process/filesystem. A future sandbox adapter must
wire those surfaces as well as provide an ExecutionEnv; injecting env alone does not isolate a
directory agent.
6. Sessions and concurrency
The reference stores are:
inMemorySessionStore()for embedding/tests;jsonlSessionStore({ dir })for restart-surviving local/single-machine continuity.
Opening an existing session reconciles a dangling leaf tool call left by an interrupted process by appending an explicit interrupted error result. This restores transcript validity; it does not make side-effecting tools exactly-once.
The core lease allows one in-flight turn per session. A collision yields:
{ type: "failed", code: "session_busy", retryable: true, details: "…" }Queueing is channel policy. Telegram, Slack, and Feishu/Lark serialize their own turns per session; HTTP and GitHub use the core fail-fast behavior.
7. Channels and hosting
A channel file has one of two explicit module forms:
// Existing HTTP route channel
(ctx: { agent, stateRoot }) => Routes
// Long-connection channel
{
name: string,
connect(ctx, signal): { ready: Promise<void>, closed: Promise<void> }
}The distinction is structural: a function is a route channel; an object with connect is a
LongConnectionChannelModule. There is no shared mount object, ingress enum, or second metadata
declaration. Deployment imports enabled channel modules to inspect that shape without invoking route
modules or opening connections, so top-level module construction must not require runtime secrets. The
adapter owns reconnects; AbortSignal is the sole shutdown command, while ready and closed expose
lifecycle observation without a second close() path.
Enabled workspace channels are files ending in .ts, .js, or .mjs under channels/. Renaming a
file to telegram.ts.disabled disables it without adding a second config source.
The loader collects all per-file diagnostics, but dev / start treats any broken enabled channel or
route collision as fatal. A declared inbound endpoint must not silently disappear, and a broken channel
must never cause the default /invoke route to appear. The default HTTP/SSE route is mounted only when there are no
enabled channel files.
The serving CLI composition adds GET /health. A long-connection channel counts as declared (so the
fallback /invoke does not appear) and keeps that health route for deployment probes. Built-in health
returns 503 until every long connection first becomes ready. The Node host serves route channels through
node:http; the CLI opens long-connection channels, aborts them on shutdown, and fails visibly when one
closes unexpectedly. SIGINT/SIGTERM does not drain Agent turns: it aborts long connections, stops the
listener, force-closes active HTTP streams, and has a bounded exit fallback so shutdown cannot hang.
GitHub
The GitHub adapter verifies the HMAC over the capped raw body, maps a verified delivery through the
workspace’s on(event) policy, acknowledges with 202, and runs turns in the process. It has no durable
post-ACK replay; an interrupted review is lost and logged.
Telegram
Telegram is the stateful channel reference. Its modules separate:
| Module | Responsibility |
|---|---|
parse.ts |
pure update/message parsing and summon policy |
invoke-turn.ts |
attachment resolution and one Agent invocation (busy-retry loop + manifest wording shared via ../invoke-turn-kit.ts) |
../turn-queue.ts |
per-session FIFO, different sessions concurrent (shared with Feishu) |
turn-store.ts |
telegram’s record + ordering over the shared generic ../turn-store.ts (pre-ACK persisted turn intent, crash replay) |
context-buffer.ts |
telegram’s entry shape + attachment selection over the shared generic ../context-buffer.ts (durable un-summoned group context, peek→completed→commit) |
preview.ts |
live preview and terminal write policy |
telegram-api.ts |
Bot API timeouts/retries and HTML-aware splitting |
../state.ts |
atomic small JSON state files (shared with Feishu) |
Telegram turn replay is at-least-once. A crash can re-run side-effecting tools, and a narrow pre-ACK window can run a delivery twice. Exactly-once execution needs a different backend/resume model.
Slack
Slack is a first-party HTTP Events API sibling under src/channels/slack/. It keeps the neutral
Agent.invoke boundary and reuses shared turn-queue, generic turn-store, generic context-buffer,
the invoke-turn kit (busy retry + manifest wording), state, seen, and the
shared turn-view reducer + preview policies (preview-kit). Platform-specific modules own signature verification/event acceptance, message subtype policy,
managed roots/context, private-file resolution, Slack Web API transport, and dual native-stream /
rate-limited edited-message rendering.
The request boundary verifies Slack’s v0 HMAC over the capped raw body and a five-minute timestamp,
then persists a turn/context/root before returning 200. Logical dedup uses (team, channel, ts) because
app_mention and message.* subscriptions may overlap; event_id alone does not identify that shared
message. context group mode subscribes to channel/private-channel/MPIM message streams, admits bare
human replies only in durably owned roots, and folds other discussion with the same peek→completed→commit
invariant as Telegram/Feishu. Direct and group sessions default to independent platform threads, with
separate continuous compatibility options. As in Feishu/Lark, only a top-level group summon creates an
owned root; an explicit summon inside an existing human thread does not adopt it. mentions keeps the
least-privilege explicit-summon surface.
File events persist IDs only. Dequeue-time files.info resolves current metadata; authenticated downloads
are host-restricted, timeout/cap guarded, and translated to vision images or absolute local paths. Primary
files fail visibly; buffered files degrade individually. Outbound file delivery uses Slack’s external
upload three-step protocol and remains at-least-once across an ambiguous completion response.
Newly onboarded apps use Slack’s agent_view, assistant:write, token rotation, suggested prompts, Agent
status/title, and chat.startStream → chat.appendStream → chat.stopStream. Standard Markdown text events append to
the stream; engine-neutral tool lifecycle events become dense task_update chunks. Raw model thinking and
generic tool arguments stay private. The compatibility renderer retains one edited message with a strict
three-second mutation interval; explicit continuous/custom top-level routes select it because native
streams require a parent user message. HTTP Events API remains the production transport; Socket Mode is a
separate future boundary rather than entering ChannelModule indirectly.
add slack owns a single-workspace internal-app control plane outside ChannelModule: a temporary
unguessable challenge/OAuth responder, mode-specific App Manifest creation, OAuth-v2 code exchange, and
irreversible-boundary recovery state. Runtime rotating bot credentials + Signing Secret go to .env and rotate into owner-only durable channel
state; the more powerful user/workspace App Configuration refresh token remains owner-local and never
enters deployment secrets. dev --tunnel and deploy --run rotate it locally and update the Request URL through
apps.manifest.update; missing onboarding state remains a truthful manual registration outcome. This is
not Marketplace/multi-workspace installation storage.
Feishu (canonical) / Lark (compatibility)
Feishu is the second stateful chat-channel reference, shaped as a sibling of Telegram. Its canonical
implementation lives in src/channels/feishu/: feishu.ts wiring, parse.ts pure policy helpers,
model.ts / normalize.ts content decoding + message-scoped resource normalization,
invoke-turn.ts IO assembly, preview.ts delivery,
owned-threads.ts durable managed-root routing, shared ../seen.ts bounded delivery dedup,
feishu-api.ts transport/token pipeline, crypto.ts security math, card.ts builders, and registration
automation. Shared mechanisms (turn-queue / generic turn-store / generic context-buffer /
invoke-turn-kit / state / wait-health) remain one level up.
Feishu is the design center; Lark is a compatibility profile. The clouds share event/card/crypto
wire formats, but Lark international trails Feishu in app creation and application-config APIs.
src/channels/lark/lark.ts is therefore a thin branded adapter over the Feishu engine, while
src/channels/lark/onboard.ts owns Lark’s degraded guided/manual onboarding. The explicit profiles in
src/channels/feishu/cloud.ts record those capability differences. A kind still owns its channel
identity, env, state, logs, and onboarding: feishuChannel returns POST /feishu, while
feishuWebSocketChannel returns a long-connection module; the Lark factories mirror those boundaries
without becoming the core. Both share channels/<kind>/ state and the same event engine. One workspace
can run both clouds. Outbound APIs and webhook protocol handling remain fetch-based; WebSocket ingress is
isolated behind the official @larksuiteoapi/node-sdk because its protobuf connection protocol is not
a stable hand-authored surface. What is platform-different:
- The live preview is a streaming CARD, not an edited text message. The platform caps text edits at
20 per message and sends at 5 QPS per chat; cardkit streaming (50 QPS per app / 10 per card, strictly
increasing
sequence) is its designed AI-output channel. A queued turn mounts that same card early with a reply-quoted⏳ Queuedstate; execution takes the entity over in place and the same card settles into the final Markdown answer, so there is no recall tombstone or ambiguous second reply. Per-session execution remains FIFO; quotes keep independently mounted queue cards attributable. Degrade tiers: card fails → static text placeholder; streaming closed mid-turn → frozen preview, the settle still lands. - Verification is modal and fail-closed. Encrypt Key set: ordinary events require a signature over
the raw body → AES decrypt, and plaintext is refused. Feishu explicitly excludes Request URL
verification from event signatures, so its encrypted
url_verificationchallenge takes the narrow decrypt → exact-type → constant-time Token path. Without an Encrypt Key, events use the same constant-time verification-token match in plaintext. - Turn identity and delivery dedup use
message_id; recovery order is an explicitseq. Feishu ids carry no arrival order, unlike Telegram’s numericupdate_id, while Feishu/Lark document duplicate pushes even after a successful ACK and recommend idempotency onmessage_id. A bounded persistedseen.tsring therefore filters message deliveries that already produced a durable turn intent or buffered-context entry. It is post-persist, best-effort insurance rather than exactly-once execution: a crash between the state and ring writes, a failed ring write, or an id beyond the cap retains L1’s at-least-once tail. The generic turn store still owns unfinished-run recovery and its poison ceiling. - Session partitioning is policy, not transport inference. P2p and groups default to threaded root
sessions: every top-level DM or summoned group message owns a new
<kind>:message_idsession, creates its platform thread withreply_in_thread, and maps continuations back through<kind>:root_id. The kind prefix isolates Feishu/Lark while keeping pi’s provider-facing cache key below its 64-character ceiling. One root remains FIFO while different roots run concurrently.directMessageSession: "continuous"andgroupMessageSession: "continuous"are explicit compatibility opt-outs; the latter restores legacychat_id/chat_id:thread_idgroup sessions. Thread continuations do not rehydrateparent_id; their session history is authoritative. A top-level quoted reply still loads its parent but owns a new root session. Group roots are indexed pre-ACK inowned-threads.json: withim:message.group_msg, bare user messages in those roots become normal required turns with the same queue, streaming-card, error, and delivery behavior as an explicit @mention. An explicit mention of only other people is targeted discussion instead and enters the context buffer. - Group visibility is scope-gated and chosen during onboarding.
Context-aware groups(recommended and initially selected) requests the sensitiveim:message.group_msgscope;Mention-onlyis the least-privilege alternative. The CLI states that the former delivers all group messages, adds it to the app draft through application-v7 config when supported, opens tenant-admin approval, and reports the granted capability again at serving startup. Explicit @bot turns always invoke; bare human messages invoke only inchat_id + root_idroots from the durable ownership index. Other human discussion is persisted inbuffers.json, bucketed by main chat or thread root, and folded into that place’s next answered turn. The Telegram consume invariant carries over: peek at dequeue, commit only oncompleted, and retain failures plus messages arriving in-flight. Non-usersenders are dropped. Summon matches thementionsarray by the bot’s open_id (fail-closed until resolved). A reply summon carries onlyparent_id— the referent’s content and attachments are fetched as primary input; buffered attachments are background input and degrade per resource. - Ingress is an onboarding-time app choice.
add feishu|larkasks for WebSocket or webhook and writes the corresponding transport-specific factory into the channel module. WebSocket needs only App ID/Secret, skips token capture, tunnel, Request URL registration, and platform crypto; the official SDK authenticates the outbound connection, reconnects it, and converts handler throws into 500 ACK frames (preserving platform re-push after a failed pre-ACK state write). Webhook retains the application-v7 PATCH/challenge flow, Verification Token, optional Encrypt Key, and Lark’s explicit config-route-404 manual fallback. Subscription mode is app-level and mutually exclusive: changing the source factory alone does not migrate the app; the console mode and published version must move with it. - A WebSocket adapter is a long-connection channel and therefore always-on. Fly generates
min_machines_running=1, Railway forbids App Sleeping, webhook registration is skipped, and only App ID/Secret travel as channel secrets. Multiple connections for one app are cluster/load-balanced rather than broadcast. Event callbacks must still finish within three seconds, so the shared acceptance boundary persists and enqueues only; the Agent turn remains fire-and-forget.
8. Schedules and self-scheduling
Static schedules are schedules/<name>.ts files exporting { cron, tz?, prompt }. The scheduler:
- derives the stable session
schedule:<name>; - claims a slot before invoking;
- catches up one overdue occurrence after downtime, not every missed slot;
- records each run in
<stateRoot>/schedule/runs.jsonl; - leaves delivery to agent tools.
With selfSchedule: true, the serving path mounts wake/unwake. Wake-ups are persisted, bounded by
minimum delay/frequency and per-session count, and fired back into the originating session. A one-shot
wake that hits session_busy is deferred because the turn never started; other failures are not replayed
because tools may already have produced side effects.
Schedules need one continuously running process. Deploy preflight prevents scale-to-zero settings that would silently miss clock events.
9. State and deployment
FASTAGENT_STATE_DIR selects the one machine-state root:
<stateRoot>/
├── auth.json
├── sessions/
├── channels/telegram/
├── channels/slack/
├── channels/feishu/
└── schedule/The shipped file-backed implementations are single-process. Multiple instances require shared session, lease, credential, and channel-state backends; sharing one local state directory between processes is unsupported.
fastagent deploy docker|fly|railway generates a Dockerfile, target config, persistent-volume wiring,
required secret names, and a runbook. Docker adds a user-owned fastagent.compose.yml with one app
service; --tunnel can add a separate ephemeral cloudflared service, while durable ingress remains
operator-owned. --run alone causes Docker/host side effects; for a tunnel topology it also reads the
Quick Tunnel URL and registers webhooks. The agentDir repo-as-workspace recipe remains experimental;
--run stays gated for every target pending explicit end-to-end validation of that layout.
10. Current boundaries
The following are explicit limits, not implied capabilities:
- pi is the reference implementation; additional engine bindings can implement the same Agent contract;
ExecutionEnvalone is not a complete sandbox for directory agents;- GitHub post-ACK work has no replay; Telegram, Slack, and Feishu/Lark replay is at-least-once;
- file-backed state is single-process;
- the repo-as-workspace deploy path is experimental;
- observability is logs/traces, without an OpenTelemetry exporter.
Keep new implementations behind the existing contract rather than adding speculative concepts to it.

