February 19, 2026 · TidyScripts

From Tool Calls to Code Generation: Upgrading the Cortex Agent Execution Engine

How we replaced structured function calling with a JavaScript sandbox execution model — and why it made our AI agent fundamentally more capable.

aiagentstypescriptarchitecturesandbox
Originally posted on tidyscripts.com · migrated with original date preserved.

Disclaimer: This post was entirely generated by Claude Code (Anthropic's AI coding agent) with support, interaction, and direction by Sheun Aluko, MD.


From Tool Calls to Code Generation: Upgrading the Cortex Agent Execution Engine

In an earlier post, we described Cortex's original architecture: an AI agent that outputs structured function calls using a composable call chain syntax. The agent would return something like:

{
  thoughts: "I need to embed the text, then store it",
  calls: [
    { name: "compute_embedding", parameters: { text: "hello world" } },
    { name: "store_in_database", parameters: { embedding: "$0" } }
  ],
  return_indeces: [1]
}

This worked — the $N reference syntax let the agent chain function outputs, and CortexRAM provided cross-call state. But it had limits. The agent was constrained to expressing computation as a sequence of pre-defined function calls. It couldn't write loops, conditionals, or compose data transformations. Every new capability required defining a new function.

So we replaced the entire execution model. Instead of structured call chains, the agent now writes JavaScript code that runs in an isolated iframe sandbox with full observability.

This post walks through the new architecture.

The New Output Schema

The LLM's structured output went from this:

// Old: declarative call chain
{
  thoughts: string,
  calls: FunctionCall[],
  return_indeces: number[]
}

To this:

// New: code generation
{
  thoughts: string,
  code: string
}

That's it. The agent thinks, then writes code. The code runs in a sandbox with all available functions injected into scope. Instead of declaring what to call, the agent writes how to use the tools.

Here's what the agent actually generates:

// Agent computes an embedding, stores knowledge, then responds
embedding = await compute_embedding({ text: "quantum computing" });
await store_declarative_knowledge({
  knowledge: "Quantum computing uses qubits",
  embedding: embedding
});
results = await retrieve_declarative_knowledge({ query: "quantum" });
await respond_to_user({
  response: `Found ${results.length} entries about quantum computing.`
});

This is real JavaScript — with variables, await, template literals, and control flow. The agent has the full expressiveness of a programming language instead of a fixed call syntax.

The Iframe Sandbox

The code runs in a persistent <iframe sandbox="allow-scripts"> element — a browser-native isolation boundary. The iframe has no access to the DOM, localStorage, cookies, fetch, or any parent APIs. Communication happens exclusively through postMessage.

Persistent State

A key design choice: the iframe persists across executions. This enables two mechanisms for cross-turn state:

workspace — a mutable object that survives across turns. The agent can store data and access it in subsequent executions:

// Turn 1: store data
workspace.userPreferences = { theme: "dark", language: "en" };

// Turn 5: access it later
if (workspace.userPreferences.theme === "dark") {
  await display_html({ html: darkModeUI });
}

last_result — the return value from the previous execution, automatically injected:

// Turn 1: compute something, return it
results = await retrieve_declarative_knowledge({ query: "AI safety" });
return results;

// Turn 2: last_result contains the previous return value
await respond_to_user({
  response: `I found ${last_result.length} entries about AI safety.`
});

This two-turn pattern — execute then respond — is enforced by the agent loop. The system continues invoking the LLM until the last function call in the execution is respond_to_user. This ensures the agent sees its own results before formulating a response, eliminating hallucinated outputs.

Function Injection

All available functions are wrapped and injected into the sandbox context. The wrapping process serves two purposes: providing utility context to each function, and bridging the iframe isolation boundary.

On the parent side, each function is wrapped with a context object:

context[fn.name] = async (params) => {
  const ops = {
    params,
    util: {
      log, event, user_output,
      get_user_data,      // human-in-the-loop input
      get_embedding,      // vector embeddings
      set_var, get_var,   // persistent variable storage
      run_structured_completion,  // ad-hoc LLM calls
      feedback            // UI sound effects
    }
  };
  return await fn.fn(ops);
};

Inside the iframe, each function becomes an async stub that sends a postMessage to the parent, waits for the result, and returns it:

Sandbox iframe                          Parent window
─────────────                          ─────────────
compute_embedding({text: "hello"})
    │
    ├─── postMessage: functionCall ──────►
    │                                     execute real function
    │                                     with full context
    ◄─── postMessage: functionResult ────┤
    │
    return result

This architecture means functions execute with full access to the application context (database connections, API keys, embeddings) while the agent's code runs in complete isolation.

The Proxy Membrane

The most interesting part of the sandbox is its observability layer. All code executes through a Proxy membrane that intercepts every interaction:

Variable assignments — tracked via the set trap:

// Agent writes:
query = "What is AI?";
// Proxy emits: { type: 'variable_set', name: 'query', value: 'What is AI?' }

Function calls — wrapped with timing and result tracking:

// Agent writes:
result = await compute_embedding({ text: query });
// Proxy emits: { type: 'function_start', name: 'compute_embedding', args: [...] }
// ... function executes ...
// Proxy emits: { type: 'function_end', name: 'compute_embedding', duration: 245, result: [...] }

Console output — intercepted and captured:

// Agent writes:
console.log("Processing", items.length, "items");
// Captured as: { level: 'log', args: ['Processing', 42, 'items'] }

The critical trick that enables variable tracking: the proxy's has trap always returns true. This means unqualified assignments like x = 5 go through the set trap instead of being treated as global declarations. The agent is instructed to use unqualified assignments (no const, let, or var) specifically so the proxy can observe them.

All events stream to the UI in real-time via postMessage as code executes — not collected and returned afterward. This means the Variable Inspector, Function Calls widget, and Sandbox Logs widget update live during execution.

The Agent Loop

The execution loop ties everything together:

User message
    ↓
LLM generates { thoughts, code }
    ↓
Build sandbox context (functions + workspace + last_result)
    ↓
Execute code in iframe sandbox
    ↓
Inspect events: was respond_to_user the last call?
    ├─ YES → extract response text, return to user
    └─ NO  → inject execution result as context, loop back to LLM

The loop continues for up to N iterations (default 4). If the agent exhausts its loops without calling respond_to_user, the system injects a message: "Loop limit reached. You must now call respond_to_user with the current status of the task." The agent gets one final turn to summarize.

This loop mechanism enables multi-step reasoning. The agent can:

  1. Query a knowledge base
  2. See the results
  3. Compute embeddings based on those results
  4. Respond to the user with an informed answer

Each step is a full LLM turn with code execution, giving the agent the ability to adapt its approach based on intermediate results.

What Changed: Old vs New

AspectCall Chains (Old)Code Generation (New)
Output formatJSON array of function callsJavaScript code string
Composability$N references between callsNative variables and expressions
Control flowNone (linear sequence only)Full: loops, conditionals, try/catch
StateCortexRAM (hash-based IDs)workspace object + last_result
ObservabilityFunction results onlyVariables, function calls, console, timing
ExpressivenessLimited to pre-defined functionsArbitrary computation + functions
DebuggingInspect call chain resultsLive variable inspector, execution replay

Why This Matters

The shift from structured function calls to code generation isn't just an implementation detail — it changes what the agent can do.

With call chains, the agent was a dispatcher. It could invoke tools in sequence and wire outputs together, but it couldn't express logic. Need to filter a list? Define a filter_array function. Need to format a string? Define a format_string function. Every new capability required a new tool definition.

With code generation, the agent is a programmer. It can filter, transform, compute, branch, and iterate using the language itself. The tools become building blocks that the agent composes with code, not the only available operations.

The proxy membrane makes this practical by providing the same level of observability you'd get from structured function calls — every variable assignment and function invocation is tracked — while giving the agent dramatically more freedom in how it uses those functions.

And because the sandbox is browser-native (an iframe with sandbox="allow-scripts"), the isolation guarantees are enforced by the browser's security model, not by our code. The agent's generated JavaScript cannot access the DOM, make network requests, or touch any state outside the sandbox boundary.

The result is an agent that writes code like a developer, executes it safely, and exposes every step for inspection.


Cortex is part of the TidyScripts project. For more on the original call-chain architecture, see the previous post.