requires: codemode
Code mode replaces a gateway's tool list with a single execute tool: the agent sends a short TypeScript script that discovers, calls and combines your tools, and Arion runs it in a sandbox and returns only the final value. Use it for multi-step work and large catalogs, where calling tools one model turn at a time costs round trips and fills the context window with intermediate results.
Why it matters
In tools mode the model is the orchestrator. It calls one tool, waits, reads the whole result, then decides the next call, so a five-step task takes five turns. Every intermediate payload also lands in the model's context, even when it is needed only for a moment. Code mode lets the model do what it does best — write code: the same steps become one small program that runs next to the tools. The tool calls still happen, but on the gateway's side of the boundary and in parallel where the script allows it. Only the answer crosses back.
Code mode changes how tools are called, never what is allowed. Every call a script makes goes through the same tool registry as a normal tools/call: the same per-tool authorization, schema validation, timeouts, size limits and metrics.
Before you begin
- Build Arion with the
codemodeCargo feature:cargo build --release -p arion-proxy --features codemode. A binary built without it rejects acode_modeconfiguration at startup rather than ignoring it. - Tools are called by qualified name,
namespace.name. Every static tool has anamespace, and dynamic servers and OpenAPI sources supply one for the tools they add. - Prefer tools with an
output_schema. Their results reach the script as parsed JSON; without one, the script receives the response body as a string.
Enable it
A gateway runs in exactly one mode. Replace tools_mode: {} with code_mode; every limit has a default, so an empty block is enough:
typed_config:
"@type": type.googleapis.com/arion.extensions.filters.http.mcp.mcp_gateway.v3.McpGateway
server_info:
name: support-tools
version: "1.0.0"
code_mode: {}
tools: [] # tools, dynamic_mcp_servers, open_api_tool_sources and tds work as in tools mode
In the Builder, type code mode to add it to an MCP gateway config, with timeout and calls for its limits.
The registry is built exactly as in tools mode. What changes is the surface agents see:
initializereturnsinstructionsthat point the client atexecute.tools/listreturns one tool,execute, whose arguments arecode(the script) and an optionaltimeout_ms.tool_searchandPOST /mcp/searchare not served; discovery happens inside the script.- The
executedescription is written for each caller. It lists the toolkits and namespaces that caller may use and the catalog calls for finding more. It is capped at 4 KiB, so a catalog of thousands of tools still gives the model a compact starting point.
Write a script
The script is the body of an async function: await works at the top level, and return gives back the value. TypeScript types are stripped before the script runs.
// Look up a customer, fetch history and plan in parallel, then open a ticket.
const customer = await tools["crm.find_customer"]({ email: "ada@example.com" });
if (!customer.ok) return { error: customer.error.code };
const [history, plan] = await Promise.all([
tools["support.recent_tickets"]({ customer_id: customer.data.id }),
tools["billing.current_plan"]({ customer_id: customer.data.id }),
]);
const ticket = await tools["support.create_ticket"]({
ticket: {
customer_id: customer.data.id,
priority: plan.ok && plan.data.tier === "enterprise" ? "high" : "normal",
summary: "Refund not received",
},
});
console.log("recent tickets:", history.ok ? history.data.items.length : "unavailable");
return { ticket: ticket.ok ? ticket.data.id : null };The script SDK
A script can use three globals.
tools calls a tool: tools["namespace.name"](args), or tools.crm.find_customer(args). Every call resolves to the same envelope — { ok: true, data } or { ok: false, error: { code, message, retryable } } — so a failed call is a value to branch on, not an exception. When the tool declares an output_schema, data is parsed JSON, not text to re-parse. Calls run concurrently under Promise.all, up to the gateway's concurrency limit.
catalog is discovery inside the script — the counterpart of tool_search in tools mode, without the extra round trip. It has six operations, and every one of them sees only the tools the caller is authorized for:
catalog.namespaces.list()— the caller's namespaces.catalog.tools.list({ namespace })— page through one namespace.catalog.tools.search({ query, namespace?, limit? })— the same BM25 or embedding ranking as tool search, over every permitted tool, returning compact entries.catalog.tools.describe({ namespace, name })— one tool's full description and schemas.catalog.toolkits.list()andcatalog.toolkits.get({ name })— the toolkit calls below.
A typical script needs little of it: the execute description already names the caller's toolkits and namespaces, so the usual sequence is one toolkits.get — or one search and a describe — and then straight to tools calls.
console.log(...) records a line that comes back with the result.
The script's return value comes back as one structured result, together with the logs and a record of every tool call with its timing and outcome. The full API, the call envelopes, every error code, the result format and the sandbox limits are in the code mode SDK reference.
Toolkits
A toolkit is a named set of related tools with instructions on how to compose them, authored in configuration. Where the raw catalog tells the model what exists, a toolkit tells it what to do: find the customer, then open a ticket, at which priority.
Toolkits are also the fastest form of discovery. A toolkit's name and description appear in the execute description, so the model starts every session already knowing it exists. One catalog.toolkits.get({ name }) call then returns the instructions together with every member tool's name, invocation and schemas — replacing the namespaces.list, search and per-tool describe calls the script would otherwise spend before doing any work:
code_mode:
toolkits:
- name: resolve_support_request
description: Find a customer, check their plan and open a ticket at the right priority.
instructions: >-
Find the customer by email first. Fetch recent tickets and the current plan
in parallel, then create one ticket; use priority high for enterprise plans.
tools:
- { namespace: crm, name: find_customer }
- { namespace: support, name: recent_tickets }
- { namespace: billing, name: current_plan }
- { namespace: support, name: create_ticket }
A toolkit needs at least two tools. A caller sees it only when every tool in it exists and that caller may use all of them, so a partial bundle never leaks. Toolkits can also be added, replaced and removed at runtime as TDS Toolkit resources; see tool discovery over xDS.
Set the limits
Each listener bounds its own executions:
code_mode:
execution_timeout: 20s # wall clock per run (default 30s)
max_host_calls: 50 # tool, catalog and log calls per run (default 100)
max_concurrent_executions: 16 # runs at once on this listener (default 32)
max_concurrent_tool_calls: 8 # calls in flight within one run (default 8)
max_output_bytes: 262144 # size of the result (default 1 MiB)
memory_limit_bytes: 33554432 # sandbox memory (default 64 MiB)
Every run also has a fixed 5-second compute budget, and time spent waiting on a tool does not count against it. Scripts can be at most 256 KiB. A process-wide ceiling under runtime.codemode caps the sandbox pool and the memory per sandbox for all listeners together. A listener that asks for more than that ceiling fails to load.
Verify
Initialize a session and export SESSION_ID as shown in tools and REST backends, then run a script:
curl -s http://127.0.0.1:8080/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute","arguments":{"code":"return await catalog.namespaces.list()"}}}'
A tools/list on the same session returns a single tool named execute. The call's structuredContent has "status": "completed", and its result holds the script's value, here the catalog envelope {"ok": true, "data": {"items": [{"namespace": "crm", "toolCount": 1}, …]}}. The empty logs and toolCalls lists sit next to it.
Limits
- One mode per gateway. A code-mode listener serves only
execute; run a second listener in tools mode for clients that need the classic tool list. - Scripts have no files, network or environment. The only ways out of the sandbox are a tool call, a catalog call and a log line.
- TypeScript is strip-only: annotations are removed, but
enum,namespacedeclarations and decorators are rejected with a parse error. - Runs beyond
max_concurrent_executionsare refused rather than queued. The client gets a JSON-RPC error and should retry. - Each run starts from a fresh environment and nothing carries over between scripts, so keep state in your tools.
The code_mode and toolkit fields are in the filter reference. A complete setup is in the code mode guide.