Skip to main content

Streaming TypeScript Execution for AI Agents

·7 mins

Code execution is becoming central to agentic AI - Anthropic and Cloudflare are both pushing in this direction. This won’t stop at background tasks, though. Code-driven agentic AI assistants will generate user interfaces, respond to interactions, and orchestrate the whole user experience on the fly.

Recently I’ve been exploring this with a prototype: The assistant writes and executes code server-side, it fetches your emails, checks your calendar and it generates React UIs that render client-side. Experimenting with this paradigm I hit a wall: code generation and execution both take time. Batch mode exposes the full sum of that latency to the user - no output until the entire pipeline completes. In interactive assistants, that latency makes or breaks the experience.

Streaming execution (top) starts rendering while tokens arrive; batch (bottom) waits for completion.

This is where streaming execution matters. Instead of waiting for the LLM to finish generating a code block, we execute statements as they arrive.

The benefit isn’t primarily about total time saved - it’s about what happens during the seconds the LLM is generating code. In batch mode, that’s dead time: the user waits. In streaming mode, it’s productive time: the UI mounts, API calls run, data loads - all while tokens are still arriving. By the time generation completes, execution is well underway. Time-to-first-output drops from “full code block generated” to “first statement generated” - often a difference of several seconds.


The Problem #

As it turns out, there’s no standard way to do this. Node’s REPL module comes close, but I’m using Bun - which doesn’t expose a programmatic REPL API. To execute TypeScript code statement-by-statement while the LLM is still generating, we need to solve two things:

  1. Identifying complete statements - As characters stream in, we need to detect when a statement is complete and ready for execution.

  2. Executing with shared context and top-level await - Once we have a complete statement, we need to run it. Variables declared in one statement must be accessible in subsequent statements. And top-level async/await needs to just work.

One simplification though: I did not need import statements. In my system, dependencies are pre-injected into the context before execution starts. The LLM-generated code just uses what’s already there.


Statement Detection #

When is a sequence of tokens ready to execute?

We buffer incoming tokens and use TypeScript’s parser as a validator. Whenever we hit a semicolon, we try to parse the buffer. If it parses, we have a complete statement. We execute it and clear the buffer. If parsing fails, we keep buffering; the semicolon was likely inside a string, template literal, or regex.

Statement detection in action

Why semicolons and not newlines? JavaScript doesn’t require semicolons to terminate statements - ASI (Automatic Semicolon Insertion) infers them. This ambiguity means a newline isn’t a reliable boundary; whether ASI kicks in depends on the next token:

const result = someArray
  .filter(x => x.active)
  .map(x => x.value);

Implementing ASI correctly requires full tokenization and lookahead logic. Semicolons give us a reliable signal without that complexity.

The tradeoff: statements without semicolons won’t execute until either the next semicolon arrives or the code block ends. Execution still works correctly - just slightly delayed. In practice this rarely matters; LLMs tend to generate semicolon-terminated code.

Code Execution #

Next: executing these statements in a way that shares context (variables persist across statements) and supports top-level await.

The ideal solution would be a REPL module. Node provides one - it handles both requirements out of the box: shared context and top-level await just work. For Node users, wiring this up with statement detection would be straightforward.

Unfortunately, I have a hard dependency on Bun - and Bun doesn’t expose a programmatic REPL API. That leaves eval(), AsyncFunction, and Bun’s node:vm module. Here’s how they compare for executing statements one at a time:

Shared Context Top-Level Await
(Node REPL)
eval()
AsyncFunction
vm.Script
vm.SourceTextModule

Nothing gives you both. So I built a workaround on top of vm.Script. Consider we need to execute:

const { name, email } = await fetchUser(123);
const greeting = `Hello ${name}, we'll contact you at ${email}`;

There is a top-level await in the first line and the second line needs name and email from the first (shared context). vm.Script would handle shared context but chokes on the await.

The workaround: wrap each statement in an async IIFE. The script returns a promise, which we await from outside.

But wrapping creates a scope problem: variables declared inside the IIFE aren’t visible outside it. So we extract declared variables and add them to the shared context manually. To know what was declared, we use the TypeScript compiler API to extract all bindings - variable names, function declarations, classes.

const { name, email } = await fetchUser(123);

then becomes something like:

const wrappedStatement = new vm.Script(`
  (async () => {
    const { name, email } = await fetchUser(123);
    return { name, email };
  })()
`)

const result = await wrappedStatement.runInContext(context)

context["name"] = result["name"];
context["email"] = result["email"];

The second statement runs in the very same context, so name and email are there.


The Library #

I’ve packaged statement detection and the execution workaround into a library: bun-streaming-exec.

Installation #

bun add bun-streaming-exec

Usage #

import { StreamingExecutor } from 'bun-streaming-exec';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { sendMail, calendar } from './skills';

const exec = new StreamingExecutor({
  context: { sendMail, calendar },
});

const { textStream } = streamText({
  model: openai('gpt-5.2'),
  prompt: 'Write code to fetch and display user data',
});

const { events, result } = exec.run(textStream);

// yields as each statement executes
for await (const event of events) {
  if (event.logs) console.log(event.logs);
  if (event.error) console.error(event.error.message);
}

// resolves when execution completes
const { logs, error } = await result;

Under the hood, each statement goes through the IIFE transformation described above. Variables persist across statements and across multiple run() calls. Use context to make dependencies and shared objects available to LLM-generated code.


Limitations #

If code doesn’t have semicolons, statements won’t execute until the next semicolon arrives or the code block ends. In practice this rarely matters - LLM-generated code tends to be semicolon-terminated.

Control structures are a bigger gap. Loops, conditionals, and try-catch blocks get buffered until the closing brace arrives:

for (const user of users) {
  await sendEmail(user);  // can't execute until } arrives
  // ...
}

It would be possible to do better here. Why wait for the closing brace before executing the first iteration? The runtime could recognize that the loop body is self-contained and start executing as statements complete inside it. Same for conditionals - once the condition is evaluated and a branch is chosen, statements in that branch could execute incrementally. This would require native runtime support. But sequential statements without control structures already covers many of the patterns LLMs generate for agentic tasks.


The Missing Primitive #

If code execution becomes the standard for agentic AI - and I think it will - then streaming execution is the missing primitive.

The requirement is simple: execute code incrementally as it arrives, the way you’d expect sequential code to behave.

No JavaScript runtime offers this out of the box. Node’s REPL gets closest, but still requires you to build statement detection. Bun doesn’t expose a REPL API at all. Let alone streaming inside control structures.

For agents that drive user interfaces, streaming execution matters. It’s the difference between snappy and sluggish; it makes or breaks the experience. Streaming is how LLMs naturally produce output. Our execution environments should meet them there.


A note on security: This executes LLM-generated code with full system access. In production, this should run inside a sandboxed container with no access to the host system.