Skip to content
Agentic AI

Codemode For AI Agents, Explained For Trading CTOs: What Pi 1.0 Shipped, Where The Trust Boundary Moves, And How To Adopt It Safely

On 1 October 2026 Earendil shipped Pi 1.0 with codemode built in: instead of calling tools one at a time, the model writes a short JavaScript program that composes the calls, and the harness runs it in a QuickJS sandbox. By our count it was the most-discussed agent-engineering topic on Hacker News this fortnight, across four threads. This guide explains what codemode is, why it saves so much context on bulk work, and where the trust boundary moves when your harness runs code a model wrote. It comes with a sandboxed TypeScript harness, a read-only tool registry, an example of the program the model writes, and a Python calibration check for the classifier inside it.

AlchmAI Engineering26 min read

1,689

Hacker News points for Earendil's Pi 1.0 launch on 1 October 2026, with 609 comments

687

Points for 'You Said No MCP!', Earendil's 29 September post bringing MCP into Pi through codemode, with 363 comments

158

Points for Armin Ronacher's 'What is Codemode', published 6 October 2026, with 78 comments

189

Points for the related thread 'Figma restricts MCP access to whitelisted clients, excluding Pi', with 106 comments

On 1 October 2026 Earendil shipped Pi 1.0, an MIT-licensed agent harness, with codemode built in. Codemode lets the model, instead of calling tools one at a time, write a short JavaScript program that composes the calls; the harness runs that program in a QuickJS sandbox and hands back only what the program returns. For a trading firm, codemode for AI agents matters for two reasons: it makes bulk work such as triaging 2,000 overnight client emails cheap in context, and it moves the trust boundary, because your harness is now executing code that a model wrote.

The change arrived in three posts over eight days. On 29 September 2026 Earendil's engineering team published 'You Said No MCP!', explaining why Pi, which once declared proudly that it did not support the Model Context Protocol (MCP, the open protocol that connects LLM applications to external tools and data sources), now supports it through codemode. On 1 October Pi 1.0 shipped with codemode, deferred tool loading and mid-conversation system messages. On 6 October Armin Ronacher, who works on Pi, published 'What is Codemode', the clearest account so far of how it runs and what it still gets wrong. By our count, those posts and a related thread about Figma and MCP drew 2,723 points and 1,156 comments on Hacker News, more than any other agent-engineering topic between 30 September and 14 October.

This guide is for CTOs and staff engineers deciding whether to let codemode near a trading business: what it is, why it saves context, where the trust boundary moves, how to build the harness, why a classifier's confidence inside it needs calibrating, and where we would not use it at all.

1. Why Does Codemode For AI Agents Matter On A Trading Desk?

Classic tool calling costs a round trip through the model for every call, and every result lands in the context window. Ask an agent to classify 2,000 overnight client emails that way and it needs 2,000 classification calls, each input and output passing through the model, plus whatever reasoning it writes between them. With codemode the model writes one program, the program makes the 2,001 calls (one to list the emails, 2,000 to classify them), and the model reads back a few hundred bytes of counts and exceptions.

Earendil's worked example has exactly this shape. Asked to find the most frustrated people on Pi's own issue tracker, Pi wrote a program that pulled the open issues from the Linear MCP server and ran each thread through Jev, Typesafe AI's decision model, via models.classify, four workers at a time. Of 167 open issues, Jev rated 156 neutral, 11 mildly frustrated and none highly frustrated, and Earendil says Pi did it 'without wasting any context at all'. Ronacher adds that a plain bash call in Pi puts only the trailing 2,000 lines of output into context, while codemode receives larger outputs as structured data, and that agents now commonly probe five to ten items to learn a response's shape before writing a program for the rest.

Trading firms are full of this work: read many things, apply the same judgement to each, report the exceptions. A broker's overnight client-services inbox. The morning news sweep against the instruments a desk holds. The end-of-day position check across 300 accounts. The program below is an illustrative example of what a capable model writes for 'triage last night's client-services inbox'. Nobody on your team writes it in production; the model does, and your harness runs it.

javascriptagent/triage-overnight-inbox.js
// The model writes this, not your team. An illustrative codemode program for "triage last night's client-services inbox".
// It runs inside the QuickJS sandbox. tools, searchTools, store and load are the only globals it has.
// An earlier call probed five emails to learn their shape; this one processes the whole night.
const { emails } = await tools.list_overnight_emails({
  mailbox: "client-services",
  since: "2026-10-13T17:00:00Z",
  limit: 2000,
});

const results = [];
const failed = [];
let next = 0;
async function worker() {
  while (next < emails.length) {
    const email = emails[next++];
    try {
      const c = await tools.classify({
        taxonomy: "client_email_v1",
        text: `${email.subject}\n\n${email.body}`,
      });
      results.push({ id: email.id, receivedAt: email.receivedAt, label: c.label, confidence: c.confidence, route: c.route });
    } catch (e) {
      failed.push({ id: email.id, error: String(e && e.message ? e.message : e) });
    }
  }
}
await Promise.all([worker(), worker(), worker(), worker()]);
store("triage_2026_10_14", { results, failed });

const counts = {};
for (const r of results) counts[r.label] = (counts[r.label] ?? 0) + 1;
const forPeople = results.filter((r) => r.route === "human");
const complaints = forPeople
  .filter((r) => r.label === "possible_complaint")
  .sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
return {
  total: emails.length,
  classified: results.length,
  failed: failed.length,
  counts,
  routedToPeople: forPeople.length,
  oldestPossibleComplaint: complaints.length ? complaints[0].id : null,
  detail: "per-email results stored under triage_2026_10_14",
};
  • Everything in that program belongs to the model. Everything it can touch belongs to you: tools, searchTools, store and load are the only globals in the sandbox, and the harness in section 2 defines all four.
  • The four workers mirror Earendil's Linear example. Our harness caps concurrency at four whatever the program does, which is the same reason Ronacher gives for why Promise.all is safe in Pi: it runs four tool executions at once and queues the rest.
  • store() writes the per-email results into the session transcript. The next codemode invocation can load() them without fetching 2,000 emails again, and an auditor can see exactly what the agent saw and decided.

2. Where Does The Trust Boundary Move When The Harness Runs Model-Written Code?

Ronacher draws the line between the 'brain', the harness that runs the agent loop on a trusted machine, and the 'hands', the execution environment where bash and other tools run. Sandboxing products protect the hands; as Ronacher notes, with a tool like Gondolin 'your bash stuff will be sandboxed just fine, but the harness itself will not be.' Codemode sits on the brain's side: 'Codemode runs in the harness, in its own sandbox.' In Pi that sandbox is QuickJS inside a WebAssembly runtime 'with intentional limitations: no network, no file system, no timers, limited RAM. The only way is to call more tools.'

That is the right design, and it changes your threat model. Before codemode, the model asked for one tool call at a time and your harness could inspect each one. After it, the model hands your most trusted process a program that can make thousands of calls in loops and in parallel, with arguments computed from data it read a moment earlier, including text from a client email or a news story that may carry a prompt injection. The controls therefore belong in the harness, not in the prompt.

  1. 01An empty sandbox per invocation: a fresh QuickJS runtime with a memory ceiling, a stack ceiling and a deadline enforced twice, by the interpreter's interrupt handler for runaway loops and by a host timer for time spent waiting on tools.
  2. 02Caps: four tool calls in flight, a total budget of calls per invocation and a per-tool cap, so a loop over the wrong list fails fast instead of hammering your back office.
  3. 03A typed registry: every argument is validated against a schema before the call, and unknown tools or bad arguments come back as errors the model can read and fix.
  4. 04No write tools: the registry refuses anything that is not read-only, so there is nothing in the sandbox that can place an order, move money or message a client.
  5. 05An audit line for every inner call, refused calls included, with the run id, sequence number, tool, validated arguments, duration and a hash of the result, plus the full text of every program the model wrote.
typescriptharness/codemode.ts
// harness/codemode.ts
// The harness side of codemode: runs a program the model wrote inside a QuickJS sandbox.
// Targets quickjs-emscripten 0.32.0, zod 4.6 and Node.js 22.
import { createHash, randomUUID } from "node:crypto";
import { appendFile } from "node:fs/promises";
import {
  getQuickJS,
  shouldInterruptAfterDeadline,
  type QuickJSDeferredPromise,
  type QuickJSHandle,
} from "quickjs-emscripten";
import { z } from "zod";

export interface ReadTool<I extends z.ZodType = z.ZodType> {
  effect: "read"; // the only effect this registry accepts
  description: string;
  input: I;
  maxCallsPerRun: number;
  run(args: z.infer<I>, signal: AbortSignal): Promise<unknown>;
}
export type Registry = Record<string, ReadTool>;

export interface Limits {
  memoryBytes: number; // QuickJS heap ceiling
  stackBytes: number;
  timeoutMs: number; // wall clock for the whole invocation, tool waits included
  maxConcurrency: number; // in-flight tool calls; Pi uses four
  maxToolCalls: number; // across all tools in one invocation
  maxStoreBytes: number; // per store() call
  maxReturnBytes: number; // what goes back into the model's context
}

export const DEFAULT_LIMITS: Limits = {
  memoryBytes: 32 * 1024 * 1024,
  stackBytes: 512 * 1024,
  timeoutMs: 120_000,
  maxConcurrency: 4,
  maxToolCalls: 2_500,
  maxStoreBytes: 2 * 1024 * 1024,
  maxReturnBytes: 12_000,
};

export interface TranscriptEntry {
  kind: "codemode_store";
  runId: string;
  key: string;
  json: string;
  at: string;
}

export interface Session {
  id: string;
  stored: Map<string, string>; // rebuilt from the transcript when a session loads
  appendTranscript(entry: TranscriptEntry): Promise<void>;
}

export interface RunResult {
  runId: string;
  ok: boolean;
  value?: string; // JSON, truncated to maxReturnBytes
  error?: string;
  toolCalls: number;
  ms: number;
}

const AUDIT_FILE = process.env.CODEMODE_AUDIT_FILE ?? "codemode-audit.jsonl";
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const audit = (entry: Record<string, unknown>) =>
  appendFile(AUDIT_FILE, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");

class Semaphore {
  private active = 0;
  private waiting: Array<() => void> = [];
  constructor(private readonly max: number) {}
  async acquire(): Promise<() => void> {
    if (this.active < this.max) this.active++;
    else await new Promise<void>((resolve) => this.waiting.push(resolve)); // slot handed over on release
    return () => {
      const next = this.waiting.shift();
      if (next) next();
      else this.active--;
    };
  }
}

// Runs inside the sandbox before the model's code. It captures the host functions,
// deletes them from the global scope and exposes a small, frozen API.
const PRELUDE = `
"use strict";
(() => {
  const call = globalThis.__call, put = globalThis.__store, get = globalThis.__load, search = globalThis.__search;
  const names = JSON.parse(globalThis.__names);
  for (const k of ["__call", "__store", "__load", "__search", "__names"]) delete globalThis[k];
  const tools = {};
  for (const name of names) tools[name] = async (args = {}) => JSON.parse(await call(name, JSON.stringify(args)));
  globalThis.tools = Object.freeze(tools);
  globalThis.searchTools = (query) => JSON.parse(search(String(query)));
  globalThis.store = (key, value) => { put(String(key), JSON.stringify(value)); };
  globalThis.load = (key) => { const s = get(String(key)); return s === undefined ? undefined : JSON.parse(s); };
})();
`;

export async function runCodemode(
  program: string,
  registry: Registry,
  session: Session,
  limits: Limits = DEFAULT_LIMITS,
): Promise<RunResult> {
  for (const [name, tool] of Object.entries(registry)) {
    if (tool.effect !== "read") throw new Error(`refusing to expose ${name}: codemode tools are read-only`);
  }
  const runId = randomUUID();
  const started = Date.now();
  const abort = new AbortController();
  const gate = new Semaphore(limits.maxConcurrency);
  const perTool = new Map<string, number>();
  const pending = new Set<QuickJSDeferredPromise>();
  const writes: Promise<void>[] = [];
  let seq = 0;
  let disposed = false;

  const QuickJS = await getQuickJS();
  const runtime = QuickJS.newRuntime(); // no fetch, no fs, no timers: QuickJS exposes nothing by default
  runtime.setMemoryLimit(limits.memoryBytes);
  runtime.setMaxStackSize(limits.stackBytes);
  runtime.setInterruptHandler(shouldInterruptAfterDeadline(started + limits.timeoutMs));
  const vm = runtime.newContext();

  const drain = () => {
    if (disposed) return;
    const r = runtime.executePendingJobs();
    if (r.error) r.error.dispose();
  };

  // Every inner tool call passes through here: lookup, caps, validation, concurrency, audit.
  async function invoke(name: string, argsJson: string): Promise<string> {
    const callSeq = ++seq;
    const t0 = Date.now();
    let args: unknown = argsJson;
    try {
      const tool = registry[name];
      if (!tool) throw new Error(`unknown tool: ${name}`);
      if (callSeq > limits.maxToolCalls) throw new Error(`budget of ${limits.maxToolCalls} tool calls exhausted`);
      const used = (perTool.get(name) ?? 0) + 1;
      perTool.set(name, used);
      if (used > tool.maxCallsPerRun) throw new Error(`${name} is capped at ${tool.maxCallsPerRun} calls per run`);
      const parsed = tool.input.safeParse(JSON.parse(argsJson));
      if (!parsed.success) throw new Error(`${name}: ${z.prettifyError(parsed.error)}`);
      args = parsed.data;
      const release = await gate.acquire();
      try {
        if (abort.signal.aborted) throw new Error("run cancelled");
        const out = JSON.stringify(await tool.run(parsed.data, abort.signal)) ?? "null";
        await audit({ runId, sessionId: session.id, seq: callSeq, tool: name, args, ok: true,
          ms: Date.now() - t0, resultBytes: out.length, resultSha256: sha256(out) });
        return out;
      } finally {
        release();
      }
    } catch (err) {
      await audit({ runId, sessionId: session.id, seq: callSeq, tool: name, args, ok: false,
        ms: Date.now() - t0, error: String(err instanceof Error ? err.message : err) });
      throw err;
    }
  }

  function settle(d: QuickJSDeferredPromise, make: () => QuickJSHandle, ok: boolean) {
    pending.delete(d);
    if (disposed || !d.alive) return; // the run already ended; drop late results
    const h = make();
    if (ok) d.resolve(h);
    else d.reject(h);
    h.dispose();
    drain();
  }

  const hostFns: Array<[string, QuickJSHandle]> = [
    ["__call", vm.newFunction("__call", (nameH, argsH) => {
      const d = vm.newPromise();
      pending.add(d);
      invoke(vm.getString(nameH), vm.getString(argsH)).then(
        (json) => settle(d, () => vm.newString(json), true),
        (err) => settle(d, () => vm.newError({ name: "ToolError", message: String(err?.message ?? err) }), false),
      );
      return d.handle;
    })],
    ["__store", vm.newFunction("__store", (keyH, jsonH) => {
      const key = vm.getString(keyH);
      const json = vm.getString(jsonH);
      if (json.length > limits.maxStoreBytes) throw new Error(`store("${key}") exceeds ${limits.maxStoreBytes} bytes`);
      session.stored.set(key, json);
      writes.push(session.appendTranscript({ kind: "codemode_store", runId, key, json, at: new Date().toISOString() }));
    })],
    ["__load", vm.newFunction("__load", (keyH) => {
      const json = session.stored.get(vm.getString(keyH));
      return json === undefined ? vm.undefined : vm.newString(json);
    })],
    ["__search", vm.newFunction("__search", (queryH) => {
      const q = vm.getString(queryH).toLowerCase();
      const hits = Object.entries(registry)
        .filter(([n, t]) => n.includes(q) || t.description.toLowerCase().includes(q))
        .map(([name, t]) => ({ name, description: t.description, input: z.toJSONSchema(t.input) }));
      return vm.newString(JSON.stringify(hits));
    })],
  ];
  for (const [name, fn] of hostFns) {
    vm.setProp(vm.global, name, fn);
    fn.dispose();
  }
  vm.newString(JSON.stringify(Object.keys(registry))).consume((h) => vm.setProp(vm.global, "__names", h));
  vm.unwrapResult(vm.evalCode(PRELUDE, "prelude.js")).dispose();

  let timer: NodeJS.Timeout | undefined;
  let promise: QuickJSHandle | undefined;
  let result: RunResult;
  try {
    // The model writes a function body, so top-level await and return both work.
    promise = vm.unwrapResult(vm.evalCode(`(async () => {\n${program}\n})()`, "agent.js"));
    const outcome = vm.resolvePromise(promise);
    drain();
    const timeout = new Promise<"timeout">((r) => { timer = setTimeout(() => r("timeout"), limits.timeoutMs); });
    const settled = await Promise.race([outcome, timeout]);
    if (settled === "timeout") throw new Error(`timed out after ${limits.timeoutMs} ms`);
    if (settled.error) {
      const e = settled.error.consume((h) => vm.dump(h));
      throw new Error(`${e?.name ?? "Error"}: ${e?.message ?? JSON.stringify(e)}`);
    }
    const json = JSON.stringify(settled.value.consume((h) => vm.dump(h))) ?? "null";
    const value = json.length <= limits.maxReturnBytes ? json
      : json.slice(0, limits.maxReturnBytes) + ` [truncated ${json.length - limits.maxReturnBytes} chars: store() the full result and load() it]`;
    result = { runId, ok: true, value, toolCalls: seq, ms: Date.now() - started };
  } catch (err) {
    result = { runId, ok: false, error: String(err instanceof Error ? err.message : err), toolCalls: seq, ms: Date.now() - started };
  } finally {
    clearTimeout(timer);
    abort.abort(); // cancels tool calls still in flight
    disposed = true;
    for (const d of pending) d.dispose();
    promise?.dispose();
    vm.dispose();
    runtime.dispose();
  }
  await Promise.allSettled(writes);
  // The program itself is evidence: log the exact code the model wrote next to its inner calls.
  await audit({ runId, sessionId: session.id, kind: "codemode_run", ok: result.ok, error: result.error,
    toolCalls: result.toolCalls, ms: result.ms, programSha256: sha256(program), program });
  return result;
}

We ran this harness with quickjs-emscripten 0.32.0 on Node.js 22. A 2,000-email run held at four calls in flight; an infinite loop ended in 'InternalError: interrupted'; a runaway allocation ended in 'InternalError: out of memory'; a program that awaited forever was cut off by the host timer; and fetch, setTimeout and require were undefined inside the sandbox. QuickJS runs on the calling thread, so put each invocation in a worker thread or separate process. And avoid MCP servers that wrap their own codemode: the agent ends up writing JavaScript inside a JSON string inside JavaScript, which Ronacher calls 'double JSON escaping', and the inner code cannot call your outer tools.

3. What Belongs In A Codemode Tool Registry, And What Never Does?

Earendil's post argues that MCP 'should be much closer to OpenAPI with intelligent tool discovery', meaning tools that return structured data and are discoverable by their documentation and description. Ronacher adds that Pi does not show MCP tools to the model at all: the agent runs a tool search inside codemode to find out what the connected servers can do. The registry below follows both ideas. Every tool has a description and a zod schema, searchTools() returns them as JSON Schema on demand, and nothing is loaded into the model's context up front.

Four rules make it fit for a trading firm. Tools are read-only, backed by a read replica and a credential that cannot write. Classifier label sets live in the harness with a version number, so calibration data always maps to one taxonomy. The harness, not the model, decides whether an item routes automatically or to a person, from a calibrated threshold the program cannot lower. And anything that places, amends or cancels an order, moves money or messages a client stays outside codemode entirely, behind the order gateway that already carries your approval gate, limits, kill switch and audit log.

typescriptharness/tools.ts
// harness/tools.ts
// The typed, read-only registry the sandbox can see. No tool here can place, amend or cancel
// an order, move money or send a message: those live behind the approval-gated order gateway.
// Targets zod 4.6, openai 7.33.0 (the Decisions API needs 7.30.0 or later) and Node.js 22.
import { readFileSync } from "node:fs";
import OpenAI from "openai";
import { z } from "zod";
import type { ReadTool, Registry } from "./codemode";

const OPS_API = process.env.OPS_API_URL ?? "";              // read-only replica, separate credentials
const OPS_TOKEN = process.env.OPS_API_READONLY_TOKEN ?? "";
if (!OPS_API || !OPS_TOKEN) throw new Error("OPS_API_URL and OPS_API_READONLY_TOKEN must be set");
const openai = new OpenAI(); // reads OPENAI_API_KEY from the environment

async function opsGet(path: string, params: Record<string, string | number>, signal: AbortSignal) {
  const url = new URL(path, OPS_API);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const res = await fetch(url, { headers: { authorization: `Bearer ${OPS_TOKEN}` }, signal });
  if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
  return res.json();
}

// Label sets belong to the harness and carry a version, so every calibration run maps to one taxonomy.
const TAXONOMIES = {
  client_email_v1: {
    instructions: "What is the client asking the firm to do? Judge the request, not the tone.",
    choices: [
      { value: "possible_complaint", description: "Dissatisfaction with the firm, a fill, a charge or the service" },
      { value: "payments", description: "Deposits, withdrawals, payment status, card or bank details" },
      { value: "account_admin", description: "Login, documents, verification, address or personal details" },
      { value: "platform_question", description: "How the platform, charts, order types or margin figures work" },
      { value: "other", description: "Anything that fits none of the categories above" },
    ],
  },
} as const;

// Written by calibration/calibrate.py. autoRouteAt is null until the classifier earns it.
const CALIBRATION: { autoRouteAt: number | null } = JSON.parse(
  readFileSync(process.env.CALIBRATION_FILE ?? "calibration/client_email_v1.json", "utf8"),
);
const ALWAYS_HUMAN = new Set(["possible_complaint"]); // whatever the confidence says

const tool = <I extends z.ZodType>(t: ReadTool<I>): ReadTool<I> => t;

export const registry: Registry = {
  list_overnight_emails: tool({
    effect: "read",
    description: "Client emails received by a shared mailbox since a time. Returns { emails: [{ id, receivedAt, subject, body }] }; bodies are cut to 4,000 characters and account numbers are masked upstream.",
    input: z.object({
      mailbox: z.enum(["client-services", "dealing-desk"]),
      since: z.iso.datetime(),
      limit: z.number().int().min(1).max(2000),
    }),
    maxCallsPerRun: 5,
    run: (a, signal) => opsGet("/v1/mailboxes/emails", a, signal),
  }),
  get_news: tool({
    effect: "read",
    description: "Headlines and summaries from the licensed news feed since a time. Returns { items: [{ id, publishedAt, headline, summary, instruments }] }.",
    input: z.object({ since: z.iso.datetime(), limit: z.number().int().min(1).max(2000) }),
    maxCallsPerRun: 5,
    run: (a, signal) => opsGet("/v1/news", a, signal),
  }),
  get_positions: tool({
    effect: "read",
    description: "End-of-day positions for one account: { positions: [{ instrument, quantity, avgPrice, currency }] }.",
    input: z.object({ account: z.string().regex(/^[A-Z0-9-]{4,32}$/), asOf: z.iso.date() }),
    maxCallsPerRun: 500,
    run: (a, signal) => opsGet(`/v1/accounts/${a.account}/positions`, { asOf: a.asOf }, signal),
  }),
  classify: tool({
    effect: "read",
    description: "Classify text against a harness-owned taxonomy. Returns { label, confidence, route }, where route is 'auto' or 'human' and is decided by the harness, not the caller.",
    input: z.object({ taxonomy: z.enum(["client_email_v1"]), text: z.string().min(1).max(8000) }),
    maxCallsPerRun: 2000,
    run: async ({ taxonomy, text }, signal) => {
      const t = TAXONOMIES[taxonomy];
      const decision = await openai.decisions.create(
        {
          model: "gpt-6-luna",
          input: text,
          questions: [{ type: "choice", name: "label", instructions: t.instructions, choices: [...t.choices] }],
        },
        { signal },
      );
      const a = decision.answers[0];
      if (!a || a.type !== "choice") return { taxonomy, label: null, confidence: 0, route: "human" };
      const label = String(a.choice);
      const cut = CALIBRATION.autoRouteAt ?? Number.POSITIVE_INFINITY;
      const route = ALWAYS_HUMAN.has(label) || a.confidence < cut ? "human" : "auto";
      return { taxonomy, label, confidence: a.confidence, route };
    },
  }),
};

The classify tool calls OpenAI's Decisions API, which opened to all developers in public beta on 6 October 2026: a dedicated POST /v1/decisions endpoint, gpt-6-luna as the only model, three question types (predicate, choice and score) and $0.10 per million input tokens with no output-token charge. A choice answer carries a label, a probability per option and a separate confidence field. Cloudflare's Clef models, released on 1 October as open-source decision models on Workers AI under the Apache 2.0 licence, are 'fully Jev-API compatible' in Cloudflare's words. Inside codemode the classifier runs 2,000 times with nobody watching each call, so its confidence field is doing a reviewer's job.

4. Is The Confidence From A Classifier Inside Codemode A Probability?

Not until you have checked it against your own labelled data. Two results from the same fortnight make the point. On 1 October Anthus tested GPT-6 Luna, the model family the Decisions API is built on, with reasoning off on 3,600 ProofWriter logic problems. It used OpenAI's standard API rather than the Decisions API itself, which was not yet documented, and Anthus allows that the specialised version may do better. 'When Luna said it was 99% sure or more, it was right 68% of the time.' Its expected calibration error was 0.32, against 0.04 and 0.03 for Jev on the two variants of the task. On 2 October Red Hat published a guardrail benchmark in which a conventional classifier, deberta-v3-base-prompt-injection-v2, detected prompt injection with 89.01% accuracy at a median 54.1 ms, against 86.35% at 348.1 ms for Jev.

Neither result says decision models are poor. They say a confidence number is a claim measured on someone else's data, and a general decision model is not automatically better than a classifier trained for one job. OpenAI's own documentation agrees: 'Use labeled examples from your application to set thresholds for routing, filtering, or review.' Expected calibration error (ECE) is the average gap between how sure a model says it is and how often it is right, weighted by how many answers fall in each confidence band. The script below computes it from a labelled sample, prints a reliability table and writes the threshold file the registry reads. It picks the lowest cut-off at which the auto-routed slice is at least 98% accurate at the 95% lower bound of a Wilson interval. If none qualifies, or ECE is above 0.05, the threshold stays null and every item goes to a person.

pythoncalibration/calibrate.py
"""calibration/calibrate.py

Reliability bins, expected calibration error (ECE) and an abstain-to-human
threshold for the classifier the harness calls inside codemode.
Python 3.11 or later, standard library only.

Input is a labelled sample exported from the audit log and reviewed by people:
    id,predicted,confidence,true_label
Output is the JSON file harness/tools.ts reads. autoRouteAt stays null, and
every item goes to a person, until the classifier earns a threshold.

    python calibration/calibrate.py labelled/client_email_v1.csv \
        --taxonomy client_email_v1 --target 0.98 --max-ece 0.05 \
        --out calibration/client_email_v1.json
"""
from __future__ import annotations

import argparse
import csv
import json
import math
from dataclasses import asdict, dataclass
from datetime import date

MIN_SAMPLE = 400      # below this, no threshold is worth trusting
MIN_AUTO_SLICE = 150  # the auto-routed slice must be big enough to measure


@dataclass(frozen=True)
class Item:
    confidence: float
    correct: bool


@dataclass(frozen=True)
class Bin:
    lower: float
    upper: float
    n: int
    mean_confidence: float
    accuracy: float


def load(path: str) -> list[Item]:
    items: list[Item] = []
    with open(path, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            c = float(row["confidence"])
            if not 0.0 <= c <= 1.0:
                raise ValueError(f"row {row['id']}: confidence {c} is outside [0, 1]")
            items.append(Item(c, row["predicted"].strip() == row["true_label"].strip()))
    if len(items) < MIN_SAMPLE:
        raise SystemExit(f"{len(items)} labelled items; label at least {MIN_SAMPLE} first")
    return items


def reliability(items: list[Item], n_bins: int = 10) -> list[Bin]:
    bins = []
    for b in range(n_bins):
        lo, hi = b / n_bins, (b + 1) / n_bins
        members = [i for i in items if lo <= i.confidence < hi or (hi == 1.0 and i.confidence == 1.0)]
        if members:
            bins.append(Bin(lo, hi, len(members),
                            sum(i.confidence for i in members) / len(members),
                            sum(i.correct for i in members) / len(members)))
    return bins


def expected_calibration_error(bins: list[Bin], total: int) -> float:
    return sum(b.n / total * abs(b.accuracy - b.mean_confidence) for b in bins)


def wilson_lower(successes: int, n: int, z: float = 1.96) -> float:
    """95% lower bound on a proportion; honest about small slices."""
    p = successes / n
    centre = p + z * z / (2 * n)
    margin = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    return (centre - margin) / (1 + z * z / n)


def abstain_threshold(items: list[Item], target: float) -> tuple[float, float, float, float] | None:
    """Lowest cut-off whose auto-routed slice is at least `target` accurate at the 95% lower bound."""
    for t in sorted({round(i.confidence, 3) for i in items}):
        auto = [i for i in items if i.confidence >= t]
        if len(auto) < MIN_AUTO_SLICE:
            return None
        right = sum(i.correct for i in auto)
        lower = wilson_lower(right, len(auto))
        if lower >= target:
            return t, len(auto) / len(items), right / len(auto), lower
    return None


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("csv")
    ap.add_argument("--taxonomy", required=True)
    ap.add_argument("--target", type=float, default=0.98, help="required accuracy of auto-routed items")
    ap.add_argument("--max-ece", type=float, default=0.05)
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    items = load(args.csv)
    bins = reliability(items)
    ece = expected_calibration_error(bins, len(items))
    print(f"{len(items)} items, accuracy {sum(i.correct for i in items) / len(items):.3f}, ECE {ece:.3f}")
    for b in bins:
        print(f"  [{b.lower:.1f}, {b.upper:.1f})  n={b.n:5d}  says {b.mean_confidence:.3f}  "
              f"is right {b.accuracy:.3f}  gap {b.accuracy - b.mean_confidence:+.3f}")
    sure = [i for i in items if i.confidence >= 0.99]
    if sure:
        print(f"  claims 99% or more on {len(sure)} items; right on {sum(i.correct for i in sure) / len(sure):.1%}")

    auto_route_at = None
    if ece > args.max_ece:
        print(f"ECE {ece:.3f} is above {args.max_ece}: every item goes to a person")
    elif (found := abstain_threshold(items, args.target)) is None:
        print(f"no cut-off reaches {args.target:.0%} at the 95% lower bound: every item goes to a person")
    else:
        auto_route_at, coverage, accuracy, lower = found
        print(f"auto-route at confidence >= {auto_route_at}: {coverage:.1%} of items, "
              f"accuracy {accuracy:.3f}, 95% lower bound {lower:.3f}; the rest go to a person")

    with open(args.out, "w", encoding="utf-8") as f:
        json.dump({"taxonomy": args.taxonomy, "autoRouteAt": auto_route_at, "ece": round(ece, 4),
                   "n": len(items), "target": args.target, "calibratedOn": date.today().isoformat(),
                   "bins": [asdict(b) for b in bins]}, f, indent=2)
    return 0 if auto_route_at is not None else 1  # non-zero fails the CI job that refreshes thresholds


if __name__ == "__main__":
    raise SystemExit(main())
  • Label from the audit log, not from a tidy test set. The emails that matter are the ones the classifier actually saw at seven on a Monday morning.
  • A threshold is valid for one combination of model, taxonomy version and inbox. Change any of the three and recalibrate before the next run routes anything automatically.
  • A classifier that routes everything to people still pays for itself: the person opens a queue that is already sorted, counted and stored, instead of a raw inbox.

5. When Should A Trading Firm Not Use Codemode?

Codemode is a good answer to bulk, read-mostly work. It is a poor answer to several jobs that look similar, and Ronacher is candid that the pattern is 'for sure not a perfect solution yet'.

  • When a small model writes the program. Ronacher lists poor results with smaller models among the open problems; keep small models on classic tool calls with a fixed plan.
  • When the work involves orders, money or client messages. A program that loops over accounts calling a write tool is a batch job nobody approved. Keep writes at the gateway: one approval per action, limits, a kill switch and an audit log.
  • When the job must survive a crash. Ronacher calls durability with codemode 'trickier' and suggests ideas from durable workflow engines, or a deterministic language such as Starlark. A reconciliation that must resume at item 1,412 after a restart belongs in a workflow engine.
  • When the inputs are binary. Ronacher names images and binary data as unsolved, and MCP does not yet support large binary data.
  • When the MCP server already runs its own codemode. The double escaping confuses smaller models, and the inner code cannot reach the tools your harness controls.

6. How Do You Adopt Codemode Safely In A Trading Firm?

  1. 01Pick one read-only bulk job with a baseline you can measure: the overnight client-services inbox, the morning news sweep against held instruments, or the end-of-day position check across accounts.
  2. 02Stand up the harness with the controls above and an empty registry, then add read-only tools one at a time, each with a schema, a per-run cap and a description good enough for searchTools() to find.
  3. 03Run in shadow for two to four weeks. The codemode run produces its triage, people keep doing theirs, and you label every disagreement.
  4. 04Calibrate on that labelled sample, and ship with the threshold set to null if the numbers say so.
  5. 05Review the audit log weekly: the programs the model wrote, the calls they made, the errors they hit. Treat each program like a pull request from a contractor you have never met.
  6. 06Write down the kill switch: one configuration flag that removes codemode from the agent's tool loadout, tested in the week it ships.

“Codemode does not make the model more trustworthy. It makes the harness the place where trust is decided.”


Where This Belongs

  1. 01Brokers and CFD providers: start with the client-services inbox. Codemode fits the overnight backlog, possible complaints always go to a person, and nothing inside the sandbox can reply to a client.
  2. 02Prop trading firms: if support tickets and payout queries arrive in bursts after each payout cycle, codemode can sort them in one pass. Rule breaches stay with deterministic code that measures the rule, never with a classifier.
  3. 03Fintech and trading startups: build the harness boundary now, while the registry has five tools. Retrofitting caps, schemas and audit to fifty tools later is the expensive version of the same work.
  4. 04Research and quant teams: news and filings sweeps across a universe of instruments are a natural fit, as long as the output is stored and reviewed as research material rather than fed straight into anything that trades.

The Bottom Line

Codemode for AI agents solves a real problem: bulk tool work that floods the context window. Pi 1.0 built it in on 1 October 2026, and the design Earendil and Armin Ronacher describe, QuickJS in WebAssembly with no network, no file system and no timers, four tool calls at a time and results stored in the transcript, is sound. What changes for a trading firm is where trust lives. The harness now runs code a model wrote, so it must own the sandbox, the caps, the typed read-only registry, the routing threshold and an audit line for every inner call, and the classifier inside has to earn its threshold on your own labelled data, because a stated 99% can mean 68%. That is how we approach agent work at AlchmAI, a London trading technology and AI studio: narrow agents whose controls live in code a compliance lead can read.

References & Further Reading

codemodeAgentic AI finance codeAI Automation Trading codeWorkflow automation code samplesTrading AI architectureMCPQuickJS sandbox
Share Email
AI

AlchmAI Engineering

Engineering, London

Written by the AlchmAI engineering team in Mayfair, London. We build trading platforms, real-time charts, market data pipelines and AI features for brokers, prop firms and fintech teams. The Playbook is where we explain how we approach these systems, with code you can run and sources you can check.

Code in this guide is illustrative and supplied without warranty. Review and test it before production use. Nothing here is investment advice. Important information