Skip to main content

Auto-reply pipeline

The auto-reply pipeline (src/auto-reply/) turns one inbound message into zero or more outbound replies. Channel adapters hand a finalized message context to the dispatcher; the dispatcher resolves routing and commands, runs the agent turn, and delivers replies through the channel. Plugins observe and mutate the path via the hooks listed at the bottom.

Pipeline stages

  • dispatchInboundMessage (src/auto-reply/dispatch.ts) wraps the run in withReplyDispatcher, which guarantees dispatcher reservations are released on every exit path — errors included.
  • Slash commands short-circuit the model run when handled; unhandled text flows to the agent.
  • Delivery is policy-gated: session.sendPolicy rules (allow/deny by channel, chat type, key prefix) can block outbound sends, and the owner can override per session at runtime with /send on|off|inherit.

The GetReplyOptions contract

getReplyFromConfig(ctx, opts, cfg) accepts GetReplyOptions (defined in src/auto-reply/types.ts; channel adapters inside the repo import it there — it is not re-exported through the plugin SDK, though ReplyPayload is). The fields a channel adapter or embedder most commonly sets:
Callback seams (all optional): onReplyStart, onPartialReply, onReasoningStream / onReasoningEnd, onAssistantMessageStart, onBlockReply(payload, { abortSignal?, timeoutMs? }), onToolResult, onToolStart, onModelSelected({ provider, model, thinkLevel }), onAgentRunStart(runId), onTypingController, onTypingCleanup. Correlation: channelCorrelation carries a privacy-safe channel/run correlation envelope for diagnostics and run storage — prefer it over raw channel ids. Heartbeat runs pass heartbeatModelOverride and heartbeatIdentity (resolved per-agent config).

Bootstrap context and cache stability

Two options control what a system-triggered run sees at startup:
  • bootstrapContextMode"full" (default) injects the standard workspace bootstrap files; "lightweight" keeps only HEARTBEAT.md, used by heartbeat.lightContext runs to stay cheap.
  • bootstrapContextRunKind — selects the run-kind flavour of the bootstrap context: "default", "heartbeat", or "cron".
Shared-session heartbeat runs pass bootstrapContextRunKind: “default” so the system prompt stays byte-identical to user turns — this preserves the provider prompt-cache prefix. Heartbeat instructions are delivered in the trigger message instead of the prompt. Only isolated heartbeat sessions use the heartbeat run kind.

Run kinds and session-run records

Every turn is recorded as a session run (src/config/sessions/session-run-types.ts):
  • Kind: "chat" | "subagent" | "cron" | "heartbeat" | "nudge" | "acp" | "work" | "unknown".
  • Status: "pending" | "running" | "completed" | "failed" | "interrupted" | "needs_recovery_decision" | "recovering" | "cancelled" | "abandoned".
  • Recovery mode: "off" | "decide" | "auto"; capturedEnd is true only when the LLM stream emitted a genuine end-of-turn terminal event.
Transcript entries record triggerSource ("user" | "heartbeat" | "cron" | "subagent" | "acp" | "system") and isAutomated (true when the trigger is not user). Runs sharing a sessionKey can be wrapped in one trace root (sessionSpans). An outer agent-turn claim can own the row — pass suppressSessionRunRecording: true for nested turns that must not double-record.

Typing policy

typingPolicy decides whether a run class shows typing indicators; session.typingMode ("never" | "instant" | "thinking" | "message") decides how they render, and session.typingIntervalSeconds the refresh cadence. suppressTyping: true overrides everything for system/internal/cross-channel routes.

Reply payload

Handlers and callbacks receive a ReplyPayload: { text?, mediaUrl?, mediaUrls?, replyToId?, replyToTag?, replyToCurrent?, audioAsVoice?, isError?, isReasoning?, channelData? }. isReasoning payloads must be suppressed by channels without a dedicated reasoning lane (WhatsApp, web). channelData carries per-channel envelope data owned by the adapter. Outbound text is then chunked per channel policy — textChunkLimit and chunkMode ("length" default, "newline", "paragraph"). See Chunk delivery.

Where plugins plug in

  • message_received / message_sending / message_sent — observe and gate the inbound/outbound edges
  • before_model_resolve / before_prompt_build / before_context_send / transform_llm_input / transform_llm_output — shape the model turn
  • onBlockReply-equivalent surfaces for channels; blockStreamingDefault for block replies
  • storage.afterAppend — observe every persisted entry
See the plugin hooks reference for the full contract.