API Contract

The Agent, session, tool, memory, skill, run, queue, and MCP sections below describe the Node SDK behavior asserted by scripts/docs_api_contract_smoke.mjs. The script starts a temporary OpenAI-compatible local test server, creates real SDK sessions, calls the native binding, and asserts the returned values. It does not require a docs build. The cluster-grade extension points at the end of the page are described from the core and SDK sources; the smoke script does not exercise them.

Run the contract from the repository root:

SHELLSCRIPT
node scripts/docs_api_contract_smoke.mjs

Agent

Verified entry points:

TypeScript
const agent = await Agent.create(aclSource);
await agent.refreshMcpTools();
const session = agent.session(workspace, options);
const named = agent.sessionForAgent(workspace, 'explore', [], options);

Agent.create() accepts ACL source text or an .acl file path; it does not parse JSON. Hosts that already hold a validated config object can call Agent.createFromConfig(config) with the typed JSON form of CodeConfig. The integration check covers both apiKey/baseUrl and api_key/base_url provider aliases against the local OpenAI-compatible test server. sessionForAgent() was verified with the built-in explore agent.

session(), resumeSession(), and sessionForAgent() are synchronous at the JavaScript surface and block the event loop while the native side runs the core async session construction path. They are marked deprecated; new code should use sessionAsync(), resumeSessionAsync(), and sessionForAgentAsync(), which take the same arguments and return a Promise<Session>. Rust embedders should use SessionBuilder::build().await or the async factories; the synchronous Rust compatibility factory requires an explicit pre-initialized memory store.

The integration check also covers the ACL fields that create a file-backed session store:

ACL
storage_backend = "file"
sessions_dir = "/tmp/a3s-doc-stores/acl-storage"

storage_url is only connection metadata for storage_backend = "custom", where the host supplies its own SessionStore. It is not the local file-session persistence path; use sessions_dir in ACL or pass sessionStore in SDK options.

Session Options

The integration check covers this option shape:

model is a per-session override. The check verifies that a session created with model: 'openai/docs-alt' sends docs-alt to the local provider.

With confirmationPolicy.enabled, a model tool call that needs approval parks the turn until the host answers it with confirmToolUse(). On the fact-log coding path the parked confirmation waits for that answer; no timer settles it.

Basic session accessors are verified:

TypeScript
console.log(session.sessionId);
console.log(session.workspace);
console.log(session.initWarning);
console.log(session.history());
console.log(session.cancel());

workspace is returned as the SDK's canonical workspace path.

planningMode accepts 'auto', 'enabled', and 'disabled'.

The check also verifies that this permissionPolicy shape is accepted at session creation:

TypeScript
agent.session(workspace, {
permissionPolicy: {
deny: ['write(**/.env*)', 'bash(rm -rf*)'],
ask: ['bash(git push*)', 'bash(npm publish*)'],
allow: ['read(*)', 'search(*)', 'bash(npm run build*)'],
defaultDecision: 'ask',
enabled: true,
},
});

Prompt slot options are strings:

TypeScript
agent.session(workspace, {
role: 'release-readiness reviewer',
guidelines:
'Find blockers before improvements. Require command evidence for done claims.',
responseStyle: 'concise, findings first',
goalTracking: true,
});

Result Shape

session.send() returns AgentResult fields on the result object itself:

TypeScript
const result = await session.send('Return a short answer');
console.log(result.text);
console.log(result.toolCallsCount);
console.log(result.promptTokens);
console.log(result.completionTokens);
console.log(result.totalTokens);
console.log(result.verificationStatus);
console.log(result.pendingVerificationCount);
console.log(result.failedVerificationCount);
console.log(result.verificationReportCount);
console.log(result.verificationSummaryJson);
console.log(result.verificationSummaryText);

send() and stream() also accept a SessionRequestOptions object ({ prompt, history?, attachments? }) in place of the prompt string. Trace events and verification reports are session APIs, not fields on AgentResult.

Streaming

session.stream() returns an EventStream. The integration check consumes it with .next():

TypeScript
const stream = await session.stream('Stream one sentence');
while (true) {
const { value: event, done } = await stream.next();
if (done) break;
if (!event) continue;
if (event.text) process.stdout.write(event.text);
}

The package entry point also installs Symbol.asyncIterator on EventStream, so for await (const event of stream) iterates the same events.

The smoke check starts another send() immediately after this loop. Exhaustion therefore verifies both event delivery and release of the stream's single-flight admission lease; no retry delay is required.

Each SDK event is an envelope-v1 projection with version === 1, an open type string, a complete payload, and optional metadata. Consumers must retain a default branch for future event types. Node exposes payloadJson and metadataJson string views; Python exposes payload_json and metadata_json and retains event_type as an alias for type.

Direct Tools

Full guide: Tools.

The integration check covers these host-driven direct calls:

TypeScript
await session.readFile('README.md');
await session.readFile('read-window.txt', { offset: 1, limit: 1 });
await session.glob('src/*.rs');
await session.grep('PermissionPolicy');
await session.bash('printf docs-bash');
await session.tool('read', { file_path: 'README.md' });
await session.git('status');
await session.git('diff');
await session.git(
'log',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
5,
);
await session.tool('search_skills', { query: 'release blockers', limit: 5 });
session.toolNames();
session.toolDefinitions();
session.registerAgentDir(path.join(workspace, 'agents'));

readFile() options are a 0-indexed line offset and a line limit. git() also accepts a GitCommandOptions object, which avoids the positional form: session.git({ command: 'log', maxCount: 5 }).

The verified local-workspace toolNames() set includes read, write, edit, patch, search, ls, bash, task, search_skills, Skill, program, git, batch, web_fetch, and web_search. The check also asserts that parallel_task is absent from both toolNames() and toolDefinitions(), and that the session has no parallelTask helper.

Direct host calls are trusted host operations: they skip the permission and HITL gates that apply to model tool calls, although a pre_tool_use hook can still deny them. Gate them in the host application before exposing them to end users, or use session.governedTool(name, args), which runs a tool without an LLM while keeping the session's permission and HITL gates.

download is registered only for writable local workspaces and is invoked through the generic direct-tool API:

TypeScript
const result = await session.tool('download', {
url: 'https://example.com/archive.tar.zst',
file_path: 'artifacts/archive.tar.zst',
overwrite: false,
connections: 4,
max_bytes: 536870912,
timeout: 300,
expected_sha256:
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
});

Only url is required. connections is limited to 1–4, max_bytes defaults to 512 MiB with an 8 GiB maximum, timeout defaults to 300 seconds with a 3600-second maximum, and expected_sha256 must contain exactly 64 hexadecimal characters. file_path is workspace-relative and may be omitted for safe filename inference; overwrite defaults to false.

Model-driven calls remain permission- and HITL-governed workspace mutations. The transfer validates redirects and DNS targets against SSRF, verifies strict Range responses, retries only within fixed bounds, falls back to a sequential transfer when needed, and promotes a temporary file only after completion and optional digest verification.

AGENTS.md

The script writes an AGENTS.md file in the workspace and asserts that its instruction token appears in the local provider request body:

Markdown
# Project Instructions
Always mention docs-contract-agents-md-token when asked for project instructions.

Keep project instructions operational and free of secrets.

Programmatic Tool Calling

session.program() runs bounded JavaScript in the embedded QuickJS runtime:

TypeScript
const result = await session.program({
source: `
export default async function run(ctx, inputs) {
const text = await ctx.readFile('README.md');
const hits = await ctx.search(inputs.q, { mode: 'grep', include: '*.md' });
const status = await ctx.git({ command: 'status' });
return {
hasHits: text.includes(inputs.q) && hits.includes(inputs.q),
gitOk: status.exitCode === 0,
};
}
`,
inputs: { q: 'planningMode' },
allowedTools: ['read', 'search', 'git'],
limits: { timeoutMs: 30000, maxToolCalls: 12, maxOutputBytes: 65536 },
});
const meta = JSON.parse(result.metadataJson);
console.log(meta.script_result);
console.log(meta.program.tool_calls);

The check exercises the ctx helpers readFile, read, search, ls, bash, git, and the generic tool(name, args). The runtime also defines tools, grep, glob, bm25, webSearch, and verify; grep and glob call the search tool. ctx.git() takes an argument object such as { command: 'status' }. fetch, WebSocket, and Worker are not available inside the script.

allowedTools limits which registered tools the script may call and defaults to every registered tool. A search call is also allowed when its mode (grep or glob) is listed. program, dynamic_workflow, and parallel_task are always removed from the script's tool set, and a task call with more than one entry in tasks is refused. Default limits are a 30-second timeout (600 seconds when task is allowed), 20 tool calls, and 64 KiB of output.

Verification

Full guide: Verification.

Verification is session-scoped:

TypeScript
const report = await session.verifyCommands('docs api check', [
{
id: 'echo',
kind: 'command',
description: 'echo works',
command: 'printf verify',
required: true,
},
]);
console.log(report.subject);
console.log(session.verificationReports());
console.log(session.verificationSummary());
console.log(session.verificationSummaryText());
console.log(session.verificationPresets());
console.log(formatVerificationSummary(session.verificationSummary()));

Memory

Full guide: Memory.

Node memory was verified with FileMemoryStore:

TypeScript
const session = agent.session(workspace, {
memoryStore: new FileMemoryStore(memoryDir),
});
console.log(session.hasMemory);
await session.rememberSuccess('docs memory success', ['search'], 'remembered');
await session.rememberFailure('docs memory failure', 'expected failure', [
'bash',
]);
await session.memoryRecent(10);
await session.recallSimilar('docs memory', 5);
await session.recallByTags(['search'], 10);

The recent-memory method is memoryRecent(). The check asserts that recallRecent() is not present on the Node SDK surface.

Skills

Full guide: Skills.

File-backed and inline skills are verified through search_skills:

TypeScript
const session = agent.session(workspace, {
skillDirs: [path.join(workspace, 'skills')],
inlineSkills: [
{
name: 'strict-release-review',
kind: 'instruction',
content: 'Always separate blockers from nice-to-have improvements.',
},
],
});
await session.tool('search_skills', { query: 'release blockers', limit: 5 });
await session.tool('search_skills', {
query: 'strict release review',
limit: 5,
});

The skill-file check uses Markdown with YAML frontmatter and the allowed-tools key.

Side Questions

Full guide: Sessions.

The SDK surface has no dedicated ephemeral-question helper. Pass explicit history when the call must not mutate the session transcript:

TypeScript
const snapshot = session.history();
const side = await session.send('What is this test?', snapshot);
console.log(side.text);
console.log(session.history().length === snapshot.length);

Runs And Cancellation

Full guide: Sessions.

Each send() or stream() records replayable run state:

TypeScript
const runs = await session.runs();
const latest = runs.at(-1);
if (latest) {
console.log(await session.runSnapshot(latest.id));
console.log(await session.runEvents(latest.id));
}
const current = await session.currentRun();
if (current?.id && current.status === 'running') {
await session.cancelRun(current.id);
}
console.log(session.traceEvents());

cancelRun() returns false for an id that is not the active run.

Headless hosts can admit exact immutable run identities and receive the authoritative snapshot without waiting for completion:

TypeScript
const admitted = await session.spawnRunWithId(
'release-42/run-7',
'Verify the release',
);
console.log(admitted.snapshot.id, admitted.replayed);
const recovered = await session.spawnRecoveryWithRunId(
'checkpoint-run-6',
'release-42/recovery-7',
);
console.log(recovered.snapshot.status, recovered.replayed);

Repeating compatible immutable input returns replayed: true without starting duplicate work. Conflicting input fails with the RUN_IDENTITY_CONFLICT error code; closing the session cancels detached workers.

currentRun() is for the current operation. When idle, it may return null or a retained snapshot depending on the preceding control flow. Use runs() for completed history.

Transcript-affecting operations are single-flight per session. An overlapping send, stream, attachment call, slash command, or resumeRun fails immediately with SessionBusy (SESSION_BUSY); it is not queued. A stream retains admission until its producer has stopped, even when its public handle is dropped.

Persistence

Full guide: Persistence and Sessions.

File-backed session persistence was verified with stable sessionId, autoSave, explicit save(), and resumeSession():

TypeScript
const session = agent.session(workspace, {
sessionStore: new FileSessionStore(sessionDir),
sessionId: 'docs-contract',
autoSave: true,
});
await session.save();
const resumed = agent.resumeSession('docs-contract', {
sessionStore: new FileSessionStore(sessionDir),
});
console.log(resumed.history());

Core persistence commits the conversation and its artifacts, traces, run records, verification reports, and subagent task snapshots together as one versioned SessionSnapshotV1. File and memory stores publish that aggregate atomically. Older fragmented records remain loadable, while custom stores must implement aggregate save explicitly. The fact log under .a3s/effect-log/ in the workspace is the control source for coding turns and is not part of the snapshot.

Close a session when the process should release its background resources. session.close() blocks until cleanup finishes and is deprecated in favor of await session.closeAsync(). Closing marks the session closed (session.isClosed() returns true, and further send / stream calls fail with Session '<id>' is closed), cancels the active run, running subagent tasks, and pending HITL confirmations, drains the lane queue when one is configured, and disconnects MCP servers the session added itself. Repeated calls are no-ops.

For control-plane callers that only know the session ID, the same cleanup is reachable from the agent:

TypeScript
await agent.listSessions(); // ['session-a', 'session-b']
await agent.closeSession('session-a'); // true if it was open
await agent.close(); // close every live session + disconnect global MCP

After agent.close(), agent.session(...) and agent.resumeSession(...) fail with a session-closed error. agent.close() is idempotent. Use it in process-shutdown handlers so no session-scoped workers outlive the agent.

Delegation

Full guide: Tasks and Orchestration.

The direct helpers for the task tool were verified:

TypeScript
await session.task({
agent: 'general',
description: 'docs delegated check',
prompt: 'Return a short response.',
maxSteps: 1,
});
await session.tasks([
{
agent: 'general',
description: 'one',
prompt: 'Return one response.',
maxSteps: 1,
},
{
agent: 'general',
description: 'two',
prompt: 'Return another response.',
maxSteps: 1,
},
]);

Both helpers return a ToolResult named task. tasks() runs the entries concurrently through the same tool. delegateTask() is an alias of task(), and DelegateTaskOptions also accepts background.

Hooks

Full guide: Hooks.

The verified hook management surface is:

TypeScript
session.registerHook(
'docs-block-bash',
'pre_tool_use',
{ tool: 'bash', commandPattern: 'docs-hook-blocked' },
{ priority: 1, timeoutMs: 1000 },
() => ({ action: 'continue' }),
);
console.log(session.hookCount());
session.unregisterHook('docs-block-bash');

The matcher also accepts pathPattern, sessionId, and skill. The config defaults are priority 100 (lower runs first) and a 30000 ms timeout. The check covers registration and removal only; validate the specific event path you depend on before using hook behavior as a production enforcement gate.

Slash Commands

Full guide: Commands.

Custom slash commands are invoked through session.send():

TypeScript
session.registerCommand(
'docs_status',
'Return docs command status',
(args, ctx) => {
return `status args=${args}; session=${ctx.sessionId}; workspace=${ctx.workspace}`;
},
);
console.log(session.listCommands());
const result = await session.send('/docs_status check');
console.log(result.text);

The handler context also carries model, historyLen, totalTokens, totalCost, and toolNames.

Lane Queue

Full guide: Lane Queue.

Queue infrastructure is opt-in:

TypeScript
const queued = agent.session(workspace, {
queueConfig: { enableDlq: true, enableMetrics: true },
});
console.log(queued.hasQueue());
await queued.setLaneHandler('execute', { mode: 'external', timeoutMs: 1000 });
await queued.pendingExternalTasks();
await queued.completeExternalTask('missing', {
success: true,
result: { output: 'done', exit_code: 0 },
});
await queued.queueStats();
await queued.queueMetrics();
await queued.deadLetters();

completeExternalTask() returns false for an unknown task id. Ordinary sessions are queue-free unless queueConfig (or an agent-level queue ACL block) is provided. The queue schedules host direct tool calls; model tool calls on the fact-log coding path run directly and do not enter it.

MCP

Full guide: MCP. Idle disconnect: Cluster Extension Points.

The integration check covers a live stdio MCP server:

TypeScript
const count = await session.addMcp({
name: 'echo',
transport: {
type: 'stdio',
command: process.execPath,
args: ['tools/mcp_echo_server.mjs', 'example-value'],
},
timeoutMs: 30000,
});
console.log(count);
console.log(await session.mcps());
console.log(
session.toolNames().filter((name) => name.startsWith('mcp__echo__')),
);
await session.tool('mcp__echo__echo', { message: 'docs mcp ok' });
await session.removeMcp('echo');

Tools from the server are named mcp__<server>__<tool>. addMcp(), mcps(), and removeMcp() are the compact forms of addMcpServerConfig(), mcpStatus(), and removeMcpServer(); the positional addMcpServer(...) overload also remains.

Live add/remove operations target a private manager owned by this session. Adding a server name that is already registered in the session fails. Agent-global and host-supplied managers are inherited read-only capability sources, so a session cannot mutate a sibling or global MCP configuration.

Cluster-grade extension points

Full guide: Cluster Extension Points (identity labels, budget guard, cluster events, deterministic IDs/replay, loop checkpoints, retention caps).

These contracts let a cluster control plane wire multi-tenancy, cost governance, and crash-tolerant runs without forking the framework. The framework defines decision points and emits structured events; the host supplies the policy implementations.

Identity labels

Four optional SessionOptions slots are propagated through hooks, traces, and SessionData but never interpreted by the framework:

TypeScript
const session = agent.session(workspace, {
tenantId: 'tenant-example',
principal: 'principal-example',
agentTemplateId: 'agent-template-example',
correlationId: 'trace-example',
sessionStore: new FileSessionStore('./sessions'),
});
session.tenantId; // -> 'tenant-example'
session.correlationId; // -> 'trace-example'

On resume, persisted labels are restored for any slot the caller leaves unset; labels passed in the resume options take precedence, so a host can relabel a session.

Budget / cost guard

BudgetGuard has three methods, each defaulting to allow or no-op: check_before_llm, record_after_llm, and check_before_tool. A Deny decision refuses the call with the message Budget exhausted on '<resource>': <reason>: a denied model request fails with CodeError::BudgetExhausted (BUDGET_EXHAUSTED), and a denied tool call returns a denied tool result carrying that message. A SoftLimit decision lets the call proceed.

Where the guard is consulted depends on the execution path:

  • Fact-log coding turns call check_before_llm before each model request, with an estimated token count of 0. They do not call record_after_llm, do not emit budget events, and do not call check_before_tool for model tool calls.
  • Agent-loop paths (child runs such as task and Skill, host direct tools, and session verification) call check_before_llm before and record_after_llm after each provider call, and check_before_tool before each tool call. On these paths a SoftLimit emits AgentEvent::BudgetThresholdHit { kind: "soft", .. } and a Deny emits one with kind: "hard" before failing.

Rust hosts inject the trait directly. Node.js, Python, and Go expose callback bridges later in this section:

Rust
let guard: Arc<dyn BudgetGuard> = /* host-supplied impl */;
let opts = SessionOptions::new().with_budget_guard(guard);

Cluster event vocabulary

AgentEvent (non-exhaustive) carries platform-level events:

  • BudgetThresholdHit { resource, kind, consumed, limit, message? }
  • PassivationRequested { reason, deadline_ms? }
  • PeerInvocation { from_session_id, from_tenant_id?, correlation_id? }

The host emits these through HookExecutor. The agent loop itself produces only BudgetThresholdHit, from the budget checks described above. In-session hooks subscribe to these events to react uniformly regardless of how the host's transport delivers them.

Deterministic IDs / time

HostEnv { id_generator, clock } replaces the default uuid::Uuid::new_v4() + wall-clock pair. Replay tooling configures SequentialIdGenerator + FixedClock to recreate a run bit-identical on another node.

Loop checkpoints + run resumption

When a SessionStore is configured, the session persists a LoopCheckpoint after each tool result that lands on the fact log, keyed by run_id, and deletes it when the run reaches a terminal state in-process. Any node holding the same store can recover a crashed run: the checkpoint seeds a fresh fact-log thread, and folding that log chooses the next step. The checkpoint never chooses the next model call itself.

TypeScript
// Node — host detected node A died mid-run; on node B:
const session = agentB.session(workspace, {
sessionStore: new FileSessionStore('./sessions'),
sessionId: 'session-from-node-a',
});
const result = await session.resumeRun('run-id-from-node-a');
Python
# Python equivalent
opts = SessionOptions()
opts.session_store = FileSessionStore('./sessions')
opts.session_id = 'session-from-node-a'
session = agent_b.session(workspace, opts)
result = session.resume_run('run-id-from-node-a')
Go
// Go equivalent
session, err := agent.Session(ctx, workspace, &code.SessionOptions{
FileSessionStoreDir: "./.a3s/sessions",
SessionID: "session-from-node-a",
})
if err != nil {
return err
}
result, err := session.ResumeRun(ctx, "run-id-from-node-a")

The resumed work is recorded as a new run; the checkpoint's run is not modified. Without a checkpoint, resumeRun folds the session's existing fact log when it has facts (a quiescent log takes no step). The two errors below fire only when the log is also empty:

  • "resume_run requires a session_store on this session": the host should fall back to a fresh session.
  • "no loop checkpoint found for run 'X'": the run never wrote a checkpoint, or the checkpoint was deleted when the run ended.

A tool call whose result is missing from the log runs once on resume, so hosts should keep non-idempotent tools behind confirmation or idempotency keys. For an exact, replay-safe recovery id use spawnRecoveryWithRunId(checkpointRunId, runId).

Retention caps for long-running sessions

SessionRetentionLimits caps the in-memory stores that grow with session age: run records, per-run event buffers (by count and by serialized bytes), trace events, and terminal subagent task snapshots. The finite defaults are 64 runs, 2,048 events and 8 MiB of events per run, 8,192 trace events, and 512 terminal subagent tasks. SessionRetentionLimits::unbounded() (or unbounded: true in the SDKs) deliberately restores unlimited retention. Eviction is FIFO and never returns an error; running subagent tasks are never dropped, and a run's cumulative event count is not decremented.

Rust
use a3s_code_core::retention::SessionRetentionLimits;
let limits = SessionRetentionLimits::new()
.with_max_runs(100)
.with_max_events_per_run(5_000)
.with_max_event_bytes_per_run(16 * 1024 * 1024)
.with_max_trace_events(10_000)
.with_max_terminal_subagent_tasks(1_000);
let opts = SessionOptions::new().with_retention_limits(limits);

Pick caps from the same observability budget that caps the rest of the host's in-memory state. Node exposes this as retentionLimits (maxRunsRetained, maxEventsPerRun, maxEventBytesPerRun, maxTraceEvents, maxTerminalSubagentTasks, unbounded); Python as opts.retention_limits; Go as SessionOptions.RetentionLimits.

MCP idle disconnect

Agent::disconnect_idle_mcp(threshold_ms) disconnects agent-global MCP servers whose last activity is older than now - threshold_ms and returns their names. Servers with no recorded activity count as idle. Session-local servers added with addMcp() are not affected.

TypeScript
// Node — periodically reap quiet MCP subprocesses.
setInterval(async () => {
const dropped = await agent.disconnectIdleMcp(5 * 60 * 1000); // 5 min
if (dropped.length) {
console.log('reaped idle MCP servers:', dropped);
}
}, 60_000);
Python
# Python — same shape.
dropped = agent.disconnect_idle_mcp(5 * 60 * 1000)
Go
// Go — milliseconds, matching the other SDKs.
dropped, err := agent.DisconnectIdleMCP(ctx, 5*60*1000)

The server's registered config stays, but the connection does not come back on its own: a tool call on a disconnected server fails with MCP server not connected: <name>. Reconnect by calling agent.syncGlobalMcpServers(configs), which connects every enabled server that is not connected, then session.republishInheritedMcpTools() on live sessions.

Activity is stamped on connect and at the start of every tool call. Rust hosts that route tool traffic through a side channel can call McpManager::touch(name) to keep a server warm.

BudgetGuard SDK bridges

All callback-capable SDKs accept the same decision shape:

ReturnEffect
None / null / {decision:'allow'}proceed silently
{decision:'soft', resource, consumed, limit, message?}proceed; on agent-loop paths, emit the soft event
{decision:'deny', resource, reason}refuse the call with Budget exhausted on '<resource>'…

Missing methods on the guard object are treated as allow or no-op. In all three SDKs, a check callback that throws, returns a malformed decision, or does not return within the timeout (default 5 seconds) is treated as a deny. recordAfterLlm failures are ignored.

Python
# Python — attach via SessionOptions before agent.session(...)
class MyGuard:
def check_before_llm(self, session_id, estimated_tokens):
return {"decision": "deny", "resource": "llm_tokens", "reason": "cap"}
def record_after_llm(self, session_id, usage):
track(session_id, usage["total_tokens"])
opts = SessionOptions()
opts.budget_guard = MyGuard()
session = agent.session(workspace, opts)
TypeScript
// Node — attach via session.setBudgetGuard after construction.
// Takes effect on the next send/stream.
session.setBudgetGuard({
checkBeforeLlm: (ctx) => {
if (overBudget(ctx.sessionId)) {
return { decision: 'deny', resource: 'llm_tokens', reason: 'cap' };
}
return null;
},
recordAfterLlm: (ctx) => {
track(ctx.sessionId, ctx.usage.totalTokens);
},
});
Go
err := session.SetBudgetGuard(ctx, &code.BudgetGuardHandlers{
CheckBeforeLLM: func(
_ context.Context,
call code.BudgetLLMContext,
) (*code.BudgetDecision, error) {
if overBudget(call.SessionID) {
return &code.BudgetDecision{
Decision: "deny",
Resource: "llm_tokens",
Reason: "cap",
}, nil
}
return &code.BudgetDecision{Decision: "allow"}, nil
},
})

Node callbacks receive a single context object ({ sessionId, estimatedTokens }, { sessionId, usage }, or { sessionId, toolName }) and accept an optional timeoutMs. Python guard methods take positional arguments and can also be installed later with session.set_budget_guard(guard, timeout_ms). Go handlers receive typed contexts, and BudgetGuardHandlers.Timeout sets the timeout.

To clear, pass null to session.setBudgetGuard (Node), call session.set_budget_guard(None) (Python), or call session.SetBudgetGuard(ctx, nil) (Go).