Moving to Sandbox
Our coding agent is working well, but it only works on our local machine. As we think about making this production ready, there are a few things we should consider:
-
Untrusted code execution: The code and actions generated by our language model are untrusted. We don't want to give the model direct access to our local machine, especially considering prompt injection attacks are a real concern.
-
Scalability limitations: Our current setup can't scale beyond our local machine. What if we wanted to run 100 tasks simultaneously? We need a solution that can handle multiple concurrent operations.
We want to create sandbox environments where the LLM can create and interact freely while being fully scalable. This is where Vercel Sandbox comes in - let's update our agent to use it.
Note that our project already has a number of utility functions (editFile
,
readFile
, listFiles
, etc.) created for us. Don't worry; they're identical
to what we did before, just interacting within the sandbox environment instead
of locally.
import { generateText, stepCountIs, tool } from "ai";
import { z } from "zod/v4";
import { type Sandbox } from "@vercel/sandbox";
import {
createSandbox,
editFile,
listFiles,
readFile,
} from "./sandbox";
export async function codingAgent(prompt: string, repoUrl?: string) {
console.log("repoUrl:", repoUrl);
let sandbox: Sandbox | undefined;
const result = await generateText({
model: "openai/gpt-4.1",
prompt,
system:
"You are a coding agent. You will be working with js/ts projects. Your responses must be concise.",
stopWhen: stepCountIs(10),
tools: {
read_file: tool({
description:
"Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.",
inputSchema: z.object({
path: z
.string()
.describe("The relative path of a file in the working directory."),
}),
execute: async ({ path }) => {
try {
if (!sandbox) sandbox = await createSandbox(repoUrl!);
const output = await readFile(sandbox, path);
return { path, output };
} catch (error) {
console.error(`Error reading file at ${path}:`, error.message);
return { path, error: error.message };
}
},
}),
list_files: tool({
description:
"List files and directories at a given path. If no path is provided, lists files in the current directory.",
inputSchema: z.object({
path: z
.string()
.nullable()
.describe(
"Optional relative path to list files from. Defaults to current directory if not provided.",
),
}),
execute: async ({ path }) => {
if (path === ".git" || path === "node_modules") {
return { error: "You cannot read the path: ", path };
}
try {
if (!sandbox) sandbox = await createSandbox(repoUrl!);
const output = await listFiles(sandbox, path);
return { path, output };
} catch (e) {
console.error(`Error listing files:`, e);
return { error: e };
}
},
}),
edit_file: tool({
description:
"Make edits to a text file. Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. If the file specified with path doesn't exist, it will be created.",
inputSchema: z.object({
path: z.string().describe("The path to the file"),
old_str: z
.string()
.describe(
"Text to search for - must match exactly and must only have one match exactly",
),
new_str: z.string().describe("Text to replace old_str with"),
}),
execute: async ({ path, old_str, new_str }) => {
try {
if (!sandbox) sandbox = await createSandbox(repoUrl!);
await editFile(sandbox, path, old_str, new_str);
return { success: true };
} catch (e) {
console.error(`Error editing file ${path}:`, e);
return { error: e };
}
},
}),
},
});
if (sandbox) {
await sandbox.stop();
}
return { response: result.text };
}
Now, we'll need to update your test.ts
file to include the new repo:
import { codingAgent } from "./agent";
import dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
codingAgent(
"Add a contributing section to the readme of this project. Use standard format.",
"https://github.com/nicoalbanese/ship-25-nextjs-playground",
)
.then(console.log)
.catch(console.error);
Run the script. It should look very familiar. The difference, is this is running on a virtual machine on Vercel's infrastructure.
We've hit an issue though. This sandbox disappears after our agent finishes, but we haven't saved our changes. Let's add a tool to make a PR to that repository. For this, we will have to go to GitHub to create a PAT token.
Set up GitHub Personal Access Token.
To create a GitHub Personal Access Token (PAT):
- Go to https://github.com/settings/personal-access-tokens
- Click "Generate new token"
- Give it a descriptive name
- Set repository access to "All repositories"
- Add the following repository permissions:
- Issues: Read and write
- Pull requests: Read and write
- Contents: Read and write
- Click "Generate token"
- Copy the token immediately (you won't be able to see it again)
- Add it to your
.env.local
file asGITHUB_TOKEN
Now we need to add a way to save our changes by creating a pull request. We'll add a create_pr
tool that allows our agent to commit changes and create a PR on GitHub when it's finished making modifications. We'll also update our system prompt to tell the LLM to use the create_pr
tool once it's done making changes.
import { generateText, stepCountIs, tool } from "ai";
import { z } from "zod/v4";
import { type Sandbox } from "@vercel/sandbox";
import {
createPR,
createSandbox,
editFile,
listFiles,
readFile,
} from "./sandbox";
export async function codingAgent(prompt: string, repoUrl?: string) {
console.log("repoUrl:", repoUrl);
let sandbox: Sandbox | undefined;
const result = await generateText({
model: "openai/gpt-4.1",
prompt,
system:
"You are a coding agent. You will be working with js/ts projects. Your responses must be concise. If you make changes to the codebase, be sure to run the create_pr tool once you are done.",
stopWhen: stepCountIs(10),
tools: {
read_file: tool({
description:
"Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.",
inputSchema: z.object({
path: z
.string()
.describe("The relative path of a file in the working directory."),
}),
execute: async ({ path }) => {
try {
if (!sandbox) sandbox = await createSandbox(repoUrl!);
const output = await readFile(sandbox, path);
return { path, output };
} catch (error) {
console.error(`Error reading file at ${path}:`, error.message);
return { path, error: error.message };
}
},
}),
list_files: tool({
description:
"List files and directories at a given path. If no path is provided, lists files in the current directory.",
inputSchema: z.object({
path: z
.string()
.nullable()
.describe(
"Optional relative path to list files from. Defaults to current directory if not provided.",
),
}),
execute: async ({ path }) => {
if (path === ".git" || path === "node_modules") {
return { error: "You cannot read the path: ", path };
}
try {
if (!sandbox) sandbox = await createSandbox(repoUrl!);
const output = await listFiles(sandbox, path);
return { path, output };
} catch (e) {
console.error(`Error listing files:`, e);
return { error: e };
}
},
}),
edit_file: tool({
description:
"Make edits to a text file. Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. If the file specified with path doesn't exist, it will be created.",
inputSchema: z.object({
path: z.string().describe("The path to the file"),
old_str: z
.string()
.describe(
"Text to search for - must match exactly and must only have one match exactly",
),
new_str: z.string().describe("Text to replace old_str with"),
}),
execute: async ({ path, old_str, new_str }) => {
try {
if (!sandbox) sandbox = await createSandbox(repoUrl!);
await editFile(sandbox, path, old_str, new_str);
return { success: true };
} catch (e) {
console.error(`Error editing file ${path}:`, e);
return { error: e };
}
},
}),
create_pr: tool({
description:
"Create a pull request with the current changes. This will add all files, commit changes, push to a new branch, and create a PR using GitHub's REST API. Use this as the final step when making changes.",
inputSchema: z.object({
title: z.string().describe("The title of the pull request"),
body: z.string().describe("The body/description of the pull request"),
branch: z
.string()
.nullable()
.describe(
"The name of the branch to create (defaults to a generated name)",
),
}),
execute: async ({ title, body, branch }) => {
const { pr_url } = await createPR(sandbox!, repoUrl!, {
title,
body,
branch,
});
return { success: true, linkToPR: pr_url };
},
}),
},
});
if (sandbox) {
await sandbox.stop();
}
return { response: result.text };
}
Run the script again and after a few steps, we should see the PR URL returned by the model!