OapiCodemode

OpenAPI search-and-execute for LLM agents, in Elixir — Cloudflare codemode style, but the sandbox never sees your credentials.

Drop in one or more OpenAPI specs and get two tools for your agent's tool loop:

Because the request never actually executes inside the sandbox, the sandbox never holds an API key, bearer token, or any other secret — credentials are attached host-side, after the JS has finished running.

Installation

def deps do
[
{:oapi_codemode, "~> 0.5.0"},
# the sandbox engine — ex_safejs is the one we use and prefer
{:ex_safejs, "~> 0.3.1"}
]
end

Use OapiCodemode.Executor.SafeJS on ex_safejs (hex) unless you have a specific reason not to. It is our preferred sandbox and the one this library is built around: QuickJS-NG embedded as a Rustler NIF with precompiled binaries, so a hex dependency is the entire deployment story — nothing to install in your image, no subprocess — and guest memory is genuinely capped, which is not true of the V8 subprocess alternative. ex_safejs is an optional dependency, so you must add it yourself alongside this library.

Executor.Deno is the supported alternative for runs whose latency is dominated by several independent API calls — it is the only executor that dispatches a guest's Promise.all concurrently — at the cost of a deno 2.x binary on PATH. See Executors for the full comparison.

Specs can be huge, and dumping every operation into the system prompt wastes context and drowns the model in noise. Instead, the spec is handed to the model as data it can query with code: filter by tag, grep summaries, pull out just the operations it needs. This is the same idea behind Cloudflare's codemode — let the model write code against tools instead of chaining tool calls one at a time — applied to OpenAPI specs specifically.

What the sandbox gets is the index, not the document. A spec is stored decomposed — one row per operation, one per component, every payload $ref left where the document wrote it — and read one operation at a time, at the moment something needs it. See Search: filter the index, then describe.

Quickstart

# 1. Start a registry (usually under your app's supervision tree).
{:ok, registry} = OapiCodemode.Registry.start_link(name: nil)
# 2. Ingest a spec and register it with the config the spec itself can't
# know (base URL, which security scheme to use, per-tenant context).
:ok =
OapiCodemode.ingest_and_register(
registry,
"petstore",
File.read!("petstore.json"),
base_url: "https://api.petstore.example.com/v1"
)
# The API name becomes a JavaScript identifier inside the sandbox
# (`apis.petstore`, `specs.petstore`, `context.petstore`), so it must match
# /^[A-Za-z_][A-Za-z0-9_]*$/ — "my-api" and "2fast" are rejected with
# {:error, {:invalid_api_name, name}}.
# 3. Get the tool definitions.
tools =
OapiCodemode.tools(
registry: registry,
executor: OapiCodemode.Executor.SafeJS,
resolver: MyApp.CredentialResolver,
policy: :read_only
)

Each entry in tools looks like:

%{
name: "search_apis" | "execute_api_code",
description: "...",
input_schema: %{...},
handler: fn args, host_ctx -> {:ok, json} | {:error, message} end
}

Wire the handlers into your host's tool loop — whatever calls tools by name and feeds results back to the model:

Enum.find(tools, &(&1.name == tool_name)).handler.(
tool_args,
%{context: %{user_id: current_user.id}}
)

host_ctx.context is opaque to the library — it's handed straight to your credential resolver so it can look up the right token for whoever is making the call. See Tool options and host context for the rest of what host_ctx accepts.

In tests, swap the executor for OapiCodemode.Executor.Mock (shipped in lib/, not test/support/, precisely so hosts can use it) and drive the callbacks from Elixir — no JS engine involved. There's a worked example in A complete integration.

Registering a spec you already store

ingest_and_register/4 above parses and registers in one call, putting the spec into an ETS store the registry owns — fine for a boot-time registry that ingests each spec once. Hosts that build a registry more often than that (per loop start, per request, ...), or that keep specs in a database, take the spec apart once and register the binding instead: OapiCodemode.ingest/1 (Ingest.decompose/1) is the pure decomposition step, OapiCodemode.SpecStore.put/2 stores the pieces and returns a ref = {spec_id, decomposer_version}, and OapiCodemode.register/4 registers the {store, ref} pair without re-parsing anything.

store = {OapiCodemode.SpecStore.ETS, MyApp.SpecTable.table()}
{:ok, decomposed} = OapiCodemode.ingest(File.read!("petstore.json"))
{:ok, ref} = OapiCodemode.SpecStore.put(store, decomposed)
# ... `put/2` is content-addressed and idempotent, so re-putting is free ...
:ok = OapiCodemode.register(registry, "petstore", {store, ref}, base_url: "https://api.petstore.example.com/v1")

A registration holds no spec bytes: the registry reads meta/2 and index/2 once, caches the slim index, and reads each operation from the store per call — against the ref it was built on, so a newer projection of the same document cannot change what a running loop sees. (The one exception is ingest_and_register/4, the convenience path: with no store of your own it puts the document into an ETS table the registry owns, so those bytes do live in the registry process. That store is the one the registry may collect from — it frees a projection no live binding names, on re-registration and on a refused registration. A host that wants a retention policy of its own makes its own store with OapiCodemode.SpecStore.ETS.new/0.)

Hosts with their own storage implement the OapiCodemode.SpecStore behaviour — put/2, meta/2, index/2, operation/3, components/3, pointer/4 — and hold it to OapiCodemode.SpecStoreCase, the conformance suite that ships in lib/ for exactly that:

defmodule MyApp.SpecStore.EctoConformanceTest do
use OapiCodemode.SpecStoreCase, async: true
defp store_fixture, do: {MyApp.SpecStore.Ecto, MyApp.Repo}
end

register/4 takes the same config options as ingest_and_register/4 (see Registration options) and returns {:error, {:invalid_config_option, key}} for an unrecognized one, same as ingest_and_register/4. It also refuses, rather than degrading, when neither the config nor the document names a base URL (:no_base_url), when the slim index alone exceeds 8 MB (:index_too_large), when the ref names a decomposer_version this library did not derive ({:unknown_decomposer_version, version}), and when the store's index/2 does not encode to the index_bytes its own meta/2 reported ({:index_bytes_mismatch, spliced, reported}).

Search: filter the index, then describe

Both steps happen inside one search_apis call. The sandbox receives specs.<api> as {operations, describe}:

declare const specs: Record<string, {
operations: Operation[]; // the slim index
describe(ids: string): Promise<Described | Failed>;
describe(ids: string[]): Promise<Array<Described | Failed>>;
}>;

operations is the whole index as plain data — {id, method, path, summary, tags, params} per operation, where params is each parameter's {name, in, required} identity, and no schemas anywhere. The model filters it in JS, then await specs.<api>.describe(id | [ids]) returns the ones it picked in full: parameters with schemas, requestBody, responses, security, plus description and deprecated. Each describe is a host round trip that resolves that operation's $refs out of the spec store; no document is ever spliced into the sandbox.

describe is metered, and the numbers live in OapiCodemode.Tools.describe_limits/0 — the tool description reads them from there, so what the model is told is what the callback enforces:

Metering is checked before anything is read, so a guest looping over describe past its budget buys no registry lookups and no store reads.

Refusals are data, never thrown: an unknown id comes back as {id, error} naming the nearest ids by Jaro distance, and an array of ids always answers an array of the same length in the same order, one entry per id — so .map() over the result is safe even when some ids were refused. Within a returned schema, three markers say where the document could not be followed: {"$circular": name} for a recursive schema, {"$unresolved": ref} for a ref the document does not define (or an external one), and {"$truncated": true} for a subtree too big for the run's remaining budget.

Credential resolver

Implement the OapiCodemode.Credentials behaviour to tell the library what credential to use for a given API and caller; the library figures out how to attach it from the spec's securityScheme.

defmodule MyApp.CredentialResolver do
@behaviour OapiCodemode.Credentials
@impl true
def resolve("petstore", _security_scheme, _request, %{user_id: user_id}) do
{:ok, {:bearer, MyApp.Tokens.fetch!(user_id, :petstore)}}
end
def resolve(_api_name, _security_scheme, _request, _context) do
{:ok, :none}
end
end

resolve/4 returns {:ok, {:bearer, token}}, {:ok, {:basic, user, pass}}, {:ok, {:api_key, value}}, {:ok, :none}, or {:error, reason}. The credential value is attached to the outgoing request by OapiCodemode.Credentials.attach/2 and never crosses into the sandbox or gets logged in a tool call transcript.

The third argument, request, is the resolved destination — %{method:, base_url:, host:, path:} — computed before credential attachment, so a resolver can enforce a spend-time allowlist (exact host, https-only, ...) at the same choke point it resolves credentials, not just at registration time. path is the OpenAPI path template (unsubstituted); the full wire path is base_url's path prefix, if any, plus the substituted path.

Error contract: return a binary {:error, message} and it crosses to the sandbox/model verbatim — never put a credential or other secret in that string. Return any non-binary reason ({:error, {:expired, token}}, {:error, :not_found}, ...) and the library logs it in full via Logger but replaces it with a fixed, redacted string before it reaches the model.

Refreshing a token

resolve/4 runs on every request, so a host that tracks expiry just refreshes there — proactively, before the call goes out. For the cases expiry can't predict (revoked tokens, server-side session resets, an expires_in you don't trust), implement the optional unauthorized/4 callback: the library calls it once when the upstream answers 401 to a credential you supplied.

@impl true
def unauthorized("petstore", _security_scheme, _request, %{user_id: user_id}) do
case MyApp.Tokens.refresh(user_id, :petstore) do
{:ok, token} -> {:retry, {:bearer, token}}
:error -> :pass
end
end

{:retry, credential} re-sends the identical request exactly once with the new credential attached — same method, URL, body bytes and idempotency key — and returns whatever comes back, even another 401. :pass hands the 401 straight through. The same error contract as resolve/4 applies. The credential that failed is deliberately not passed to the callback: you resolved it, so you can look it up, and the library won't put a live secret into your refresh path. If the callback raises or returns something unexpected, the original 401 is returned and the problem is logged. A 401 on a request where resolve/4 returned :none is never refreshed — no credential was attached, so the 401 isn't about credential staleness.

Telemetry

No metadata field ever carries a credential.

Registration options

ingest_and_register/4 and register/4 take the same ApiConfig options:

Tool options and host context

OapiCodemode.tools/1 (OapiCodemode.Tools.definitions/1) takes:

Descriptions are a snapshot of registry state at the moment tools/1 is called: register an API — or re-register one under a different ref — afterwards and you must re-emit the tools, or the model is told about a surface that no longer matches. (The handlers re-read the registry per call, so they stay correct either way.)

The second argument to a handler, host_ctx, is a map that may carry:

Custom tool names, and a separate mutating tool

:search_tool_name (default "search_apis") and :execute_tool_name (default "execute_api_code") rename the emitted tools — useful when a host runs one registry per API instance and wants per-instance tool names instead of one shared pair:

OapiCodemode.tools(registry: reg, executor: OapiCodemode.Executor.SafeJS,
resolver: MyApp.CredentialResolver, policy: :read_only,
search_tool_name: "petstore_api_search", execute_tool_name: "petstore_api_execute")

A host that wants reads auto-approved and writes confirmed calls tools/1 twice — once with the defaults, once with policy: :all, a distinct :execute_tool_name, and include_search: false (search only needs offering once). Two names, so a tool-approval layer can gate on the name alone without inspecting arguments:

OapiCodemode.tools(registry: reg, executor: OapiCodemode.Executor.SafeJS,
resolver: MyApp.CredentialResolver, policy: :all,
execute_tool_name: "petstore_api_mutations", include_search: false)

The execute result envelope

The execute handler returns JSON with a fixed key order — calls, logs, then result or error:

{
"calls": [
{"api": "petstore", "operation": "POST /pets", "status": 201,
"duration_ms": 84, "idempotency_key": "5f1c..."}
],
"logs": ["checking inventory"],
"result": {"id": 42}
}

The order matters: truncation to :max_result_tokens chops the tail, so the record of what the code actually did upstream survives a huge result.

A sandbox crash or timeout is not a tool error — it comes back as this same envelope with error in place of result, because mutations may have landed before the crash and the caller has to see them. A call that was dispatched but hadn't returned when the run died stays in the log as {"status": "in_flight", "note": "...outcome is unknown... Verify before retrying."}. {:error, message} from a handler is reserved for failures before the sandbox ever ran (a missing code argument).

Executors

The sandbox that runs the LLM-written JS sits behind the OapiCodemode.Executor behaviour. Swapping executors doesn't change how you call OapiCodemode.tools/1.

SafeJS (preferred)DenoZapCodeMock
EngineQuickJS-NG, Rustler NIFV8 subprocesszapcode interpreter, NIFan Elixir function
Deploymentoptional dep ex_safejs, precompileddeno 2.x on PATHoptional dep ex_zapcodenone
Hard memory capyes, ArrayBuffers includednoyes, max_memoryn/a
Promise.all requestsserialconcurrentserialn/a
:timeout meansJS compute time onlywall clock, callbacks includedwall clock at suspension pointsn/a
Search over big specsyesyesengine-blocked (O(n²) scans)n/a

OapiCodemode.Executor.SafeJS — the one we use and recommend, behind the optional ex_safejs dep (QuickJS-NG embedded as a Rustler NIF, precompiled binaries; our hard fork of quicksand, carrying the rquickjs 0.12 fix for the timeout-during-promise-job BEAM abort, lpgauth/quicksand#2). Nothing to add to your image. QuickJS's own allocator is the sole memory authority, so typed-array/ArrayBuffer bombs that walk past V8's heap limit under Deno come back as a structured out-of-memory error here; size the cap with executor_opts: [memory_limit: bytes]. Guest code is the async arrow the tool descriptions teach — await, .then, Promise.all, regex — with requests resolving serially. Its :timeout is a JS compute budget: host-callback time doesn't count, so a guest looping over cheap request() calls is unbounded in wall time unless you pass executor_opts: [wall_clock_ms: ms] (:max_calls bounds the same class at the tool layer regardless). A promise nothing can settle is reported as a deadlock immediately rather than burning the timeout. A callback that raises reaches the model as a fixed redacted string; the detail goes to Logger.

OapiCodemode.Executor.Deno — a real sandbox driven over a Port with a line-delimited JSON protocol, spawned with no permission flags plus --no-remote --no-npm, so the child has no network, filesystem, or env access and cannot resolve remote modules. Take it when a run's latency is dominated by several independent API calls: it is the only executor that dispatches Promise.all requests concurrently. The costs are the deno 2.x binary in every image that runs it (tested against 2.9.5) and V8's lack of a hard memory cap.

OapiCodemode.Executor.ZapCode — behind the optional ex_zapcode dep. Execute works end to end, but search over real specs is engine-blocked (container copy semantics make scans O(n²)), the guest dialect has no regex, and console.log output after the first API call is dropped. Prefer SafeJS unless you specifically want zapcode's interpreter.

OapiCodemode.Executor.Mock — the test executor: the "sandbox" is an Elixir function you set per test with set_response/1, receiving the code string and the env whose named callbacks (callbacks.request, callbacks.describe) you can invoke directly — each takes the argument list guest code would have passed. It ships in lib/ so downstream hosts can point their whole test env at it.

Writing your own executor

Two things in OapiCodemode.Executor are easy to miss, and both are silent failures if you miss them. env.callbacks is a map of name to an arity-1 function over the JSON-decoded argument list (fn [api_name, opts] -> ... end), and you must expose each one to the guest as host.<name>(...args) — one host object per run whose properties are exactly the keys of env.callbacks, nothing more (:request in an execute run, :describe in a search run). And globals["apiNames"] is not a data global but a build instruction: read it and build one apis.<name>.request(opts) binding per name, each forwarding to the :request callback as [name, opts]. Treat it as ordinary data and apis never exists, so every execute_api_code call fails with "apis is not defined". Everything else in globals is injected as inert JSON, and the specs.<api> bindings a search run sees are built by OapiCodemode.Tools, not by you.

A complete integration

The shape below is lifted from a production host that exposes its own API to an agent through these tools, anonymized (names, spec, surrounding plumbing). It shows the pieces in the order you build them: one place that picks the engine, one process that owns the registry, a resolver, and the glue that runs a handler.

# config/config.exs — one place picks the engine; test.exs overrides it.
config :my_app, MyApp.Codemode,
executor: OapiCodemode.Executor.SafeJS,
executor_opts: [memory_limit: 128 * 1024 * 1024, wall_clock_ms: 60_000]
# config/test.exs
config :my_app, MyApp.Codemode, executor: OapiCodemode.Executor.Mock
# lib/my_app/codemode/loader.ex — owns the registry, registers once at boot.
defmodule MyApp.Codemode.Loader do
use GenServer
@api_name "billing"
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def registry, do: MyApp.OapiRegistry
def registered? do
not is_nil(Process.whereis(registry())) and
match?({:ok, _entry}, OapiCodemode.Registry.lookup(registry(), @api_name))
end
@impl true
def init(_opts) do
{:ok, _pid} = OapiCodemode.Registry.start_link(name: registry())
{:ok, %{}, {:continue, :load}}
end
@impl true
def handle_continue(:load, state) do
:ok =
OapiCodemode.ingest_and_register(
registry(),
@api_name,
File.read!(Application.app_dir(:my_app, "priv/specs/billing.json")),
base_url: "https://billing.example.com",
auto_idempotency_header: "idempotency-key",
response_headers: ["idempotent-replayed"]
)
{:noreply, state}
end
end

Ingest happens in handle_continue, not init, so a slow parse doesn't block the supervisor; the host this came from also runs the loader under its own supervisor with restart: :temporary and a bounded restart count, so a spec that will never parse leaves codemode unavailable rather than taking the app down. registered?/0 is what the tool layer checks before advertising anything.

# lib/my_app/codemode/credential_resolver.ex
defmodule MyApp.Codemode.CredentialResolver do
@behaviour OapiCodemode.Credentials
@api_name "billing"
# Not part of the behaviour: mint once per tool call so the per-request
# resolve/4 below is a map lookup, not a round trip.
def prepare(user, grant) do
context = %{user: user, grant: grant}
with {:ok, {:bearer, token}} <- resolve(@api_name, nil, nil, context) do
{:ok, Map.put(context, :token, token)}
end
end
@impl true
def resolve(@api_name, _scheme, _request, %{token: token}) when is_binary(token),
do: {:ok, {:bearer, token}}
def resolve(@api_name, _scheme, _request, %{user: user, grant: grant}) do
case MyApp.Tokens.mint(user, grant) do
{:ok, token} -> {:ok, {:bearer, token}}
{:error, :revoked} -> {:error, "this connection's grant was revoked — reconnect to restore access"}
end
end
def resolve(_api_name, _scheme, _request, _context),
do: {:error, "no credential for this caller"}
# 0.4.0 reactive refresh. The host this example came from doesn't
# implement it yet — shown here because it's the answer for tokens whose
# expiry you can't predict.
@impl true
def unauthorized(@api_name, _scheme, _request, %{user: user, grant: grant}) do
case MyApp.Tokens.mint(user, grant, force: true) do
{:ok, token} -> {:retry, {:bearer, token}}
{:error, _reason} -> :pass
end
end
end
# lib/my_app/codemode/tool_bridge.ex — the glue the host's tool modules call.
defmodule MyApp.Codemode.ToolBridge do
alias MyApp.Codemode.{CredentialResolver, Loader}
def tools(opts) do
config = Application.fetch_env!(:my_app, MyApp.Codemode)
OapiCodemode.tools(
Keyword.merge(opts,
registry: Loader.registry(),
resolver: CredentialResolver,
executor: Keyword.fetch!(config, :executor),
executor_opts: Keyword.get(config, :executor_opts, [])
)
)
end
def run(tool_name, policy, args, user, grant) do
if Loader.registered?() do
with {:ok, context} <- CredentialResolver.prepare(user, grant) do
tools =
tools(
policy: policy,
search_tool_name: "search_billing_api",
execute_tool_name: execute_tool_name(policy)
)
handler = Enum.find(tools, &(&1.name == tool_name)).handler
handler.(args, %{
context: context,
req_options: [],
annotate_call: &MyApp.Codemode.classify_call/1
})
end
else
{:error, "the API catalog is still loading — try again in a moment"}
end
end
defp execute_tool_name(:read_only), do: "execute_billing_api_code"
defp execute_tool_name(:all), do: "execute_billing_api_mutations"
end

Three host tool modules sit on top of that — search_billing_api and execute_billing_api_code classified read-only, execute_billing_api_mutations classified destructive — each a wrapper that resolves the caller's grant and calls ToolBridge.run/5 with the matching policy. The name split is what the host's approval layer gates on; the proxy's :read_only policy is what makes the read-only half true at the wire.

Testing needs no JS engine — with Executor.Mock configured for the test env, the "sandbox" is a function that calls the request callback the way guest code would:

test "the read tool round-trips a GET through the host's plumbing" do
OapiCodemode.Executor.Mock.set_response(fn _code, env ->
response = env.callbacks.request.(["billing", %{"method" => "GET", "path" => "/invoices"}])
{:ok, %{value: response, logs: []}}
end)
assert {:ok, json} =
ToolBridge.run(
"execute_billing_api_code",
:read_only,
%{"code" => "async () => {}"},
user,
grant
)
assert %{"result" => %{"status" => 200}} = Jason.decode!(json)
end

Two things worth copying: emit the tool definitions per turn rather than per call where you can (tools/1 reads the registry and builds descriptions each time), and put a concurrency bound in front of run/5 if several agents share the node — the library bounds calls per run (:max_calls) and time per run, not runs in flight.

Design rationale

See docs/plans/2026-08-16-openapi-search-execute-design.md for the full design writeup — why search and execute are separate tools, why validation and credentialing live in Elixir rather than the sandbox, and how the registry, ingest pipeline, and proxy fit together.