---
title: "Code mode for agents"
url: "/guides/mcp/code-mode-for-agents/"
kind: "guide"
summary: "Serve REST tools to agents through one execute tool: the agent sends a TypeScript script, and Arion runs the whole workflow in a sandbox."
---

> Arion site index: [/llms.txt](/llms.txt)

# Code mode for agents

Serve REST tools to agents through one execute tool: the agent sends a TypeScript script, and Arion runs the whole workflow in a sandbox.

{{ <badge label="requires: codemode" kind="requires" /> }}

This guide builds an MCP gateway in code mode for a support workflow. Code mode plays to the model's strength — writing code: the agent resolves a request (find the customer, check their history and plan, open a ticket at the right priority) by writing one TypeScript script instead of making four tool calls over four model turns. Use it when agents run multi-step workflows over your APIs and you want fewer round trips and a cleaner context, with the same tools and the same guardrails.

## Outcome

- A listener on port 8080 serving MCP at `POST /mcp`, with a single tool, `execute`.
- Four REST-backed tools in three namespaces: `crm.find_customer`, `billing.current_plan`, `support.recent_tickets` and `support.create_ticket`.
- A toolkit, `resolve_support_request`, that hands the model the instructions and all four schemas in one call.
- Scripts limited to 20 seconds and 50 tool, catalog and log calls each.

## Prerequisites

- An Arion binary built with the `codemode` feature: `cargo build --release -p arion-proxy --features codemode`. A build without it rejects this config at startup.
- Three REST services, at `10.0.4.21`, `10.0.4.22` and `10.0.4.23` on port 8080 in the config. Edit the cluster endpoints to match yours.
- An MCP client that can call tools. `curl` is enough to verify.

## The architecture

- The MCP gateway filter runs in code mode. Agents see one `execute` tool, and each call runs the agent's script in a fresh WebAssembly sandbox inside Arion.
- The script calls tools by qualified name. Each call goes through the gateway's tool registry, with schema validation, authorization and limits, then out to the tool's cluster.
- When the script returns, Arion sends back one result: the script's value, its log lines and a record of every tool call.

## The complete config

````yaml
# arion.yaml — an MCP gateway in code mode. Needs a binary built with the codemode feature.
logging:
  log_level: info
envoy_bootstrap:
  static_resources:
    listeners:
      - name: http_8080
        address:
          socket_address: { address: 0.0.0.0, port_value: 8080 }
        filter_chains:
          - name: http_8080_chain
            filters:
              - name: http_gateway
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                  http_filters:
                    - name: arion.filters.http.mcp
                      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:
                          execution_timeout: 20s
                          max_host_calls: 50
                          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 with crm.find_customer first. Then fetch
                                support.recent_tickets and billing.current_plan in parallel with the
                                customer's id. Create exactly one ticket with support.create_ticket:
                                priority high for the enterprise tier, normal otherwise. Return the
                                ticket id and how many recent tickets the customer had, not the raw
                                responses.
                              tools:
                                - { namespace: crm, name: find_customer }
                                - { namespace: support, name: recent_tickets }
                                - { namespace: billing, name: current_plan }
                                - { namespace: support, name: create_ticket }
                        tools:
                          - namespace: crm
                            name: find_customer
                            description: Find a customer by email address.
                            input_schema:
                              inline_string: |
                                {"type": "object", "properties": {"email": {"type": "string"}}, "required": ["email"]}
                            output_schema:
                              inline_string: |
                                {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}}, "required": ["id"]}
                            rest_backend:
                              cluster: crm_api
                              method: GET
                              path: /v1/customers
                              query_params:
                                - name: email
                                  source: email
                          - namespace: billing
                            name: current_plan
                            description: Get a customer's current billing plan and tier.
                            input_schema:
                              inline_string: |
                                {"type": "object", "properties": {"customer_id": {"type": "string"}}, "required": ["customer_id"]}
                            output_schema:
                              inline_string: |
                                {"type": "object", "properties": {"tier": {"type": "string"}, "renews_at": {"type": "string"}}, "required": ["tier"]}
                            rest_backend:
                              cluster: billing_api
                              method: GET
                              path: /v1/customers/{{customer_id | encode_path_segment}}/plan
                          - namespace: support
                            name: recent_tickets
                            description: List a customer's most recent support tickets.
                            input_schema:
                              inline_string: |
                                {"type": "object", "properties": {"customer_id": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["customer_id"]}
                            output_schema:
                              inline_string: |
                                {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object"}}}, "required": ["items"]}
                            rest_backend:
                              cluster: support_api
                              method: GET
                              path: /v1/customers/{{customer_id | encode_path_segment}}/tickets
                              query_params:
                                - name: limit
                                  source: limit
                          - namespace: support
                            name: create_ticket
                            description: Open a support ticket for a customer.
                            input_schema:
                              inline_string: |
                                {"type": "object", "properties": {"ticket": {"type": "object", "properties": {"customer_id": {"type": "string"}, "priority": {"type": "string", "enum": ["low", "normal", "high"]}, "summary": {"type": "string"}}, "required": ["customer_id", "priority", "summary"]}}, "required": ["ticket"]}
                            output_schema:
                              inline_string: |
                                {"type": "object", "properties": {"id": {"type": "string"}, "status": {"type": "string"}}, "required": ["id"]}
                            rest_backend:
                              cluster: support_api
                              method: POST
                              path: /v1/tickets
                              body_json: ticket
                    - name: envoy.filters.http.router
                      typed_config:
                        "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                  route_config:
                    name: http_8080_routes
                    virtual_hosts:
                      - name: backend
                        domains: ["*"]
                        routes:
                          - match:
                              prefix: /
                            route:
                              cluster: crm_api
    clusters:
      - name: crm_api
        connect_timeout: 5s
        type: STATIC
        load_assignment:
          cluster_name: crm_api
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: 10.0.4.21, port_value: 8080 }
      - name: billing_api
        connect_timeout: 5s
        type: STATIC
        load_assignment:
          cluster_name: billing_api
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: 10.0.4.22, port_value: 8080 }
      - name: support_api
        connect_timeout: 5s
        type: STATIC
        load_assignment:
          cluster_name: support_api
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: 10.0.4.23, port_value: 8080 }
````

## Walkthrough

### 1. The gateway in code mode

`code_mode` takes the place of `tools_mode: {}` and makes `execute` the gateway's only tool. Its limits are per listener: `execution_timeout: 20s` bounds each run's wall-clock time, and `max_host_calls: 50` bounds the tool calls, catalog calls and log lines a single script may make. Everything else keeps its default, including 32 concurrent runs, 8 parallel calls per run and a 1 MiB result. See [code mode](@/docs/mcp-gateway/code-mode.md#set-the-limits).

### 2. Tools in namespaces

Every tool has a `namespace` and a `name`, and scripts call it by the qualified name, such as `crm.find_customer`. The namespaces also organize discovery: the `execute` description lists them, and `catalog.tools.list({ namespace })` pages through one. See [tools and REST backends](@/docs/mcp-gateway/tools-and-rest-backends.md).

### 3. Output schemas and safe paths

Each tool declares an `output_schema`, so its JSON response reaches the script already parsed. That is what makes `customer.data.id` work; without a schema the script would get the body as text. The two path templates use `encode_path_segment`, so a customer id the model produced cannot escape its path segment. `create_ticket` sends its `ticket` argument as the JSON body with `body_json`.

### 4. A toolkit

`resolve_support_request` names the four tools and says, in plain words, how to combine them. The model sees the toolkit in the `execute` description, and one `catalog.toolkits.get` call returns the instructions with every member tool's input and output schemas. A caller sees a toolkit only if it may use all of its tools.

### 5. What the agent sends

With the toolkit in hand, a model writes a script like this and sends it as the `code` argument of `execute`:

```typescript
const customer = await tools["crm.find_customer"]({ email: "ada@example.com" });
if (!customer.ok) return { error: "unknown customer" };

const id = customer.data.id;
const [history, plan] = await Promise.all([
  tools["support.recent_tickets"]({ customer_id: id, limit: 5 }),
  tools["billing.current_plan"]({ customer_id: id }),
]);

const ticket = await tools["support.create_ticket"]({
  ticket: {
    customer_id: id,
    priority: plan.ok && plan.data.tier === "enterprise" ? "high" : "normal",
    summary: "Refund not received",
  },
});
if (!ticket.ok) return { error: ticket.error.code };
return { ticket: ticket.data.id, seen: history.ok ? history.data.items.length : 0 };
```

The two lookups run in parallel. Every call resolves to `{ ok, data }` or `{ ok: false, error }`, so the script branches instead of catching exceptions. Only the small object on the last line goes back to the model.

### 6. What comes back

The `execute` result's `structuredContent` holds `status`, the script's `result`, any `logs`, and `toolCalls`, which lists the four calls with their outcome and duration. A failed script comes back as `status: "error"` with a diagnostic, such as a parse error with its line number or a limit that was hit, and without gateway internals. The shapes are listed in the [code mode SDK reference](@/reference/sdk/code-mode-sdk-and-limits.md#the-execute-result).

## Run and verify

Save the config as `arion.yaml` and start Arion:

```bash
arion --config arion.yaml
```

Initialize a session and keep the `mcp-session-id` from the response headers:

```bash
MCP_ENDPOINT=http://127.0.0.1:8080/mcp

curl -i -s "$MCP_ENDPOINT" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'

SESSION_ID=<session-id-from-initialize>
```

The `initialize` result includes `instructions` pointing the client at `execute`. List the tools, then run a script that fetches the toolkit:

```bash
curl -s "$MCP_ENDPOINT" \
  -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/list","params":{}}'

curl -s "$MCP_ENDPOINT" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H "mcp-session-id: $SESSION_ID" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"execute","arguments":{"code":"return await catalog.toolkits.get({ name: \"resolve_support_request\" })"}}}'
```

`tools/list` returns exactly one tool, `execute`, whose description names the `resolve_support_request` toolkit and the `billing`, `crm` and `support` namespaces. The `tools/call` result has `"status": "completed"`. Its `result.data.content` starts with the toolkit's instructions and includes an `Invocation: tools["crm.find_customer"](arguments)` line for each tool. Send the script from step 5 the same way to run the whole workflow against your services.

## Production notes

- Authenticate first. Put the [JWT filter](@/docs/security/jwt.md) before the gateway, as in the [MCP gateway guide](@/guides/mcp/mcp-gateway.md). Per-tool `rbac` then applies to every call a script makes, and hidden tools never show up in the catalog.
- Keep limits tight. Size `execution_timeout` and `max_host_calls` to the workflows you expect, not the defaults. A runaway script then fails fast, and each run has a fixed 5-second compute budget on top.
- Plan capacity. Runs beyond `max_concurrent_executions` are refused, not queued. Sandboxes for all listeners come from one pool under [`runtime.codemode`](@/reference/configuration/file-format.md#runtime), which caps them process-wide.
- Keep a tools-mode listener for clients that need the classic tool list. A gateway is one mode or the other, never both.
- Change toolkits without a restart. Deliver them as TDS `Toolkit` resources; see [tool discovery over xDS](@/docs/mcp-gateway/tool-discovery-tds.md).

## Next

- [Code mode](@/docs/mcp-gateway/code-mode.md)
- [Code mode SDK and limits](@/reference/sdk/code-mode-sdk-and-limits.md)
- [Tool authorization](@/docs/mcp-gateway/tool-authorization.md)
