Local Implementation
The interface from the last lesson does nothing on its own. We need a backend.
The local sandbox is the boring one. It wraps the same readFileSync and execSync calls you've been using all along. The difference is they're hidden behind the interface now, where every tool calls them the same way.
Boring is the point. The local sandbox proves the interface works without introducing new complexity. It's the baseline every other backend will be compared to.
Outcome
src/sandbox-local.ts exports createLocalSandbox(dir), a factory that returns a Sandbox whose methods wrap Node's readFileSync and execSync. The agent runs the same way as before, but through the interface.
Fast Track
- Create
src/sandbox-local.tsexportingcreateLocalSandbox(dir): Sandbox - Wrap
readFileSyncinasync readFile - Wrap
execSyncinasync execwith try/catch returning{ stdout, exitCode } - Make
stop()an async no-op
Hands-on Exercise 4.2
Implement the local sandbox.
Requirements:
createLocalSandbox(dir: string): Sandboxreturns an object that satisfies the interfacereadFileresolves the path againstdirand reads UTF-8execruns the command withcwd: dirand a 30-second timeout- On
execerror, return{ stdout: <whatever output there was>, exitCode: <non-zero> }instead of throwing stopisasync () => {}
Implementation hints:
- The whole file is around 15 lines. If yours is longer, you're probably handling cases the cloud backend will care about and the local one doesn't
execshould never throw, even on a non-zero exit. Tools expect a result object. Catching the error and returning it is the right shapetype: "local"is whatsandboxTypeinterpolates into the system prompt from Module 3
The implementation
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { execSync } from "node:child_process";
import type { Sandbox } from "./sandbox";
export function createLocalSandbox(dir: string): Sandbox {
return {
type: "local",
workingDirectory: dir,
readFile: async (p) => readFileSync(resolve(dir, p), "utf-8"),
exec: async (command) => {
try {
const stdout = execSync(command, {
cwd: dir,
encoding: "utf-8",
timeout: 30_000,
});
return { stdout, exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout || e.stderr || e.message || "",
exitCode: e.status ?? 1,
};
}
},
stop: async () => {},
};
}That's the whole backend. stop is a no-op because there's nothing to clean up. The local filesystem and child_process will outlive the agent.
Wire it up
import { createLocalSandbox } from "./src/sandbox-local";
import { createReadTool, createGrepTool, createBashTool } from "./src/tools";
const sandbox = createLocalSandbox(cwd);
console.error(`Sandbox: ${sandbox.type}`);
const tools = {
read: createReadTool(sandbox),
grep: createGrepTool(sandbox),
bash: createBashTool(sandbox, createApproval({ mode: "interactive" })),
};The factories now take the sandbox. They close over it and call its methods from inside execute. Same tools, same agent, same prompt.
Try It
Run the prompts you've been using. The output should be unchanged:
bun run index.ts . "Read the tsconfig.json"
bun run index.ts . "Find all TODO comments"
bun run index.ts . "List all files in this directory"The agent should behave exactly the same way. The plumbing under the hood is different. Confirm the sandbox identity once:
console.error(`Sandbox: ${sandbox.type}`);You should see Sandbox: local.
npx tsc --noEmitAfter this lesson, agent behavior on the same prompts should match Module 3 exactly. If something changed (different routing, different output, a new error), look for a place where a tool is still reaching for a Node API directly instead of going through sandbox.
Commit
git add src/sandbox-local.ts index.ts
git commit -m "feat(sandbox): add local backend wrapping Node APIs"Done-When
src/sandbox-local.tsexportscreateLocalSandbox(dir)- The returned object satisfies the
Sandboxinterface readFileandexecroute through Node APIs as beforestopis a no-op that doesn't crash- All three tools still work, same as Module 3
npx tsc --noEmitpasses
execSync waits for the command to finish, then dumps all stdout at once. For a long build, that's painful. Try swapping to spawn and streaming each chunk back. The challenge: the Sandbox.exec signature returns one final { stdout, exitCode }. To stream, you'd need a different shape, maybe an async iterator. Notice how that ripples back into every tool that calls exec. Interface decisions are sticky.
Solution
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { execSync } from "node:child_process";
import type { Sandbox } from "./sandbox";
export function createLocalSandbox(dir: string): Sandbox {
return {
type: "local",
workingDirectory: dir,
readFile: async (p) => readFileSync(resolve(dir, p), "utf-8"),
exec: async (command) => {
try {
const stdout = execSync(command, {
cwd: dir,
encoding: "utf-8",
timeout: 30_000,
});
return { stdout, exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout || e.stderr || e.message || "",
exitCode: e.status ?? 1,
};
}
},
stop: async () => {},
};
}Was this helpful?