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
There is ONE agent shape and ONE marker. The shape:
<agent dir>/ # any name — the config below is what makes it an agent
├── persona.md # optional identity
├── AGENTS.md # optional project context
├── skills/
├── tools/
├── channels/
├── schedules/
├── fastagent.config.mjs # THE marker
├── models.json # optional: custom model endpoints (pi's schema, definition-LOCAL so it
│ # travels into the image — pi's machine-global ~/.pi one stays unread)
├── .gitignore # scaffolded ONCE by init, yours after: node_modules, .state, a stray .env
├── .secrets/ # secrets: .env + auth.json; only tracked .env.example + .gitignore travel
└── .state/ # mutable machine state: sessions, channel state, schedule stateThe other noun is the WORKSPACE — what the agent works ON: its cwd, its coding tools’ root, deploy’s
build context, and whose AGENTS.md ancestors are ② context.
The workspace is the directory you point fastagent at. That is the whole rule, and it means the same tree answers two ways depending on where you aim it:
repo/ # `fastagent dev` here → agent = repo/agent, workspace = repo
├── AGENTS.md
├── src/
└── agent/ # `fastagent dev` here → agent = repo/agent, workspace = repo/agent
├── persona.md skills/ tools/ channels/ schedules/
├── fastagent.config.mjs
└── .secrets/ .state/Neither reading is wrong. Point at the project and its agent serves with the project as its workspace
(what init sets up, and the common case). Point at the agent directory — all a deployed box may have
been shipped — and it works on itself; a rule insisting the workspace is always the parent would hand
that container /. The same holds when the agent directory IS the project (init --flat: a standalone
agent repo, a monorepo package), where the agent’s read/write/bash tools operate on its own
definition. That is not a separate placement mode; it is the one rule with the two directories equal.
dir |
Result |
|---|---|
holds a fastagent.config.* |
{ agentDir: dir, workspace: dir } |
| exactly one directory inside it holds one | { agentDir: <that dir>, workspace: dir } |
| several do | FASTAGENT_AGENT names one, else the one named fastagent — else throws, naming them |
| none | throws: not a fastagent agent, with the exit that fits the position |
- The marker is the config, at every position — and it is a DECLARATION, not configuration. Nothing
in an agent directory is logically required to serve a turn (the model can come from
--model, the tools default from pi, the loop is fastagent); what a directory must do is SAY it is an agent, because the alternative is guessing.export default {}is a signature. So the marker has to be the one artifact present in EVERY agent and absent from every non-agent:persona.md,skills/,tools/,channels/andschedules/are each optional by design, and generic enough that scanning for them would read half the world’s repositories as agents. Only the config qualifies — the same jobpackage.json,Cargo.toml,go.modandpyproject.tomldo for their tools, none of which sniff for evidence or make the manifest optional. An agent directory needs no reserved name either (--agent-dircalls it anything), and a directory holding nothing but a config IS a complete agent. - The scan is ONE level. Deeper is that directory’s own workspace, not this one’s agent: for
repo/packages/reviewer/you point at the package (or run from inside it), and it works on itself. - Resolution never walks UP (an agent must not be claimed from arbitrarily deep inside it), but the
REFUSAL reads the path so each dead end gets its own exit: inside an agent →
cdto it; on a directory holding several → point at one. - The cost of aiming being load-bearing is that
cd agent && fastagent devnarrows the workspace to the agent’s own directory. Three things carry it:dev/start/infoprintagent:andworkspace:on every run; the explicit form (fastagent dev ..) is always exact; and when the parent carries anAGENTS.mdor a.git, the report adds ahint:line pointing at it. A hint may use that heuristic precisely because a RULE may not.
init mirrors the same discipline — it either creates, or refuses with the reason. Its one placement
duty follows from the lookup: the target must be an agent the lookup would return, so init refuses
when dir already resolves over something else (a config AT dir beats anything inside it; a second
sibling agent would make dir name neither). Beyond that, a SUBDIRECTORY target must be empty (content
there is an unfinished agent or something unrelated, and landing persona.md beside it would be a silent
mix), while --agent-dir . is a directory being adopted — content is expected, so every existing file is
KEPT (reported, never overwritten, never verified).
The two machinery dirs map onto deploy lifecycles: .secrets/ values travel through the host’s secret
store (never an image; only the tracked .env.example + .gitignore scaffolds travel), .state/ through
a volume (FASTAGENT_SECRETS_DIR/FASTAGENT_STATE_DIR point both at it in a container).
Git is the author’s, not fastagent’s — with one stated exception. init scaffolds two ignore files:
the agent’s own (node_modules, .state, a stray .env) and .secrets/.gitignore (* minus the
template). No command reads, verifies or rewrites an ignore file, ever. The exception is narrow and
one-directional: the directory fastagent writes secrets into carries its own .gitignore — so
add <channel>, which mints an unrecoverable app secret, writes that file (wx, never over an existing
one) when the DEFAULT <agentDir>/.secrets has none — the reachable case being a hand-made agent. The
accepted cost: someone who deleted that file to track secrets deliberately gets it back once. The risk
is not symmetric — that is an annoyance, the other way is a published credential. The two ignore files
are split because the root one is the file an author has reason to edit: git’s nested-ignore precedence
keeps the credentials protected whatever happens to it.
The exception stops at fastagent’s OWN directory. A secrets dir named by FASTAGENT_SECRETS_DIR belongs
to the operator, and the template is * plus two negations — dropping it there would hide that
directory’s other contents from their git add, a bigger harm than the one it prevents and inflicted on
a path they chose deliberately. add <channel> states the fact instead and lets them own it.
What was deleted, then, is fastagent MANAGING ignore files: unconditionally, from
several commands, behind a decision procedure (“is this path under a directory we control?”) that
approximated git’s own semantics with containment comparisons against a different anchor per command. It
produced four reversals of the same predicate in review, and could ABORT login over an ignore file the
author had edited. The question it was approximating — “will git see this?” — has exactly one authority,
and users configure their own ignore rules. What remains is one write, from one command, at the moment a
credential is created, and never over an existing file.
(“Embedded” in fastagent’s docs means one thing only: using fastagent as a LIBRARY inside your app — see docs/embedding.md.)
Known boundary (accepted, documented): the workspace is agentDir itself or its immediate parent —
never further, because the lookup only ever finds an agent at the directory you named or one level
inside it. Reaching an agent two levels down means pointing at the level above it.
Several agents on ONE workspace is a supported shape, not a collision: an engineer’s, a PM’s and a
content owner’s agent can each drive the same repository, all with that repository as their workspace.
FASTAGENT_AGENT selects between them, and the directory named fastagent (the init default) breaks
the tie when nothing else does.
Two properties of that, both choices:
- The env selects, a file does not. Selection is per-PERSON — that is the whole scenario — and a
committed workspace file is shared by construction, so it cannot express “mine”. The per-repo,
per-person file this needs already exists and is not ours to invent:
.envrc(export FASTAGENT_AGENT=pm), committed for a shared default or ignored for a personal one. A workspace-level REGISTRY was considered and rejected for a second reason too: the one-level scan already answers “which agents are here”, so a list could only drift from it — and it would centralize what the scan decentralizes (adding an agent means creating a directory, not editing a file three teams share). - It ASSERTS, at any count. A directory holding no agent by that name resolves to nothing, even when exactly one agent is sitting there: serving a DIFFERENT agent than the one asked for is the silent wrong-target refused everywhere else here, and a rule that changed meaning with the sibling count would be worse than the cost it avoids. Stated cost: a value exported in a shell PROFILE refuses in every unrelated directory it travels into — scope it per-repo, which the refusal says.
deploybakes it. The container re-resolves placement at/app, and a workspace holding several agents ships all of them (the build context is the whole tree), so the generated Dockerfile pinsENV FASTAGENT_AGENT=<name>. Without it the image would pick by its own rules rather than by the deploy — the artifact depending on the builder’s environment, which is what it must never do.- The default NAME breaking the tie is the one place a directory name carries weight, and it is
deliberately not an identity rule: the config alone says what IS an agent, the name only decides which
already-identified one answers. It buys that adding a second agent to a working
<workspace>/fastagent/setup does not break the command everyone already types.
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 the agent dir and the workspace ancestor walk |
| ③ skills listing | Pi appends definition-local skills when read is active |
| ④ runtime context | Pi appends cwd, without a date line that would invalidate the provider prefix cache daily |
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
prompt body without directory identity or project-context assembly. Pi appends skills and cwd on both paths.
3. Assembly ladder
The pi reference implementation has three reusable rungs:
| Rung | Function | Responsibility |
|---|---|---|
| L0 | createPiAgentFromSession |
Adapt a pi AgentSession 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 |
createPiAgentFromDir sits above L2. It resolves the placement (resolvePlacement), config,
model, auth, tools, sessions, and machinery paths. dev, start, invoke, and fire share this
assembly rather than carrying parallel implementations.
Each invocation binds a fresh AgentSession to its record and disposes it after the turn. Conversation
continuity comes from PiSessionRecordStore, not a resident session. That is L0’s choice on the axis a
deployment owns rather than the architecture: per-invoke state (SPEC MUST 6 — what AgentCore and every
scaled channel host require), swappable at this rung alone;
conformance-levels.md states what each posture owes. Reopening is faithful to the whole
record, not just the messages: pi does not read active-tool changes back (its own session is resident,
so it never needs to), which is why piAgentSessionFactory resolves the active-tool set itself: 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:
a session’s own observe/act/update calls beside invoke, plus the deployment’s session list — never
a second way to start work, and never resident process state as the source of continuity.
4. Event translation and terminal discipline
Pi’s AgentSession exposes a subscription for streaming events and a prompt() that resolves without
a value — a turn’s outcome is the assistant message the stream ended on, never an index into session
state (compaction and overflow recovery both rewrite that array mid-turn).
src/engines/pi/invoke-session.ts combines the two into one async iterable, over the lease/terminal/
queue parts in turn-kit.ts:
- acquire the per-session lease;
- publish
run_startedwith the run’s controls — BEFORE binding, so a dispatch that races the build queues on it rather than finding no run; - open/create the record and bind a session to it;
- subscribe, translating pi events ONCE into the rich
SessionEventvocabulary (the SPEC stream is its projection); - run the prompt;
- emit exactly one
completedorfailedterminal, then exactly onerun_settled; - unsubscribe, dispose, 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 session. Cleanup anomalies are logged but
cannot turn an already-terminal stream into a throw.
pi retries a failed assistant request itself. That is free resilience while the turn is silent and corruption once it is not — SPEC deltas are append-only, so a second attempt would concatenate its answer onto the first one’s half-sentence. The L0 refuses the retry exactly there: once answer text has been streamed, never on tool events (refusing on those would push the retry out to the caller, who can only re-run the whole prompt and execute the tool a second time).
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:
- all pi coding tools:
read/grep/find/ls/bash/edit/write; config.tools;- discovered
tools/*.ts|js|mjs.
The coding set is fixed for directory agents. Isolation belongs around the whole agent process, where
it also covers authored tools and channel code; a built-in allowlist would not. Conditional built-ins
(search_tools for deferred tools, wake for self-scheduling) keep their own policies. 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 the session it
binds for the turn; chat adapts its resident one; both go through the same adapter onto the
FastAgent-owned read-only port (getSessionId, getHeader, getBranch) — and getSessionId answers
the CALLER’s id, not pi’s encoded record name. Sessionless direct execution provides cwd but no
manager. Native Pi tools receive the same workspace cwd and caller session id; their thinkingLevel
getter reads the bound AgentSession, keeping shell effort consistent with the invoking session during
concurrent runs.
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 governs definition loading, not the tools. The coding tools are
pi-coding-agent’s, using the session’s workspace, and they reach node:fs directly. For a
while they were pi-agent-core’s look-alikes, which take the env as the turn’s tool context, so that
env would be the single seam a sandbox adapter implements. That was given up deliberately: the seam
never closed anything by itself — author-written tools/ import whatever they like — while it cost a
167-line parity suite and a hand-built image pipeline for a read that ships none. All seven tools now
come from coding-agent and bypass ExecutionEnv together.
env is therefore NOT a sandbox, and the gaps are specific: the default tools bypass it, fastagent’s
OWN tools (tools/) are author code that can import anything, loadProjectContextFiles reads ②
context through node fs directly, and deploy/channel machinery runs outside it entirely. A sandbox
adapter constrains the process it runs in;
env alone narrows the blast radius rather than closing it.
6. Sessions and concurrency
The reference stores are:
piInMemorySessionRecordStore()for embedding/tests;piSessionRecordStore({ 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 agent 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.
mountAgentService adds GET /health, starts long connections and schedules, and owns their shutdown.
A long-connection channel counts as declared, so the fallback /invoke does not appear. Built-in health
returns 503 until every long connection is ready and again if one closes unexpectedly. The CLI binds
the service’s handler through channels/serve.ts and exits on unexpected channel closure. Its
SIGINT/SIGTERM handler closes the service and listener, force-closes active HTTP streams, and bounds
shutdown time; it does not drain Agent turns.
channels/sse.ts owns the Fetch-only response lifecycle shared by HTTP invoke and session observation:
eager subscription, heartbeat, serialization and iterator cleanup. The callers own their event shapes.
Synchronous subscription errors reach the HTTP error boundary before a response is created; errors
during body streaming close the source and heartbeat and propagate through the response body.
The remote invoke client stops at the first terminal event; malformed control envelopes fail visibly.
GitHub
The GitHub adapter verifies the HMAC over the capped raw body, maps a verified delivery through the
agent’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 ../kit/invoke-turn-kit.ts) |
../kit/turn-runner.ts |
the durable-turn lifecycle over the queue + store + buffer (shared with Slack and Feishu); ../kit/turn-queue.ts is its per-session FIFO |
turn-store.ts |
telegram’s record + ordering over the shared generic ../kit/turn-store.ts (pre-ACK persisted turn intent, crash replay) |
context-buffer.ts |
telegram’s entry shape + attachment selection over the shared generic ../kit/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 |
../kit/state.ts |
atomic small JSON state files (shared with Slack and 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,
thread participation/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 the turn intent and any buffered context 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. Sessions follow the place, not the ask: an answer goes in a
thread on the ask and that thread IS the session, so there are no session modes to select. context
group mode subscribes to channel/private-channel/MPIM message streams, admits a bare human reply where
the participation rule allows it (the agent has answered there and no second human has been heard —
see participant-model.md §3, and the Feishu bullets above for the shared store), and folds other
discussion with the same peek→completed→commit invariant as Telegram/Feishu. Answering an explicit
summon inside an existing human thread is exactly what makes the agent a participant of 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, suggested prompts, Agent
status/title, and chat.startStream → chat.appendStream → chat.stopStream. Standard Markdown text events append to
the stream; each engine-neutral tool start appends a compact factual Markdown trace, and a failed tool end
appends one line naming the call. Raw model thinking and tool output stay private — reading output would
mean guessing the engine’s result shape. An append-only stream is also the one renderer whose persisted
message keeps the process beside the answer; the others settle into the answer alone. The compatibility renderer retains one edited message with a strict
three-second mutation interval; a custom route reaching a top-level target selects it (either way of
getting there is listed on the rendering option in slack.ts) 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. The long-lived bot token + Signing Secret go to .env (bot-token
rotation is left off: it is irreversible and would ship the refresh token and client secret beside the
access token); 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,
shared ../kit/thread-participants.ts thread-participation cache, ../kit/seen.ts bounded delivery dedup,
feishu-api.ts transport/token pipeline, crypto.ts security math, card.ts builders, and registration
automation. shared-api.ts gives mounted channels and proactive send tools one transport per cloud and
state root, so credentials, gateways, token caching, retries and text splitting have one implementation. Shared mechanisms (turn-queue / generic turn-store / generic context-buffer /
invoke-turn-kit / state) live in channels/kit/, whose defining property is that its consumers
are only platform directories like this one — wait-health (deploy/) and registration (deploy/ and
cli/ as well as platform dirs) sit one level up because theirs are not.
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 agent
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 follows the place, not the ask. A chat is one session
(
<kind>:<chat_id>) and a thread is another (<kind>:<chat_id>:<thread_id>) — branded with the channel kind because session ids share ONE namespace across every channel in a deployment. The id becomes an escaped jsonl filename (piSessionId), so its real bound is the filesystem’s 255 bytes, which platform ids do not come close to. A room keeps one memory that everyone in it shares and a side conversation keeps its own. Keyed bythread_id, neverroot_id: the platform’sroot_idtracks the reply chain and can differ between messages of one thread, which would split a side conversation across sessions (and across context-buffer buckets). One place stays FIFO while different places run concurrently — the concurrency unit is the place because the causal unit is. The rules, and why they are derived rather than configured, are in participant-model.md; there is deliberately no session-mode option. - Speaking is gated by who is in the place, listening is not. Direct messages always answer;
a group’s main timeline requires an @mention; inside a thread the agent answers bare messages only
while it takes part and has not heard a second human. Everything else it can see is buffered as context
(
im:message.group_msgis what buys the hearing). An explicit mention of only other people is discussion, never an ask. A message’sparent_idreferent is ALWAYS loaded — a quote is the user pointing at something that may predate this session. (Skipping it inside a thread the agent had answered in was tried and removed: see participant-model.md §8.) An unreadable referent degrades to a marker in the prompt rather than failing the turn. - Thread participation is what the channel HEARD, not a claim about the thread’s membership. Two
decisions:
- Predicate. The agent speaks unprompted in a thread only where it has answered before and has
heard at most one human. Both facts come from the messages the channel observes; nothing is read
back from the platform. That is a deliberate weakening: a thread joined before this deployment (or
before a lost state file) reads as unheard and takes one mention to re-enter — the same bootstrap
every thread starts with, self-healing in one message and visible to the user. The alternative was
built and removed: a pre-ACK
listThreadSendersbought a membership claim its own 50-message page cap made incomplete anyway, at the price of a failure taxonomy, an ACK budget, request aborts, a completeness flag, and a duplicate-delivery join — where nearly every defect in the feature lived. Seesrc/channels/kit/thread-participants.tsand design/participant-model.md §3. - Storage.
thread-participants.jsonrecords, per thread, the humans heard (capped at two — the rule only asks whether a second one exists) and whether this agent has spoken. Observations only ever accumulate: no platform emits an event when someone stops taking part, and the error directions are asymmetric — over-counting makes the agent ask to be named, under-counting makes it speak into a crowd. Because nothing is fetched, acceptance stays synchronous inside the ACK window and the delivery dedup ring alone keeps a re-push idempotent.
- Predicate. The agent speaks unprompted in a thread only where it has answered before and has
heard at most one human. Both facts come from the messages the channel observes; nothing is read
back from the platform. That is a deliberate weakening: a thread joined before this deployment (or
before a lost state file) reads as unheard and takes one mention to re-enter — the same bootstrap
every thread starts with, self-healing in one message and visible to the user. The alternative was
built and removed: a pre-ACK
- 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. A mention arriving before the startupbot/v3/infosettles is kept as context rather than answered (fail-closed: without its own open_id the channel cannot tell a mention of itself from one of someone else) and is folded into the next answered turn in that place. Explicit @bot turns always invoke; bare human messages invoke only under the thread-participation rule above. Other human discussion is persisted inbuffers.json, bucketed by main chat or thread, 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, and the chain ABOVE the referent is resolved as context (oldest-first; one shared text budget; ancestors’ attachments join the buffered tier under its cap; a walk ending short of the root leaves a visible truncation line — participant-model.md §8); 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>/ # <agent dir>/.state (FASTAGENT_STATE_DIR overrides)
├── sessions/
├── channels/telegram/
├── channels/slack/
├── channels/feishu/
└── schedule/Credentials live separately, under <agent dir>/.secrets/ (FASTAGENT_SECRETS_DIR overrides):
a different deploy lifecycle — secrets ride the host’s secret store / the auth seed, state rides the
volume; a deployed box points both env knobs at its volume so a rotated OAuth credential persists.
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|agentcore 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. Deploy has ONE semantic — bake the
workspace as the image (WYSIWYG: what you see is what ships, git or not, clean or not). Every artifact
(Dockerfile, fly.toml, compose, railway.json) sits under ONE derived value, the agent prefix: the agent
directory’s name plus a slash when it sits inside the workspace, "" when the agent IS the workspace.
When they differ, the single write outside the agent is the workspace-root .dockerignore the host CLIs’
context packers require (kept if the workspace owns one — when they are the same directory that file is
the agent’s own artifact and is
refreshed like the rest; preflight then ASKS that file — through the ignore matcher, with dockerignore’s
root-anchoring applied — whether it would drop the agent dir or ship fastagent/.secrets/auth.json:
either gates --run, else warn). .git
ships by default: freshness (pull) and write-back (commit/push) are the AGENT’s runtime behavior, not
deploy machinery — the git binary is baked in exactly when the workspace ships a .git; a non-git
workspace adds it via config.deploy.apt.
AgentCore (AWS Bedrock AgentCore Runtime) differs from the resident-box hosts in kind: the platform
has no public URL (ingress is the SigV4 InvokeAgentRuntime API only) and no resident process (compute
is per-session microVMs, reclaimed when idle). The generated CloudFormation stack therefore carries a
forwarder Lambda (public Function URL → {method,path,headers,bodyB64} envelope → InvokeAgentRuntime)
fronting the webhooks, and EventBridge Scheduler rules delivering each cron slot. Inside the container,
FASTAGENT_AGENTCORE=1 makes start mount the adapter (channels/agentcore.ts): POST /invocations
unwraps the envelope — a webhook is reconstructed verbatim and dispatched to the SAME channel routes
(signature verification unchanged; the channel’s real HTTP response rides back inside a transport-200
reply so the forwarder re-emits it byte-exact), a schedule fire goes through fireScheduleOnce with the
slot as the idempotency key (EventBridge delivery is at-least-once), and an invoke streams back as SSE.
GET /ping reports HealthyBusy while background turns run (the shared turn-queue/task-tracker report
into channels/busy.ts) so an idle reclaim cannot kill a post-ACK turn — and it always carries
time_of_last_update (updated only on real status transitions). The field is documented as optional,
but measured platform behavior reads ONLY it: without the field, the idle timer counts from the last
InvokeAgentRuntime and reclaims mid-turn regardless of HealthyBusy (see the handler comment in
channels/agentcore.ts). All ingress traffic shares ONE
fixed runtime session — channel state is single-writer by design, and a stopped session’s id stays valid
until the runtime is deleted.
The two process boundaries remain two explicit log sources: Runtime application stdout/stderr and the
forwarder Lambda’s ingress log. fastagent logs agentcore derives the stack from the same workspace name,
discovers the Runtime endpoint log group from its RuntimeArn, and tails it; --source forwarder selects
the Lambda group. It applies no stream filter: AgentCore names streams YYYY/MM/DD/[runtime-logs]<session>,
so the marker is an infix after the UTC date path and a --log-stream-name-prefix match is always empty. It changes neither
log content nor FASTAGENT_LOG_LEVEL — it is discovery plus aws logs tail, not another logger.
State durability is an S3 snapshot, not the mount. The platform’s SessionStorage (/mnt/state) is
reset on every runtime VERSION UPDATE — i.e. on every deploy — and after 14 idle days, so it is a local
disk, not the source of truth (a real deployment proved this: the truncation point in a live chat matched
the deploy timestamp exactly). channels/agentcore-state.ts restores the state root from one gzipped
JSON object on the first ingress envelope and pushes a coalesced snapshot on the 0-in-flight edge
(busy.ts onIdle). The container holds NO AWS credentials (verified on a live box), so the forwarder
mints SigV4-presigned GET/PUT URLs and rides them on every envelope — keeping the container AWS-SDK-free
and credential-free. Failure policy is fail-visible: a snapshot that exists but cannot be restored 503s
the request (serving an empty agent would then overwrite the good copy with that emptiness), while a 404
is first boot. Channel construction is deferred to that same moment (start hands the adapter a LAZY
channel surface, resolved on the first trusted ingress — webhook, schedule fire, wake poke, or probe —
after ready()): channels load their state files and replay durable turn intent at construction, so
building them at boot — against the pre-restore mount — would cache emptiness (thread participation,
delivery dedup, pending turns) and then clobber the restored files with it. The outcome is cached
EITHER WAY, one activation per process: construction has side effects (healthy channels start and
replay before another module’s failure is reported) and no cleanup contract, so a retry could replay
the same recovered turn concurrently — the retry boundary is a fresh session. A construction failure
fails webhook/wake-poke requests (503) but not a schedule fire (cron does not consume channels; the
error is logged). Verification moved to deploy time — the boot-time failStartup this host cannot
have: deploy agentcore --run gates on a failed ingress-session stop (the probe must not “verify”
the previous image), then drives the forwarder’s reserved /__fastagent/probe path (answers on every
forwarder topology; schedule-only URLs refuse ordinary public traffic), which relays a trusted
probe envelope and passes back the runtime’s transport-200 structured verdict { ok, error? } —
transport-200 because the ordinary webhook relay folds a non-200 into an opaque 502, which would
strip exactly these diagnostics.
Credentials ride that snapshot, so the secrets dir is INSIDE the state root (/mnt/state/.secrets,
deploy/agentcore/plan.ts SECRETS_DIR) — the one place AgentCore departs from the sibling layout the
volume-backed hosts use (/data/.state + /data/.secrets). There the persistence boundary is the mount
point; here the mount is wiped every deploy and the boundary is packStateRoot(stateRoot), which copies
a single tree. A sibling secrets dir would therefore sit inside the mount but outside the snapshot. The
ordering is what makes this work: boot seeds auth.json absent-only from FASTAGENT_AUTH_SEED, the
first envelope’s restore overwrites it with the snapshot’s copy, and only then can a model call happen
(the auth store re-reads the file per request, so no restart is needed). The snapshot’s copy must win —
an OAuth refresh token is single-use, so the seed is the DEPLOY-TIME copy and the box’s rotated one is
the only valid credential; a deployment that keeps re-seeding loses model access once that token is
spent. The bucket is consequently credential storage (public access blocked + versioning, converged on
every deploy).
Only the INGRESS session is snapshotted: a direct-invoke session runs in its own storage, which the
platform wipes on a version update, so cross-deploy memory is a property of the ingress path and the
docs say so. --run sends a checkpoint envelope before stop-runtime-session — the stop cuts an
in-flight turn whose durable intent (written pre-ACK by every replaying channel) would otherwise sit
only on the mount the version update erases, which is what makes replay real rather than aspirational.
It protects a LIVE session; one already idle-reclaimed has nothing to lose, because its snapshot was
written when work settled, before the reclaim. The reply reports whether a snapshot was actually
written and --run prints that verbatim — a blanket “checkpointed” would be the only signal an
operator has about an interrupted turn, saying the same thing whether or not anything happened.
The bucket is created OUTSIDE the stack (like the ECR repo) so delete-stack cannot take the agent’s
memory with it; a durable MOUNT instead (EFS/S3 Files) requires VPC mode and therefore a NAT gateway for
model/channel egress, which would replace pay-per-use with a fixed ~$33/mo floor. The same bucket hosts
the forwarder’s deployment package, whose key is content-hashed (the presigning pushed it past
CloudFormation’s 4096-byte inline cap; a hashed key is also what makes CloudFormation notice new code).
A live session keeps its
old compute (and the OLD image) until reclaimed — so --run stops the ingress session after a
successful deploy, making the new image serve immediately (an in-flight turn is cut; channels with
replay re-run it). Self-scheduled wake-ups are EventBridge-backed: every wakeups-store mutation
notifies a sink (schedule/wake-alarm.ts) that POSTs the pending set to the forwarder’s reserved
path (shared secret), and the forwarder mirrors each into a self-deleting one-shot EventBridge
schedule that pokes it at the instant — waking the container, whose ordinary wake pump fires the due
entry (a recurring wake re-arms itself through the same store-save → sink loop). The forwarder
injects its own URL into every envelope, so nothing is circularly baked into the template. Structural
limits, gated/warned/noted at deploy time: long-connection channels cannot run (the connection IS the
ingress; nothing wakes a reclaimed session), and a wake set inside a direct-invoke session (its own
per-session storage, not the ingress session’s) has no alarm — it fires only while that session is
awake.
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 AgentCore target has no resident process: long-connection channels are unsupported there, and a wake-up set in a direct-invoke session (outside the ingress surface) fires only while that session’s compute is awake;
-
observability is logs/traces, without an OpenTelemetry exporter.
Keep new implementations behind the existing contract rather than adding speculative concepts to it.

