Files
2026-11-03 16:52:18 +00:00

278 lines
15 KiB
Markdown

# @opencode/codemode
Code Mode runs small programs over tools supplied by the host. OpenCode's agent-facing `execute` tool runs **Lua**.
The package also supports its existing JavaScript interpreter: `CodeMode.make` and `CodeMode.execute` default to
JavaScript for compatibility. Set `language: "lua"` for Lua or `language: "javascript"` to pin JavaScript. Neither
parser retries a failed program in the other language. Stored schedule checks remain JavaScript.
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](https://blog.cloudflare.com/code-mode/) to learn more about the concept and their isolate-based approach.
## Writing a Lua program
```lua
local matches = search({ query = "order lookup" })
local order = tools.orders["lookup-by-id"]({ id = "order_42" })
return { id = order.id, needsAttention = order.status ~= "complete" }
```
Use the exact path returned by `search`; the input is a Lua table, not a JavaScript object literal. Calls wait for the
tool's Effect in the execution's environment, so the next statement sees the result. Calls within one Lua program
are sequential: there is no `await`, `Promise.all`, detached work, or parallel Lua task API. Independent executions
still have separate state and can run concurrently.
- Use an explicit `return`: only the first value is exported. Falling off the end or returning `nil` yields JSON null.
- Tables with contiguous integer keys starting at **1** are arrays; tables with string keys are objects. `{}` makes
an empty object; `json.array()` makes an empty array. Arrays and objects received from a tool keep their shape
even when empty. Mixed-key, sparse, cyclic and non-data results cannot cross to the host.
- `nil` removes a table field. Use `json.null` to keep a JSON null in a field or array slot.
- Use `local ok, result = pcall(tools.ns.lookup, { id = id })` to recover from an ordinary failed call. On failure
the program sees a message, not a host exception. Interruption and resource exhaustion cannot be caught this way.
- Only explicit capabilities can enter host code. Tools, search, bindings and extension results cross as plain data,
never a host object, function, Promise or proxy. Declare Lua globals with `Extension.lua({ name, globals })`:
each function returns an Effect producing JSON (or `undefined`), not an object with callable methods. In
OpenCode `fetch(url, init)` returns data (`body` is text), not a JavaScript `Response`.
## JavaScript compatibility
- **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](./interpreter-support.md).
## Quick Start (host API)
```ts
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({
language: "lua",
tools: { orders: { lookup: lookupOrder } },
})
const result = await Effect.runPromise(
runtime.execute(`
local order = tools.orders.lookup({ id = "order_42" })
return { id = order.id, needsAttention = order.status ~= "complete" }
`),
)
```
`result` is always a [`CodeMode.Result`](#results).
## 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:
```ts
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:
```ts
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog // structured tool descriptions
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
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.
### `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.
```ts
const web = Extension.make({ name: "web", globals: { fetch: async (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })
```
In the JavaScript interpreter, 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:
```ts
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:
```ts
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 result =
yield *
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`.
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.
```js
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:
```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
const runtime = CodeMode.make({ language: "lua", 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:
```ts
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 for every visible tool (path, description and signature in the
selected language). For Lua instructions use `CodeMode.luaSearchSignature` and `CodeMode.toolExpression(path, "lua")`;
`CodeMode.searchSignature` and `CodeMode.toolExpression(path)` retain their JavaScript meanings.
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. Lua sees the result directly.
In JavaScript it can also be awaited or chained with `then`, `catch`, and `finally`. Search counts toward
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
## Execution Limits
| Limit | Lua default | JavaScript default | Controls |
| ---------------- | ----------- | ------------------ | --------------------------------------------------------------------------------- |
| `timeoutMs` | 120,000 | unlimited | Total execution time. |
| `maxToolCalls` | 128 | unlimited | Lua host call attempts (tools, search and extensions); JavaScript admitted tools. |
| `maxOutputBytes` | 65,536 | unlimited | Retained result value and logs. |
Lua also enforces non-configurable bounds on source and syntax depth, interpreter work and call frames, string and
table growth, and expanded data copies. These bounds apply even when the host raises its configured limits. The
production evaluator shares the [Lua interpreter](./lua/README.md)'s frames and call loop but installs only metered
operations: no prototype libraries, dynamic loading, I/O, metatables, regex/pattern engine or coroutines. Lua strings
are byte strings; strings crossing JSON boundaries must be valid UTF-8.
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. In JavaScript a result returned before cleanup times out remains successful with a
`TimeoutExceeded` warning; JavaScript tool calls may run concurrently.