docs/reference/code-mode.md
Code mode is an experimental OpenClaw agent-runtime feature. It is off by
default. When you enable it, OpenClaw changes what the model sees for one run:
instead of exposing every enabled tool schema directly, the model sees only
exec and wait.
This page documents OpenClaw code mode. It is not Codex Code mode. The two
features share a name, but they are implemented by different runtimes and expose
different exec contracts:
exec.command contract.tools.codeMode.enabled: true is
configured. It runs in the OpenClaw generic agent runtime, where the model
writes JavaScript or TypeScript programs through an exec.code contract.Codex Code Mode and Codex-native dynamic tool search are stable Codex harness
surfaces. OpenClaw code mode is an OpenClaw-owned experimental tool-surface
adapter for generic OpenClaw runs. It uses quickjs-wasi, a hidden OpenClaw
tool catalog, and the normal OpenClaw tool executor.
OpenClaw code mode lets the model write a small JavaScript or TypeScript program instead of choosing directly from a long list of tools.
When code mode is active:
exec and wait.exec evaluates model-generated JavaScript or TypeScript in a constrained
QuickJS-WASI worker.ALL_TOOLS and tools.MCP namespace. In code mode, this namespace
is the only supported way to call MCP tools.wait resumes a suspended code-mode run when nested tool calls are still
pending.The important distinction: code mode changes the model-facing orchestration surface. It does not replace OpenClaw tools, plugin tools, MCP tools, auth, approval policy, channel behavior, or model selection.
Code mode makes large tool catalogs easier for models to use.
Code mode is especially useful for agents with a large enabled tool catalog or for workflows where the model repeatedly needs to search, combine, and call tools before producing an answer.
Add tools.codeMode.enabled: true to the agent or runtime config:
{
tools: {
codeMode: {
enabled: true,
},
},
}
The shorthand is also accepted:
{
tools: {
codeMode: true,
},
}
Code mode remains off when tools.codeMode is omitted, false, or an object
without enabled: true.
When you use sandboxed agents with configured MCP servers, also make sure the
sandbox tool policy allows the bundled MCP plugin, for example with
tools.sandbox.tools.alsoAllow: ["bundle-mcp"]. See
Configuration - tools and custom providers.
Use explicit limits when you want tighter bounds:
{
tools: {
codeMode: {
enabled: true,
timeoutMs: 10000,
memoryLimitBytes: 67108864,
maxOutputBytes: 65536,
maxSnapshotBytes: 10485760,
maxPendingToolCalls: 16,
snapshotTtlSeconds: 900,
searchDefaultLimit: 8,
maxSearchLimit: 50,
},
},
}
To confirm the model payload shape while debugging, run the Gateway with targeted logging:
OPENCLAW_DEBUG_CODE_MODE=1 \
OPENCLAW_DEBUG_MODEL_TRANSPORT=1 \
OPENCLAW_DEBUG_MODEL_PAYLOAD=tools \
openclaw gateway
With code mode active, the logged model-facing tool names should be exec and
wait. If you need the redacted provider payload, add
OPENCLAW_DEBUG_MODEL_PAYLOAD=full-redacted for a short debugging session.
The rest of this page describes the runtime contract and implementation details. It is intended for maintainers, plugin authors debugging tool exposure, and operators validating high-risk deployments.
quickjs-wasi.Code mode owns the model-facing orchestration shape for a prepared run. It does not own model selection, channel behavior, auth, tool policy, or tool implementations.
In scope:
exec and wait tool definitionsOut of scope:
Provider-owned tools such as remote Python sandboxes remain separate tools. See Code execution.
Code mode is the OpenClaw runtime mode that hides normal model tools and
exposes only exec and wait.
Guest runtime is the QuickJS-WASI JavaScript VM that evaluates model code.
Host bridge is the narrow JSON-compatible callback surface from guest code back into OpenClaw.
Catalog is the run-scoped list of effective tools after normal tool policy, plugin, MCP, and client-tool resolution.
Nested tool call is a tool call made from guest code through the host bridge.
Snapshot is serialized QuickJS-WASI VM state saved so wait can continue a
suspended code-mode run.
tools.codeMode.enabled is the activation gate. Setting other code-mode fields
does not enable the feature.
Supported fields:
enabled: boolean. Default false. Enables code mode only when true.runtime: "quickjs-wasi". Only supported runtime.mode: "only". Exposes exec and wait, hides normal model tools.languages: array of "javascript" and "typescript". Default includes
both.timeoutMs: wall-clock cap for one exec or wait. Default 10000.
Runtime clamp: 100 to 60000.memoryLimitBytes: QuickJS heap cap. Default 67108864. Runtime clamp:
1048576 to 1073741824.maxOutputBytes: cap for returned text, JSON, and logs. Default 65536.
Runtime clamp: 1024 to 10485760.maxSnapshotBytes: cap for serialized VM snapshots. Default 10485760.
Runtime clamp: 1024 to 268435456.maxPendingToolCalls: cap for concurrent nested tool calls. Default 16.
Runtime clamp: 1 to 128.snapshotTtlSeconds: how long a suspended VM can be resumed. Default 900.
Runtime clamp: 1 to 86400.searchDefaultLimit: default hidden-catalog search result count. Default 8.
Runtime clamps this to maxSearchLimit.maxSearchLimit: maximum hidden-catalog search result count. Default 50.
Runtime clamp: 1 to 50.If code mode is enabled but QuickJS-WASI cannot load, OpenClaw fails closed for that run. It does not silently expose normal tools as a fallback.
Code mode is evaluated after the effective tool policy is known and before the final model request is assembled.
Activation order:
tools.codeMode.enabled is false, continue with normal tool exposure.exec and wait.Runs that intentionally have no tools, such as raw model calls, disableTools,
or an empty allowlist, do not activate the code-mode surface even if the config
contains tools.codeMode.enabled: true.
The code-mode catalog is run-scoped. It must not leak tools from another agent, session, sender, or run.
When code mode is active, the model sees exactly these top-level tools:
execwaitAll other enabled tools are hidden from the model-facing tool list and registered in the code-mode catalog.
The model should use exec for tool orchestration, data joining, loops,
parallel nested calls, and structured transformations. The model should use
wait only when exec returns a resumable waiting result.
execexec starts a code-mode cell and returns one result. The input code is model
generated and must be treated as hostile.
Input:
type CodeModeExecInput = {
code?: string;
command?: string;
language?: "javascript" | "typescript";
};
Input rules:
code or command must be non-empty.code is the documented model-facing field.command is accepted as an exec-compatible alias for hook policies and
trusted rewrites; when both are present, the values must match.exec hook events include toolKind: "code_mode_exec" and
include toolInputKind: "javascript" | "typescript" when the input language
is known, so policies can distinguish code-mode cells from shell-style exec
calls that share the same tool name.language defaults to "javascript".language is "typescript", OpenClaw transpiles before evaluation.exec rejects import, require, dynamic import, and module-loader patterns
in v1.exec does not expose the normal shell exec implementation recursively.Result:
type CodeModeResult = CodeModeCompletedResult | CodeModeWaitingResult | CodeModeFailedResult;
type CodeModeCompletedResult = {
status: "completed";
value: unknown;
output?: CodeModeOutput[];
telemetry: CodeModeTelemetry;
};
type CodeModeWaitingResult = {
status: "waiting";
runId: string;
reason: "pending_tools" | "yield";
pendingToolCalls?: CodeModePendingToolCall[];
output?: CodeModeOutput[];
telemetry: CodeModeTelemetry;
};
type CodeModeFailedResult = {
status: "failed";
error: string;
code?: CodeModeErrorCode;
output?: CodeModeOutput[];
telemetry: CodeModeTelemetry;
};
exec returns waiting when the QuickJS VM suspends with resumable state that
still needs a model-visible continuation. The result includes a runId for
wait. Namespace bridge calls, including MCP namespace calls, are auto-drained
inside the same exec/wait call while they are ready, so a compact code block
can inspect $api() and call an MCP tool without forcing one model tool call per
namespace await.
exec returns completed only when the guest VM has no pending work and the
final value is JSON-compatible after OpenClaw's output adapter runs.
waitwait continues a suspended code-mode VM.
Input:
type CodeModeWaitInput = {
runId: string;
};
The output is the same CodeModeResult union returned by exec.
wait exists because nested OpenClaw tools can be slow, interactive, approval
gated, or stream partial updates. The model should not need to keep one long
exec call open while the host waits for external work.
QuickJS-WASI snapshot and restore is the v1 resume mechanism:
exec evaluates code until completion, failure, or suspension.wait restores the VM snapshot.wait returns completed, failed, or another waiting result.Snapshots are runtime state, not user artifacts. They are size-limited, expired, and scoped to the run and session that created them.
wait fails when:
runId is unknown.The guest runtime exposes a small global API:
declare const ALL_TOOLS: ToolCatalogEntry[];
declare const tools: ToolCatalog;
declare const MCP: Record<string, unknown>;
declare const namespaces: Record<string, unknown>;
declare function text(value: unknown): void;
declare function json(value: unknown): void;
declare function yield_control(reason?: string): Promise<void>;
ALL_TOOLS is compact metadata for the run-scoped catalog. It does not contain
full schemas by default.
type ToolCatalogEntry = {
id: string;
name: string;
label?: string;
description: string;
source: "openclaw" | "plugin" | "mcp" | "client";
sourceName?: string;
};
Full schema is loaded only on demand:
type ToolCatalogEntryWithSchema = ToolCatalogEntry & {
parameters: unknown;
};
Catalog helpers:
type ToolCatalog = {
search(query: string, options?: { limit?: number }): Promise<ToolCatalogEntry[]>;
describe(id: string): Promise<ToolCatalogEntryWithSchema>;
call(id: string, input?: unknown): Promise<unknown>;
[safeToolName: string]: unknown;
};
Convenience tool functions are installed only for unambiguous safe names:
const files = await tools.search("read local file");
const fileRead = await tools.describe(files[0].id);
const content = await tools.call(fileRead.id, { path: "README.md" });
// If the hidden catalog has an unambiguous `web_search` entry:
const hits = await tools.web_search({ query: "OpenClaw code mode" });
MCP catalog entries are not callable through tools.call(...) or convenience
functions in code mode. They are exposed only through the generated MCP
namespace. TypeScript-style declaration files are available through the
read-only API virtual file surface, so agents can inspect MCP signatures
without adding MCP schemas to the prompt:
const files = await API.list("mcp");
const githubApi = await API.read("mcp/github.d.ts");
const issue = await MCP.github.createIssue({
owner: "openclaw",
repo: "openclaw",
title: "Investigate gateway logs",
});
const snapshot = await MCP.chromeDevtools.takeSnapshot({ output: "markdown" });
const resource = await MCP.docs.resources.read({ uri: "memo://one" });
const prompt = await MCP.docs.prompts.get({
name: "brief",
arguments: { topic: "release" },
});
API.read("mcp/<server>.d.ts") returns compact declarations inferred from MCP
tool metadata:
type McpToolResult = {
content?: unknown[];
structuredContent?: unknown;
isError?: boolean;
[key: string]: unknown;
};
declare namespace MCP.github {
/** Return this TypeScript-style API header. */
function $api(toolName?: string, options?: { schema?: boolean }): Promise<McpApiHeader>;
/**
* Create a GitHub issue.
* @param owner Repository owner
* @param repo Repository name
* @param title Issue title
*/
function createIssue(input: {
owner: string;
repo: string;
title: string;
body?: string;
}): Promise<McpToolResult>;
}
The declaration files are virtual, not files written under the workspace or
state directory. For each code-mode exec call, OpenClaw builds the run-scoped
tool catalog, keeps the visible MCP entries, renders mcp/index.d.ts plus one
mcp/<server>.d.ts declaration per visible server, and injects that small
read-only table into the QuickJS worker. Guest code sees only the API object:
API.list(prefix?) returns file metadata and API.read(path) returns the
selected declaration content. Unknown paths and . / .. segments are rejected.
This keeps large MCP schemas out of the model prompt. The agent learns that the
virtual API exists from the exec tool description, reads only the needed
declaration file, and then calls MCP.<server>.<tool>() with one object argument.
MCP.<server>.$api() remains available as an inline fallback when the agent
needs a single-tool schema response inside the program.
The guest runtime must not expose host objects directly. Inputs and outputs cross the bridge as JSON-compatible values with explicit size caps.
Internal namespaces give code mode a concise domain API without adding more
model-visible tools. A loader-owned integration can register a namespace such
as Issues, Fictions, or Calendar; guest code then calls that namespace
inside the QuickJS program while OpenClaw still shows only exec and wait to
the model.
Namespaces are internal for now. There is no public plugin SDK namespace API: external plugin namespaces need a loader-owned contract so plugin identity, installed manifests, auth state, and cached catalog descriptors cannot drift from the plugin tools that back the namespace. Core code mode owns only the sandbox, serialization, catalog gating, and bridge dispatch.
Guest code can then use either the direct global or the namespaces map:
const open = await Issues.list({ state: "open" });
const alsoOpen = await namespaces.Issues.list({ state: "open" });
return { count: open.length, alsoCount: alsoOpen.length };
The namespace registry is process-local and keyed by namespace id. A typical run follows this path:
registerCodeModeNamespaceForPlugin(pluginId, registration).ToolSearchRuntime for the run and reads its
run-scoped catalog.createCodeModeNamespaceRuntime(ctx, catalog) keeps only registrations
whose requiredToolNames are all visible and owned by the same pluginId.createScope(ctx) for the current run. The
scope receives run context such as agentId, sessionKey, sessionId,
runId, config, and abort state.namespaces.<globalName>.ToolSearchRuntime.call.exec/wait tool call. If namespace work is still pending at the timeout or
the guest yields explicitly, wait resumes the same namespace runtime later.clearCodeModeNamespacesForPlugin(pluginId)
so stale globals do not survive a failed plugin load.The important invariant: namespace calls are catalog tool calls. They use the
same policy hooks, approvals, abort handling, telemetry, transcript projection,
and suspend/resume behavior as tools.call(...).
Register namespaces from the integration that owns the backing tools. Keep the scope small and only expose domain verbs that map to declared catalog tools.
import {
createCodeModeNamespaceTool,
registerCodeModeNamespaceForPlugin,
} from "../agents/code-mode-namespaces.js";
const pluginId = "github";
registerCodeModeNamespaceForPlugin(pluginId, {
id: "github-issues",
globalName: "Issues",
description: "GitHub issue helpers for the current repository.",
requiredToolNames: ["github_list_issues", "github_update_issue"],
prompt: "Use Issues.list(params) and Issues.update(number, patch).",
createScope: (ctx) => ({
repository: ctx.config,
list: createCodeModeNamespaceTool("github_list_issues", ([params]) => params ?? {}),
update: createCodeModeNamespaceTool("github_update_issue", ([number, patch]) => ({
number,
patch,
})),
}),
});
createCodeModeNamespaceTool(toolName, inputMapper) marks a scope member as a
callable namespace function. The optional inputMapper receives the guest
arguments and returns the input object for the backing catalog tool. Without an
input mapper, the first guest argument is used, or {} when omitted.
Raw host functions are rejected before guest code runs:
createScope: () => ({
// Wrong: this bypasses the catalog tool lifecycle and will be rejected.
list: async () => githubClient.listIssues(),
});
Namespace ownership is bound to the registration caller's pluginId.
requiredToolNames is both a visibility gate and an ownership check:
sourceName === pluginIdrequiredToolNamesThis prevents another plugin from exposing a namespace by registering a same-named tool. It also keeps namespaces aligned with ordinary agent policy: if the run cannot see the backing tools, it cannot see the namespace.
For example, a GitHub namespace should live behind a GitHub-owned extension that owns GitHub auth, REST or GraphQL clients, rate limits, write approvals, and tests. Core code mode should not embed GitHub-specific APIs, token handling, or provider policy.
createScope(ctx) may return a plain object containing JSON-compatible values,
arrays, nested objects, and createCodeModeNamespaceTool(...) call markers.
Host objects never enter QuickJS directly.
The serializer rejects:
__proto__, constructor, prototype, empty keys, or
keys containing the internal path separatorglobalName values that are not JavaScript identifiersglobalName collisions with built-in code-mode globals such as tools,
namespaces, text, json, yield_control, or __openclaw*Values that cannot be JSON-serialized are converted to JSON-safe fallback values before crossing the bridge. Binary data, handles, sockets, clients, and class instances should stay behind ordinary catalog tools.
The namespace description and optional prompt are appended to the model
visible exec schema only when the namespace is visible for that run. Use them
to teach the smallest useful surface:
{
description: "Fiction production service helpers.",
prompt:
"Use Fictions.riskAudit(), Fictions.promoteIfReady(id, status), and Fictions.unpaidOver(amount).",
}
Keep prompts about the namespace contract, not auth setup, implementation history, or unrelated plugin behavior.
Namespaces are process-local registrations. Remove them when the owning plugin is disabled, uninstalled, or rolled back:
clearCodeModeNamespacesForPlugin(pluginId);
Use unregisterCodeModeNamespace(namespaceId) only when removing one known
namespace. Tests can call clearCodeModeNamespacesForTest() to avoid leaking
registrations across cases.
Namespace changes should cover the security boundary and the guest behavior:
sourceName do not expose the namespacewaitNamespaces complement the generic tools.search / tools.call catalog. Use the
catalog for arbitrary enabled OpenClaw, plugin, and client tools; use MCP for
MCP tools; use other namespaces for plugin-owned, documented domain APIs where
concise code is more reliable than repeated schema lookups.
text(value) appends human-readable output to the output array.
json(value) appends a structured output item after JSON-compatible
serialization.
The guest code's final returned value becomes value in a completed result.
Output item:
type CodeModeOutput = { type: "text"; text: string } | { type: "json"; value: unknown };
Output rules:
maxOutputBytesThe hidden catalog includes tools after effective policy filtering:
Catalog ids are stable within one run and deterministic across equivalent tool sets when possible.
Recommended id shape:
<source>:<owner>:<tool-name>
Examples:
openclaw:core:message
plugin:browser:browser_request
mcp:github:create_issue
client:app:select_file
The catalog omits code-mode control tools:
execwaittool_search_codetool_searchtool_describetool_callThis prevents recursion and keeps the model-facing contract narrow.
MCP entries stay in the run-scoped catalog so policy, approvals, hooks,
telemetry, transcript projection, and exact tool ids remain shared with normal
tool execution. The guest-facing ALL_TOOLS, tools.search(...),
tools.describe(...), and tools.call(...) views omit MCP entries. The
generated MCP.<server>.<tool>({ ...input }) namespace resolves back to the
exact catalog id and then dispatches through the same executor path.
Code mode supersedes the OpenClaw Tool Search model surface for runs where it is active.
When tools.codeMode.enabled is true and code mode activates:
tool_search_code, tool_search, tool_describe,
or tool_call as model-visible tools.ALL_TOOLS metadata and search, describe,
and call helpers for non-MCP tools.MCP namespace and its $api() headers instead
of tools.call(...).The existing Tool Search page describes the OpenClaw compact
catalog bridge. Code mode is the generic OpenClaw alternative for runs that can
use exec and wait.
The model-visible exec tool is the code-mode tool. If the normal OpenClaw
shell exec tool is enabled, it is hidden from the model and cataloged like any
other tool.
Inside the guest runtime:
tools.call("openclaw:core:exec", input) can call the shell exec tool if
policy allows it.tools.exec(...) is installed only if the shell exec catalog entry has an
unambiguous safe name.exec tool is never recursively available through tools.If two tools normalize to the same safe convenience name, OpenClaw omits the
convenience function and requires tools.call(id, input).
Every nested tool call crosses the host bridge and re-enters OpenClaw.
Nested execution preserves:
before_tool_call hooksNested calls project into the transcript as real tool calls so support bundles can show what happened. The projection identifies the parent code-mode tool call and the nested tool id.
Parallel nested calls are allowed up to maxPendingToolCalls.
Each code-mode run has a state machine:
running: VM is executing or nested calls are in flight.waiting: VM snapshot exists and can be resumed with wait.completed: final value returned; snapshot deleted.failed: error returned; snapshot deleted.expired: snapshot or pending state exceeded retention; cannot resume.aborted: parent run/session cancelled; snapshot deleted.State is scoped by agent run, session, and tool call id. A wait call from a
different run or session fails.
Snapshot storage is bounded:
OpenClaw loads quickjs-wasi as a direct dependency in the owning package. The
runtime does not rely on a transitive copy installed for proxy, PAC, or other
unrelated dependencies.
Runtime responsibilities:
waitThe runtime executes outside OpenClaw's main event loop in a worker. A guest infinite loop must not block the Gateway process indefinitely.
TypeScript support is a source transform only:
import or require in v1failed resultsThe TypeScript compiler is loaded lazily only for TypeScript cells. Plain JavaScript cells and disabled code mode do not load the compiler.
The transform should preserve useful line numbers where feasible.
Model code is hostile. The runtime uses defense in depth:
quickjs-wasi as a direct dependency, not through Codex or a transitive
packageexec, wait, and Tool Search control toolsThe sandbox is one security layer. Operators can still need OS-level hardening for high-risk deployments.
type CodeModeErrorCode =
| "runtime_unavailable"
| "invalid_config"
| "invalid_input"
| "unsupported_language"
| "typescript_transform_failed"
| "module_access_denied"
| "timeout"
| "memory_limit_exceeded"
| "output_limit_exceeded"
| "snapshot_limit_exceeded"
| "snapshot_expired"
| "snapshot_restore_failed"
| "too_many_pending_tool_calls"
| "nested_tool_failed"
| "aborted"
| "internal_error";
Errors returned to the guest are plain data. Host Error instances, stack
objects, prototypes, and host functions do not cross into QuickJS.
Code mode reports:
exec and wait countsTelemetry must not include secrets, raw environment values, or unredacted tool inputs beyond existing OpenClaw trajectory policy.
Use targeted model transport logging when code mode behaves differently from a normal tool run:
OPENCLAW_DEBUG_CODE_MODE=1 \
OPENCLAW_DEBUG_MODEL_TRANSPORT=1 \
OPENCLAW_DEBUG_MODEL_PAYLOAD=tools \
OPENCLAW_DEBUG_SSE=events \
openclaw gateway
For payload-shape debugging, use OPENCLAW_DEBUG_MODEL_PAYLOAD=full-redacted.
This logs a capped, redacted JSON snapshot of the model request; it should only
be used while debugging because prompts and message text can still appear.
For stream debugging, use OPENCLAW_DEBUG_SSE=peek to log the first five
redacted SSE events. Code mode also fails closed if the final provider payload
does not contain exactly exec and wait after the code-mode surface has
activated.
Implementation units:
tools.codeModeexec and waitThe implementation reuses catalog and executor concepts from Tool Search, but
does not use the node:vm child as the sandbox.
Code mode coverage should prove:
enabled: true leaves code mode disabledexec and wait to the model when tools are
active for the rundisableTools, and empty allowlists do not trigger code-mode
payload enforcementALL_TOOLSALL_TOOLStools.search, tools.describe, and tools.call work for OpenClaw toolsAPI.list("mcp") and API.read("mcp/<server>.d.ts") expose TypeScript-style
MCP declarations without a bridge/tool call$api() remains available as an inline fallback for schemastools.*exec is hidden from the model but callable by catalog id when allowedexec and wait are not callable from guest codeimport, require, filesystem, network, and environment access failwait resumes a suspended snapshot and returns the final valuerunId values failRun these as integration or end-to-end tests when changing the runtime:
tools.codeMode.enabled: false.tools.codeMode.enabled: true.exec, wait.exec, read ALL_TOOLS and assert the effective test tools are present.exec, call OpenClaw/plugin/client tools through tools.search,
tools.describe, and tools.call.exec, call API.list("mcp") and API.read("mcp/<server>.d.ts") and
assert the declaration files describe visible MCP tools.exec, call MCP tools through MCP.<server>.<tool>({ ...input }) and
assert direct MCP catalog entries are absent from ALL_TOOLS and tools.*.exec returns waiting.wait and assert the restored VM receives the tool result.Docs-only changes to this page should still run pnpm check:docs.