API Contract
SDK mechanisms covered by the local integration check
API Contract
This page documents only the A3S Code Node SDK behavior covered by
crates/code/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.
Run the contract from crates/code:
node scripts/docs_api_contract_smoke.mjsAgent
Verified entry points:
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. JSON config is
not part of the verified contract. 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.
The integration check also covers ACL file storage fields:
storage_backend = "file"
storage_url = "/tmp/a3s-doc-stores/acl-storage"Session Options
The integration check covers this option shape:
const session = agent.session(workspace, {
model: 'openai/docs-alt',
builtinSkills: true,
planningMode: 'disabled',
memoryStore: new FileMemoryStore(memoryDir),
sessionStore: new FileSessionStore(sessionDir),
sessionId: 'docs-contract',
autoSave: true,
securityProvider: new DefaultSecurityProvider(),
skillDirs: [path.join(workspace, 'skills')],
inlineSkills: [
{
name: 'strict-release-review',
kind: 'instruction',
content: 'Always separate blockers from nice-to-have improvements.',
},
],
maxToolRounds: 24,
maxParseRetries: 3,
toolTimeoutMs: 120000,
circuitBreakerThreshold: 4,
autoCompact: true,
autoCompactThreshold: 0.75,
continuationEnabled: true,
maxContinuationTurns: 3,
maxExecutionTimeMs: 300000, // 5 minutes timeout
confirmationPolicy: {
enabled: true,
defaultTimeoutMs: 60000,
timeoutAction: 'reject',
},
});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.
Basic session accessors are verified:
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 the explicit modes documented elsewhere: 'auto',
'enabled', and 'disabled'.
The check also verifies that this permissionPolicy shape is accepted at
session creation:
agent.session(workspace, {
permissionPolicy: {
deny: ['write(**/.env*)', 'bash(rm -rf*)'],
ask: ['bash(git push*)', 'bash(npm publish*)'],
allow: ['read(*)', 'grep(*)', 'glob(*)', 'bash(npm run build*)'],
defaultDecision: 'ask',
enabled: true,
},
});Prompt slot options are strings:
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:
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);Trace events and verification reports are session APIs, not fields on
AgentResult.
Streaming
session.stream() returns EventStream. The verified consumption contract is
.next():
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);
}Do not depend on for await unless your installed SDK version has separately
validated async iteration support.
Direct Tools
Full guide: Tools.
The integration check covers these host-driven direct calls:
await session.readFile('README.md');
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'));The verified toolNames() set includes read, write, edit, patch,
grep, glob, ls, bash, task, parallel_task, search_skills, Skill,
program, git, batch, web_fetch, and web_search.
Direct host calls are privileged. Gate them in the host application before exposing them to end users.
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:
# 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:
const result = await session.program({
source: `
export default async function run(ctx, inputs) {
const text = await ctx.readFile('README.md');
const hits = await ctx.grep(inputs.q, { glob: '*.md' });
return { summary: 'ok', hasHits: text.includes(inputs.q) && hits.includes(inputs.q) };
}
`,
inputs: { q: 'planningMode' },
allowedTools: ['read', 'grep'],
limits: { timeoutMs: 30000, maxToolCalls: 12, maxOutputBytes: 65536 },
});
const meta = JSON.parse(result.metadataJson);
console.log(meta.script_result);
console.log(meta.program.tool_calls);Verified ctx helpers: readFile, read, grep, glob, ls, bash,
git, and generic tool(name, args). allowedTools limits which registered
tools the script may call. The program tool is not included in its own default
tool set.
Verification
Full guide: Verification.
Verification is session-scoped:
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:
const session = agent.session(workspace, {
memoryStore: new FileMemoryStore(memoryDir),
});
console.log(session.hasMemory);
await session.rememberSuccess('docs memory success', ['grep'], 'remembered');
await session.rememberFailure('docs memory failure', 'expected failure', ['bash']);
await session.memoryRecent(10);
await session.recallSimilar('docs memory', 5);
await session.recallByTags(['grep'], 10);The verified recent-memory method is memoryRecent(). recallRecent() is not
present on the current Node SDK surface.
Skills
Full guide: Skills.
File-backed and inline skills are verified through search_skills:
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.
btw() asks a read-only side question and returns a separate result:
const side = await session.btw('What is this test?');
console.log(side.question);
console.log(side.answer);
console.log(side.totalTokens);Runs And Cancellation
Full guide: Sessions.
Each send() or stream() records replayable run state:
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());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.
Persistence
Full guide: Persistence and Sessions.
File-backed session persistence was verified with stable sessionId,
autoSave, explicit save(), and resumeSession():
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());Use session.close() when a Node process should release session-scoped
background resources promptly. close() is the full graceful-stop entry
point: it flips session.isClosed to true (further send / stream
calls reject with a Session closed error), fires the session-level
CancellationToken so every in-flight run, delegated subagent task, and
HITL confirmation aborts, and emits the AHP recordRunCancelled hook for
the currently active run. Subsequent close() calls are no-ops.
For control-plane callers that only know the session ID, the same cleanup is reachable from the agent:
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 MCPAfter agent.close(), subsequent agent.session(...) and
agent.resumeSession(...) calls reject with a Session closed error.
Idempotent. Use this in process-shutdown handlers to guarantee no
session-scoped workers outlive the agent.
Delegation
Full guide: Tasks and Orchestration.
The direct helpers for the core delegation tools were verified:
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 },
]);They return ToolResult values from task and parallel_task.
Hooks
Full guide: Hooks.
The verified hook management surface is:
session.registerHook(
'docs-observer',
'pre_tool_use',
{ tool: 'bash' },
{ priority: 1, timeoutMs: 1000 },
() => ({ action: 'continue' }),
);
console.log(session.hookCount());
session.unregisterHook('docs-observer');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():
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);Lane Queue
Full guide: Lane Queue.
Queue infrastructure is opt-in:
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: { ok: true } });
await queued.queueStats();
await queued.queueMetrics();
await queued.deadLetters();Ordinary sessions are queue-free unless queueConfig is provided.
MCP
Full guide: MCP. Idle disconnect: Cluster Extension Points.
The integration check covers a live stdio MCP server:
const count = await session.addMcpServer(
'echo',
'stdio',
process.execPath,
['tools/mcp_echo_server.mjs', 'secret'],
);
console.log(count);
console.log(await session.mcpStatus());
console.log(session.toolNames().filter(name => name.startsWith('mcp__echo__')));
await session.tool('mcp__echo__echo', { message: 'docs mcp ok' });
await session.removeMcpServer('echo');Tools from the server are named mcp__<server>__<tool>.
AHP Transport Objects
Constructor shape and kind values were verified:
new HttpTransport('http://localhost:8080/ahp', 'token').kind; // 'http'
new WebSocketTransport('ws://localhost:8080/ahp', 'token').kind; // 'websocket'
new StdioTransport('node', ['server.mjs']).kind; // 'stdio'
new UnixSocketTransport('/tmp/a3s.sock').kind; // 'unix_socket'The check does not assert a live AHP server exchange.
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:
const session = agent.session(workspace, {
tenantId: 'acme-prod',
principal: 'svc-deploy-bot',
agentTemplateId: 'ci-runner-v7',
correlationId: 'trace-1234abcd',
sessionStore: new FileSessionStore('./sessions'),
});
session.tenantId; // -> 'acme-prod'
session.correlationId; // -> 'trace-1234abcd'apply_persisted_runtime_options restores them on resume; caller-
supplied options on resume take precedence so you can relabel.
Budget / cost guard
BudgetGuard is consulted before every LLM call (and after, for
usage accounting). Deny returns
CodeError::BudgetExhausted { resource, reason }; SoftLimit emits
an AgentEvent::BudgetThresholdHit { kind: "soft", .. } and proceeds.
Wire from Rust today (Node/Python wrappers will follow):
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 the host
emits via HookExecutor:
BudgetThresholdHit { resource, kind, consumed, limit, message? }PassivationRequested { reason, deadline_ms? }PeerInvocation { from_session_id, from_tenant_id?, correlation_id? }
In-session hooks subscribe to these 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 agent loop persists a
LoopCheckpoint after each completed tool round, keyed by run_id.
Any node holding the same store can rehydrate a run from its last
boundary:
// 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 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')A new run id is allocated for the resumed work — the framework does not pretend the old run continues. Two distinguishable error paths:
"resume_run requires a session_store"— host should fall back to a fresh session."no loop checkpoint found for run 'X'"— host can retry later (race against checkpoint write) or treat the run as lost.
Boundary policy: checkpoints are taken only between tool rounds, never mid-tool. If a process dies mid-tool the work of that round is lost; the LLM re-deliberates from the previous boundary. This trades retry cost for correctness — re-executing a non-idempotent tool across the boundary is worse than re-asking the LLM.
Retention caps for long-running sessions
SessionRetentionLimits lets the host cap the four in-memory stores
that grow with session age: the run records, per-run event buffers,
trace events, and terminal subagent task snapshots. Each cap is
optional (None keeps the unbounded default — fine for short
sessions, a memory leak for hour- or day-long ones). Eviction is
strict FIFO; running subagent tasks are never dropped.
use a3s_code_core::retention::SessionRetentionLimits;
let limits = SessionRetentionLimits::new()
.with_max_runs(100)
.with_max_events_per_run(5_000)
.with_max_trace_events(10_000)
.with_max_terminal_subagent_tasks(1_000);
let opts = SessionOptions::new().with_retention_limits(limits);The host should pick caps from the same observability budget that caps the rest of its in-memory state (Prometheus carries history anyway). SDK shapes for retention land in a follow-up.
MCP idle disconnect
Agent::disconnect_idle_mcp(threshold_ms) walks the connected MCP
servers and drops any whose last activity is older than
now - threshold_ms. The server's registered config stays — a
later tool call will reconnect on demand. Returns the names of
disconnected servers.
// 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 — same shape.
dropped = agent.disconnect_idle_mcp(5 * 60 * 1000)Activity is stamped on connect and on every successful call_tool.
Hosts that route tool traffic through a side channel can call
McpManager.touch(name) to manually keep a server warm.
BudgetGuard SDK bridges
Both SDKs accept the same decision shape:
| Return | Effect |
|---|---|
None / null / {decision:'allow'} | proceed silently |
{decision:'soft', resource, consumed, limit, message?} | emit BudgetThresholdHit('soft') event, proceed |
{decision:'deny', resource, reason} | abort the call, throw RuntimeError("Budget exhausted...") (Python) |
/ reject with "Budget exhausted..." (Node) |
Missing methods on the guard object are treated as a permissive default (Allow / no-op). Callback errors fall back to Allow — a misbehaving guard cannot halt a live session.
# 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)// Node — attach via session.setBudgetGuard after construction.
// JsFunction values can't live inside the value-typed SessionOptions,
// so the guard is installed on the Session itself; takes effect on
// the next send/stream.
session.setBudgetGuard({
checkBeforeLlm: (sessionId, estimatedTokens) => {
if (overBudget(sessionId)) {
return { decision: 'deny', resource: 'llm_tokens', reason: 'cap' };
}
return null;
},
recordAfterLlm: (sessionId, usage) => {
track(sessionId, usage.total_tokens);
},
});Pass null to setBudgetGuard (Node) or set opts.budget_guard = None and re-create the session (Python) to clear.