nanocodex
v0.6.1
Published
Viem-style Node and browser bindings for the Nanocodex WASM agent
Readme
Nanocodex for JavaScript
The Node, browser, and Web API host entrypoints expose the same viem-v3-style
API. A Transport owns authentication, placement, and socket setup;
Agent.create(...) owns tools and the common Agent/Turn lifecycle. Generated
WASM handles, managed control-plane handles, and host routing remain private.
import { Actions, Agent, Transport } from "nanocodex/node";
const agent = await Agent.create({
transport: Transport.openAi({ apiKey: process.env.OPENAI_API_KEY }),
model: "gpt-5.6-luna",
instructions: "You are a Rust coding agent. Preserve unrelated work and run relevant tests.",
reasoningMode: "pro",
thinking: "high",
tools,
workspace: process.cwd(),
});
const turn = agent.turn.prompt({ input: "Build the thing." });
const result = await turn.result();
turn.dispose();
console.log(result.finalMessage);
const usage = await result.usage();
console.log(usage);
console.log(usage.estimated_cost?.usd);
console.log(usage.cost_status);
await agent.session.setThinking("high");
await agent.session.setFastMode(true);
await agent.session.compact();
const branch = await agent.session.fork({ at: result });
const branchTurn = branch.turn.prompt({ input: "Try another approach." });
const branchResult = await branchTurn.result();
branchTurn.dispose();
console.log(branchResult.finalMessage);
branchResult.dispose();
const followOn = Actions.turn.prompt(agent, { input: "Now explain it." });
const followResult = await Actions.turn.getResult(followOn);
console.log(followResult.finalMessage);
followOn.dispose();
followResult.dispose();
result.dispose();
await branch.session.shutdown();
await agent.session.shutdown();Transports are explicit, immutable configurations, like viem v3 transports:
Transport.openAi({ apiKey, websocketUrl });
Transport.chatGpt({ subscription });
Transport.mpp({ session: paymentSession });
Transport.managed({ agent: { create: true } });
Transport.managed({ agent: { id: retainedAgentId } });Managed identity is always explicit. { create: true } provisions one new
account-owned durable Agent; { id } eagerly verifies and opens that existing
Agent. Omitting agent never creates a durable resource. Both return the same
sessionId, events.watch(), turn.prompt() / Turn, dispose(), and
session.shutdown() lifecycle used by local transports. Managed shutdown
closes this client and any reverse tool attachment; it does not delete the
durable Agent.
Choose the entrypoint by execution owner:
nanocodex/browsercreates and owns a package module Worker. Its options are structured-clone-safe and its default harness includes the browser workspace.nanocodex/hostruns in the current Web API isolate. Use it inside a caller-owned browser Worker, Cloudflare Worker, Vercel Function, or similar host when transports, tools, filesystems, or durability contain functions.nanocodex/noderuns in the current Node process with Node host adapters.
The browser transports additionally expose Transport.hostManaged(...) for a
Worker, Durable Object, or application proxy that owns rotating credentials.
Authentication modes are constructors rather than a union of mutually
exclusive fields on Agent.create.
Compose and place tools
createTools owns one deterministic tool recipe. Custom functions, a portable
workspace, and MCP are composed once; placement is selected afterward. Pass the
recipe to an in-process Node or Web API host, or reverse-attach it to a managed
agent target:
For a reverse machine attachment, attachmentId is its stable safe-ASCII source
identity (at most 123 bytes), and must equal the id of its sole non-secret
machines entry. Multiple machines may stay attached through independent
Tools runtimes; reconnect one runtime to replace that machine route while the
durable managed agent stays alive. Generic attachments may omit machine metadata.
import { createTools } from "nanocodex";
import { Agent, Transport, Workspace } from "nanocodex/node";
import WebSocket from "ws";
const workspace = await Workspace.open({ path: process.cwd() });
const tools = await createTools({
attachmentId: "laptop",
machines: [{
id: "laptop",
name: "My laptop",
workspace: process.cwd(),
capabilities: ["filesystem", "native-shell"],
}],
workspace,
tools: {
lookup_issue: {
description: "Read one issue from the application database.",
parameters: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
handler: ({ id }) => issues.get(id),
},
},
mcp: {
docs: { url: "https://mcp.example.test" },
},
});
const agent = await Agent.create({
transport: Transport.managed({
agent: { id: agentId },
baseUrl: managedOrigin,
apiKey,
toolsTransport: (target, options) => new WebSocket(target, {
headers: options.headers,
}),
}),
tools,
});
// On shutdown:
await agent.session.shutdown();The managed target retains credentials in a private transport closure; the API key is not embedded in the endpoint or serializable target data. While the attachment is live, an exact same-name attached tool wins over the cloud tool. After detach, the cloud definition is immediately eligible again. Definition parity is validated before the attached catalog becomes active, and calls already admitted retain their pinned placement.
Tools has one Agent owner and owns the lifecycle of its MCP runtime and
reverse attachments. Local transports host the recipe in process; a managed
transport starts a bounded reverse-attachment supervisor while the durable
Agent remains available through its cloud tools. A successful catalog
acknowledgement upgrades later admissions to the attached placement. A second
Agent host rejects the same value. Do not also supply legacy top-level
workspace or MCP configuration to an Agent that already receives them through
Tools.
Browser consumers can attach Codex's ChatGPT Realtime voice lifecycle to the
same retained Agent. The resource owns microphone, speaker, WebRTC, sideband,
and delegation cleanup; stopping voice does not cancel an active coding turn.
Snapshots update each speaker's transcript row as speech arrives, using a stable
id and isPartial flag. Completion replaces that row. transcript.delta events
carry the current partial text; transcript events retain completed-turn semantics.
Internal Realtime envelopes are projected into spoken text before publication.
Transcript updates continue while a delegation waits for durable admission.
Snapshots retain the latest 200 rows across stop/start. Subscribe to events if
an application needs its own longer transcript history.
Both local and managed browser Agents use the shared Rust/WASM client-managed
handoff policy. Only a completed final answer from the current spoken request
is submitted for speech. Commentary stays private; superseded, oversized, or
unconfirmed answers remain visible as recovered transcript rows and
answer.recovered events. Workspace and conversation history are not injected
into call startup. Browser media uses WebRTC echo cancellation, noise suppression,
and gain control; the native audio helper is used by native clients.
start() resolves after the media peer and backend session are ready. A media
connection timeout gets one retry after the first call has been closed.
The one-operation-at-a-time action surface is the canonical imperative API:
import { Actions } from "nanocodex/browser";
const voice = Actions.voice.create(agent);
await Actions.voice.start(voice); // defaults to Codex's `cove` voice
Actions.voice.setMuted(voice, true); // also works while connecting
Actions.voice.toggleMuted(voice);
const { microphoneLevel, speakerLevel } = Actions.voice.getSnapshot(voice);
// Fence old speech before submitting typed input. The shared terminal does this.
await Actions.voice.noteTypedInput(voice);
await agent.turn.prompt("Check the tests.");
await Actions.voice.stop(voice);
await Actions.voice.destroy(voice);Subscription voice preferences use the same Rust policy in browsers and native
apps. start and create accept voice, instructions, pace (slow,
natural, fast), updates (auto, results, silent), and optional
acknowledgements. Pace and style are speaking instructions.
updates: "silent" retains coding results as text without automatic speech.
handoffMode remains accepted for compatibility; browser client-managed
handoffs deliver completed finals and do not stream intermediate commentary.
Apply changed settings by stopping and starting a call. The shared terminal provides a saved
Voice settings panel with an Apply and reconnect action.
During an active call, Actions.voice.speak(voice, text) queues explicit speech,
appendText(voice, text, { role: "developer" }) adds text using Codex's
subscription adapter (which treats all roles as context), and
appendContext(voice, text) adds background commentary without
requesting speech. Context and speech are split into provider-sized messages.
These commands retain frames until sent and preserve them
across a sideband reconnect. They are also methods on the resource and on
useVoice from nanocodex-react. These settings use ChatGPT subscription voice;
custom voices and Platform audio configuration are not accepted.
Voice.create(...) remains the equivalent namespaced resource constructor, and
Voice.voices is the exact ChatGPT V3 voice catalog. The constructor accepts a
normal browser Agent, an account-owned managed Agent, or a grant-scoped
ConnectAgent. Authentication stays in the owning host routes; Connect uses a
fresh one-use sideband ticket, and the browser binding never receives ChatGPT
credentials or places its reusable grant bearer in a WebSocket URL.
Durable Cloudflare Agent
nanocodex/cloudflare is the standard Durable Object consumer. It keeps the
host transport, SQLite durable state, private runtime identity, event persistence,
hibernatable socket fan-out, and cursor replay inside the adapter:
import { DurableObject } from "cloudflare:workers";
import { Agent } from "nanocodex/cloudflare";
export class CodingAgent extends DurableObject {
#ready;
constructor(context, env) {
super(context, env);
this.#ready = Agent.create(this, {
instructions: "You are a focused coding agent.",
});
}
async prompt(input) {
const agent = await this.#ready;
const turn = agent.turn.prompt({ input });
let result;
try {
result = await turn.result();
return result.finalMessage;
} finally {
try {
result?.dispose();
} finally {
turn.dispose();
}
}
}
async fetch(request) {
return (await this.#ready).events.connect(request);
}
}The returned value is the normal typed Agent: follow-on prompts reuse its owned
history, and results remain independently awaitable. events.connect(request)
is only a read-only AgentEvent WebSocket surface; it does not define prompt,
membership, room, quota, or application routing policy. Event frames are
{ cursor, event }. Replay is bounded; a far-behind client can receive
{ type: "replay_paused", cursor, latest_cursor } followed by close code
1013, then continues by reconnecting with that pause cursor as
?cursor=<decimal>.
Cloudflare Agents default to direct tool mode because Workers prohibit dynamic
eval/new Function. Caller-defined tools therefore work without a code
evaluator. Select toolMode: "code" only when also supplying an evaluator that
is explicitly compatible with the deployed Worker runtime. Runtime-owned
Subagents are installed by default, including on a durable root. Clean children
persist independent execution state under their own agent session IDs. The
Rust task-tree registry remains in memory and is closed with the live root, so
tree-local IDs and topology are not reconstructed from those agent states. Use
Subagents.create({ maxConcurrency }) in tools to set an explicit finite
concurrency limit. Active subagent turns are unlimited by default.
Each Durable Object persists a private runtime identity in its own SQLite
storage and derives its state identity from it, so multiple objects in one
isolate remain independent and eviction reuses the same identity. Before
replacing an Agent inside a still-live object, await agent.session.shutdown();
deleting the Durable Object and its retained event/state rows remains an
application-owned lifecycle operation.
Internally this constructor uses Transport.hostManaged and an exact brokered
Responses WebSocket. authMode is required and accepts only "api_key" or
"chatgpt"; URLs and non-secret placeholders are fixed. Agent.create awaits
the private binding's WebSocket upgrade, so a missing binding or a broker whose
single policy does not match the selected mode rejects startup. The managed
Worker API deliberately has no provider-key, token, transport, or durability
option.
The managed Worker needs only the Durable Object and private broker bindings; the broker's separate Wrangler configuration owns the real provider secret:
{
"services": [{ "binding": "EGRESS", "service": "my-private-egress-broker" }],
"durable_objects": {
"bindings": [{ "name": "AGENTS", "class_name": "CodingAgent" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["CodingAgent"] }],
"vars": { "NANOCODEX_AUTH_MODE": "chatgpt" }
}Do not put OPENAI_API_KEY, OAuth material, account IDs, or relay capabilities
in this managed Worker configuration. A private Service Binding is a
controlled-code boundary, so the separately deployed broker must still enforce
one exact destination, one matching credential policy, placeholder replacement,
header allowlisting, and no public route.
Task-tree orchestration is an optional extension over the core agent. Both
native and WASM consumers run the same Rust implementation and receive the
same seven tools: spawn_agent, submit_result, send_agent_message,
list_agents, wait_agent, interrupt_agent, and close_agent.
Inside a caller-owned Worker or server isolate, host capabilities stay as ordinary functions without crossing another compatibility protocol:
import { Agent, Transport } from "nanocodex/host";
import nanocodexWasm from "./nanocodex.wasm";
const myApplicationTool = {
name: "lookup_order",
description: "Look up one order.",
parameters: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
handler: ({ id }) => orders.get(id),
};
const agent = await Agent.create({
module: nanocodexWasm,
transport: Transport.hostManaged({
websocketUrl: "/api/responses",
createWebSocket: (endpoint) => new WebSocket(endpoint),
}),
tools: [myApplicationTool],
});parameters is optional and defaults to an open object. TypeScript types are
erased at runtime, so provide JSON Schema only when the model needs a precise
argument contract, as lookup_order does above.
Standard web and browser tools
nanocodex/tools contains composable named tools rather than another agent or
runtime. Each factory returns an entry that can sit beside application tools
and Rust/WASM extensions in the same array:
import { Agent, Transport } from "nanocodex/host";
import {
dataset,
imageGeneration,
updatePlan,
web,
} from "nanocodex/tools";
const agent = await Agent.create({
transport: Transport.hostManaged({
websocketUrl: "/api/responses",
createWebSocket: (endpoint) => new WebSocket(endpoint),
}),
tools: [
web(),
dataset(),
imageGeneration({
recentImages: (sessionId, count) => images.get(sessionId).slice(-count),
rememberImage: (sessionId, imageUrl) => images.get(sessionId).push(imageUrl),
}),
updatePlan(),
myApplicationTool,
],
});The web and image factories use the canonical OpenAI/Codex tool names, argument
schemas, bounds, and image-edit modes, and normalize common malformed model
arguments before dispatch. In a browser, they default to the same-origin
/api/tools/web-search and /api/tools/image-generation routes. The host owns
only a bounded JSON endpoint, credentials, authorization, and persistence.
web(...) posts { commands, session_id, model }, where model is the
effective model of the invoking root or subagent; imageGeneration(...) posts
{ images, prompt }. The host owns model authorization and may ignore or
override this value. Pass url when the host route lives elsewhere.
dataset() runs entirely in the caller and inspects public HTTPS Parquet,
uncompressed JSONL, and Hugging Face datasets. It opens a session-scoped handle,
returns schema metadata, and supports projection and filtering queries without
hard row or offset ceilings. Input and output bytes remain bounded; partial
results return an opaque nextCursor that retains the query and resumes from a
physical Parquet row batch or JSONL byte position. Parquet uses HTTP range reads
and predicate pushdown where possible; JSONL scans incrementally and requires
byte-range support for cursor continuation. The implementation, Parquet reader,
and non-Snappy codecs load only after the model first calls the tool. Direct URLs
must allow browser CORS, and Parquet servers must support byte ranges.
Consumers that only need this capability can import dataset from the smaller
nanocodex/tools/dataset leaf entry.
const datasets = dataset();
const opened = await datasets.handler({
operation: "open",
source: {
kind: "huggingface",
dataset: "openai/gsm8k",
config: "main",
split: "train",
},
}, { sessionId: "thread-1" });
const page = await datasets.handler({
operation: "query",
dataset_id: opened.datasetId,
columns: ["question", "answer"],
filters: [{ column: "question", op: "contains", value: "how many" }],
limit: 5,
}, { sessionId: "thread-1" });
if (page.nextCursor) {
await datasets.handler({
operation: "query",
dataset_id: opened.datasetId,
cursor: page.nextCursor,
limit: 5,
}, { sessionId: "thread-1" });
}This same adapter works inside a Cloudflare Worker or Durable Object:
import { Agent, Transport } from "nanocodex/host";
import { web } from "nanocodex/tools";
const agent = await Agent.create({
module: env.NANOCODEX_WASM,
transport: Transport.hostManaged({
websocketUrl: env.RESPONSES_WEBSOCKET_URL,
createWebSocket: (endpoint) => new WebSocket(endpoint),
}),
toolMode: "direct",
tools: [
web({
url: env.WEB_TOOL_URL,
headers: { authorization: `Bearer ${env.WEB_TOOL_TOKEN}` },
}),
],
});For a caller-owned browser Worker, browser(...) composes the same tools with
one persistent OPFS workspace and a lazy WASM-backed shell (Python through
Pyodide, C/C++ through wasm-clang, plus browser Git and bounded commands):
import { Agent } from "nanocodex/host";
import { browser } from "nanocodex/tools/browser";
const runtime = await browser({
threadId,
recentImages,
rememberImage,
});
const agent = await Agent.create({
transport,
filesystem: runtime.filesystem,
instructions: runtime.instructions,
executionEnvironment: {
currentDate,
timezone,
projectInstructions: runtime.projectInstructions,
},
tools: runtime.tools,
});browser(...) runs in a browser Worker because OPFS is a browser capability;
use the individual factories in server-side Cloudflare Workers. Vite integration
is provided separately by nanocodex-vite.
The browser composition includes native browseX public X browsing, advertised
by accountInfo().apis without an X connector. The embedding app serves
/api/tools/x/browse and /api/tools/x/convert; Nanocodex's account app forwards
these requests to the private X Worker.
The browser composition includes render_artifact as a normal typed tool. For
other hosts, compose the same factory with any workspace implementing the
Nanocodex workspace contract:
import { artifact, web } from "nanocodex/tools";
const tools = [
web({ url: env.WEB_TOOL_URL }),
artifact({ workspace }),
];The artifact factory performs no dynamic evaluation and is safe to load in a
Cloudflare Worker. Browser hosts additionally install the exact iframe syntax
validator. The model calls tools.render_artifact({ id, title, source }) from
Code Mode, or render_artifact directly when the host selects direct mode; no
artifact CLI is installed. Artifact capacity is host-owned: the binding adds no
byte, source-length, ID-length, or document-count policy limits.
Application tools may provide outputSchema alongside parameters. The
binding serializes it to Rust's output_schema, so Code Mode receives the same
generated TypeScript return declaration as native Codex tools instead of
guessing result fields:
const execCommand = {
name: "exec_command",
description: "Run a command.",
parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] },
outputSchema: {
type: "object",
properties: { output: { type: "string" }, wall_time_seconds: { type: "number" } },
required: ["output", "wall_time_seconds"],
additionalProperties: false,
},
handler: runCommand,
};This is what loading a Rust-written tool from JavaScript looks like here.
nanocodex-subagents is statically linked into nanocodex.wasm; every JS
Agent.create(...) installs it by default. Spreading Subagents.create() into
tools overrides its maximum concurrency and contributes one opaque extension
entry, not seven JavaScript handlers. Inside the binding, Rust creates one
shared registry and installs fresh tools for every root, spawn, and fork:
let (registry, control, updates) = nanocodex_subagents::channel(max_concurrency);
let tools = Tools::builder().without_defaults().build()?;
let tools = nanocodex_tools::embedded::bind_host(tools, javascript_host);
let (agent, events) = Nanocodex::builder(openai)
.tools_factory(move |handle| {
nanocodex_subagents::install_tools(tools.clone(), handle, registry.clone())
})
.build()?;This is deliberately static composition, not a generic runtime loader for an
arbitrary second .wasm plugin. A custom Rust extension is linked into the
binding crate at build time and exposed by a small branded JS configuration;
adding a dynamic component ABI would be a separate feature with a much larger
contract and runtime cost.
The root owns the task tree. agent.session.shutdown() closes every child
before stopping the root driver; applications do not maintain a parallel JS
scheduler or reimplement the communication tools.
Persistent workspaces
Runtime-specific Workspace adapters give an embedding application one file
contract for both local browser kernels and Node kernels. The browser adapter
uses the origin-private file system (OPFS), so reopening the same stable name
after a Worker, page, or agent-session restart reuses its files. The Node
adapter roots the same operations in an ordinary directory and refuses path
traversal and symbolic-link escapes.
import { Workspace } from "nanocodex/browser/workspace";
import { Agent, Transport } from "nanocodex/host";
const workspace = await Workspace.open({ name: "my-notebook" });
const agent = await Agent.create({
transport: Transport.hostManaged({
websocketUrl: "/api/responses",
createWebSocket: (endpoint) => new WebSocket(endpoint),
}),
filesystem: workspace,
});
await workspace.writeFile("README.md", "# Durable browser workspace\n");
console.log(await workspace.list(".", { recursive: true }));The returned handle is application-owned and remains usable by a file browser,
editor, upload/download surface, or another agent session. Workspace.tools
exposes bounded list_files, read_file, write_file, make_directory, and
delete_file operations through the normal caller-defined tool boundary. It
does not add a fake browser shell.
Node uses the same shape with a real directory:
import { Agent, Transport, Workspace } from "nanocodex/node";
const workspace = await Workspace.open({ path: process.cwd() });
const agent = await Agent.create({
transport: Transport.openAi({ apiKey: process.env.OPENAI_API_KEY }),
filesystem: workspace,
});Node and browser applications can instead pay through MPP without an OpenAI
API key. Pass an MPP session with a ws(endpoint) method; an mppx Tempo
session manager has this shape. Nanocodex defaults the socket to
wss://openai.mpp.tempo.xyz/v1/responses when mpp is present.
import { Agent, createTempoProviderFromAccounts, Transport } from "nanocodex/node";
import { Expiry } from "accounts";
import { Provider } from "accounts/cli";
import { parseUnits } from "viem";
import { connect } from "viem/experimental/erc7846";
import WebSocket from "ws";
const pathUsd = "0x20c0000000000000000000000000000000000000";
const provider = Provider.create({ mpp: false });
if (!provider.store.persist.hasHydrated()) {
await new Promise((resolve) => provider.store.persist.onFinishHydration(resolve));
}
const status = await provider.getAccessKeyStatus();
if (status === "missing" || status === "expired") {
await connect(provider.getClient(), {
capabilities: { authorizeAccessKey: {
expiry: Expiry.days(1),
limits: [{ token: pathUsd, limit: parseUnits("25", 6) }],
} },
});
}
const root = provider.getAccount();
const account = await provider.store.accessKeys.select({
account: root.address,
chainId: provider.getClient().chain.id,
});
if (!account) throw new Error("Tempo account has no usable access key");
console.error(`Tempo access-key signer: ${account.accessKeyAddress}`);
const tempoProvider = await createTempoProviderFromAccounts({
wallet: provider,
accessKey: account.accessKeyAddress,
policy: {
autoSwap: { tokenIn: [pathUsd], slippage: 1 },
maxDeposit: "0.05",
topUpAmount: "0.05",
},
session: { bootstrap: true, webSocket: WebSocket },
});
const mpp = tempoProvider.session;
const agent = await Agent.create({
transport: Transport.mpp({ session: tempoProvider }),
thinking: "none",
fastMode: true,
tools,
});
const events = agent.events.watch();
const unwatch = events.onEvent((event) => {
process.stdout.write(`${JSON.stringify(event)}\n`);
});
let turn;
let result;
try {
turn = agent.turn.prompt({ input: "Build the thing." });
result = await turn.result();
console.error(result.finalMessage);
} finally {
try {
result?.dispose();
} finally {
turn?.dispose();
}
unwatch();
events.off();
const cleanupErrors = [];
try {
await agent.session.shutdown();
} catch (error) {
cleanupErrors.push(error);
}
try {
await mpp.close();
} catch (error) {
cleanupErrors.push(error);
}
if (cleanupErrors.length === 1) throw cleanupErrors[0];
if (cleanupErrors.length > 1) {
throw new AggregateError(cleanupErrors, "agent shutdown and MPP settlement both failed");
}
}The application still owns its wallet, deposit policy, persisted payment
channel store, and final settlement. Keep the manager alive to reuse its channel
across agents, and supply mppx channelStore for reuse after a process or page
restart. Nanocodex never closes a caller-owned MPP session.
createTempoProviderFromAccounts({ wallet, ... })
accepts any provider returned by Accounts SDK Provider.create(...), regardless
of its wallet adapter, and constructs both payment paths from that provider's
adapter-neutral getMppxParameters() contract. The lower-level
createTempoProvider({ session, payment }) remains available when the
application constructs MPPx itself. Both explicitly select Tempo provider mode.
In that mode Nanocodex automatically adds its built-in Mercator MCP and wraps it
with the same wallet and payment policy. The provider also exposes an MPP-aware
fetch; Mercator's paid REST handoffs use that same method rather than a second
wallet or payment configuration. Its MCP transport remains wrapped at the MCP
protocol layer, so browser requests do not need an Accept-Payment CORS header.
Browser Connect consumers send paid REST handoffs through the Connect API's
fixed Mercator relay because Mercator's job endpoint is not itself CORS-enabled;
the relay preserves MPP challenges, credentials, and receipts but never signs.
Passing a generic MppSession, an OpenAI key, or ChatGPT host auth does not
initialize Mercator. Pass mcp: false to opt out explicitly.
Remote Streamable HTTP MCP servers are configured directly on the agent. The
JavaScript binding uses the official MCP SDK transport, keeps remote tools
deferred, and mirrors native Nanocodex exposure: the initial Responses request
contains provider-native tool_search, while canonical mcp__<server>__<tool>
functions are callable only below Code Mode. Code Mode also exposes
tools.tool_search, so one cell can discover a deferred tool and invoke the
returned canonical name. Search results return loadable namespaces for the next
model request; remote tools never become a flat set of top-level model-visible
calls.
MPP-enabled MCP uses MPPx's in-place McpClient.wrap. Ordinary paid HTTP uses
Mppx.create(...).fetch. The public tempo() method is installed in both and
supports Tempo charge and session challenges, so paid services composed behind
Mercator use the same signer and spending policy as the model:
const mcpMethod = tempo({
account,
channelStore,
getClient: () => provider.getClient(),
maxDeposit: "0.05",
topUpAmount: "0.05",
});
const agent = await Agent.create({
transport: Transport.mpp({
session: createTempoProvider({
session: mpp,
payment: { methods: [mcpMethod] },
}),
}),
});Explicit mcp entries are merged over the Tempo defaults, so an application
can replace mercator or add other servers without rebuilding the provider.
Each server also accepts headers, fetch, allow/deny tool lists, a timeout,
or an already initialized MCP SDK-compatible client. Nanocodex closes clients
it creates and leaves caller-owned clients open. Connection failures are
reported by tool_search so one unavailable server does not prevent the agent
from starting.
Code Mode is the default. Model-facing exec cells can yield with a first-line
// @exec: {"yield_time_ms": 1000, "max_output_tokens": 1000} directive or
yield_control(). The model resumes the returned cell ID through wait, which
returns only new output and can terminate the cell. Cells belong to their agent
session and are invalidated when the host shuts down; a persisted wait never
restarts missing work. Embedded cells retain ownership of all nested tool calls
until they finish or are cancelled.
Custom evaluators receive audio, notify, yield_control, setTimeout, and
clearTimeout alongside the existing globals in CodeEvaluatorEnvironment.
Forward those helpers into the guest environment to preserve the model-visible
contract. image accepts individual MCP image blocks and honors explicit detail
before MCP metadata; audio accepts MCP audio blocks. Both accept data URLs.
Runtimes whose content-security policy rejects eval/new Function can supply
a Code Mode evaluator. createQuickJsEvaluator accepts an asyncified
quickjs-emscripten-core module, serializes Asyncify execution, and exposes only
the standard Nanocodex Code Mode globals across the interpreter boundary. This
keeps deferred MCP plus Code Mode functional in Cloudflare Workers:
import asyncVariant from "@jitl/quickjs-wasmfile-release-asyncify";
import { Agent, createQuickJsEvaluator, createTempoProvider, Transport } from "nanocodex/host";
import { newQuickJSAsyncWASMModuleFromVariant } from "quickjs-emscripten-core";
const quickJs = await newQuickJSAsyncWASMModuleFromVariant(asyncVariant);
const agent = await Agent.create({
transport: Transport.mpp({ session: tempoProvider }),
// module and mcp omitted here
codeEvaluator: createQuickJsEvaluator(quickJs),
});Cloudflare requires the QuickJS .wasm file to be statically imported and
passed with newVariant(..., { wasmModule }); the complete deployment is in
examples/cloudflare-fetch-mcp.
Completed results can be persisted and resumed by a fresh Node or browser agent:
const snapshot = await result.snapshot();
result.dispose();
await agent.session.shutdown();
const resumed = await Agent.create({
transport: Transport.openAi({ apiKey: process.env.OPENAI_API_KEY }),
resume: snapshot,
tools,
});
await resumed.session.shutdown();The snapshot contains authoritative typed history but no provider response ID, so the first resumed request safely replays the committed conversation. Resume with the same instructions and tool definitions, and release the original agent before handing its snapshot to another writer.
For crash recovery inside a turn, provide the generic durability host instead of manually persisting snapshots. The host stores one opaque Rust state value; model replay, tool ambiguity, operation deduplication, and checkpoint recovery remain in Rust/WASM:
import { Agent, Transport } from "nanocodex/host";
const agent = await Agent.create({
transport: Transport.openAi({ apiKey: process.env.OPENAI_API_KEY }),
durability: {
async load(stateId) {
return database.loadState(stateId);
},
async acquire(stateId, { ownerId }) {
return database.acquireState(stateId, ownerId);
},
async replace(stateId, { ownerId, fence, expectedRevision, payload }) {
return database.compareAndReplace(
stateId,
ownerId,
fence,
expectedRevision,
payload,
);
// { status: "replaced", revision: "8" }
// or { status: "conflict", actualRevision: "8" }
// or { status: "not_committed", message: "transaction rolled back" }
},
},
durabilityId: "customer-agent-123",
});
// Every prompt is durable because the state store is configured. Supply `id`
// only when an external retry must identify the same logical operation.
const turn = agent.turn.prompt({ input: "Build the thing." });
// const turn = agent.turn.prompt({ id: "request-7", input: "Build the thing." });
let result;
try {
result = await turn.result();
console.log(result.finalMessage);
} finally {
try {
result?.dispose();
} finally {
turn.dispose();
await agent.session.shutdown();
}
}Revisions are unsigned decimal strings so JavaScript preserves Rust's full
u64 range. Import durabilityRevision, createMemoryDurabilityStore,
createSqliteDurabilityStore, and sqliteDurabilitySchema from the small
nanocodex/durability leaf. Durable step hosts can carry the memory store's
snapshot() into the next step. SQLite hosts provide one transaction query
adapter and execute the canonical schema; the platform never interprets the
opaque Rust state. See js/managed,
examples/vercel-workflows, and examples/rivet-actors for all three host
shapes.
Cloudflare Durable Objects can bind their colocated SQLite and initialize the canonical schema in one call. The adapter is structural and adds no Workers runtime dependency:
import { createCloudflareDurabilityStore } from "nanocodex/durability/cloudflare";
const durability = createCloudflareDurabilityStore(this.ctx.storage);
const agent = await Agent.create({
module: env.NANOCODEX_WASM,
transport,
durability,
durabilityId: sessionId,
});Vercel and other PostgreSQL hosts use createPostgresDurabilityStore(pool)
from nanocodex/durability/postgres; connection ownership and secret policy
remain in the application.
The built-in stores can move one stopped agent across providers without decoding or rebasing its Rust state. Cloudflare owners should use the adapter's lifecycle-safe export instead of reconstructing its private state ID:
import { Agent as CloudflareAgent } from "nanocodex/cloudflare";
import { importDurabilityStatePages } from "nanocodex/durability";
import { createPostgresDurabilityStore } from "nanocodex/durability/postgres";
await cloudflareAgent.session.shutdown();
const pages = [];
let cursor;
let to;
do {
const page = await CloudflareAgent.exportDurabilityState(durableObjectOwner, {
from: "0", // exclusive destination revision
to, // omit once, then repeat the selected inclusive source revision
cursor,
});
pages.push(page);
to = page.to;
cursor = page.nextCursor ?? undefined;
} while (cursor !== undefined);
// Send the pages through an authenticated, encrypted operator path.
const destination = createPostgresDurabilityStore(vercelPostgresPool);
await importDurabilityStatePages(destination, JSON.parse(JSON.stringify(pages)));
const vercelAgent = await Agent.create({
module: wasmModule,
transport,
durability: destination,
durabilityId: pages[0].stateId,
});from is exclusive and to is inclusive. For a nonzero from, load the
destination once, hash that exact state with durabilityStateDigest, and repeat
the short fromDigest on every page request; revision zero's null-state digest
is implied. Each page carries that SHA-256 lineage digest, so import atomically
succeeds only if the destination still has the exact revision and payload
selected at from.
Because to is one complete Rust state, no intermediate revision log is
needed. Export fences the old source owner, and PostgreSQL reconciles lost
COMMIT responses internally by retrying the identical idempotent request, so
the API never reports an ambiguous write outcome. Stop source admission before
the first page and never resume it after cutover begins. Pages can contain
conversation and tool state, so handle them as secrets. The Vercel example
includes a WASM integration test that executes the
same agent Cloudflare → PostgreSQL → Cloudflare, replays committed turn IDs
without model calls, rebuilds the first new provider request from committed
history without a previous-response handle, and then continues with new turns
on each destination.
The managed Cloudflare service exposes the same offline cutover at POST
/v1/agents/<agent-id>/durability; the call permanently closes source admission.
Create a destination with POST /v1/agents, an Idempotency-Key header, and
{ "durability": <archive> }. The stable key owns resumable receipt adoption.
The Vercel example accepts that same body at POST /api/sessions and exports a
stopped PostgreSQL state through POST /api/durability/export with
{ "state_id": <durability-id>, "from": <revision>,
"fromDigest": <required-for-nonzero-from>, "to": <optional-revision>,
"cursor": <optional-cursor> }.
Node embedders whose bundler relocates package assets may compile and pass the web-target artifact explicitly. The runtime still uses the Node host for WebSockets and Code Mode:
const module = await WebAssembly.compile(await readFile(wasmAssetPath));
const agent = await Agent.create({ transport: Transport.openAi({ apiKey }), module });A Codex-compatible rollout can also be resumed by materializing its committed
response_item history into a snapshot with no request_prefix. Nanocodex
rebuilds the current prefix from the supplied instructions and JavaScript tools
while preserving the rollout's workspace, lineage, cache key, canonical user
context, and typed history.
Agent and Actions are module namespaces, not classes. Agent.create returns
an owned client decorated with matching domain actions:
agent.turn.prompt(...)/Actions.turn.prompt(agent, ...)turn.accepted()/Actions.turn.accepted(turn)turn.result()/Actions.turn.getResult(turn)result.snapshot()/Actions.turn.getSnapshot(result)result.usage()/Actions.turn.getUsage(result)agent.session.fork(...)/Actions.session.fork(agent, ...)agent.session.compact()/Actions.session.compact(agent)agent.session.setThinking(...)/Actions.session.setThinking(agent, ...)agent.session.setFastMode(...)/Actions.session.setFastMode(agent, ...)agent.session.shutdown()/Actions.session.shutdown(agent)agent.session.spawn()/Actions.session.spawn(agent)agent.events.watch(...)/Actions.events.watch(agent, ...)
turn.accepted() resolves when Rust has admitted the prompt. A durable agent
returns its stable request ID; a custom runtime without durable admission
returns undefined. Managed HTTP hosts can await this narrow boundary before
acknowledging a request without waiting for model execution or materializing a
result.
turn.result() resolves to a frozen, opaque completed TurnResult handle. Its
finalMessage is eager. The async usage() and snapshot() actions materialize
immutable values once and cache their promises. A package Worker completes a
turn with only the message and hidden result identity; Rust-produced snapshot
JSON crosses the Worker boundary only on first demand and is parsed once in the
calling isolate. Historical fork({ at }) consumes the hidden identity directly,
never an unfinished turn, clone, snapshot, or provider response ID.
The completed result owns its identity independently from the Turn, so
turn.dispose() does not invalidate a successful result. Call result.dispose()
after its last fork/materialization; this releases the retained Worker/native
checkpoint and invalidates future snapshot(), usage(), and historical forks.
An undisposed result intentionally keeps its package Worker alive after the last
Agent shuts down so its lazy values remain available. Garbage collection is only
a fallback for forgotten handles, not deterministic cleanup.
turn.dispose() only releases the JavaScript/WASM handle; like dropping the
Rust Turn, it does not cancel accepted work. Await turn.cancel() before
disposing unfinished work. At an application or session boundary,
agent.session.shutdown() cancels unfinished turns and joins driver, model,
tool, and transport cleanup.
Every action owns its types, for example Actions.turn.prompt.Options,
Actions.turn.prompt.ReturnType, and Actions.events.watch.Watcher.
Event watches are lazy, terminal handles:
const watch = agent.events.watch();
const unlisten = watch.onEvent(console.log);
unlisten();
watch.off();A throwing callback is reported through the host's reportError hook (or
console.error when that hook is unavailable) without interrupting later
listeners or the owned agent lifecycle.
The same watcher can instead be consumed as an ordered async iterable; breaking
the loop releases that iterator, while watch.off() terminates the whole watch.
const watch = agent.events.watch();
for await (const event of watch) {
console.log(event);
if (done) break;
}
watch.off();Applications add typed action domains with decorators:
const extended = agent.extend((client) => ({
inspect: {
session: () => client.sessionId,
},
}));
extended.inspect.session();The package-owned browser Worker accepts the same transport policy without function-valued callbacks:
import { Agent, Transport } from "nanocodex/browser";
const agent = await Agent.create({
transport: Transport.hostManaged({
websocketUrl: signedOrCookieAuthorizedEndpoint,
}),
threadId,
});Caller-owned browser Workers and server isolates import nanocodex/host when
they need function-valued tools or socket construction. Server-side runtimes
can await a fetch()-based WebSocket upgrade. The third callback argument is a
discriminated authorization request plus connection metadata, including the
eager preconnect request. With Transport.openAi, authorization is
"bearer" and bearerToken is present. With Transport.hostManaged, it is
"host_managed"; the host must resolve credentials without exposing them to
WASM. Do not retain or log bearer tokens. Return the socket alone or a
descriptor containing response metadata:
import { Agent, Transport } from "nanocodex/host";
import module from "nanocodex/wasm";
const agent = await Agent.create({
transport: Transport.openAi({
apiKey,
async createWebSocket(endpoint, sessionId, request) {
if (request.authorization !== "bearer") {
throw new Error("this host requires Nanocodex bearer authorization");
}
const response = await fetch(endpoint.replace("wss:", "https:"), {
headers: {
Authorization: `Bearer ${request.bearerToken}`,
Upgrade: "websocket",
"session-id": sessionId,
},
});
if (!response.webSocket) throw new Error(`upgrade failed: ${response.status}`);
response.webSocket.accept();
return { socket: response.webSocket, status: response.status };
},
}),
module,
});Transport.hostManaged is useful when the embedding runtime owns rotating credentials. The
callback can acquire a fresh token, attempt the upgrade, and refresh-and-retry
on 401. Bound and reject upgrade work in the callback: until it returns a
socket, there is no connection handle for Nanocodex to close. Selecting one
transport makes authentication modes mutually exclusive by construction.
After publication, a browser can load the current-isolate host without a package manager or build step:
<script type="module">
import { Agent, Transport } from "https://cdn.jsdelivr.net/npm/[email protected]/host/index.mjs";
const agent = await Agent.create({
transport: Transport.hostManaged({
websocketUrl: "/api/responses",
createWebSocket: (endpoint) => new WebSocket(endpoint),
}),
});
const turn = agent.turn.prompt({ input: "Hello." });
let result;
try {
result = await turn.result();
console.log(result.finalMessage);
} finally {
try {
result?.dispose();
} finally {
turn.dispose();
await agent.session.shutdown();
}
}
</script>Pin the package version in production. The adjacent WASM file is part of the
npm package and is resolved relative to the host module. This no-build path
runs in the current page isolate; bundled applications should prefer the
package-owned Worker from nanocodex/browser. The endpoint must be authorized
by the embedding application because browser WebSockets cannot attach OpenAI's
upgrade authorization header.
The owned Rust session retains follow-on history, response state, tool output, its WebSocket, and stable prompt-cache identity. Typed browser content accepts ordered text, remote/data-URL image, and audio items. JavaScript tools are ordinary async handlers described by JSON Schema and appear in the same ordered agent event stream as built-in code mode.
Run the standalone Node proof with:
cd examples/node
npm install
OPENAI_API_KEY=... npm startManaged clients can save Agent.definitions and Agent.environments, select them
with Agent.create({ definitionId, environmentTemplateId, configuration }), and
inspect agent.configuration(), environment(), usage(), requests(),
artifacts, webhook, and requiredActions. See the
managed configuration and operations guide
for examples, authorization, delivery semantics, and runtime limits.
