Guides

Set Up Verklet with AI SDK

Build an AI SDK chat where the model can write files, run JavaScript or Python in Verklet, inspect stdout, and iterate without starting a container first.

This guide turns the pattern from the AI SDK weather article into a concrete app shape. The model runs through Vercel AI SDK. The code execution tool runs through Verklet in the browser, so the user gets a real filesystem and process API without exposing your model provider secret.

The important split is:

AI SDK supports this client-side tool pattern with useChat, onToolCall, addToolOutput, and sendAutomaticallyWhen. Verklet supplies the runtime that the client-side tool uses.

Install the packages

npm install @verklet/sdk ai @ai-sdk/openai zod

Use your package manager of choice. The examples below assume a Next.js app, but the same split works in any React app that can expose an API route for AI SDK.

Get a project ID

Create an account at /account, select an organization, and copy the generated project ID. It starts with prj_ and is safe to use in browser code.

Keep your model provider key on the server:

OPENAI_API_KEY=...
NEXT_PUBLIC_VERKLET_PROJECT_ID=prj_your_project_id

The NEXT_PUBLIC_ value is only the Verklet project ID. Do not put a Verklet private grant secret or an OpenAI key in browser-exposed env vars.

Define the tool on the server

Create a route such as app/api/chat/route.ts. The route gives the model a runCode tool schema, but it does not execute the tool on the server. AI SDK streams the tool call to the client, where Verklet is already booted inside the browser.

import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  isStepCount,
  streamText,
  tool,
} from 'ai';
import { z } from 'zod';

export const maxDuration = 60;

export async function POST(request: Request) {
  const { messages } = await request.json();

  const result = streamText({
    model: openai('gpt-4o'),
    messages: await convertToModelMessages(messages),
    stopWhen: isStepCount(6),
    system: [
      'You are a coding agent with access to a real runtime.',
      'When you need evidence, write a small file and call runCode.',
      'Inspect stdout and stderr before answering.',
      'If execution fails, fix the code and run it again.',
    ].join('\n'),
    tools: {
      runCode: tool({
        description:
          'Write files into a workspace and run node or python. Return stdout, stderr, exitCode, and backend.',
        inputSchema: z.object({
          files: z.record(z.string(), z.string()),
          command: z.enum(['node', 'python']),
          args: z.array(z.string()).default([]),
        }),
      }),
    },
  });

  return result.toUIMessageStreamResponse();
}

The model can now request runCode, but the server still owns model credentials and generation policy.

Execute the tool in the browser

In your chat component, boot Verklet once and handle AI SDK tool calls with onToolCall. The tool writes files into /workdir, spawns the requested process, and returns bounded output to AI SDK.

'use client';

import { useChat } from '@ai-sdk/react';
import { Runtime } from '@verklet/sdk';
import {
  DefaultChatTransport,
  lastAssistantMessageIsCompleteWithToolCalls,
} from 'ai';
import { useState } from 'react';

type RunCodeInput = {
  files: Record<string, string>;
  command: 'node' | 'python';
  args?: string[];
};

type RuntimeHandle = Awaited<ReturnType<typeof Runtime.boot>>;

let runtimePromise: Promise<RuntimeHandle> | undefined;

function getRuntime() {
  runtimePromise ??= Runtime.boot({
    projectId: process.env.NEXT_PUBLIC_VERKLET_PROJECT_ID!,
    backend: 'auto',
    persistenceKey: 'ai-sdk-agent-demo',
    python: { preloadPackages: ['micropip'] },
  });

  return runtimePromise;
}

export function AgentChat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, addToolOutput } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    async onToolCall({ toolCall }) {
      if (toolCall.dynamic || toolCall.toolName !== 'runCode') return;

      try {
        const output = await runCode(toolCall.input as RunCodeInput);

        addToolOutput({
          tool: 'runCode',
          toolCallId: toolCall.toolCallId,
          output,
        });
      } catch (error) {
        addToolOutput({
          tool: 'runCode',
          toolCallId: toolCall.toolCallId,
          state: 'output-error',
          errorText: error instanceof Error ? error.message : String(error),
        });
      }
    },
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        sendMessage({ text: input });
        setInput('');
      }}
    >
      <div>
        {messages.map((message) => (
          <div key={message.id}>
            {message.parts.map((part, index) => (
              <pre key={`${message.id}:${index}`}>
                {JSON.stringify(part, null, 2)}
              </pre>
            ))}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(event) => setInput(event.currentTarget.value)}
        placeholder="Ask the agent to write and run code"
      />
    </form>
  );
}

async function runCode(input: RunCodeInput) {
  const runtime = await getRuntime();

  for (const [path, contents] of Object.entries(input.files)) {
    assertSafeRelativePath(path);
    await runtime.fs.writeFile(`/workdir/${path}`, contents);
  }

  const proc = await runtime.spawn(input.command, input.args ?? [], {
    cwd: '/workdir',
  });

  const [stdout, stderr, exitCode] = await Promise.all([
    readStream(proc.output),
    readStream(proc.stderr),
    proc.exit,
  ]);

  return {
    backend: runtime.capabilities.backend ?? 'browser',
    exitCode,
    stdout: stdout.slice(0, 12_000),
    stderr: stderr.slice(0, 12_000),
  };
}

async function readStream(stream: ReadableStream<string>) {
  let output = '';
  for await (const chunk of stream) output += chunk;
  return output;
}

function assertSafeRelativePath(path: string) {
  if (path.startsWith('/') || path.split('/').includes('..')) {
    throw new Error(`Unsafe file path: ${path}`);
  }
}

That is enough for prompts such as:

Write JavaScript that fetches current weather from Open-Meteo for Oslo,
run it, and explain the result from stdout.

The model writes the file, calls runCode, receives stdout and stderr, and continues the conversation from execution evidence.

Add a server grant only when you need promotion

For browser-supported work, no server session is required. If the agent needs uv, native Python wheels, or other Linux-only tools, keep backend: 'auto' and add a server grant callback:

const runtime = await Runtime.boot({
  projectId: process.env.NEXT_PUBLIC_VERKLET_PROJECT_ID!,
  backend: 'auto',
  persistenceKey: 'ai-sdk-agent-demo',
  server: {
    grant: async (grantRequest) => {
      const response = await fetch('/api/verklet/server-grant', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(grantRequest),
      });

      if (!response.ok) {
        throw new Error(`Server grant denied: ${response.status}`);
      }

      return response.json();
    },
  },
});

The private vks_... grant secret belongs in your backend endpoint, not in this browser component. See server runtime for the full endpoint shape.

Production boundaries

The runCode tool is deliberately general. Before exposing it to real users, set policy around it:

That gives AI SDK the agent loop while Verklet owns the workspace and process boundary.