20.05.26
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:
- Your server route calls AI SDK and keeps provider credentials private.
- Your browser component boots Verklet and executes the
runCodetool. - AI SDK sends the tool result back to the model before it writes the final answer.
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:
- Restrict allowed commands and arguments.
- Validate paths, file count, file size, and total mounted bytes.
- Truncate stdout and stderr before returning them to the model.
- Add a process timeout and call
proc.kill()for runaway programs. - Scope
persistenceKeyby user, chat, or workspace. - Keep server promotion behind authenticated grant endpoints and quotas.
That gives AI SDK the agent loop while Verklet owns the workspace and process boundary.