Files

@opencode/codemode

This is our take on code mode: a lightweight, pure interpreter for a JavaScript-like language built around calling tools. It supports familiar JavaScript syntax with a few key differences and limitations. See the interpreter support checklist for more details.

Rather than trying to sandbox arbitrary JavaScript, CodeMode only runs the language features we implement. Programs cannot directly access the network, filesystem, processes, or application APIs. They can interact with the outside world only through tools provided by the host, which can also limit execution time, tool calls, output size, and data.

The idea of code mode was originally introduced by Cloudflare. See their post to learn more about the concept and their isolate-based approach.

How it differs from JavaScript

  • Only supported APIs are available. Programs can use the provided tools, supported JavaScript built-ins, and the globals the host adds through extensions. Timers, process, filesystem access, packages, and export are unavailable.
  • Unfinished work is interrupted. Tool calls and async functions start when called. When the program finishes, anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
  • REPL-style results. Without an explicit return, the final top-level expression becomes the result. undefined becomes null.

Unsupported syntax returns an UnsupportedSyntax diagnostic with a source location. Current gaps are tracked in the interpreter support checklist.

Quick Start

import { CodeMode, Namespace, Tool } from "@opencode/codemode"
import { Effect, Schema } from "effect"

const lookupOrder = Tool.make({
  description: "Look up an order by ID",
  input: Schema.Struct({ id: Schema.String }),
  output: Schema.Struct({ id: Schema.String, status: Schema.String }),
  execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})

const runtime = CodeMode.make({
  tools: { orders: { lookup: lookupOrder } },
})

const result = await Effect.runPromise(
  Effect.scoped(
    Effect.flatMap(
      runtime.execute(`
        const order = await tools.orders.lookup({ id: "order_42" })
        return { id: order.id, needsAttention: order.status !== "complete" }
      `),
      (execution) => execution.result,
    ),
  ),
)

result is a CodeMode.Result, or a suspended run's saved state when something suspends it.

API

Tool.make

input and output accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is decoded before execute; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only shape the model-visible signature. Without output, the signature uses Promise<void>.

Descriptions and schemas are model-visible contracts. Authorization belongs in execute.

Nested records are the shorthand for ordinary namespaces. Use Namespace.make when a namespace needs a description:

const runtime = CodeMode.make({
  tools: {
    orders: Namespace.make({
      description: "Purchases, fulfillment, and shipment tracking",
      tools: { lookup: lookupOrder },
    }),
  },
})

Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come from record keys, so the wrapper does not repeat orders. Dots in keys create nested paths; other characters use bracket notation, such as tools.context7["resolve-library-id"](...).

CodeMode.execute and CodeMode.make

CodeMode.execute({ ...options, code }) runs once. CodeMode.make(options) creates a reusable runtime:

const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })

runtime.catalog() // structured tool descriptions
runtime.execute(source) // Effect<CodeMode.Execution, never, ToolServices | Scope>
execution.result // Effect<CodeMode.Result | CodeMode.Suspended>

execute starts the program in the current scope and returns at once. execution.result waits for its outcome, and execution.suspend saves its state (see below). Closing the scope interrupts the program and cancels its pending durable work.

The Effect environment is inferred from the supplied tools. hooks surround every call the program makes into the host: tool.before/tool.after receive { name, input } with decoded input, extension.before/extension.after receive { extension, name, args }. An after hook also receives how the call ended (success with its value, failure with its error, or interrupted). A before hook runs before the call reaches the host, and a failing one denies it: the host function is never called, and the program catches the failure as a thrown error. A denied call fails the way the call itself would: a tool call or asynchronous global rejects its promise, and a synchronous global throws.

Suspending and resuming a run

runtime.execute(code, bindings?, { runId? }) starts a run that can be suspended. execution.suspend saves its state and stops it at once; it and execution.result give the same outcome, a Result or:

type Suspended = {
  ok: "suspended"
  state: CodeMode.Snapshot // JSON; CodeMode.toBytes / CodeMode.fromBytes convert it
  pending: Array<{ key; path; args; handle? }> // the durable calls it waits on
}

const suspended = Effect.scoped(
  Effect.gen(function* () {
    const execution = yield* runtime.execute(source)
    // ... once the run waits on a durable call:
    return yield* execution.suspend
  }),
)
// later, in this process or another one built from the same version:
const result = Effect.scoped(
  Effect.gen(function* () {
    const resumed = yield* runtime.resume(state, { [pending[0].key]: { ok: true, value } })
    return yield* resumed.result
  }),
)

A run can be suspended only while every host call it has running is a durable tool's work that has its handle. Any other call in progress blocks it: a tool call or search, any extension call (fetch, a method on a value it returned, a synchronous global, one queued but not started), and a durable call still in start. Then execution.suspend fails with a CodeMode.SuspendError naming each blocking call by its key and path (tools.x.y, fetch), and the run goes on untouched; try again later. Tool calls queued but not started don't block: they start on resume. Outcomes already waiting for the program are part of the state. A suspend after the run ended gives its result.

The suspend is immediate: the program shares the thread with the caller, so it is at a step boundary. Its checks, saving the state, and checking it against suspend.maxBytes all happen before anything about the run changes, so a state that can't be saved or is too large fails the suspend the same way. Once saved, running durable calls are detached: their work runs on, tool.after doesn't see them, and cancel doesn't run.

resume takes an outcome, { ok: true, value } | { ok: false, error }, for every pending durable call, by key: the host waits on the work itself (with the call's handle). It fails with a CodeMode.SnapshotError when one is missing, or given for a key that isn't a pending durable call. A value is copied as JSON; an error rejects the call's promise with a tool error the program can catch. A resumed run keeps the limits it was saved with: timeoutMs counts active time across every resume, and maxToolCalls counts every call of the run. Every call has an idempotency key, <runId>:<id>.

A state resumes only on the same build of this package, with the same extensions (and the same snapshot hooks; see Extension.make); resume fails with a CodeMode.SnapshotError otherwise, or when the state is malformed. Built-ins are saved by name and only their changes are copied. Closing the scope of a run that wasn't suspended cancels its running durable calls.

A durable tool runs its work under the call's key and returns a JSON handle:

const build = Tool.make({
  description: "Run a build",
  input: Schema.Struct({ target: Schema.String }),
  output: Schema.Struct({ ok: Schema.Boolean }),
  durable: {
    start: (input, { key }) => jobs.start(key, input), // launch the work, return a handle quickly
    attach: (handle) => jobs.wait(handle), // its output; interrupting this detaches, never cancels
    cancel: (handle) => jobs.stop(handle), // a run that ends with the call still running cancels it
    lookup: (key) => jobs.find(key), // optional, for the host: the runtime doesn't call it
  },
})

start and attach also get ctx.suspend, which suspends the run with this call pending, for the host to finish the call and give its outcome to resume (an approval, say). It may be called at once, as start begins; calls queued after this one then stay queued, and start on resume. It never succeeds (the call's fiber is detached), and fails with a SuspendError inside the tool, leaving the run running, while another call blocks a suspend. Only a durable tool can suspend its run.

Every call sees its key: a tool's execute gets it as execute(input, { key }), and both hooks as call.key. A durable call's tool.before runs when it starts and its tool.after when its work ends; a call detached by a suspend reports nothing, while one cancelled because its run ended reports interrupted.

Nothing in OpenCode suspends runs yet; this is the capability a host builds on.

Set CODEMODE_ROUNDTRIP=1 (bun run test:roundtrip) to run the tests with every run saved to bytes and restored into a brand-new runtime at every host call and every few steps.

Extension.make

Extensions are host functions a program calls directly as globals, such as fetch. Unlike tools they are not in the catalog, not counted against maxToolCalls, and not described to the model; the host decides what they mean.

const web = Extension.make({ name: "web", globals: { fetch: async (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })

A global is synchronous unless it is an async function or is marked with Extension.async. Mark a plain function that returns a Promise or an Effect, so a denied call rejects its promise like any other failure:

Extension.make({ name: "web", globals: { fetch: Extension.async((url: string) => globalThis.fetch(url)) } })

A global may return a value, a Promise, or an Effect. An Effect's requirements join the runtime's environment the same way a tool's do, so the host can provide per-execution services such as the calling session:

const web = Extension.make({
  name: "web",
  globals: {
    fetch: Extension.async((url: string) =>
      Effect.gen(function* () {
        const session = yield* Session
        yield* session.authorize(url)
        return yield* Effect.promise(() => globalThis.fetch(url))
      }),
    ),
  },
})
const started = CodeMode.make({ extensions: [web] })
  .execute(code)
  .pipe(Effect.provideService(Session, session))

Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as copies, and a function inside a result is callable the same way. A global that shadows a built-in or another extension throws at CodeMode.make.

Functions inside a result have no data form, so a suspended run loses them: after a resume, calling one throws a TypeError. An extension keeps them with snapshot hooks. encode gets a value one of its functions returned, as the host function returned it, and gives plain data (JSON values and Uint8Arrays); decode gets that data back on resume, once per value, and gives a value of the same shape, whose functions the program's copies call from then on:

const data = Symbol("data")
const response = (saved: { url: string; body: string }) => ({
  [data]: saved,
  url: saved.url,
  text: async () => saved.body,
})
const web = Extension.make({
  name: "web",
  globals: { fetch: async (url: string) => response({ url, body: await (await globalThis.fetch(url)).text() }) },
  snapshot: { encode: (value) => value[data], decode: (saved) => response(saved) },
})

A suspend fails, and the run goes on, when encode throws or gives anything else. A state saved with an extension's hooks resumes only with an extension of the same name that has hooks too.

A global can also be an object of functions, such as fs. Every function reachable from it is an entry point like a global, so calls fire the extension hooks with their path as the name (fs.readFile, fs.promises.readFile).

Programs load globals the way Node code loads modules. import and require take a global by its Node module name, with or without node:; fs/promises, url, util, and buffer resolve to the globals that provide them. There are no packages, so anything else throws.

import fs, { readFile } from "node:fs" // the `fs` global, if the host provides one
import { URL } from "url"
const crypto = require("crypto")
const { default: same } = await import("fs")

OpenAPI tools

OpenAPI.fromSpec converts an OpenAPI 3.x document into one tool per supported operation. Dotted operationId values create namespaces:

const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
const runtime = CodeMode.make({ tools: { opencode: api.tools } })

The synchronous result is { tools, skipped }. Operations with unsupported parameter encodings, request bodies without JSON content, WebSocket or SSE semantics, or binary responses are reported in skipped.

Authentication is resolved by the host and never shown to the model. Generated tools require HttpClient.HttpClient. Request signatures omit readOnly properties; response signatures omit writeOnly properties. These JSON Schemas shape model-visible signatures but do not filter runtime values: nested JSON body properties and decoded server responses pass through unchanged. See src/openapi/types.ts for option details.

Results

Every execution returns:

type Result =
  | {
      readonly ok: true
      readonly value: CodeMode.DataValue
      readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
      readonly logs?: ReadonlyArray<string>
      readonly truncated?: boolean
      readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
    }
  | {
      readonly ok: false
      readonly error: CodeMode.Diagnostic
      readonly logs?: ReadonlyArray<string>
      readonly truncated?: boolean
      readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
    }

value is JSON-safe. warnings are non-fatal diagnostics, logs contain program console output, and truncated indicates that retained output was cut by maxOutputBytes. toolCalls retains admitted calls in order, including after failure.

Diagnostic kinds:

Kind Meaning
ParseError Source is empty or cannot be parsed.
UnsupportedSyntax Parsed JavaScript is outside the supported subset.
UnknownTool The program referenced an unavailable tool.
InvalidToolInput Tool input failed schema decoding or safe-data copying.
InvalidToolOutput Tool output failed schema decoding or safe-data copying.
InvalidDataValue Program data violated the plain-data contract.
ToolCallLimitExceeded The program exceeded maxToolCalls.
TimeoutExceeded Execution timed out; as a warning, background work was interrupted after the program returned.
ToolFailure A tool refused or failed.
ExecutionFailure The program threw or another execution error occurred.
Truncated Warning only: additional warnings were omitted by maxOutputBytes.

Host failures and defects report their messages and underlying causes. Invalid outputs include the validation or copying error. Interruption propagates without becoming an error diagnostic.

Discovery

runtime.catalog() returns structured descriptors — exact path, description, and generated TypeScript signature — for every visible tool. Hosts render their own model-facing instructions from these descriptors; CodeMode.searchSignature and CodeMode.toolExpression(path) supply the exact callable forms.

The synchronous search(...) built-in is always available. It supports exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full signatures. Its result can be used directly, awaited, or chained with then, catch, and finally, so programs work whichever way they treat it. Search counts toward maxToolCalls. Search also matches descriptions from enclosing Namespace values.

Execution Limits

Limit Default Controls
timeoutMs unlimited Total execution time.
maxToolCalls unlimited Admitted tool calls.
maxOutputBytes unlimited Retained result value and logs.

Execution limits have no default values.

Invalid limit configuration throws RangeError. Warnings receive a separate budget equal to maxOutputBytes. Truncation does not fail execution; an oversized value becomes a string with an in-band marker. Timeouts interrupt tool calls and busy loops, while a result returned before cleanup times out remains successful with a TimeoutExceeded warning. Tool-call concurrency is unrestricted. Boundary data is limited to 32 nested levels.