File: README.md
Open Agents
Open Agents is an open-source reference app for building and running background coding agents on Vercel. It includes the web UI, the agent runtime, sandbox orchestration, and the GitHub integration needed to go from prompt to code changes without keeping your laptop involved.
The repo is meant to be forked and adapted, not treated as a black box.
What it is
Open Agents is a three-layer system:
Web -> Agent workflow -> Sandbox VM- The web app handles auth, sessions, chat, and streaming UI.
- The agent runs as a durable workflow on Vercel.
- The sandbox is the execution environment: filesystem, shell, git, dev servers, and preview ports.
The key architectural decision: the agent is not the sandbox
The agent does not run inside the VM. It runs outside the sandbox and interacts with it through tools like file reads, edits, search, and shell commands.
That separation is the main point of the project:
- agent execution is not tied to a single request lifecycle
- sandbox lifecycle can hibernate and resume independently
- model/provider choices and sandbox implementation can evolve separately
- the VM stays a plain execution environment instead of becoming the control plane
Current capabilities
- chat-driven coding agent with file, search, shell, task, skill, and web tools
- durable multi-step execution with Workflow SDK-backed runs, streaming, and cancellation
- isolated Vercel sandboxes with snapshot-based resume
- repo cloning and branch work inside the sandbox
- optional auto-commit, push, and PR creation after a successful run
- session sharing via read-only links
- optional voice input via ElevenLabs transcription
Runtime notes
A few details that matter for understanding the current implementation:
- Chat requests start a workflow run instead of executing the agent inline.
- Each agent turn can continue across many persisted workflow steps.
- Active runs can be resumed by reconnecting to the stream for the existing workflow.
- Sandboxes expose ports
3000,5173,4321, and8000, can optionally use a configured base snapshot, and hibernate after inactivity. - Auto-commit and auto-PR are supported, but they are preference-driven features, not always-on behavior.
Environment variables
See apps/web/.env.example for the full list. Summary:
Minimum runtime
POSTGRES_URL=
BETTER_AUTH_SECRET=Required for sign-in (Vercel OAuth)
NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=
VERCEL_APP_CLIENT_SECRET=Required for GitHub repo access, pushes, and PRs
NEXT_PUBLIC_GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
NEXT_PUBLIC_GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=Optional
REDIS_URL=
KV_URL=
OPEN_AGENTS_RESOURCE_PROFILE=
VERCEL_PROJECT_PRODUCTION_URL=
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL=
VERCEL_SANDBOX_BASE_SNAPSHOT_ID=
ELEVENLABS_API_KEY=REDIS_URL/KV_URL: optional skills metadata cache (falls back to in-memory when not configured).OPEN_AGENTS_RESOURCE_PROFILE: optional deployment resource profile. Set tohobbyto use Hobby-compatible defaults for chat and sandbox resources; leave unset for standard behavior.VERCEL_PROJECT_PRODUCTION_URL/NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL: canonical production URL for metadata and some callback behavior.VERCEL_SANDBOX_BASE_SNAPSHOT_ID: optional base snapshot for fresh sandboxes. If unset, sandboxes start from Vercel's standard Sandbox runtime. Use a snapshot created in/accessible to your own Vercel scope.ELEVENLABS_API_KEY: voice transcription.
Deploy your own copy on Vercel
Fork this repo.
Import the repo into Vercel. Neon Postgres is auto-provisioned if you use the deploy button above.
Generate a secret for session signing:
bashopenssl rand -base64 32 # BETTER_AUTH_SECRETAdd env vars in Vercel project settings:
envPOSTGRES_URL= BETTER_AUTH_SECRET=Deploy once to get a stable production URL.
Create a Vercel OAuth app with callback URL:
texthttps://YOUR_DOMAIN/api/auth/callback/vercelAdd these env vars and redeploy:
envNEXT_PUBLIC_VERCEL_APP_CLIENT_ID= VERCEL_APP_CLIENT_SECRET=If you want the full GitHub-enabled coding-agent flow, create a GitHub App using:
- Homepage URL:
https://YOUR_DOMAIN - Callback URL:
https://YOUR_DOMAIN/api/auth/callback/github - Setup URL:
https://YOUR_DOMAIN/api/github/app/callback
In the GitHub App settings:
- use the GitHub App's Client ID and Client Secret for
NEXT_PUBLIC_GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET - make the app public if you want org installs to work cleanly
- Homepage URL:
Add the GitHub App env vars and redeploy.
Optionally add Redis/KV,
OPEN_AGENTS_RESOURCE_PROFILE=hobbyfor Hobby-compatible resource defaults, the canonical production URL vars, and your ownVERCEL_SANDBOX_BASE_SNAPSHOT_IDif you want fresh sandboxes to start from a preconfigured image.
Local setup
Install dependencies:
bashcorepack enable pnpm installCreate your local env file:
bashcp apps/web/.env.example apps/web/.envFill in the required values in
apps/web/.env.Start the app:
bashpnpm web
If you already have a linked Vercel project, you can pull env vars locally with vc env pull.
OAuth and integration setup
Vercel OAuth
Authentication is handled by Better Auth with Vercel and GitHub as social providers. All auth routes are served from the /api/auth/[...all] catchall.
Create a Vercel OAuth app and use this callback:
https://YOUR_DOMAIN/api/auth/callback/vercelFor local development, use:
http://localhost:3000/api/auth/callback/vercelThen set:
NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=...
VERCEL_APP_CLIENT_SECRET=...GitHub App
You do not need a separate GitHub OAuth app. Open Agents uses the GitHub App's OAuth credentials as a Better Auth social provider, plus the App's installation tokens for repo access.
Create a GitHub App for installation-based repo access and configure:
- Homepage URL:
https://YOUR_DOMAIN - Callback URL:
https://YOUR_DOMAIN/api/auth/callback/github - Setup URL:
https://YOUR_DOMAIN/api/github/app/callback - make the app public if you want org installs to work cleanly
For local development, use http://localhost:3000 as the homepage URL, http://localhost:3000/api/auth/callback/github as the callback URL, and http://localhost:3000/api/github/app/callback as the setup URL.
Then set:
NEXT_PUBLIC_GITHUB_CLIENT_ID=... # GitHub App Client ID
GITHUB_CLIENT_SECRET=... # GitHub App Client Secret
GITHUB_APP_ID=...
GITHUB_APP_PRIVATE_KEY=...
NEXT_PUBLIC_GITHUB_APP_SLUG=...
GITHUB_WEBHOOK_SECRET=...GITHUB_APP_PRIVATE_KEY can be stored as the PEM contents with escaped newlines or as a base64-encoded PEM.
Useful commands
pnpm web # run dev server
pnpm check # lint + format check
pnpm fix # lint + format fix
pnpm typecheck # typecheck all packages
pnpm run ci # full CI: check, typecheck, tests, migration check
pnpm sandbox:snapshot-base # refresh sandbox base snapshotRepo layout
apps/web Next.js app, workflows, auth, chat UI
packages/agent agent implementation, tools, subagents, skills
packages/sandbox sandbox abstraction and Vercel sandbox integration
packages/shared shared utilitiesFile: apps/web/docs/design-system.md
Design System
This document defines the visual design system used across the Open Agents web application, particularly for unauthenticated/marketing pages.
Design Philosophy
The design follows a dark terminal aesthetic that reflects the developer-focused, CLI-centric nature of Open Agents. The visual language communicates:
- Technical credibility - feels like a tool built by developers, for developers
- Terminal familiarity - macOS window chrome, command prompts, monospace typography
- Modern polish - glassmorphism, ambient lighting, subtle animations
Color Palette
Background Colors
| Token | Value | Usage |
|---|---|---|
bg-primary |
#0a0a0b |
Main page background |
bg-card |
#111113 |
Card/panel backgrounds |
bg-subtle |
white/[0.02] |
Subtle surface elevation |
bg-hover |
white/[0.04] |
Hover states |
Text Colors
| Token | Value | Usage |
|---|---|---|
text-primary |
white |
Headings, primary text |
text-secondary |
white/50 |
Body text, descriptions |
text-muted |
white/40 |
Secondary descriptions |
text-subtle |
white/30 |
Hints, labels, metadata |
Border Colors
| Token | Value | Usage |
|---|---|---|
border-default |
white/[0.08] |
Card borders, dividers |
border-subtle |
white/[0.06] |
Subtle separators |
border-hover |
white/[0.12] |
Hover state borders |
Accent Colors
| Token | Value | Usage |
|---|---|---|
accent-emerald |
emerald-400 / emerald-500 |
Success, terminal prompts, status indicators |
accent-blue |
blue-400 / blue-500 |
Links, secondary highlights |
accent-violet |
violet-400 / violet-500 |
Tertiary accents |
accent-amber |
amber-400 |
Warnings |
macOS Window Chrome
| Element | Color |
|---|---|
| Close button | #ff5f57 |
| Minimize button | #febc2e |
| Maximize button | #28c840 |
Background Effects
Ambient Glow
Large, blurred gradient orbs that create depth and atmosphere:
{/* Primary glow - top center */}
{/* Secondary glow - bottom right */}
{/* Tertiary glow - left */}Dot Grid Pattern
Subtle dot matrix for texture:
Scanline Effect
Retro CRT-style scanlines for terminal feel:
Components
Terminal Window Card
macOS-style window with traffic light controls:
{/* Window chrome */}
terminal
{/* Content */}
{/* ... */}Command Prompt
Animated terminal prompt with blinking cursor:
$
openharness auth login
! Authentication requiredIcon Container
Bordered container for icons:
Primary Button (CTA)
High-contrast white button on dark background:
Sign in with GitHubStatus Badge
Pill-shaped badge with animated indicators:
Status textGradient Divider
Horizontal divider that fades at edges:
Typography
Headings
- H1:
text-4xl sm:text-5xl font-semibold tracking-tight text-white - H2:
text-lg font-medium tracking-tight text-white
Body Text
- Primary:
text-base leading-relaxed text-white/50 - Secondary:
text-sm leading-relaxed text-white/40
Monospace
- Code/Terminal:
font-mono text-sm text-white/60 - Labels:
font-mono text-xs text-white/30
Spacing
- Card padding:
p-5orp-6 - Section gaps:
gap-4togap-6 - Element margins:
mb-2tomb-6
Shadows
- Card shadow:
shadow-2xl shadow-black/20 - Deep shadow:
shadow-2xl shadow-black/50
Animations
- Pulse:
animate-pulsefor cursor blink, status indicators - Transitions:
transition-allortransition-colorsfor hover states
Icons
Use inline SVGs for consistency. Common icons:
Terminal Icon
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="19" x2="20" y2="19" />
</svg>Lock Icon
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
</svg>Shield Icon
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
</svg>Cloud Icon
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
</svg>Usage Guidelines
- Consistency: Use the terminal window card for all prominent UI containers on marketing pages
- Hierarchy: Use ambient glows sparingly - one primary (emerald), one or two secondary (blue/violet)
- Text contrast: Ensure sufficient contrast - primary text at
white, body atwhite/50minimum - Interactivity: All interactive elements should have visible hover states
- Responsiveness: Use
sm:breakpoint modifiers for mobile adaptations
File: apps/web/docs/diff-viewer-plan.md
Diff Viewer Implementation Plan
Overview
The diff viewer shows users all changes made by the agent in real-time, comparing the current sandbox state against HEAD (essentially all staged + unstaged changes).
Data Flow:
Agent modifies files -> Tool completes -> refreshKey++ -> DiffViewer fetches -> Git commands run -> UI updatesThe key insight is that it's not polling continuously - it only refreshes when the agent actually modifies something, keeping it efficient while staying up-to-date.
1. API Route: `/api/tasks/[taskId]/diff`
Location: apps/web/app/api/tasks/[id]/diff/route.ts
Responsibilities
- Validate sandbox is active via
sandboxIdquery param - Run
git diff HEADfor full unified diff - Run
git diff HEAD --statfor file statistics - Run
git diff HEAD --name-statusfor file status (A/M/D/R) - Parse output into structured response
Response Schema
type DiffFile = {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
additions: number;
deletions: number;
diff: string; // Raw unified diff for this file
oldPath?: string; // For renamed files
};
type DiffResponse = {
files: DiffFile[];
summary: {
totalFiles: number;
totalAdditions: number;
totalDeletions: number;
};
};Implementation Notes
- Follow pattern from
generate-pr/route.ts- useconnectVercelSandbox({ sandboxId }) - Parse
--statoutput for per-file stats (format:filename | 10 ++++------) - Parse
--name-statusfor file status:A= addedM= modifiedD= deletedR= renamed (includes old path)
- Split full diff by file headers (
diff --git a/... b/...)
Example Git Commands
# Get file status
git diff HEAD --name-status
# Get stats per file
git diff HEAD --stat
# Get full unified diff
git diff HEAD2. UI Component: `diff-viewer.tsx`
Location: apps/web/app/tasks/[id]/diff-viewer.tsx
Component Structure
DiffViewer
โโโ Header
โ โโโ Tabs (Diff | Logs)
โ โโโ Window controls (split, close)
โ โโโ Total stats badge (+51 -0)
โโโ FileList
โ โโโ FileEntry (collapsible)
โ โโโ Chevron
โ โโโ File path
โ โโโ Status badge (New/Modified/Deleted/Renamed)
โ โโโ Per-file stats
โ โโโ Actions menu (optional)
โโโ ExpandedDiff (when file is expanded)
โโโ Line numbers gutter
โโโ +/- prefix column
โโโ Code content with syntax highlightingProps Interface
type DiffViewerProps = {
sandboxId: string;
refreshKey: number;
onClose: () => void;
};Styling (based on reference image)
- Width:
w-[500px](matches existing placeholder) - Dark theme with:
- Green additions:
bg-green-950/50,text-green-500 - Red deletions:
bg-red-950/50,text-red-400
- Green additions:
- Monospace font for code:
font-mono text-xs - Border styling:
border-border,bg-card - Line numbers: gray gutter, right-aligned
Existing Patterns to Reuse
createEditDiffLinesfrom@open-agents/shared/lib/diff- adapt for unified diff parsing- Diff line rendering from
edit-renderer.tsx(lines 101-144) - Collapsible pattern from
edit-renderer.tsx - Shiki themes from
task-detail-content.tsx(lines 49-52)
States to Handle
- Loading - Show skeleton/spinner while fetching
- Empty - "No changes" message when working directory is clean
- Error - Show error message (e.g., sandbox expired)
- Success - Show file list and diffs
3. TaskContext Enhancement
Location: apps/web/app/tasks/[id]/task-context.tsx
Changes Required
Add to TaskChatContextValue:
type TaskChatContextValue = {
// ... existing fields
diffRefreshKey: number;
triggerDiffRefresh: () => void;
};Implementation
const [diffRefreshKey, setDiffRefreshKey] = useState(0);
const triggerDiffRefresh = useCallback(() => {
setDiffRefreshKey((prev) => prev + 1);
}, []);4. Smart Refresh Trigger
Location: apps/web/app/tasks/[id]/task-detail-content.tsx
Tool Types to Watch
write- file creationedit- file modificationbash- could modify files (git commands, mv, rm, etc.)
Implementation
// Track completed tool operations to trigger diff refresh
const prevToolStatesRef = useRef<Map<string, string>>(new Map());
useEffect(() => {
const currentToolStates = new Map<string, string>();
let shouldRefresh = false;
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (!isToolUIPart(part)) continue;
const toolId = part.id;
const toolState = part.state;
currentToolStates.set(toolId, toolState);
// Check if this tool just completed
const prevState = prevToolStatesRef.current.get(toolId);
const isFileModifyingTool = ["write", "edit", "bash"].includes(part.toolName);
const justCompleted =
toolState === "output-available" &&
prevState !== "output-available";
if (isFileModifyingTool && justCompleted) {
shouldRefresh = true;
}
}
}
prevToolStatesRef.current = currentToolStates;
if (shouldRefresh) {
triggerDiffRefresh();
}
}, [messages, triggerDiffRefresh]);5. Integration Points
task-detail-content.tsx
Replace placeholder (lines 564-594) with:
{showDiffPanel && sandboxInfo && (
<DiffViewer
sandboxId={sandboxInfo.sandboxId}
refreshKey={diffRefreshKey}
onClose={() => setShowDiffPanel(false)}
/>
)}Add button to open diff panel
Add to header actions (around line 296):
setShowDiffPanel(!showDiffPanel)}
>
<GitCompare className="mr-2 h-4 w-4" />
Diff6. File Structure
apps/web/
โโโ app/
โ โโโ api/
โ โ โโโ tasks/
โ โ โโโ [id]/
โ โ โโโ diff/
โ โ โโโ route.ts # NEW: Diff API endpoint
โ โโโ tasks/
โ โโโ [id]/
โ โโโ task-context.tsx # MODIFY: Add diffRefreshKey
โ โโโ task-detail-content.tsx # MODIFY: Integrate DiffViewer
โ โโโ diff-viewer.tsx # NEW: Main diff viewer component7. Open Questions
These should be answered before implementation:
Logs Tab - Should the "Logs" tab be implemented now, or left as a placeholder? What logs would it show - agent execution logs, sandbox shell output?
Syntax Highlighting - The existing codebase uses Shiki via Streamdown. Should we reuse that setup, or is a simpler approach preferred? Full syntax highlighting adds complexity.
File Actions Menu - The reference image shows a three-dot menu on each file. What actions should be available? (e.g., "Copy path", "View file", "Revert changes"?)
Empty State - What should display when there are no changes (clean working directory)?
Error Handling - If the sandbox expires mid-session, should the diff panel show a "Sandbox expired" message with a reconnect option?
Split/Full-screen Toggle - The reference image shows window control icons. Should the panel support full-screen mode or split-view mode?
8. Implementation Order
- API Route - Create
/api/tasks/[id]/diff/route.tswith git commands and parsing - TaskContext - Add
diffRefreshKeyandtriggerDiffRefresh - DiffViewer Component - Build the UI component with file list and diff rendering
- Integration - Wire up in
task-detail-content.tsx - Smart Refresh - Implement tool completion watching
- Polish - Error states, loading states, empty states
9. Dependencies
No new dependencies required. Uses existing:
@open-agents/sandboxforconnectVercelSandbox@open-agents/shared/lib/difffor diff utilitieslucide-reactfor icons- Existing UI components (Button, etc.)
10. Testing Considerations
- Test with various diff scenarios:
- New files only
- Modified files only
- Deleted files
- Renamed files
- Large diffs (100+ lines)
- Binary files (should show placeholder)
- Test refresh mechanism with rapid tool executions
- Test sandbox expiry edge case
- Test with no changes (empty state)
File: apps/web/docs/sandbox-state-persistence-plan.md
Sandbox State Persistence via Git Diffs
Overview
Persist sandbox working state using Git diffs so that when a sandbox times out and a new one is created, the user's work is automatically restored. This creates a seamless experience where users don't notice sandbox recreation.
Scope
- Web app only (not CLI/TUI)
- Capture untracked files with 1MB size limit per file
- Show subtle "Restoring workspace..." indicator during restoration
- Use
beforeStophook to capture diff (assumes hook fires on timeout)
Implementation Plan
Phase 1: Database Schema
File: apps/web/lib/db/schema.ts
Add a new taskDiffs table to store captured diffs:
export const taskDiffs = pgTable("task_diffs", {
id: text("id").primaryKey(),
taskId: text("task_id")
.notNull()
.references(() => tasks.id, { onDelete: "cascade" }),
diffContent: text("diff_content").notNull(), // git diff HEAD output
untrackedFiles: jsonb("untracked_files"), // Array<{path, content (base64)}>
baseCommit: text("base_commit"), // SHA for validation
createdAt: timestamp("created_at").defaultNow().notNull(),
});New migration required after schema update.
Phase 2: Diff Capture Logic
New file: apps/web/lib/sandbox/capture-state.ts
export async function captureSandboxState(sandbox: Sandbox): Promise<{
diffContent: string;
untrackedFiles: Array<{ path: string; content: string }>;
baseCommit: string;
}>Captures:
git rev-parse HEAD- base commit SHAgit diff HEAD- all tracked file changes (staged + unstaged)git ls-files --others --exclude-standard- untracked file paths- Read each untracked file (skip if >1MB), base64 encode content
Phase 3: Diff Storage Operations
New file: apps/web/lib/db/task-diffs.ts
// Create new diff, keep only latest 3 per task
export async function createTaskDiff(data: NewTaskDiff): Promise<TaskDiff>
// Get latest diff for restoration
export async function getLatestTaskDiff(taskId: string): Promise<TaskDiff | null>Phase 4: Hook into Sandbox beforeStop
Modify: apps/web/app/api/sandbox/route.ts
When creating a sandbox, pass a beforeStop hook that captures and saves the diff:
const sandbox = await connectVercelSandbox({
timeout: DEFAULT_TIMEOUT,
source: { ... },
hooks: {
beforeStop: async (sandbox) => {
if (taskId) {
try {
const state = await captureSandboxState(sandbox);
if (state.diffContent || state.untrackedFiles.length > 0) {
await createTaskDiff({
id: nanoid(),
taskId,
diffContent: state.diffContent,
untrackedFiles: state.untrackedFiles,
baseCommit: state.baseCommit,
});
}
} catch (error) {
console.error("Failed to capture sandbox state in beforeStop:", error);
}
}
},
},
});Note: This requires taskId to be available in the hook context. We'll need to capture it in a closure.
Phase 5: Diff Restoration Logic
New file: apps/web/lib/sandbox/restore-state.ts
export async function restoreSandboxState(
sandbox: Sandbox,
diff: TaskDiff
): Promise<{ success: boolean; error?: string }>Steps:
- Optionally verify/checkout base commit
- Write diff to temp file, run
git apply --3way /tmp/restore.patch - Restore untracked files by writing base64-decoded content
- Clean up temp file
- Return success/failure status
Error handling:
- If
git applyfails, trygit apply --rejectfor partial restoration - Log failures but don't block sandbox creation
- Skip untracked files that already exist
Phase 6: Integrate Restoration into Sandbox Creation
Modify: apps/web/app/api/sandbox/route.ts
After creating the sandbox, restore state if a diff exists:
const sandbox = await connectVercelSandbox({
// ... config with beforeStop hook
});
// Restore state if this task has a stored diff
let stateRestored = false;
if (taskId) {
const latestDiff = await getLatestTaskDiff(taskId);
if (latestDiff) {
const result = await restoreSandboxState(sandbox, latestDiff);
stateRestored = result.success;
if (!result.success) {
console.warn("Partial state restoration:", result.error);
}
}
}
return Response.json({
sandboxId: sandbox.id,
createdAt: Date.now(),
timeout: DEFAULT_TIMEOUT,
currentBranch: sandbox.currentBranch,
stateRestored, // inform client
});Phase 7: Client-Side UI Feedback
Modify: apps/web/app/tasks/[id]/task-detail-content.tsx
When createSandbox is called:
- Show "Restoring workspace..." text during sandbox creation (if task has messages)
- If
stateRestored: truein response, continue normally - If restoration failed, show subtle warning but continue
File Changes Summary
| File | Action |
|---|---|
apps/web/lib/db/schema.ts |
Add taskDiffs table |
apps/web/lib/db/task-diffs.ts |
New - CRUD operations |
apps/web/lib/sandbox/capture-state.ts |
New - diff capture logic |
apps/web/lib/sandbox/restore-state.ts |
New - diff restoration logic |
apps/web/app/api/sandbox/route.ts |
Add beforeStop hook + restoration after creation |
apps/web/app/tasks/[id]/task-detail-content.tsx |
Add "Restoring workspace..." UI |
Key Design Points
Why beforeStop Hook
Using beforeStop is more efficient than capturing after every message:
- Only runs once when sandbox is stopping
- No per-message overhead
- Git diff operation is slightly slow (~100-500ms), so doing it once is better
Assumption: The beforeStop hook will be called even when the sandbox times out (not just on explicit stop() calls). If this isn't the case, we may need to add periodic capture as a fallback.
Diff Retention
Keep only the latest 3 diffs per task to prevent unbounded storage growth. Older diffs are automatically deleted when a new one is created.
Untracked File Handling
- Files >1MB are skipped (likely generated files)
- Binary files are base64 encoded (safe for any content)
- Existing files are not overwritten during restoration
Verification Plan
Manual testing:
- Create a task, make file changes via the agent
- Wait for sandbox to expire (or manually trigger
stop()) - Create a new sandbox for the same task
- Verify files are restored correctly
Edge cases to test:
- Large untracked file (>1MB) - should be skipped
- Binary files - should be handled gracefully
- Conflicting changes (rare) - should partially restore with warnings
- Empty diff (no changes) - should not create a diff record
Database verification:
- Check
task_diffstable is populated after sandbox stop - Verify old diffs are cleaned up (only latest 3 kept)
- Check
File: apps/web/README.md
This is a Next.js project bootstrapped with create-next-app.
Environment Variables
The following environment variables are required:
| Variable | Description |
|---|---|
POSTGRES_URL |
PostgreSQL connection string. Required for builds โ migrations run automatically during next build via lib/db/migrate.ts. Preview deployments use a Neon branch database; production uses the main database. |
Getting Started
First, run the development server:
pnpm devOpen http://localhost:3000 with your browser to see the result.
You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.
This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.
Learn More
To learn more about Next.js, take a look at the following resources:
- Next.js Documentation - learn about Next.js features and API.
- Learn Next.js - an interactive Next.js tutorial.
You can check out the Next.js GitHub repository - your feedback and contributions are welcome!
Deploy on Vercel
The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js.
Check out our Next.js deployment documentation for more details.
File: docs/plans/lazy-sandbox-session-creation.md
Lazy sandbox session creation
Summary: Allow sessions to be created without an active sandbox, and make sandbox provisioning a lazy first step of chat execution or explicit resume. This keeps session creation lightweight, removes the up-front wait before the first message, and makes programmatic session creation simpler.
Context
apps/web/app/api/sessions/route.tsalready creates a session and initial chat without a livesandboxId; it stores desired sandbox configuration insandboxState: { type: "vercel" }.apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsxcurrently auto-creates a sandbox on page entry and blocks sending until the sandbox is active.apps/web/app/api/chat/route.tscurrently requires an active sandbox before a chat request can proceed.apps/web/app/api/sandbox/route.tscontains the create/reconnect logic that provisions a sandbox and persists the resulting runtime state.apps/web/app/workflows/chat.tsassumes the workflow is started with an already-usable sandbox.
Approach
Treat session creation and sandbox creation as separate concerns:
- Session creation creates durable app state only.
- The first message (or an explicit resume action) ensures a sandbox exists.
- Once a sandbox is ready, the normal agent workflow proceeds unchanged.
- Eager sandbox creation can remain as an optimization, not a requirement.
This makes the default contract:
POST /api/sessionsreturnssessionIdandchatIdeven if no runtime sandbox exists yet.POST /api/chatdoes not require a caller-providedsandboxIdfor normal usage.- Sandbox creation/reconnect/restore becomes an internal โensure runtimeโ step.
Recommended workflow
- Create a session.
- Navigate to the chat immediately.
- Let the user send the first message without waiting for sandbox creation.
- On first send:
- reuse an already-active sandbox if one exists
- otherwise reconnect to an existing runtime if possible
- otherwise restore from snapshot if appropriate
- otherwise create a new sandbox
- Persist the resulting runtime state (
sandboxId, expiry, branch, lifecycle data). - Start the agent turn.
- Reuse that sandbox for later messages.
API shape
Session creation
POST /api/sessions
-> {
session: { id: string, ... },
chat: { id: string, ... },
sandbox: { state: "pending" }
}Chat execution
POST /api/chat
{
sessionId: string,
chatId: string,
messages: [...],
sandboxId?: string
}Notes:
sandboxIdstays optional for reconnect or advanced callers.- Normal callers should not need to know whether a sandbox already exists.
Changes
apps/web/app/api/sessions/route.ts- Keep session creation lightweight.
- Consider renaming lifecycle semantics so a newly created session is not described as actively provisioning when no sandbox boot has started yet.
apps/web/app/api/sandbox/route.ts- Extract the create/reconnect logic into a shared
ensureSessionSandbox(...)helper so manual sandbox actions and chat startup use the same code path.
- Extract the create/reconnect logic into a shared
apps/web/app/api/chat/route.ts- Remove the hard precondition that the sandbox must already be active.
- Ensure the session sandbox before creating the chat runtime.
apps/web/app/api/chat/_lib/runtime.ts- Accept the ensured runtime state rather than assuming
sessionRecord.sandboxStateis already active.
- Accept the ensured runtime state rather than assuming
apps/web/app/workflows/chat.ts- Option A: keep sandbox ensuring in the API route before starting the workflow.
- Option B: make sandbox ensuring the formal first workflow step.
- Recommendation: start with Option A for the smallest change.
apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx- Stop blocking the first send purely because no sandbox is active yet.
- Replace that with a visible โPreparing sandboxโฆโ state after send.
- Keep explicit create/resume controls for manual recovery cases.
apps/web/lib/sandbox/utils.ts- Clarify the distinction between โdesired sandbox config existsโ and โruntime sandbox is activeโ.
apps/web/lib/db/schema.ts- Consider adding a clearer lifecycle state such as
pending/awaiting_startif the currentprovisioninglabel becomes misleading.
- Consider adding a clearer lifecycle state such as
Tradeoffs
Benefits
- Better UX: users can send immediately.
- Lower cost: abandoned sessions never allocate a sandbox.
- Cleaner API: sessions can be created programmatically without infra setup.
- Better separation of concerns: a session is durable state; a sandbox is attachable runtime.
Risks
- The first-send path now needs a session-level lock to avoid double sandbox creation.
- Sandbox boot latency moves into the first message path, so the UI must explain what is happening.
- Sandbox failures become chat-start failures and need retryable UX.
- Existing lifecycle labels may no longer accurately describe state.
Recommendation
Adopt lazy sandbox creation as the default contract.
Specifically:
- keep
POST /api/sessionssandbox-agnostic - make
sandboxIdoptional in the mental model and API contract - introduce a shared
ensureSessionSandbox(...)helper - call that helper from chat startup
- preserve eager prewarming only as an optional optimization
Verification
- Create an empty session and immediately send a first message; sandbox should provision and the turn should run.
- Create a repo-backed session and immediately send a first message; clone/branch setup should happen exactly once.
- Send the first message from two tabs concurrently; only one sandbox should be created.
- Create a session programmatically through
POST /api/sessions; later chat execution should still work. - Manual sandbox create/resume flows should continue to work.
- Snapshot restore should still bypass stale pre-created runtime state.
File: docs/agents/architecture.md
Architecture
This is a Turborepo monorepo for "Open Agents" - an AI coding agent built with AI SDK.
Core Flow
Web -> Agent (packages/agent) -> Sandbox (packages/sandbox)- Web handles authentication, session management, and the primary user interface
- Agent (
deepAgent) is aToolLoopAgentwith tools for file ops, bash, and task delegation - Sandbox abstracts file system and shell operations for cloud execution backends
Key Packages
- packages/agent/ - Core agent implementation with tools, subagents, and context management
- packages/sandbox/ - Execution environment abstraction for cloud sandboxes
- packages/shared/ - Shared utilities across packages
Subagent Pattern
The task tool delegates to specialized subagents:
- explorer: Read-only, for codebase research (grep, glob, read, safe bash)
- executor: Full access, for implementation tasks (all tools)
Workspace Structure
apps/
web/ # Web interface
packages/
agent/ # Core agent logic (@open-agents/agent)
sandbox/ # Sandbox abstraction (@open-agents/sandbox)
shared/ # Shared utilities (@open-agents/shared)
tsconfig/ # Shared TypeScript configsFile: docs/agents/code-style.md
Code Style & Patterns
Detailed coding conventions, tool implementation patterns, and common patterns for the Open Agents codebase.
Package Manager
- Use pnpm exclusively for dependency management
- The monorepo uses
[email protected]through Corepack - Use Node 24's built-in TypeScript support for utility scripts
- Use Bun only as the test runner
TypeScript Configuration
- Strict mode enabled
- Target: ESNext with module "Preserve"
noUncheckedIndexedAccess: true- always check indexed accessverbatimModuleSyntax: true- use explicit type imports
Formatting (Ultracite โ oxfmt)
- Indent: 2 spaces
- Quote style: double quotes for JavaScript/TypeScript
- Run
pnpm fixbefore committing
Naming Conventions
- Files: kebab-case (e.g.,
deep-agent.ts,paste-blocks.ts) - Types/Interfaces: PascalCase (e.g.,
TodoItem,AgentContext) - Functions/Variables: camelCase (e.g.,
getSandbox,workingDirectory) - Constants: UPPER_SNAKE_CASE for true constants (e.g.,
TIMEOUT_MS,SAFE_COMMAND_PREFIXES)
Imports
- Do NOT use
.jsextensions in imports (e.g.,import { foo } from "./utils"not"./utils.js")- The
.jsextension causes module resolution issues with Next.js/Turbopack - This applies to all packages and apps in the monorepo
- The
- Use explicit
.tsextensions in modules loaded directly by Node 24 utility scripts - Prefer named exports over default exports
- Group imports: external packages first, then internal packages, then relative imports
- Use type imports when importing only types:
import type { Foo } from "./types"
Types
- Never use
any- useunknownand narrow with type guards - Define schemas with Zod, then derive types:
type Foo = z.infer<typeof fooSchema> - Prefer interfaces for object shapes, types for unions/intersections
- Export types alongside their related functions
Error Handling
- Return structured error objects rather than throwing when possible:typescript
return { success: false, error: `Failed to read file: ${message}` }; - When catching errors, extract message safely:typescript
const message = error instanceof Error ? error.message : String(error); - Use descriptive error messages that include context (tool name, file path, etc.)
Testing
- Use Bun's test runner:
import { test, expect } from "bun:test" - Test files use
.test.tssuffix - Colocate tests with source files
Runtime APIs
- Use Node APIs for utility scripts
- Use Bun APIs only in tests when the Bun test runner requires them
AI SDK Patterns
- Tools are defined with Zod schemas for input validation
- Use
ToolLoopAgentfor agent implementations - Tools receive context via
experimental_contextparameter - Implement
needsApprovalas boolean or function for tool approval logic
Tool Implementation Patterns
When creating tools in packages/agent/tools/:
import { tool } from "ai";
import { z } from "zod";
import { getSandbox, getApprovalContext } from "./utils";
const inputSchema = z.object({
param: z.string().describe("Description for the agent"),
});
export const myTool = (options?: { needsApproval?: boolean }) =>
tool({
needsApproval: (args, { experimental_context }) => {
const ctx = getApprovalContext(experimental_context, "myTool");
// Return true if approval needed, false otherwise
return options?.needsApproval ?? true;
},
inputSchema,
execute: async (args, { experimental_context }) => {
const sandbox = getSandbox(experimental_context, "myTool");
// Implementation using sandbox methods
return { success: true, result: "..." };
},
});Common Patterns
Large UI files
- In already-large React view/page/client components, do not add new feature-specific state, effects, network calls, and JSX inline by default.
- Extract feature logic into a colocated hook (for example
use-dev-server.ts) and extract self-contained UI regions into a colocated component (for exampledev-server-menu-items.tsx). - Keep the parent view responsible for shared capability flags and long-lived page state; pass the extracted feature controls down as props.
- If the feature state must survive menu/popover/dialog open-state changes, mount the hook in the parent view and pass its controls into the extracted child component instead of mounting the hook inside ephemeral UI content.
Workspace Dependencies
Use workspace:* for internal packages:
{
"dependencies": {
"@open-agents/sandbox": "workspace:*"
}
}Catalog Dependencies
Use catalog: for shared external versions:
{
"dependencies": {
"ai": "catalog:",
"zod": "catalog:"
}
}File: docs/agents/lessons-learned.md
Lessons Learned
Hard-won knowledge from building this codebase. When you make a mistake or discover a non-obvious behavior, add it here.
General / Tooling
- Skill discovery de-duplicates by first-seen name, so project skill directories must be scanned before user-level directories to allow project overrides.
- The system prompt should list all model-invocable skills (including non-user-invocable ones), and reserve user-invocable filtering for the slash-command UI.
- Glob patterns ending in
**(for example"**"or"src/**") should be treated as recursive, even when**is the final segment. - In shell tools, avoid piping primary command output directly to
headwhen exit-code handling matters; pipeline semantics can mask real failures from the primary command. - Bash approval heuristics should reserve prompts for clearly destructive commands (for example
rm -rf,sudo, or mutating git/package-manager operations); treating pipes/chaining and common filesystem reads as dangerous creates too many false-positive approvals for normal inspection commands. - Verification instructions must tell the agent to consult AGENTS.md /
package.jsonscripts before listing generic steps like "typecheck -> lint -> build"; otherwise models default to raw commands (npx tsc,eslint .) which bypass project-specific tool config (turbo pipelines, tsconfig references, ultracite, etc.) and produce incorrect or incomplete results. - Tool renderer
part.outputvalues may beunknown; when accessing fields likefilesormatches, add runtime narrowing/type guards first (in both TUI and web renderers) to satisfy strict typecheck. - AI SDK stream handles may return
PromiseLikevalues (not fullPromise), so avoid methods like.finally()and usethen/catchpatterns that work withPromiseLike. - After schema edits, review generated Drizzle migrations for unrelated schema drift changes before committing (for example defaults on untouched columns), since
drizzle-kit generatecan include those alongside intended changes. - pnpm 11 requires an explicit
allowBuildsmap inpnpm-workspace.yaml; approve required native/tooling builds deliberately and keep non-functional lifecycle scripts disabled. - Keep pnpm release-age policy explicit in
pnpm-workspace.yaml: enforce a strict one-dayminimumReleaseAgeand fail closed when publish timestamps are missing. - Use
pnpm run cifor the repository verification script.pnpm ciinvokes pnpm's built-in clean-install command instead of the package script. - Keep Kysely pinned to
0.28.xuntil Better Auth's bundled Kysely adapter stops importing migration constants from Kysely's root entrypoint; Kysely0.29.xremoved those root exports and breaks the Next production bundle. - Node 24's built-in TypeScript support uses native ESM resolution and ignores tsconfig path aliases, so utility-script dependency chains need explicit
.tsextensions and relative imports. bunx @vercel/config validateexecutes the CLI under Node via its shebang and cannot parse TypeScript-stylevercel.tsimports; usebunx --bun @vercel/config validate(orbun node_modules/@vercel/config/dist/cli.js validate) for reliable local validation.- Successful Vercel CLI auth (
vercel whoami, team/project REST APIs,.vercellinking) does not guarantee Workflow observability access.workflow inspect ... --backend vercelcan still fail with401 {"error":{"code":"unauthorized","message":"You are not allowed to access this endpoint."}}when the user/token lacks the Vercel product permission documented asVercel Workflow(and possibly related Observability access), even ifWORKFLOW_VERCEL_AUTH_TOKENis passed explicitly from the Vercel CLI auth file.
Next.js
- In Next.js App Router, dynamic route param names must match the folder segment exactly (e.g.
[sessionId]requiresparams.sessionId, notparams.id), or DB queries can receiveundefinedand fail at runtime. - Some planning docs still reference legacy
apps/web/app/tasks/[id]/...paths; current UI/API code is centered onapps/web/app/sessions/[sessionId]/chats/[chatId]/..., so verify file paths before implementing plan items. - Next.js
after()defers callbacks until the response is fully sent; for streaming endpoints this meansafter()runs after the entire stream completes, not at call time. Use fire-and-forget (void run()) for lifecycle kicks that must happen at request start. - In Next.js Route Handlers,
cookies()fromnext/headerscombined withResponse.redirect()silently drops Set-Cookie headers from the redirect response. UseNextResponse.redirect()withresponse.cookies.set()instead to ensure cookies are included in redirect responses. - In this codebase's Next.js version,
revalidateTagmust be called with a second argument (for example{ expire: 0 }); single-argument calls fail typecheck. - For Workflow SDK discovery in Next.js, ensure workflow files live in scanned directories (for this app,
app/), otherwise manifests can show steps but0 workflowsandstart()will not run durable workflows. - Server-side optimistic chat route lookup must allow realistic persistence latency (multi-second retry window), otherwise
/sessions/[sessionId]/chats/[chatId]can redirect away before chat creation finishes.
Sandbox Lifecycle
- Detached/background bash results may have
exitCode: nullfor both successful starts and explicit tool failures; bash renderer error state must also honoroutput.success === false(not only numeric non-zero exit codes), and detached quick-failure probing should prefer a timer-vs-wait race branch over matching SDK-specific error names. - Creating a sandbox snapshot automatically shuts down that sandbox; lifecycle plans and implementations must treat snapshotting as a stop/hibernate transition, not a non-disruptive backup.
- Vercel
sdk.domain(port)throws when a sandbox has no route for that port (common on some restored/reconnected sandboxes); environment/prompt metadata should guard per-port URL generation instead of assuming every configured port is routable. - Vercel sandbox creation has a hard timeout limit of
18_000_000ms; if you add an internal timeout buffer before calling the SDK, clamp proactive timeout sotimeout + buffernever exceeds that API limit. - In serverless environments, lifecycle checks that only run inline during request handlers are not durable; long-gap sandbox lifecycle actions must be scheduled with a durable workflow run (
start(...)+sleep(...)) so they execute without a connected client. - Vercel
snapshot()may return422 sandbox_snapshottingwhen another snapshot is already in progress; lifecycle code should treat this as an idempotent/in-progress condition and reconcile state instead of marking lifecycle as failed. - The reconnect API can return
expiredwhen a sandbox has already stopped; client reconnection state should treatexpiredlikeno_sandboxso restore UX does not get stuck in a generic failure path. - For workflow-managed sandbox lifecycle, avoid client-side timeout auto-stop logic in the chat UI; it can race with workflow hibernate and produce confusing paused overlays while the tab remains open.
- Snapshot restore should be idempotent when a sandbox is already running: return success with an
alreadyRunningsignal instead of a 400, and let the client reconnect/sync rather than surfacing a hard error. - For lifecycle workflow kicks in request handlers, call
kickSandboxLifecycleWorkflow(...)directly instead of wrapping it inafter(...); delayed/deferred scheduling can miss the initial hibernation timer for idle sessions. - For sandbox lifecycle kicks, do not persist
lifecycleRunIdbeforestart(...); start first and let the durable workflow claim/verify the lease so canceled fire-and-forget kicks cannot strand a stale lease. - Lifecycle workflow must retry after a
skipped/not-due-yetevaluation; without retry the sandbox never hibernates unless a new event kicks a fresh workflow. - When the lifecycle workflow inline fallback runs (SDK unavailable), it evaluates immediately and skips because the sandbox isn't due yet; the status endpoint should detect overdue
hibernateAfterand kick the lifecycle as a safety net.
Sandbox UI State
- Status chips that derive from time-based sandbox validity should not rely on memoization without a time dependency; otherwise header state can drift from overlay/input state as
Date.now()changes. - Keep sandbox status UI elements (chip, overlay, and indicator dot) on a shared
isSandboxActivesource; mixed heuristics (e.g., one using grace-window validity and another using raw countdown) can show contradictory states likePausedwith a green dot. - Treat
/api/sandbox/reconnectas a read-only status probe; reconnect polling should never refresh lifecycle activity timestamps or kick lifecycle workflows, or idle sessions can fail to hibernate correctly. - For paused sessions, auto-resume on entry should trigger only after reconnect confirms
no_sandbox; do not auto-restore on generic reconnect failures. - Do not use
snapshotUrlalone to infer paused/hibernating UI state; active sessions may retain a snapshot reference. Require absence of runtime sandbox state (sandboxId/files) before labeling hibernation. - Keep sandbox mode details out of page/presentation components: expose capability flags (for example
supportsDiff,supportsRepoCreation,hasRuntimeSandboxState) from shared context and branch UI on capabilities, not rawsandboxState.type. - Auto-resume-on-entry for paused sessions must not require a prior
no_sandboxreconnect result when there is no runtime sandbox state in DB; snapshot-only sessions can otherwise get stuck inidleand never restore. - For predictive lifecycle UI countdowns, use server-provided timestamps (
hibernateAfter,sandboxExpiresAt) plus a server-time offset from reconnect responses; do not rely on client clock alone for transition timing. - Auto-resume for paused sessions must run only on initial session entry; once a tab has had an active sandbox, do not auto-resume after a later inactivity hibernate in that same tab.
- Keep the sandbox indicator dot on the same derived lifecycle state machine as the status chip; during inactivity countdown it should show a pausing state, and during server
hibernatingit should not remain green. - Split lifecycle UI polling from connectivity probing: poll a lightweight DB-backed sandbox status endpoint for timing/state, and reserve reconnect/connect checks for entry/resume or explicit recovery paths.
- Prefer event-first lifecycle sync in the chat UI (chat completion, visibility return, window focus, network online), with sparse status polling (about 60s baseline, tighter only near transitions) instead of frequent fixed-interval polling.
- When syncing status timestamps, avoid rewriting sandbox connection state on every response; only update if expiry materially changes, or UI effects can enter rapid request loops.
- Resume/paused UI must not rely only on
session.snapshotUrlfrom initial page props; keep a livehasSnapshotsignal from reconnect/status responses, or the UI can incorrectly showNo sandboxand hide resume actions. /api/sandbox/reconnectshould treat DB runtime state (sandboxId/files) as the source of reconnect eligibility; usingisSandboxActive(which includes expiry heuristics) can misclassify recoverable sessions asno_sandboxand break restore/reconnect flows.- When
/api/sandbox/reconnectreportsconnected, it must persist refreshed sandbox runtime state/expiry (sandboxState,sandboxExpiresAt) back to DB; otherwise/filesand/diffcan still fail withSandbox not initializedagainst stale expired state while UI thinks reconnect succeeded. - For sandbox lifecycle UI, keep the client simple and server-authoritative: poll
/api/sandbox/statuson a fixed cadence (currently 15s) instead of combining multiple client-side event/predictive sync paths, which can drift or loop under reconnect/hibernation edge cases. - Reconnect liveness probes can time out right after snapshot restore while the sandbox is still starting; treat probe timeouts as transient (non-terminal) and clear runtime state only for hard unavailability signals (stopped/not found/stream unavailable).
- Keep
/api/sandbox/statusas a DB-backed read-only view; do not mutate/clear sandbox runtime state from status polling, or active sessions can be downgraded tono_sandboxand later restore from stale snapshots. - On Vercel reconnect (
state.sandboxId), do not passremainingTimeout=0from stalestate.expiresAt; that creates an immediately-expired local wrapper and can make the header/API checks flip toNo sandboxeven while the VM is reachable. - Reconnect success should refresh full active lifecycle timestamps (
lastActivityAt,hibernateAfter,sandboxExpiresAt) before responding; otherwise UI status chips can stay stuck inPausingfrom stale lifecycle fields. - Lifecycle countdown UI windows should scale with configured inactivity timeout; fixed windows (for example 2 minutes) can make short test timeouts (for example 1 minute) appear to be perpetually pausing.
- Reconnect can return a sandbox handle whose command stream is unusable (
Expected a stream of command data); reconnect should probe command execution before declaringconnected, and file/diff routes should treat that error as sandbox-unavailable (hibernated) rather than a git-repo error. - Archive uses a deferred background snapshot; if unarchive runs before
snapshotUrlis persisted, resume/restore can race withno_snapshot, so unarchive/restore flows must gate on snapshot readiness (or surface a clear snapshot-in-progress state). - Client UI
sandboxUiStatusmust check serverlifecycleTiming.state(from status poll) as primary source, not only localsandboxInfo; otherwise UI stays "Active" after server-side hibernation until the local timeout expires or user refreshes. - The
isSandboxActiveclient flag must incorporatelifecycleTiming.state; localisSandboxValid(sandboxInfo)alone is insufficient because the server can hibernate the sandbox while the local timeout is still valid. - In the sandbox lifecycle evaluator, treat any non-null chat
activeStreamIdas an authoritative no-hibernate signal; do not inspect workflow status or clear stream ids from the lifecycle path, and recheck immediately before snapshotting to avoid racing a newly-started stream.
Chat / Streaming UI
- In large chat/page client components, extract new feature-specific UI flows into colocated hooks and child components instead of adding more state/effects/handlers inline; if the feature state must survive dropdown/popover/dialog toggles, mount the hook in the parent view and pass its controls down.
- In the web chat UI, do not keep
@ai-sdk/reactChat instances alive after route transitions while they are still streaming; abort local stream processing and remove the instance on teardown, then rely on resumable stream reconnect when revisiting that chat. - For client-side tool flows (
ask_user_question),onFinish-only assistant persistence is insufficient across route switches: persist the latest incoming message snapshot at API request start (upsert by message id) so answered/declined tool state survives teardown/resume and does not rehydrate staleinput-availableUI. - Request-start assistant snapshot persistence must be scoped and ownership-guarded: only upsert assistant messages when the request still owns the chat stream token, and refuse upserts on message-id scope conflict (different chat/role) to prevent stale writes and cross-chat overwrites.
- Keep
activeStreamIdresumable at all times: do not publish pre-registration ownership placeholders toactiveStreamId(resume probes can clear them as stale), and gateonFinishwrites on the atomic compare-and-set result that clears the currently owned token. - Usage analytics
messageCountmust represent assistant turns, not rawusage_eventsrows; when subagent/model breakdown rows are recorded, count only canonical main-agent rows in additive rollups to avoid inflated totals and heatmaps. - Unread correctness depends on visibility-aware read receipts and insert-only assistant activity updates: block read receipts for hidden tabs, but allow forced read marks on visible tabs without waiting for focus; only advance
lastAssistantMessageAtwhen an assistant message upsert actually inserts a new row (not snapshot/tool-result updates). - Post-turn automations that must happen even after the user leaves the chat (for example auto-commit/push) should be scheduled from the server chat completion path, not only from client
status === "ready"effects; client-only hooks can miss turns that finish while the page is closed and can lag behind background completion. - Chat list streaming indicators should poll more frequently while any chat is actively streaming (for example ~1s) and fall back to a slower cadence when idle, to avoid delayed white-to-complete indicator transitions after chat switches.
- Sidebar chat lists should hydrate from server-fetched initial chat summaries (layout props) in addition to SWR fetches, so transient
/api/sessions/[sessionId]/chatsfailures do not render an empty list on hard refresh. - For hydration-sensitive SWR endpoints (notably sidebar chat lists), use a dedicated
no-storefetcher instead of changing the shared SWR fetcher globally; otherwise browser HTTP caching gets disabled across unrelated/apihooks (models, branches, repos, settings). - Optimistic chat-title previews for
"New chat"must have an explicit rollback on send failures; otherwise the sidebar can keep a title that was never persisted if the first request errors. hadInitialMessagesis an initial-load snapshot, not a live "first turn" signal; guard one-time optimistic UI (like first-message title previews) with a dedicated runtime ref/state that resets on send failure.- When session overlay maps are deleted after becoming empty, any later overlay writes in the same hook instance must re-register the map in the global registry, or optimistic overlays will not survive route transitions.
- For resumed chat streams,
chat.stop()alone is insufficient because reconnect fetches are not wired to the active abort signal; always pair stop with aborting the managed transport tied to that chat instance. - Automatic stream retries should use soft reconnect semantics and single-flight guards; overlapping hard retries can replay resumable chunks and cause visible reasoning/tool UI flicker.
- In chat UI rendering, treat both
submittedandstreamingas in-flight. If onlystreamingis considered active, task/tool parts can be marked interrupted too early and staleThinking...indicators can linger until a full page refresh. - In Streamdown,
plugins.code.getThemes()overrides theshikiThemeprop; configure code themes increateCodePlugin(...)and pass actual custom theme objects for non-bundled themes (for examplevercelLight/vercelDark) or highlighting can fall back to unstyled/plain tokens. - Shiki dual-theme
TokensResultcan encode dark variants inside semicolon-delimitedfg/bgvalues and tokenhtmlStylefields (for examplecolor+--shiki-dark); normalize these into Streamdown'scolor/bgColorfields plus root CSS vars, or inline light colors can override dark-mode classes and keep code blocks stuck in light theme.
GitHub App / PR Flows
- GitHub App install flow uses a three-path strategy: (1) no linked account -- OAuth authorize URL with explicit
redirect_uri, callback chains to install withtarget_id; (2) linked account but no installations --installations/new/permissions?target_id={githubId}directly; (3) linked account with installations --select_targetfor the account/org picker. Disable "Request user authorization (OAuth) during installation" on the GitHub App -- it causes auto-redirect loops for already-authorized users on bothselect_targetandinstallations/new/permissions. - GitHub App must be made public for the org picker to appear during installation. While the app is private,
/installations/select_targetonly shows the owner's personal account -- users cannot install on organizations. Use "Make public" in the GitHub App's Danger Zone when ready. - Use
/installations/select_targetinstead of/installations/newfor the GitHub App install URL; the latter silently redirects to an existing personal installation's settings page instead of showing the account/org picker. - GitHub App callbacks that process OAuth
codeorinstallation_idmust validate a server-storedstatenonce before linking accounts or syncing installations; never trust callback query params without CSRF/state verification. - Installation sync that prunes DB records must fetch all GitHub API pages first (
per_page=100+ pagination); pruning from a partial page can silently remove valid installations. - In the GitHub App install flow, do a user-token installation sync before redirecting after OAuth-only callbacks or treating zero local installation rows as "not installed"; GitHub can skip callback emissions for pre-existing installs.
- Public upstream repositories may reject direct branch pushes; PR generation should fall back to creating/pushing to the user's fork and PR creation must use a qualified head ref (
forkOwner:branch). - GitHub fork creation can take longer than a few seconds to become pushable; PR fallback should retry fork push on transient
repository not founderrors instead of failing immediately. - Git push failures from Vercel sandboxes can return empty output even when auth/write is denied; PR fallback logic should not rely only on matching "permission" text before attempting fork fallback.
- When the GitHub App lacks push access (e.g. repo removed from installation scope), fail fast with a 403 directing users to /settings/connections rather than silently forking.
File: docs/agents/react-best-practices-audit.md
React Best Practices Audit: `apps/web`
Audited against the Vercel React Best Practices (57 rules, 8 categories). Findings are ordered by impact.
1. Eliminating Waterfalls -- CRITICAL
~~1a. Sequential `await` chains in Server Components~~ FIXED
Rule violated: async-parallel (Promise.all for independent operations)
app/sessions/[sessionId]/chats/[chatId]/page.tsx:67-93 -- Four sequential await calls where some are independent:
const session = await getServerSession(); // 1. auth
const sessionRecord = await getSessionById(sessionId); // 2. depends on nothing
const chat = await getChatByIdWithRetry(chatId, sessionId); // 3. depends on sessionId only
const dbMessages = await getChatMessages(chatId); // 4. depends on chatId onlygetServerSession() and getSessionById() are independent and could run in parallel. After the ownership check, getChatByIdWithRetry and getChatMessages could also overlap (chatId is known from params, not from the session fetch).
Fix: Start independent promises early, await late:
const sessionPromise = getServerSession();
const sessionRecordPromise = getSessionById(sessionId);
const session = await sessionPromise;
if (!session?.user) redirect("/");
const sessionRecord = await sessionRecordPromise;
// ... ownership check ...
const [chat, dbMessages] = await Promise.all([
getChatByIdWithRetry(chatId, sessionId),
getChatMessages(chatId),
]);~~1b. Missing Suspense boundaries~~ FIXED
Rule violated: async-suspense-boundaries
There are zero loading.tsx files and zero error.tsx files in the entire app. Only one <Suspense> boundary exists (app/settings/accounts/page.tsx:8). The root layout (app/layout.tsx:24-39) has no Suspense wrapping {children}.
This means:
- No streaming SSR for any page
- The entire page tree (including the retry loop at
page.tsx:31-48that can block for up to 5 seconds) must resolve before anything paints
Fix: Added loading.tsx files for key route segments (sessions/[sessionId]/chats/[chatId], sessions/[sessionId], shared/[shareId]). Next.js automatically wraps these in <Suspense> boundaries.
~~1c. Blocking retry loop~~ FIXED
app/sessions/[sessionId]/chats/[chatId]/page.tsx:31-48 -- getChatByIdWithRetry can do 50 retries x 100ms = 5 seconds of blocking in the server component render. This blocks the entire page with no streaming or fallback UI.
Fix: The loading.tsx boundary now provides a loading indicator while the retry loop runs, instead of a blank page.
2. Bundle Size Optimization -- CRITICAL
~~2a. No `next/dynamic` usage anywhere~~ FIXED
Rule violated: bundle-dynamic-imports
The entire codebase has zero next/dynamic or import() calls for lazy-loading components. Every component is statically imported. Heavy, on-demand-only components are eagerly bundled:
| Component | File | Lines | When Used |
|---|---|---|---|
DiffViewer |
diff-viewer.tsx |
~308 | Modal opened on button click |
CreatePRDialog |
create-pr-dialog.tsx |
~686 | Modal opened on button click |
CreateRepoDialog |
create-repo-dialog.tsx |
~348 | Modal opened on button click |
Streamdown (shiki) |
External dependency | Large | Only during message rendering |
All are imported at session-chat-content.tsx:43-52,83.
Fix: Use next/dynamic with { ssr: false } for dialog/modal components:
const DiffViewer = dynamic(
() => import("./diff-viewer").then(m => m.DiffViewer),
{ ssr: false }
);
const CreatePRDialog = dynamic(
() => import("@/components/create-pr-dialog").then(m => m.CreatePRDialog),
{ ssr: false }
);~~2b. Barrel imports from `lucide-react`~~ FIXED
Rule violated: bundle-barrel-imports
session-chat-content.tsx:6-29 imports 22 icons via barrel export:
import {
Archive, ArchiveRestore, ArrowDown, ArrowLeft, ArrowUp,
Check, Copy, ExternalLink, FolderGit2, GitCompare,
GitPullRequest, Link2, Loader2, Menu, MessageSquare,
Mic, Paperclip, Pencil, Plus, Share2, Square, Trash2, X,
} from "lucide-react"This loads the entire lucide-react module graph (~1,583 modules).
Fix: Add lucide-react to optimizePackageImports in next.config.ts:
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["lucide-react"],
},
// ...
};3. Server-Side Performance -- HIGH
~~3a. Context provider value not memoized~~ FIXED
Rule violated: server-serialization + rerender-memo
session-chat-context.tsx:853-892 -- The SessionChatContext.Provider value is an inline object literal with ~40 properties, recreated every render. While individual callbacks inside are memoized with useCallback, the value object itself is not wrapped in useMemo. This causes all consumers to re-render whenever the provider re-renders.
Fix: Wrap the value in useMemo:
const contextValue = useMemo(() => ({
session: sessionRecord,
chatInfo,
chat,
// ... all 40 properties
}), [sessionRecord, chatInfo, chat, /* ... relevant deps */]);
return (
<SessionChatContext.Provider value={contextValue}>
{children}
</SessionChatContext.Provider>
);~~3b. Full session record passed to client component~~ INVESTIGATED
Rule violated: server-serialization (minimize data at RSC boundaries)
page.tsx:98 passes the entire sessionRecord object to the client provider. Only a subset of fields are actually used by the client components. This increases serialization cost at the RSC boundary.
Result: After investigation, nearly every field of the Session object is read or mutated by SessionChatProvider and SessionChatContent. The provider reads id, sandboxState, snapshotUrl, lifecycleState, lastActivityAt, hibernateAfter, sandboxExpiresAt, cachedDiff, cachedDiffUpdatedAt for initialization, and mutates 14+ fields through setSessionRecord. The serialization cost for a single DB row is minimal. Not worth refactoring.
4. Re-render Optimization -- MEDIUM
~~4a. Derived state not memoized~~ FIXED
Rule violated: rerender-derived-state-no-effect
session-chat-content.tsx:689-697 -- Several derived values are recomputed on every render without memoization:
const renderMessages = hasMounted ? messages : initialMessages; // line 689
const lastMessage = renderMessages[renderMessages.length - 1]; // line 690
const showThinkingIndicator = status === "submitted" || ... // line 691-697During streaming, messages changes on every token, so these are recalculated very frequently. renderMessages is used in 3+ useMemo hooks downstream, so not memoizing it means those downstream memos also re-run.
~~4b. `sandboxUiStatus` IIFE on every render~~ FIXED
session-chat-content.tsx:1476-1521 -- A complex IIFE computing an object literal runs on every render. This should be a useMemo:
const sandboxUiStatus = useMemo(() => {
if (isArchived) return { label: "Archived", ... };
// ...
}, [isArchived, isCreatingSandbox, isRestoringSnapshot, /* ... */]);~~4c. `hasMounted` effect pattern~~ FIXED
Rule violated: rerender-derived-state-no-effect
session-chat-content.tsx:575-577 -- Uses a useEffect just to set hasMounted:
useEffect(() => { setHasMounted(true); }, []);This causes an extra render on mount. A ref would avoid the extra render if the only purpose is guarding hydration.
Fix: Replaced with useSyncExternalStore which returns false during SSR and true on the client without triggering an extra render cycle:
const emptySubscribe = () => () => {};
function useHasMounted() {
return useSyncExternalStore(emptySubscribe, () => true, () => false);
}~~4d. ~25 useState calls in one component~~ IMPROVED
session-chat-content.tsx:553-565,698-703 -- The SessionChatContent function has approximately 25 useState calls and ~15 useEffect hooks. This monolithic component means any state change triggers re-evaluation of the entire 2200-line render function. Extracting sub-trees into memoized child components (memo()) would significantly reduce work per render.
Fix: Extracted the sidebar into a ChatSidebar component (chat-sidebar.tsx) with its own local state for title editing and chat renaming. This moves 6 useState calls and 2 useEffect hooks out of the parent, so sidebar editing no longer triggers re-evaluation of the message list and input areas. Further extraction of the header, message list, and input area into memoized components would provide additional wins.
~~4e. Scroll effect triggered too frequently~~ FIXED
Rule violated: rerender-dependencies
session-chat-content.tsx:1108-1112:
useEffect(() => {
if (isAtBottom) { scrollToBottom(); }
}, [messages, isAtBottom, scrollToBottom]);messages changes on every streaming token, causing this effect to fire on every token. A more targeted dependency (e.g., messages.length) or debouncing would reduce overhead.
5. Client-Side Data Fetching -- MEDIUM-HIGH
~~5a. Direct `fetch()` bypasses SWR cache~~ FIXED
Rule violated: client-swr-dedup
Multiple components make direct fetch() calls for data that SWR already caches:
CreateRepoDialogfetchesGET /api/github/installationswith rawfetch(), butRepoSelectorCompactfetches the same endpoint via SWR. The directfetch()misses the SWR cache and deduplication.
Fix: Replaced CreateRepoDialog's raw fetch() + useEffect with useSWR using the same "github-installations" cache key as RepoSelectorCompact, ensuring shared cache and deduplication.
6. Rendering Performance -- MEDIUM
~~6a. Conditional rendering with `&&`~~ FIXED
Rule violated: rendering-conditional-render
There are likely instances of {condition && <Component />} patterns throughout the codebase. When condition is 0 or "", React renders those falsy values. Prefer explicit ternaries: {condition ? <Component /> : null}.
Fix: Converted 5 instances across usage-section.tsx, question-panel.tsx, diff-viewer.tsx, and task-renderer.tsx to explicit ternary patterns. All conditions were boolean or string values (none could produce 0), but converted for consistency.
7. Unused Code / Dead Weight
~~7a. Unused dependencies~~ FIXED
react-hook-formand@hookform/resolversare inpackage.jsonbut never imported in any application component.app/chat-context.tsxexportsChatProvider/useChatContextbut no route imports it. This is likely legacy code.
Fix: Removed react-hook-form and @hookform/resolvers from package.json. Deleted the unused components/ui/form.tsx (only consumer of react-hook-form) and app/chat-context.tsx (dead legacy code replaced by SessionChatProvider).
~~7b. No error boundaries~~ FIXED
No error.tsx files exist in any route segment and no <ErrorBoundary> components are used. If any server component throws, users see the default Next.js error page.
Fix: Added error.tsx files for key route segments (sessions/[sessionId]/chats/[chatId], shared/[shareId]) providing user-friendly error UI with retry functionality.
Summary by Priority
| Priority | Issue | Rule | Impact | Status |
|---|---|---|---|---|
| CRITICAL | Sequential awaits in page.tsx | async-parallel |
2-3x slower page load | FIXED |
| CRITICAL | No Suspense boundaries | async-suspense-boundaries |
No streaming, 5s blocking possible | FIXED |
| CRITICAL | No dynamic imports for modals | bundle-dynamic-imports |
Inflated initial bundle | FIXED |
| CRITICAL | Barrel imports from lucide-react | bundle-barrel-imports |
+200-800ms cold start | FIXED |
| HIGH | Context value not memoized (40 props) | rerender-memo |
All context consumers re-render | FIXED |
| HIGH | Full session record to client | server-serialization |
Serialization overhead | INVESTIGATED (full record needed) |
| HIGH | Monolithic 2200-line component | rerender-memo |
No render bailouts possible | IMPROVED (sidebar extracted) |
| MEDIUM | Derived state not memoized | rerender-derived-state |
Unnecessary per-token work | FIXED |
| MEDIUM | Scroll effect on every token | rerender-dependencies |
Excessive effect runs | FIXED |
| MEDIUM | sandboxUiStatus IIFE | rerender-memo |
Unnecessary per-render work | FIXED |
| MEDIUM | hasMounted effect pattern | rerender-derived-state |
Extra render on mount | FIXED |
| MEDIUM | Direct fetch bypasses SWR | client-swr-dedup |
Duplicate network requests | FIXED |
| MEDIUM | Conditional rendering with && | rendering-conditional-render |
Potential falsy renders | FIXED |
| LOW | Unused dependencies | bundle-* |
Unnecessary package weight | FIXED |
| LOW | No error boundaries | N/A | Poor error UX | FIXED |
All audit items have been addressed.