Multi-agent framework with pluggable modules, persistent state, streaming inference, and concurrent tool execution.
Agent Framework orchestrates one or more LLM-powered agents that interact with the world through modules (pluggable capability providers). It handles the full lifecycle: event processing, context compilation, inference (streaming or request/response), tool dispatch, and state persistence via Chronicle.
External events (Discord, API, MCPL servers, timers)
↓
ProcessQueue → Module.onProcess() → EventResponse
↓ ↓
Inference trigger Messages / state updates
↓
Context compilation (with injections from modules + MCPL hooks)
↓
Membrane (LLM abstraction) → YieldingStream
↓
Tool calls → Module.handleToolCall() → results → stream resumes
↓
Agent speech → Module.onAgentSpeech() → external delivery
import { AgentFramework, ApiModule, ApiServer } from '@connectome/agent-framework';
import { Membrane, AnthropicAdapter } from 'membrane';
const membrane = new Membrane({ adapter: new AnthropicAdapter() });
const framework = await AgentFramework.create({
storePath: './data/store',
membrane,
agents: [{
name: 'assistant',
model: 'claude-sonnet-4-20250514',
systemPrompt: 'You are a helpful assistant.',
}],
modules: [new ApiModule()],
});
framework.start();
const server = new ApiServer(framework, { port: 8765 });
await server.start();An agent wraps an LLM identity: model, system prompt, context strategy, and tool permissions. Multiple agents can coexist, each with independent context and inference state.
Agents can opt into the tool result guard through
agent_settings with {"action":"update","tool_result_guard":true}. The
setting persists across restarts; withheld results remain recoverable in
Chronicle's audit history.
{
name: 'researcher',
model: 'claude-opus-4-20250514',
systemPrompt: '...',
strategy: new AutobiographicalStrategy({ recentWindowTokens: 30000 }),
allowedTools: ['search--query', 'utils', 'files--archive'], // or 'all'
triggerSources: ['discord'], // or 'all'
maxTokens: 8192,
maxStreamTokens: 150_000, // input token budget before stream restart
}allowedTools is enforced at dispatch for registered agents. Omission or
'all' permits all tools; an explicit list matches exact names, including
module--utility names. Utilities aren't tools of their own: an agent's model
and its scripts are offered them only through utils, whose run checks the
utility's own name. An agent that should use a utility lists both utils and
module--utility. The name alone passes the dispatch check for any call by
that name, whether the host makes it with executeToolCall on the agent's
behalf or the model or a script makes it without being offered it. The slot
is hidden when the agent can use no registered utility. If utilities exist
but none are permitted, list returns a failed ToolResult with the registered
and permitted counts and migration advice, and a list containing utils
logs one warning per Agent instance after registration.
An empty registry still returns a successful empty list.
Trusted module calls are exempt from agent allowlists. Genuinely unidentified
public callers, including unknown legacy caller names, remain unrestricted.
Framework-created ephemeral and conversation identities fail closed while
unregistered. Hosts using createEphemeralAgent must register and run the
returned agent through runEphemeralToCompletion for tool authority.
Inference modes:
- Streaming (default in framework):
startStreamWithInjections()returns aYieldingStreamthat emits tokens, tool calls, and completion events. The framework drives the stream, dispatches tools, and resumes automatically. - Request/response:
runInferenceWithInjections()for simple complete-and-return usage with optionalAbortSignalfor cancellation.
State machine:
idle → inferring → streaming ⇄ waiting_for_tools → ready → streaming → ... → idle
↘ (abort) → idle
Modules are pluggable capability providers. They process events, expose tools, deliver speech, and optionally inject context before inference.
interface Module {
readonly name: string;
start(ctx: ModuleContext): Promise<void>;
stop(): Promise<void>;
getTools(): ToolDefinition[];
handleToolCall(call: ToolCall): Promise<ToolResult>;
onProcess(event: ProcessEvent, state: ProcessState): Promise<EventResponse>;
// Optional
onAgentSpeech?(agentName: string, content: ContentBlock[], context: SpeechContext): Promise<void>;
gatherContext?(agentName: string): Promise<ContextInjection[]>;
}An EventResponse can add/edit/remove messages, request inference, signal tool changes, and atomically update module state:
return {
addMessages: [{ participant: 'user', content: [{ type: 'text', text: '...' }] }],
requestInference: true,
stateUpdate: { lastProcessed: Date.now() },
};Built-in modules:
- DiscordModule - Discord bot integration (messages, reactions, threads, typing indicators)
- ApiModule - WebSocket API event processing
- WorkspaceModule - Mountable filesystem abstraction with Chronicle tree state, filesystem watching, and materialization
The framework runs an event loop over a ProcessQueue. Each event is processed by all modules, which return EventResponse objects. If any response requests inference, the framework compiles context and starts a stream.
Event types: ExternalMessageEvent, ToolCallEvent, ToolResultEvent, InferenceRequestEvent, McplPushEvent, McplChannelIncomingEvent, TimerFiredEvent, ApiMessageEvent, ModuleEvent, CustomEvent
Each agent has a ContextManager (from @connectome/context-manager) that maintains conversation history with optional compression strategies:
- PassthroughStrategy - No compression, raw message replay up to budget
- AutobiographicalStrategy - Chunks old messages and summarizes them into diary entries, preserving recent context uncompressed
Before inference, modules and MCPL hooks can inject additional context via gatherContext().
All state is persisted in a Chronicle store:
| State | Strategy | Description |
|---|---|---|
framework/state |
snapshot | Agent configs |
framework/inference-log |
append_log | Raw LLM requests/responses |
messages |
(context-manager) | Shared conversation log |
agents/{name}/context |
(context-manager) | Per-agent context log |
modules/{name}/state |
snapshot | Per-module persistent state |
Chronicle's branching support enables time-travel and what-if exploration across all state.
If an inference API rejects message history and the normal host must remain stopped, create a safe branch with the recovery CLI:
agent-framework-recover \
--store ./data/agent-store \
--agent cairn \
--message-id 123456789012345678 \
--branch recovery/cairn/poisoned-tailThe specified Discord message is the last message the agent will still see;
everything after it is left on the old branch. The command scans in bounded
windows, never compiles message content, activates
the new Chronicle branch, and writes only Discord {serverId, channelId, messageId} metadata to
<store>/recovery/discord-awareness-outbox.json. When the host and that
agent's discord-mcpl bot reconnect, each addressable discarded message is
marked with 💤. Delivery state is recorded per message: retryable failures
remain queued, permanent deleted/inaccessible-message failures remain in the
audit ledger without blocking later markers. Portal and other non-Discord
records are ignored.
When the safe point is an assistant/tool-side record rather than a Discord message, use its exact ContextManager message ID:
agent-framework-recover \
--store ./data/agent-store \
--agent cairn \
--context-id 15184Use --dry-run to inspect the proposed branch and message IDs without writing.
--message-id also accepts a Discord message link. The older --messages N
mode remains available when a count of internal context entries is genuinely
desired.
To branch at the current message while suppressing selected earlier messages only on the new branch:
agent-framework-recover \
--store ./data/agent-store \
--agent cairn \
--message-id <current-discord-message-id> \
--suppress <message-id-to-hide> \
--suppress <another-message-id>--suppress may be repeated or given a comma-separated list.
--suppress-range <first>..<last> suppresses the inclusive context interval
between two Discord messages, including intervening agent/tool entries. These
removals exist only on the recovery branch; the source branch remains intact.
Suppressed Discord messages are queued for the same 💤 awareness marker.
Selections that split a sharded body group or a tool-use/tool-result exchange
are rejected. The suppression plan is journaled before the branch switch; if
the recovery process is interrupted between interval removals, framework
startup resumes the remaining atomic intervals before connecting MCPL.
For old stores whose messages lack metadata.serverId, supply
--discord-server discord (or the configured Discord MCPL server id). The
normal agent host must be stopped while this command has the Chronicle store
open.
The marker sidecar is a retained operation ledger, not a delete-on-success
queue. Switching back to the source branch queues removal of the bot's marker;
returning to the recovery branch queues it again. Initial MCPL events remain
buffered until the ledger has been reconciled. Startup, reconnect, runtime
list-change, and online undo use one framework-global generation: every MCPL
data plane waits while the control planes remain live for registration and
marker service. A connection whose policy exchange is in flight, after a
connect or a reconnect, keeps both planes closed until the exchange settles,
since its traffic is admitted against the grant the exchange establishes.
Awareness marker calls also have a mandatory deadline that is
independent of requestTimeoutMs (including when that value is 0); configure
it with discordAwarenessDeadlineMs (default 10000ms, clamped to 50..60000ms).
Configure the online marker with discordAwarenessEmoji; the offline CLI
accepts --emoji.
For stdio MCPL children, the framework defaults DISCORD_SUPPRESSED_REACTIONS_BASELINE to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so the next child spawned gets the markers retained since the last spawn. When the awareness ledger exists but can't be read, a runtime connect reads it first and refuses before anything is registered or spawned (a startup connect, whose routing is staged beforehand, before anything spawns), and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with inheritEnv: true, takes precedence, and the framework logs that it replaces the default. A value with no tokens, as discord-mcpl's parser reads them, counts as unset, since the adapter would read it as suppression off. The parser splits on commas and keeps a token only when its normalizer leaves something of it, after stripping U+FE0F, trimming, and stripping one leading and one trailing colon: so a blank value, commas alone, :, :: or a lone U+FE0F (say from ${A},${B} or :${NAME}: with the variables empty) has no tokens. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off.
framework.getPlacedReactionBaseline() returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact.
Optional host-side implementation of the MCP Live protocol. External servers (game engines, dev tools, etc.) can:
- Push events into agent context
- Hook into inference lifecycle (beforeInference/afterInference)
- Initiate inference requests
- Publish and observe channels
- Provide tools (namespaced automatically)
{
mcplServers: [{
id: 'game-engine',
command: 'node',
args: ['./game-server.js'],
toolPrefix: 'game',
}],
}An MCPL tool reaches the model as <toolPrefix>--<tool>. toolPrefix
defaults to mcpl--<serverId>: server search with no toolPrefix offers
mcpl--search--query, and with toolPrefix: 'search' it offers
search--query.
No two servers' prefixes may meet, since their servers could then give one
tool name. Two prefixes meet when one is the other's followed by -, or
begins with the other's followed by --: server foo listing bar--echo and
server foo--bar listing echo would both give foo--bar--echo. create()
refuses such a pair, and so do a runtime connect and a restart (before it
disconnects anything), naming both servers and the rule. foo beside
foo-bar or foobar is fine. A server that lists one tool more than once has
its first listing offered to the model and the rest left out, and each tool
refresh that finds it raises an mcpl-tool-duplicate ops alert naming the
tools. Its webhook post goes out at most once per server in 15 minutes.
These settings take RFC-007 §6.2 patterns (* matches any run of characters)
over that full model-facing name, so they need the prefix:
toolClassOverrides(framework config), e.g.{ 'mcpl--search--*': ['web'] }toolLifecycle.observe.toolsandtoolLifecycle.inputs.tools(per server)
enabledTools / disabledTools (per server) are different: they take the
bare server-side name (query) with no prefix.
A pattern that matches no tool is reported once, as a console line and an
mcpl:tool-pattern-unmatched trace. When the pattern used the bare server id
(search--* under the default prefix), the report suggests the prefixed form.
A pattern that could name a server's tools is not judged until that server
has listed them, so late connects and reconnects don't cause early reports.
The same mcplServers list also takes servers that speak modern MCP
(revision 2026-07-28), reached through the official SDK client. The
configuration chooses the family, and nothing is probed:
| Configuration | Family | Transport |
|---|---|---|
url: 'ws://…' / 'wss://…' |
MCPL (MCP 2024-11-05) |
WebSocket |
url: 'http://…' / 'https://…' |
modern MCP | Streamable HTTP (token / accessProvider as bearer) |
command |
MCPL | stdio |
command + protocol: 'modern' |
modern MCP | stdio |
{
mcplServers: [
{ id: 'docs', url: 'https://mcp.example.com/mcp', accessProvider: getToken },
{ id: 'search', command: 'search-mcp', protocol: 'modern', reconnect: true },
],
}A configuration with both command and url needs transport to say which
one is used; without it, it is refused rather than silently ignoring one.
A modern server's tools get the same prefix, enabledTools/disabledTools,
class overrides and dispatch as an MCPL server's, and listMcplServers()
shows its family, protocolVersion and transport. Its config differs in
three ways:
- It has no MCPL surface (grant, push, channels, inference requests, feature
sets). A setting that asks for that surface can't apply, so it is a
configuration error:
allowHostCommands: true, anenabledCapabilitiespattern that matches a capability denied by default,channelSubscription: 'auto'or a non-empty list, a scope's non-emptywhitelist, atoolLifecycleobserveorinputsblock, and atoolLifecycle.maxInputBytesor anautofetchbound above its default. A setting that only takes something away, or repeats a default, is already satisfied, so it is accepted:enabledFeatureSetsand the rest ofenabledCapabilities, which are allow-lists,disabledFeatureSets,disabledCapabilities, a scope'sblacklist,channelSubscription: 'manual', atoolLifecycle.maxInputBytesor anautofetchbound at or below its default, andshouldTriggerInference. So a host that puts a deny-list on every server it deploys can deploy a modern one. requestTimeoutMsis one deadline per tool call and must be 1 to 2^31−1; 0 is refused, where MCPL reads it as no watchdog.- Over stdio it takes no credential:
tokenoraccessProvideron one is refused.
At the deadline, cancellation is requested for a leg still in flight;
between the rounds of an input_required continuation nothing is in flight
to cancel. Either way the outcome is reported as unknown (no-response),
and the call is never retried. The protocol has no cancel for a paused call,
so whatever the server holds for it, beyond the requestState it gave this
client, stays until the server lets it go. A call that ends for any other
reason after the server asked for another round, before its final answer, is
reported the same way, since the server may already have acted. An MCPL
server that refuses 2024-11-05 with -32022 fails with
McplProtocolVersionError, and is not retried. The error names the
revisions the server says it supports ("none listed" for an empty list), or its error text when it sends no list,
and when the list includes 2026-07-28 it says to configure the server with
protocol: 'modern' (stdio) or reach it over http(s).
Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the tool:failed trace get it. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it), credential-like path segments and query names, credential-shaped or credential-named query values, and a WebSocket url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole name=value, where no letter or digit adjoins it; the static token, and each token an access provider handed a dial, the newest sixteen, raw and url-encoded; and a stdio server's env values whose names have a part ending in a credential word, such as GITHUB_TOKEN, PGPASSWORD or SESSION_COOKIE, raw and url-encoded. A stdio server's command-line arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with restartMcplServer(id, newConfig). Secrets neither the configuration holds nor a dial sent remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent.
enabledFeatureSets and disabledFeatureSets select which of a server's
declared feature sets are enabled (.-segment wildcards such as chat.*;
disabledFeatureSets wins on overlap):
enabledFeatureSetsomitted: every declared set is a candidate.enabledFeatureSets: []: no set is enabled.- In both cases a set is enabled only if its declared
useslists recognized capabilities that the server's grant covers. A set that declares nousesstays disabled even whenenabledFeatureSetslists it. This is reported on the console and as anmcpl:feature-set-disabledtrace.
A server that refuses the host's policy is reported as an mcpl:policy-refused
trace. With fallback: 'close' the host closes the connection; with
'mcp-only' only plain MCP tools keep working.
The host advertises eventCoalescing (both lanes, deferred rendering,
channel-scoped pushes, a one-hour retry window). A server may add
coalesce: { key } to a push/event or a channels/incoming message so that a
later occurrence of the same subject replaces the earlier one while it is
still unread, and appends once a model has seen it; retract: true
withdraws unread content and appends the supplied deletion notice only when
some version was read (or history is unknown). initial: true on a create lets
the host know the subject's history is complete, so a create → edit → delete
that nobody read leaves no trace. deferred: true (push only) holds a batch of
notices outside context and asks the server for the content with push/render
when a turn is assembled.
Plain content stays in context and is delivered on arrival through the ordinary
channel/push path; the host only remembers where it landed. "Unread" means:
above the agent's consumed watermark (advanced at every compile of a model
request), on the current branch, not a sharded message, and not folded into a
summary by compression. On any doubt — restart, branch switch, eviction — the
host appends, as it does today. There is no per-subject cap: a busy channel's
unread backlog is simply its unread backlog. Receipts and subject history are
kept in the mcpl/coalescing state (retry window 1 h); audit lines are
mcpl:coalescing trace events.
Deprecated:
FrameworkConfig.conversations(ConversationRouter). Per-channel conversation routing sends each channel's traffic to its own fork agent, spawned from a template agent and closed after an idle TTL. It is being retired (#235). Its'mention'bind/trigger rule, the default for channels, readsmetadata.mentioned, which discord-mcpl does not set, so on Discord channels an @-mention never binds a fork or triggers a bound one. Don't adopt it in new hosts. Existing configurations still route exactly as before, and the framework logs one[deprecated]line when it is created withconversationsset. Removal is a follow-up.
- Start: Framework calls
agent.startStreamWithInjections()→YieldingStream - Drive: Framework iterates stream events (tokens, tool-calls, complete, error)
- Tool yield: Stream yields tool calls → framework dispatches concurrently → collects results
- Resume: All results in → stream resumes with tool results
- Budget restart: If input tokens exceed
maxStreamTokens, the stream is cancelled, context is recompressed, and a fresh stream starts - Complete: Final response saved to context, agent returns to idle
// Cancel in-flight inference for an agent
framework.abortInference('assistant', 'user requested stop');The non-streaming path uses AbortSignal forwarded to membrane.stream(), which returns an AbortedResponse with partial content. The streaming path cancels the YieldingStream directly.
A turn whose stream is cancelled this way, or by agent.cancelStream(), ends as a stop, whether the stream reports the cancel as aborted, as an error, or by just closing: one inference:aborted carrying the caller's reason (user when none was given), a [turn-interrupted] note in the agent's context, and never inference:exhausted, which is for failed inferences. A stream that membrane aborts on its own, at a deadline or on an error (reason timeout or error), is a failed inference. stop() and an operator's quiesce abandon end a turn as a stop too, with their own reasons, shutdown and quiesce_abandoned, and write no note. A stopped stream still writes its inference-log row, with success: false.
A caller's stop and an operator's quiesce abandon also reach a turn that has no live stream: one running its modules' gatherContext, its MCPL hooks or its compile, one waiting to retry a failed inference or compile (abortInference() and an operator's abandon end the wait, and a stop through Agent.cancelStream() waits for it to run out), and one whose tool-result guard is waiting to retry after a refusal, which holds only the round's finished stream. The turn ends at its next check, before it sends anything more, as a stop on a live stream ends it, and a turn whose compile fails after the stop ends at the failure, before a provider hold, the error policy's retry or inference:exhausted. A caller's stop leaves the note saying the turn was stopped before it sent anything or before it finished, and an operator's abandon leaves none. abortInference() returns true for such a turn. In the guard's wait, stop() ends the turn as a stop with reason shutdown, and an operator's abandon ends it as the abandon, even when a caller's stop came first; with both, it ends as stop()'s. stop() drops a turn in its hooks, its compile or a wait to retry before it sends anything, as before, and drops one whose compile fails while it runs, once a caller's stop has reached it, rather than ending it as that stop. While the failure of a turn's stream is reported (inference:failed), the agent no longer holds that stream, so abortInference() from a listener reports false. A failure to build a turn's request is reported while the turn's check is still ahead, so there abortInference() reports true, and the turn ends at that check. A stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, while a turn finishes its last delivery, while a budget restart or a non-streaming turn's tool results wait to start the turn's next call, and while a provider-capacity hold or a provider's stated wait has parked a turn until the hold ends.
WebSocket server for external clients (UIs, scripts, other agents).
const server = new ApiServer(framework, { port: 8765 });
await server.start();Commands: message.send, message.list, inference.request, inference.abort, branch.*, agent.*, module.*, store.*, inference.tail/inspect/search, events.*, host.quiesce/resume/status/maintenanceTick, host.providerWaits/releaseProviderWait
Also available as an MCP server via the agent-framework-mcp binary (npx -p @animalabs/agent-framework agent-framework-mcp without installing it). It is a client of this API's WebSocket server, at AGENT_FRAMEWORK_WS_URL (default ws://localhost:8765/ws), and offers a subset of these commands as tools; its tools/list names them.
Pause the inference thread and MCPL data planes while keeping the framework,
context managers, and membrane hot — so compression/refold/quarantine work runs
through the live machinery instead of offline scripts (issue #122). Quiesce
persists across restarts; resume gates on a fresh per-agent feasibility
preview of the current runtime settings (force overrides).
await framework.quiesce({ reason: 'refold', timeoutMs: 120_000, abandon: false }); // throws QuiesceBlockedError if the deferred writes can't reach disk
await framework.maintenanceTick(); // drain quarantine / advance merges
await framework.resume(); // throws ResumeBlockedError if the layout won't compileAlso reachable over HTTP (POST /quiesce, POST /resume,
POST /maintenance/tick, GET /hostmode; options via query string) and as
host/command verbs (quiesce, resume, maintain, host-status) from MCPL
servers granted allowHostCommands. The same servers can lift a provider's
stated wait with release-provider-wait (agentName, optional model), as
host.releaseProviderWait does over the WebSocket API, and
host.providerWaits lists the waits that bind. The HTTP host verbs accept an
optional shared secret (ApiServerConfig.adminToken, sent as x-admin-token)
for deployments that front the port with a proxy.
Subscribe to trace events for logging, UI updates, or debugging:
framework.onTrace((event) => {
// inference:started, inference:completed, inference:aborted
// tool:started, tool:completed, tool:failed
// process:received, process:completed
// message:added, module:added, module:removed
console.log(event.type, event);
});Trace events are observability-only. They are intended for logs, metrics, UI updates, and debugging, not framework-internal control flow. Internal lifecycle code should use state-machine signals or process events instead of consuming traces, so trace emit order does not become an API contract.
Query raw inference logs:
const logs = framework.queryInferenceLogs({ agentName: 'assistant', limit: 10 });| Package | Role |
|---|---|
| membrane | LLM provider abstraction (Anthropic, Bedrock, OpenRouter) |
| @connectome/context-manager | Context window management and compression |
| chronicle | Branchable persistent event store (Rust + N-API) |
npm install
npm run build # TypeScript compilation
npm run dev # Watch mode
npm test # Run tests
npm run typecheck # Type-check without emittingRequires Node.js >= 20. Chronicle requires a Rust toolchain for native module compilation (npm run build in the chronicle directory).