Lifecycle Hooks
Creating a sandbox is half the work. The other half is everything that happens around it.
A fresh cloud VM doesn't have your git config. It doesn't have node_modules. It doesn't have your .env. Before the agent does anything useful, something has to configure git, install dependencies, copy environment files. And before the sandbox shuts down, something has to check whether there's uncommitted work and decide what to do about it.
Those somethings are lifecycle hooks. The local sandbox barely needs them. The cloud sandbox would be unusable without them.
Outcome
The sandbox setup in index.ts calls afterStart and beforeStop hooks, with type definitions in src/sandbox.ts. The local sandbox runs with empty hooks. The plumbing is in place for the cloud and lifecycle work in Module 7.
Fast Track
- Add a
SandboxLifecycleinterface tosrc/sandbox.tswith optionalafterStart,beforeStop,onTimeout - After creating the sandbox in
index.ts, callawait lifecycle.afterStart?.(sandbox) - Before
sandbox.stop(), callawait lifecycle.beforeStop?.(sandbox) - Keep the lifecycle empty for local. The hook points exist, the bodies don't have to
Hands-on Exercise 4.5
Wire optional lifecycle hooks around the sandbox.
Requirements:
- Define
SandboxLifecyclewith three optional methods, each taking aSandboxand returningPromise<void> - In
index.ts, pass alifecycleobject next to the sandbox creation - Call
await lifecycle.afterStart?.(sandbox)immediately after creating the sandbox - Call
await lifecycle.beforeStop?.(sandbox)beforesandbox.stop() - Default to an empty
lifecycle = {}so the local sandbox runs unchanged
Implementation hints:
- Optional chaining (
?.()) does the conditional call for you. No need forif (lifecycle.afterStart)blocks - Even an empty lifecycle is still a lifecycle. Don't make it optional at the outer level
onTimeoutis the one hook the harness invokes, not you. The cloud backend triggers it whenexpiresAtis reached. Stub it now, use it in Module 7
The interface
export interface SandboxLifecycle {
afterStart?(sandbox: Sandbox): Promise<void>;
beforeStop?(sandbox: Sandbox): Promise<void>;
onTimeout?(sandbox: Sandbox): Promise<void>;
}All three are optional. A local sandbox might never need any of them. A cloud sandbox in a production harness probably uses all three.
What each hook is for
afterStart runs after the sandbox is created and ready to take commands. This is where setup happens:
const cloudLifecycle: SandboxLifecycle = {
afterStart: async (sandbox) => {
await sandbox.exec('git config user.name "Agent"');
await sandbox.exec('git config user.email "agent@example.com"');
await sandbox.exec("npm install");
await sandbox.exec("cp .env.example .env");
},
};beforeStop runs before the sandbox shuts down, so anything important gets a chance to escape:
beforeStop: async (sandbox) => {
const { stdout } = await sandbox.exec("git status --porcelain");
if (stdout.trim()) {
await sandbox.exec('git add -A && git commit -m "WIP: auto-save"');
}
if (sandbox.snapshot) {
await sandbox.snapshot();
}
},onTimeout runs when the sandbox hits its time limit. The cloud backend invokes this, not you. The body usually reuses beforeStop plus some logging:
onTimeout: async (sandbox) => {
console.error("Sandbox timed out, saving state");
await cloudLifecycle.beforeStop?.(sandbox);
},Wire it into the agent loop
const sandbox = await createSandboxByEnv(cwd);
const lifecycle: SandboxLifecycle = {};
await lifecycle.afterStart?.(sandbox);
try {
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);
} finally {
await lifecycle.beforeStop?.(sandbox);
await sandbox.stop();
}The try/finally is important. Even if the agent throws mid-run, beforeStop should fire. That's where the uncommitted-work check belongs.
For the local sandbox with an empty lifecycle = {}, none of the hooks run. The agent behaves exactly as before. The structure is there for when we add real hooks in Module 7.
For the local backend, the lifecycle hooks are mostly ceremony. For the cloud backend, skipping beforeStop means losing uncommitted work when the VM dies. The fact that the interface forces you to think about both is the point. The local case is the simpler shape of the cloud case, not a different shape.
Try It
The agent should behave exactly the same as the previous lesson, since the local lifecycle is empty.
bun run index.ts . "Read the package.json"Confirm the type plumbing works by adding a temporary log:
const lifecycle: SandboxLifecycle = {
afterStart: async (sb) => console.error(`[lifecycle] after start: ${sb.type}`),
beforeStop: async (sb) => console.error(`[lifecycle] before stop: ${sb.type}`),
};Run any prompt. You should see the two log lines bracketing the agent's work.
npx tsc --noEmitCommit
git add src/sandbox.ts index.ts
git commit -m "feat(sandbox): add lifecycle hook points"Done-When
SandboxLifecycleinterface defined with three optional methodsafterStartis called once after sandbox creationbeforeStopis called once beforesandbox.stop(), inside afinally- With an empty
lifecycle, the agent runs unchanged - With logging hooks, the lifecycle calls fire in order
npx tsc --noEmitpasses
Lifecycle hooks aren't just for setup and teardown. Try this pairing: afterStart checks for a saved snapshot in a known location and restores from it if found. beforeStop auto-snapshots before shutting down. Now your harness has crash-resume behavior with no extra code at the call site. Where does the snapshot live? How do you tell a real new run from a resumed one? What happens when the snapshot is from a different code version? Module 7 covers this in depth, but the shape comes from the lifecycle interface you just defined.
Solution
export interface SandboxLifecycle {
afterStart?(sandbox: Sandbox): Promise<void>;
beforeStop?(sandbox: Sandbox): Promise<void>;
onTimeout?(sandbox: Sandbox): Promise<void>;
}import type { SandboxLifecycle } from "./src/sandbox";
const sandbox = await createSandboxByEnv(cwd);
const lifecycle: SandboxLifecycle = {};
await lifecycle.afterStart?.(sandbox);
try {
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);
} finally {
await lifecycle.beforeStop?.(sandbox);
await sandbox.stop();
}Was this helpful?