Run SDK: Safely Execute Agent-Generated Code

Peter Bubenik · Vercel · · Source
Image for Introducing Run SDK: secure eval for your agents

After studying this material, you should be able to:

  1. Explain why sandboxed code execution is necessary for AI agents
  2. Implement basic sandboxed JavaScript/TypeScript execution using the Run SDK
  3. Design host functions that safely expose application capabilities to sandboxed code
  4. Apply human-in-the-loop interruption patterns for sensitive operations
  5. Configure execution limits to prevent resource abuse

Step-by-Step Study Material

Step 1: The Problem — Why Sandboxing Matters

Before touching any code, understand why this exists.

The Core Problem

When AI agents generate and execute code, that code needs to run somewhere. The naive approach is using JavaScript's built-in eval().

Why eval() is dangerous:

Your Application
├── Database credentials        ← eval() can access ALL of this
├── API secret keys             ← eval() can access ALL of this
├── Internal services           ← eval() can access ALL of this
└── eval(agentGeneratedCode)    ← runs with FULL application access

Three specific risks:

RiskDescription
Secret exposureAgent code can read process.env.DATABASE_URL
No pause pointsCannot stop execution for human approval mid-run
No audit trailNo durable record of what ran and what it returned

The Mental Model

Think of it like a contractor working in your building:

  • ❌ Bad: Give them a master key (full eval access)
  • ✅ Good: Give them a keycard that only opens specific doors (host functions)

Step 2: Core Concept — How the Run SDK Works

The Architecture

┌─────────────────────────────────────────┐
│           YOUR APPLICATION              │
│                                         │
│  database credentials  ✓               │
│  API keys              ✓               │
│  internal services     ✓               │
│                                         │
│  ┌───────────────────────────────────┐  │
│  │        SANDBOX (QuickJS)          │  │
│  │                                   │  │
│  │  agent-generated code runs here   │  │
│  │                                   │  │
│  │  can ONLY call host functions ────┼──┼──► your app handles it
│  │  cannot access Node.js            │  │
│  │  cannot access network directly   │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

Key Technical Details

  • Runs inside a QuickJS context (a lightweight JavaScript engine)
  • Executes in a worker thread (isolated from main process)
  • Code crosses the boundary via serialization (no shared memory)
  • Each run gets a fresh context (no state leaks between runs)

Step 3: Basic Implementation

Installation

pnpm add run

Your First Sandboxed Run

import { run } from 'run';

const result = await run({
  source: `
    const orders = await store.listOrders("customer_123");
    const total = orders.reduce((sum, order) => sum + order.amount, 0);
    return { count: orders.length, total };
  `,
  hostFunctions: {
    store: {
      listOrders: async (customerId: string) => {
        return database.orders.findMany({ customerId }); // real DB call
      },
    },
  },
});

if (result.status === 'completed') {
  console.log(result.value);
}

Breaking This Down

run({
  source: "...",        ← the code that runs INSIDE the sandbox
  hostFunctions: {...}  ← the doors you give the sandbox to knock on
})

What the sandbox sees:

// Inside sandbox — this is ALL it knows about
store.listOrders("customer_123")  // ✓ available
database.credentials              // ✗ does not exist here
process.env                       // ✗ does not exist here

What your application keeps:

// Outside sandbox — stays in your application
database.orders.findMany(...)     // actual implementation
process.env.DATABASE_URL          // credentials never exposed

Checking the Result

if (result.status === 'completed') {
  console.log(result.value);      // use the result
} else if (result.status === 'interrupted') {
  // handle pause point (covered in Step 5)
}

Step 4: Designing Good Host Functions

The Principle: Narrow, Purposeful Doors

Host functions should map to specific actions in your product, not generic capabilities.

❌ Too broad — hard to control:

hostFunctions: {
  http: {
    request: async (url, method, body) => {
      return fetch(url, { method, body }); // agent can call ANYTHING
    }
  }
}

✅ Narrow and purposeful:

hostFunctions: {
  orders: {
    refund: async (orderId: string) => {
      // clear place to add authorization checks
      await verifyUserOwnsOrder(orderId);
      return processRefund(orderId);
    }
  }
}

Why Narrow Functions Win

Generic: http.request(url)
  → Who is calling this? For what reason? Is it authorized?
  → Very hard to reason about

Specific: orders.refund(orderId)
  → Exactly one thing happens
  → Easy to add auth checks
  → Easy to audit

Practical Example: Concurrent Operations

One major benefit — the agent can coordinate parallel work:

const result = await run({
  source: `
    // These two calls happen AT THE SAME TIME
    const [account, invoices] = await Promise.all([
      crm.getAccount("account_123"),
      billing.listInvoices("account_123"),
    ]);

    // Filtering logic stays inside sandbox
    const overdue = invoices.filter(i => i.status === "overdue");

    // Only the useful result comes back
    return { account: account.name, overdue };
  `,
  hostFunctions: {
    crm: { getAccount },
    billing: { listInvoices },
  },
});

What this achieves:

Without Run SDKWith Run SDK
Two sequential LLM callsOne LLM response
Full billing response in contextOnly overdue invoices returned
Agent coordinates in promptsAgent coordinates in code

Step 5: Human-in-the-Loop Interruption

The Core Concept

Some operations need a human to approve before continuing. The Run SDK supports pausing execution mid-run and resuming it later without repeating completed work.

The Interruption Flow

Program starts
     │
     ▼
host function called (e.g., publish document)
     │
     ▼
Is this the first time? ──YES──► interrupt execution
     │                               │
     │NO                             ▼
     │                    Save token + ask human
     │                               │
     ▼                               ▼
Check approval result         Human decides
     │                               │
     ├── approved ──────────────────►│
     │                    Resume with token
     └── denied ──► return { published: false }

Implementation

import { getHostFunctionContext } from 'run';

const hostFunctions = {
  documents: {
    publish: async (draftId: string) => {

      const context = getHostFunctionContext();

      // FIRST CALL: no resume data exists yet
      if (context.resume === undefined) {
        context.interrupt({
          kind: 'approval',
          message: `Publish ${draftId}?`,
        });
        // execution pauses here
      }

      // RESUMED CALL: check what the human decided
      if (context.resume.resolution !== true) {
        return { published: false };  // denied
      }

      return publishDraft(draftId);   // approved
    },
  },
};

What Happens to the Interrupted Run

const result = await run({ source, hostFunctions });

if (result.status === 'interrupted') {
  // result contains a signed token
  await db.save({
    token: result.token,        // save this
    message: result.message,    // "Publish draft_456?"
    requestedBy: currentUser,
  });
  // worker does NOT need to stay alive
  // resume whenever the human responds
}

Resuming After Approval

// Later, when human approves...
const resumed = await run({
  source,
  hostFunctions,
  resume: {
    token: savedToken,
    resolution: true,   // approved
  }
});

Critical Behavior: No Repeated Work

Run 1:  getAccount() ✓  →  listInvoices() ✓  →  publish() INTERRUPTED
                                                        │
                                                   results saved

Run 2 (resume):  getAccount() [uses saved result]
                 listInvoices() [uses saved result]
                 publish() [receives approval, continues]

Key insight: Completed work is recorded and replayed, not re-executed. Your database is not called twice.


Step 6: Setting Execution Limits

Why Limits Are Necessary

Sandboxing prevents access violations, but you still need to handle:

  • Infinite loops — agent code that never terminates
  • Memory bombs — code that allocates unbounded memory
  • Oversized results — returning gigabytes of data

Creating a Runner with Shared Limits

import { createRunner } from 'run';

// Create once, reuse across many runs
const runner = createRunner({
  limits: {
    timeoutMs: 10_000,                  // 10 seconds max
    memoryLimitBytes: 32 * 1024 * 1024, // 32 MB max
  },
});

// Use like run()
const result = await runner.run({ source, hostFunctions });

Limits Apply to Two Things

┌─────────────────────────────────────┐
│  QuickJS heap (sandbox memory)      │ ← memoryLimitBytes covers this
│                                     │
│  Values crossing the boundary       │ ← also covered
│  (host function return values)      │
└─────────────────────────────────────┘

Security Hardening (Built-in)

The Run SDK applies these automatically — you do not configure them:

ProtectionWhat It Does
Fresh context per runNo state leaks between executions
Dynamic eval disabledNo eval() inside the sandbox
Hardened prototypesPrevents prototype pollution attacks

Important Boundary Reminder

Sandbox code ──► UNTRUSTED — Run SDK enforces limits
Host functions ──► TRUSTED — YOU must add authorization checks

Host functions are your application code. The Run SDK does not protect them from themselves. If orders.refund() needs auth checks, you write those checks.


Step 7: When to Use Run SDK vs. Alternatives

Run SDK Is Right For

  • Agent-generated code that calls your application's services
  • Code interpreters in your product
  • Customer-defined transformations (e.g., custom data processing rules)
  • Workflows needing human approval gates

Use Vercel Sandbox Instead When You Need

  • Operating system access
  • Package installation (npm install inside the sandbox)
  • Process-level isolation
  • Full shell environments

Summary: The Complete Mental Model

PROBLEM:  eval() gives agent code full application access

SOLUTION: Run SDK

  1. ISOLATE    → QuickJS sandbox in worker thread
  2. EXPOSE     → only narrow host functions
  3. PAUSE      → interrupt for human approval
  4. RESUME     → replay with saved results, no repeated work
  5. LIMIT      → timeout and memory bounds

RESULT: Agent code can coordinate your tools
        without touching your secrets or infrastructure

Quick Reference

ConceptKey API
Basic executionrun({ source, hostFunctions })
Shared limitscreateRunner({ limits })
Interrupt for approvalcontext.interrupt({ kind, message })
Check resume statecontext.resume
Resume a runrun({ source, hostFunctions, resume: { token, resolution } })

More to study