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:
search_apis— the LLM writes JS that filters each spec's slim operation index as plain data (specs.<name>.operationsis an array of{id, method, path, summary, tags, params}) inside a sandbox, then callsspecs.<name>.describe(id | [ids])for the schemas of the few operations it picked — a metered host round trip into the spec store, no whole document anywhere. No network access; it's just exploring the shape of the API surface.execute_api_code— the LLM writes JS that callsapis.<name>.request({...}). Each call is intercepted at the sandbox boundary and handled entirely in Elixir: matched against the spec, validated, credentialed, and sent over the wire. The result comes back into the sandbox as data.
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.
Why spec-as-data search
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:
- 10 ids per call. A bigger batch is refused whole, per id, and reads nothing.
- One call per id against the run's
:max_calls(default 100), so an array of five ids costs five. - 512 KB of resolved JSON per search run, after which every
describeanswers{id, error: "describe budget exhausted"}.
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
[:oapi_codemode, :request, :start]— measurements%{}, metadata%{api, operation, method}(operationisniluntil the request is matched).[:oapi_codemode, :request, :stop]— measurements%{duration}, metadata%{api, operation, method, status, retried}.retriedsays whether the response came from a credential-refresh retry.[:oapi_codemode, :request, :retry]— measurements%{}, metadata%{api, operation, method, status: 401}, emitted just before a refreshed credential is re-sent.[:oapi_codemode, :request, :error]— measurements%{duration}, metadata%{api, operation, method, error}, whereerroris the failing phase (:match,:policy,:resolve,:validate,:credentials,:transport).:resolveis the spec store failing to hand back an operation the search index lists, or handing back one that disagrees with it — our storage, not the model's request, so the message crossing back to the sandbox is fixed and the store's own reason goes toLogger.
No metadata field ever carries a credential.
Registration options
ingest_and_register/4 and register/4 take the same ApiConfig options:
base_url— overrides the spec'sservers[]; required if the spec has none or picks the wrong one.security_scheme— either the name of asecuritySchemefrom the spec'scomponents,nil(use the first scheme the matched operation declares), or an inline scheme map — e.g.%{"type" => "http", "scheme" => "bearer"}— for specs that omit or mis-declaresecuritySchemesentirely. The host, not the spec, usually knows the true auth kind; an inline map lets it say so directly instead of forcing a schemeless spec through:none.sandbox_globals— a model-visible map merged into the JScontextglobal for this API (e.g.%{"accountId" => "..."}→context.petstore.accountIdin the sandbox). Model-visible: never put secrets here — this is not the samecontextas the resolver'scontextargument, which is host-identity data and never enters the sandbox.req_options— a per-API keyword list ofReq.new/1options (e.g.connect_optionsfor an egress proxy), appended ahead of the call-timehost_ctx.req_optionspassed to the tool handler. Scalar options (like:connect_optionsor:redirect) let the call-time value win on conflict;:headersand:paramsare merged (call-time wins on key collision, registration-time entries survive otherwise), so entries from both layers survive. (:headersmerging isReq's own doing;:paramsis merged by the proxy itself before the request reachesReq—Reqonly entry-merges options across separateReq.new/Req.mergecalls, so a single combined options list with two:paramsentries would otherwise let the later one silently replace the earlier one wholesale.)passthrough_headers— request header names sandbox code may set via the request options'headersmap (matched case-insensitively, forwarded downcased), e.g.["idempotency-key"]. Default[]: any header the model sets that isn't listed is a policy error, never a silent drop. Headers the library or the credential layer owns (authorization,content-type,host, ...) stay reserved even if you list them, and the emitted tool description only advertises aheadersoption when some API allows one.auto_idempotency_header— when set (e.g."idempotency-key"), every mutating call (non-GET/HEAD) that doesn't carry a key gets a proxy-generated UUID v4 in that header, and the key used lands in the call log as"idempotency_key". The model writes nothing in the normal path; it passes a logged key back via theidempotencyKeyrequest option only to retry a call whose outcome is unknown. Reserved header names are rejected at registration with{:error, {:invalid_idempotency_header, name}}.response_headers— extra response header names surfaced to the sandbox for this API, on top of the built-in whitelist (content-type,x-request-id,retry-after,x-ratelimit-*). Register the marker your upstream sets on a deduplicated replay (e.g.["idempotent-replayed"]) — without it the model cannot tell "this executed" from "this was deduped", which is exactly what makes a same-key retry trustworthy.validate—:strict(default, rejects on the first schema mismatch),:warn(logs and proceeds), or:off.max_response_bytes— upstream response body cap surfaced to the sandbox (default200_000). A byte cap, not a character cap.max_resolve_bytes— cap on the spec bytes one request may resolve (default5_000_000). Its sibling above bounds what comes back from the API; this one bounds what comes back from the spec store. Each stored row is capped at 1 MB, but a request resolves an operation's whole transitive$refclosure, and nothing else caps that aggregate. Over-budget subtrees arrive as{"$truncated": true}, which validation reads as "no schema here" and lets through — a guardrail against a pathological spec, not a reason to refuse the model's request.
Tool options and host context
OapiCodemode.tools/1 (OapiCodemode.Tools.definitions/1) takes:
:registry,:executor,:resolver— required.:policy—:read_only(default; the proxy rejects anything but GET/HEAD at the request level, whatever the host's own gating believes) or:all.:max_calls— host round trips allowed per run, default100,:infinityto disable: interceptedrequest()calls in an execute run, anddescribe()calls in a search run, which read the store and are metered the same way. Calls past the limit come back to the sandbox as an error payload; in an execute run only the first refusal is logged, so the call log stays bounded. This is the executor-independent bound on a guest that loops over cheap calls.:timeout— sandbox timeout in ms, default30_000. What it means is the executor's business: a wall-clock deadline underExecutor.Deno, a JS compute budget underExecutor.SafeJS.:executor_opts— a keyword list forwarded verbatim to the executor'srun/3. For SafeJS:[memory_limit: 128 * 1024 * 1024, wall_clock_ms: 60_000].:max_result_tokens— result-envelope budget, default6000.:search_tool_name/:execute_tool_name/:include_search— see below.
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:
:context— opaque identity handed to your credential resolver. Never reaches the sandbox.:api_allowlist— a list of registered API names this call may see and address. Absent means all;[]means none. Enforced at the request-dispatch boundary, with the sandbox globals filtered to match. The descriptions are built attools/1time and are not allowlist-aware, so emit per-scope definitions if a description must not name the full set.:req_options— extraReqoptions for this call (e.g. a test plug).:annotate_call— apayload -> map()function run host-side on each intercepted call's response payload; the result is merged into that call's log entry, library keys winning. The call log carries no response body on purpose, so this is how a host records its own classification of a response ("this 403 was a step-up refusal, not an ordinary denial") somewhere sandbox code can neither forge nor suppress.
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) | Deno | ZapCode | Mock | |
|---|---|---|---|---|
| Engine | QuickJS-NG, Rustler NIF | V8 subprocess | zapcode interpreter, NIF | an Elixir function |
| Deployment | optional dep ex_safejs, precompiled | deno 2.x on PATH | optional dep ex_zapcode | none |
| Hard memory cap | yes, ArrayBuffers included | no | yes, max_memory | n/a |
Promise.all requests | serial | concurrent | serial | n/a |
:timeout means | JS compute time only | wall clock, callbacks included | wall clock at suspension points | n/a |
| Search over big specs | yes | yes | engine-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.