Overview
MainAgentGraphState is a LangGraph annotation-based state. Every node in the graph reads it and may return a partial update; the runtime merges updates via per-field reducers and checkpoints the result to per-user SQLite (backed by Matrix).
import { Annotation, MessagesAnnotation } from '@langchain/langgraph';
export const MainAgentGraphState = Annotation.Root({
messages: MessagesAnnotation.spec.messages,
config: Annotation<{ wsId?: string; did: string }>({ /* ... */ }),
client: Annotation<'portal' | 'matrix' | 'slack'>({ /* ... */ }),
editorRoomId: Annotation<string | undefined>({ /* ... */ }),
spaceId: Annotation<string | undefined>({ /* ... */ }),
currentEntityDid: Annotation<string | undefined>({ /* ... */ }),
browserTools: Annotation<BrowserToolCall[] | undefined>({ /* default: () => [] */ }),
agActions: Annotation<AgAction[] | undefined>({ /* default: () => [] */ }),
userContext: Annotation<UserContextData>({ /* ... */ }),
userPreferences: Annotation<UserPreferences | undefined>({ /* ... */ }),
loadedPlugins: Annotation<string[]>({
reducer: (current, update) =>
Array.from(new Set([...(current ?? []), ...(update ?? [])])),
default: () => [],
}),
});Plugins read state via rtCtx.history.state (typed as ReadonlyState) or via specific helpers like rtCtx.history.messages / rtCtx.history.userContext.
Fields
messages
- Type: LangChain
BaseMessage[] - Reducer:
MessagesAnnotationdefault (append, dedupe by id). - Owner: runtime + agent loop.
The thread's full message history. Plugins read it via rtCtx.history.messages (readonly) or rtCtx.history.recent(n).
config
- Type:
{ wsId?: string; did: string } - Owner: runtime.
Per-request runtime config — primarily the user's DID and the WebSocket connection ID.
client
- Type:
'portal' | 'matrix' | 'slack' - Owner: runtime.
Which client surface this turn arrived through. Same value plugins read via rtCtx.session.client.
editorRoomId
- Type:
string | undefined - Owner: Editor plugin.
The active BlockNote room for editor sub-agent work.
spaceId
- Type:
string | undefined - Owner: runtime / plugins.
The active workspace/space ID, when relevant.
currentEntityDid
- Type:
string | undefined - Owner: Domain Indexer plugin.
The IXO entity DID currently in focus, when set by a domain lookup.
browserTools
- Type:
BrowserToolCall[] | undefined - Default:
[](empty array). - Owner: Portal client.
Browser tools declared by the Portal frontend for this turn. Each entry is { name, description, schema }. The Portal plugin's sub-agent only builds when this array is non-empty.
agActions
- Type:
AgAction[] | undefined - Default:
[](empty array). - Owner: Portal client (AG-UI).
AG-UI actions declared by the frontend. Each entry is { name, description, schema, hasRender? }. The AG-UI plugin's sub-agent only builds when this is non-empty.
userContext
- Type:
UserContextData(Record<string, unknown>) - Owner: Memory plugin (writes via enrichment middleware).
Memory-enriched user profile. Other plugins read via rtCtx.shared.userProfile (registered by Memory's getSharedState).
userPreferences
- Type:
UserPreferences | undefined - Owner: user-preferences plugin.
Behavioural preferences (tone, format, length) injected into the prompt.
loadedPlugins
- Type:
string[] - Reducer: union via Set (deduplicating).
- Default:
[]. - Owner: runtime — written by the
load_capabilitymeta-tool.
The names of on-demand plugins the agent has loaded for this thread. Monotonically growing across turns. Cleared on new thread.
This is the single new field the plugin runtime added to the state — every other field above pre-dates the plugin rewrite.
ReadonlyState
Plugins access the state via rtCtx.history.state, which is typed as ReadonlyState:
export interface ReadonlyState {
readonly messages: readonly BaseMessage[];
readonly userContext?: UserContextData;
readonly loadedPlugins?: ReadonlySet<string>;
readonly [key: string]: unknown;
}The full annotation state is open-ended ([key: string]: unknown), so plugins can read field names they know exist but the type doesn't enforce it. For fully-typed reads, declare the field on SharedAccessors via shared state, or check existence at runtime.
Reducers
Each field has a reducer that merges partial updates from agent nodes:
messages— append + dedupe by ID (LangGraph default for the messages channel).loadedPlugins— union via Set; never removes.- Most other fields use last-write-wins or "merge if present" semantics; consult the source for the exact reducer when authoring middleware that mutate state.
Plugin middleware hooks must not return a partial state object ({ messages: ... }, etc.). A middleware hook returns only undefined (pass through) or { jumpTo: 'end' as const } (flow control). Returning a state channel from beforeAgent / wrapModelCall / afterModel breaks LangGraph checkpointer thread continuity (the symptom is "a new thread per message"). Message rewrites belong in the transport/messages layer, not in a middleware.
Checkpointing
State is checkpointed per thread to per-user SQLite via UserMatrixSqliteSyncService + SqliteSaver. The DB lives under SQLITE_DATABASE_PATH, synced to Matrix in the background.
The runtime exposes hooks.checkpointerForUser(userDid) so hosts can override the default per-user checkpointer with an alternate implementation — pass via createOracleApp({ hooks }).
Related references
- RuntimeContext —
rtCtx.historyfield. - Meta-tools concept — how
loadedPluginsis populated.