open-agents Documentation

An open source template for building cloud agents.

5,263 stars TypeScript TypeScript 1 file ยท ~19,612 tokens #agent#agents#ai#background-agents
RAW Doc

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:

text
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, and 8000, 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

env
POSTGRES_URL=
BETTER_AUTH_SECRET=

Required for sign-in (Vercel OAuth)

env
NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=
VERCEL_APP_CLIENT_SECRET=

Required for GitHub repo access, pushes, and PRs

env
NEXT_PUBLIC_GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
NEXT_PUBLIC_GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=

Optional

env
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 to hobby to 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

  1. Fork this repo.

  2. Import the repo into Vercel. Neon Postgres is auto-provisioned if you use the deploy button above.

  3. Generate a secret for session signing:

    bash
    openssl rand -base64 32   # BETTER_AUTH_SECRET
  4. Add env vars in Vercel project settings:

    env
    POSTGRES_URL=
    BETTER_AUTH_SECRET=
  5. Deploy once to get a stable production URL.

  6. Create a Vercel OAuth app with callback URL:

    text
    https://YOUR_DOMAIN/api/auth/callback/vercel
  7. Add these env vars and redeploy:

    env
    NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=
    VERCEL_APP_CLIENT_SECRET=
  8. 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_ID and GITHUB_CLIENT_SECRET
    • make the app public if you want org installs to work cleanly
  9. Add the GitHub App env vars and redeploy.

  10. Optionally add Redis/KV, OPEN_AGENTS_RESOURCE_PROFILE=hobby for Hobby-compatible resource defaults, the canonical production URL vars, and your own VERCEL_SANDBOX_BASE_SNAPSHOT_ID if you want fresh sandboxes to start from a preconfigured image.

Local setup

  1. Install dependencies:

    bash
    corepack enable
    pnpm install
  2. Create your local env file:

    bash
    cp apps/web/.env.example apps/web/.env
  3. Fill in the required values in apps/web/.env.

  4. Start the app:

    bash
    pnpm 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:

text
https://YOUR_DOMAIN/api/auth/callback/vercel

For local development, use:

text
http://localhost:3000/api/auth/callback/vercel

Then set:

env
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:

env
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

bash
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 snapshot

Repo layout

text
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 utilities

File: 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:

tsx
{/* Primary glow - top center */}


{/* Secondary glow - bottom right */}


{/* Tertiary glow - left */}

Dot Grid Pattern

Subtle dot matrix for texture:

tsx

Scanline Effect

Retro CRT-style scanlines for terminal feel:

tsx

Components

Terminal Window Card

macOS-style window with traffic light controls:

tsx
{/* Window chrome */}
  
    
      
      
      
    
    
      terminal
    
  
  
  {/* Content */}
  
    {/* ... */}

Command Prompt

Animated terminal prompt with blinking cursor:

tsx
$
    openharness auth login
    
  
  
    ! Authentication required

Icon Container

Bordered container for icons:

tsx

Primary Button (CTA)

High-contrast white button on dark background:

tsx
Sign in with GitHub

Status Badge

Pill-shaped badge with animated indicators:

tsx
Status text

Gradient Divider

Horizontal divider that fades at edges:

tsx

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-5 or p-6
  • Section gaps: gap-4 to gap-6
  • Element margins: mb-2 to mb-6

Shadows

  • Card shadow: shadow-2xl shadow-black/20
  • Deep shadow: shadow-2xl shadow-black/50

Animations

  • Pulse: animate-pulse for cursor blink, status indicators
  • Transitions: transition-all or transition-colors for hover states

Icons

Use inline SVGs for consistency. Common icons:

Terminal Icon

tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
  
  <line x1="12" y1="19" x2="20" y2="19" />
</svg>

Lock Icon

tsx
<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

tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
  
</svg>

Cloud Icon

tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
  
</svg>

Usage Guidelines

  1. Consistency: Use the terminal window card for all prominent UI containers on marketing pages
  2. Hierarchy: Use ambient glows sparingly - one primary (emerald), one or two secondary (blue/violet)
  3. Text contrast: Ensure sufficient contrast - primary text at white, body at white/50 minimum
  4. Interactivity: All interactive elements should have visible hover states
  5. 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:

text
Agent modifies files -> Tool completes -> refreshKey++ -> DiffViewer fetches -> Git commands run -> UI updates

The 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 sandboxId query param
  • Run git diff HEAD for full unified diff
  • Run git diff HEAD --stat for file statistics
  • Run git diff HEAD --name-status for file status (A/M/D/R)
  • Parse output into structured response

Response Schema

typescript
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 - use connectVercelSandbox({ sandboxId })
  • Parse --stat output for per-file stats (format: filename | 10 ++++------)
  • Parse --name-status for file status:
    • A = added
    • M = modified
    • D = deleted
    • R = renamed (includes old path)
  • Split full diff by file headers (diff --git a/... b/...)

Example Git Commands

bash
# Get file status
git diff HEAD --name-status

# Get stats per file
git diff HEAD --stat

# Get full unified diff
git diff HEAD

2. UI Component: `diff-viewer.tsx`

Location: apps/web/app/tasks/[id]/diff-viewer.tsx

Component Structure

text
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 highlighting

Props Interface

typescript
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
  • Monospace font for code: font-mono text-xs
  • Border styling: border-border, bg-card
  • Line numbers: gray gutter, right-aligned

Existing Patterns to Reuse

  • createEditDiffLines from @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

  1. Loading - Show skeleton/spinner while fetching
  2. Empty - "No changes" message when working directory is clean
  3. Error - Show error message (e.g., sandbox expired)
  4. Success - Show file list and diffs

3. TaskContext Enhancement

Location: apps/web/app/tasks/[id]/task-context.tsx

Changes Required

Add to TaskChatContextValue:

typescript
type TaskChatContextValue = {
  // ... existing fields
  diffRefreshKey: number;
  triggerDiffRefresh: () => void;
};

Implementation

typescript
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 creation
  • edit - file modification
  • bash - could modify files (git commands, mv, rm, etc.)

Implementation

typescript
// 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:

tsx
{showDiffPanel && sandboxInfo && (
  <DiffViewer
    sandboxId={sandboxInfo.sandboxId}
    refreshKey={diffRefreshKey}
    onClose={() => setShowDiffPanel(false)}
  />
)}

Add button to open diff panel

Add to header actions (around line 296):

tsx
setShowDiffPanel(!showDiffPanel)}
>
  <GitCompare className="mr-2 h-4 w-4" />
  Diff

6. File Structure

text
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 component

7. Open Questions

These should be answered before implementation:

  1. 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?

  2. 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.

  3. 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"?)

  4. Empty State - What should display when there are no changes (clean working directory)?

  5. Error Handling - If the sandbox expires mid-session, should the diff panel show a "Sandbox expired" message with a reconnect option?

  6. 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

  1. API Route - Create /api/tasks/[id]/diff/route.ts with git commands and parsing
  2. TaskContext - Add diffRefreshKey and triggerDiffRefresh
  3. DiffViewer Component - Build the UI component with file list and diff rendering
  4. Integration - Wire up in task-detail-content.tsx
  5. Smart Refresh - Implement tool completion watching
  6. Polish - Error states, loading states, empty states

9. Dependencies

No new dependencies required. Uses existing:

  • @open-agents/sandbox for connectVercelSandbox
  • @open-agents/shared/lib/diff for diff utilities
  • lucide-react for 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 beforeStop hook 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:

typescript
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

typescript
export async function captureSandboxState(sandbox: Sandbox): Promise<{
  diffContent: string;
  untrackedFiles: Array<{ path: string; content: string }>;
  baseCommit: string;
}>

Captures:

  1. git rev-parse HEAD - base commit SHA
  2. git diff HEAD - all tracked file changes (staged + unstaged)
  3. git ls-files --others --exclude-standard - untracked file paths
  4. Read each untracked file (skip if >1MB), base64 encode content

Phase 3: Diff Storage Operations

New file: apps/web/lib/db/task-diffs.ts

typescript
// 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:

typescript
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

typescript
export async function restoreSandboxState(
  sandbox: Sandbox,
  diff: TaskDiff
): Promise<{ success: boolean; error?: string }>

Steps:

  1. Optionally verify/checkout base commit
  2. Write diff to temp file, run git apply --3way /tmp/restore.patch
  3. Restore untracked files by writing base64-decoded content
  4. Clean up temp file
  5. Return success/failure status

Error handling:

  • If git apply fails, try git apply --reject for 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:

typescript
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:

  1. Show "Restoring workspace..." text during sandbox creation (if task has messages)
  2. If stateRestored: true in response, continue normally
  3. 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

  1. 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
  2. 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
  3. Database verification:

    • Check task_diffs table is populated after sandbox stop
    • Verify old diffs are cleaned up (only latest 3 kept)

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:

bash
pnpm dev

Open 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:

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.ts already creates a session and initial chat without a live sandboxId; it stores desired sandbox configuration in sandboxState: { type: "vercel" }.
  • apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx currently auto-creates a sandbox on page entry and blocks sending until the sandbox is active.
  • apps/web/app/api/chat/route.ts currently requires an active sandbox before a chat request can proceed.
  • apps/web/app/api/sandbox/route.ts contains the create/reconnect logic that provisions a sandbox and persists the resulting runtime state.
  • apps/web/app/workflows/chat.ts assumes the workflow is started with an already-usable sandbox.

Approach

Treat session creation and sandbox creation as separate concerns:

  1. Session creation creates durable app state only.
  2. The first message (or an explicit resume action) ensures a sandbox exists.
  3. Once a sandbox is ready, the normal agent workflow proceeds unchanged.
  4. Eager sandbox creation can remain as an optimization, not a requirement.

This makes the default contract:

  • POST /api/sessions returns sessionId and chatId even if no runtime sandbox exists yet.
  • POST /api/chat does not require a caller-provided sandboxId for normal usage.
  • Sandbox creation/reconnect/restore becomes an internal โ€œensure runtimeโ€ step.
  1. Create a session.
  2. Navigate to the chat immediately.
  3. Let the user send the first message without waiting for sandbox creation.
  4. 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
  5. Persist the resulting runtime state (sandboxId, expiry, branch, lifecycle data).
  6. Start the agent turn.
  7. Reuse that sandbox for later messages.

API shape

Session creation

ts
POST /api/sessions
-> {
  session: { id: string, ... },
  chat: { id: string, ... },
  sandbox: { state: "pending" }
}

Chat execution

ts
POST /api/chat
{
  sessionId: string,
  chatId: string,
  messages: [...],
  sandboxId?: string
}

Notes:

  • sandboxId stays 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.
  • 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.sandboxState is already active.
  • 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_start if the current provisioning label becomes misleading.

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/sessions sandbox-agnostic
  • make sandboxId optional 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

text
Web -> Agent (packages/agent) -> Sandbox (packages/sandbox)
  1. Web handles authentication, session management, and the primary user interface
  2. Agent (deepAgent) is a ToolLoopAgent with tools for file ops, bash, and task delegation
  3. 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

text
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 configs

File: 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 access
  • verbatimModuleSyntax: true - use explicit type imports

Formatting (Ultracite โ€” oxfmt)

  • Indent: 2 spaces
  • Quote style: double quotes for JavaScript/TypeScript
  • Run pnpm fix before 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 .js extensions in imports (e.g., import { foo } from "./utils" not "./utils.js")
    • The .js extension causes module resolution issues with Next.js/Turbopack
    • This applies to all packages and apps in the monorepo
  • Use explicit .ts extensions 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 - use unknown and 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.ts suffix
  • 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 ToolLoopAgent for agent implementations
  • Tools receive context via experimental_context parameter
  • Implement needsApproval as boolean or function for tool approval logic

Tool Implementation Patterns

When creating tools in packages/agent/tools/:

typescript
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 example dev-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:

json
{
  "dependencies": {
    "@open-agents/sandbox": "workspace:*"
  }
}

Catalog Dependencies

Use catalog: for shared external versions:

json
{
  "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 head when 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.json scripts 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.output values may be unknown; when accessing fields like files or matches, add runtime narrowing/type guards first (in both TUI and web renderers) to satisfy strict typecheck.
  • AI SDK stream handles may return PromiseLike values (not full Promise), so avoid methods like .finally() and use then/catch patterns that work with PromiseLike.
  • After schema edits, review generated Drizzle migrations for unrelated schema drift changes before committing (for example defaults on untouched columns), since drizzle-kit generate can include those alongside intended changes.
  • pnpm 11 requires an explicit allowBuilds map in pnpm-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-day minimumReleaseAge and fail closed when publish timestamps are missing.
  • Use pnpm run ci for the repository verification script. pnpm ci invokes pnpm's built-in clean-install command instead of the package script.
  • Keep Kysely pinned to 0.28.x until Better Auth's bundled Kysely adapter stops importing migration constants from Kysely's root entrypoint; Kysely 0.29.x removed 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 .ts extensions and relative imports.
  • bunx @vercel/config validate executes the CLI under Node via its shebang and cannot parse TypeScript-style vercel.ts imports; use bunx --bun @vercel/config validate (or bun node_modules/@vercel/config/dist/cli.js validate) for reliable local validation.
  • Successful Vercel CLI auth (vercel whoami, team/project REST APIs, .vercel linking) does not guarantee Workflow observability access. workflow inspect ... --backend vercel can still fail with 401 {"error":{"code":"unauthorized","message":"You are not allowed to access this endpoint."}} when the user/token lacks the Vercel product permission documented as Vercel Workflow (and possibly related Observability access), even if WORKFLOW_VERCEL_AUTH_TOKEN is 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] requires params.sessionId, not params.id), or DB queries can receive undefined and fail at runtime.
  • Some planning docs still reference legacy apps/web/app/tasks/[id]/... paths; current UI/API code is centered on apps/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 means after() 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() from next/headers combined with Response.redirect() silently drops Set-Cookie headers from the redirect response. Use NextResponse.redirect() with response.cookies.set() instead to ensure cookies are included in redirect responses.
  • In this codebase's Next.js version, revalidateTag must 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 but 0 workflows and start() 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: null for both successful starts and explicit tool failures; bash renderer error state must also honor output.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 so timeout + buffer never 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 return 422 sandbox_snapshotting when 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 expired when a sandbox has already stopped; client reconnection state should treat expired like no_sandbox so 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 alreadyRunning signal 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 in after(...); delayed/deferred scheduling can miss the initial hibernation timer for idle sessions.
  • For sandbox lifecycle kicks, do not persist lifecycleRunId before start(...); 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-yet evaluation; 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 hibernateAfter and 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 isSandboxActive source; mixed heuristics (e.g., one using grace-window validity and another using raw countdown) can show contradictory states like Paused with a green dot.
  • Treat /api/sandbox/reconnect as 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 snapshotUrl alone 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 raw sandboxState.type.
  • Auto-resume-on-entry for paused sessions must not require a prior no_sandbox reconnect result when there is no runtime sandbox state in DB; snapshot-only sessions can otherwise get stuck in idle and 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 hibernating it 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.snapshotUrl from initial page props; keep a live hasSnapshot signal from reconnect/status responses, or the UI can incorrectly show No sandbox and hide resume actions.
  • /api/sandbox/reconnect should treat DB runtime state (sandboxId/files) as the source of reconnect eligibility; using isSandboxActive (which includes expiry heuristics) can misclassify recoverable sessions as no_sandbox and break restore/reconnect flows.
  • When /api/sandbox/reconnect reports connected, it must persist refreshed sandbox runtime state/expiry (sandboxState, sandboxExpiresAt) back to DB; otherwise /files and /diff can still fail with Sandbox not initialized against stale expired state while UI thinks reconnect succeeded.
  • For sandbox lifecycle UI, keep the client simple and server-authoritative: poll /api/sandbox/status on 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/status as a DB-backed read-only view; do not mutate/clear sandbox runtime state from status polling, or active sessions can be downgraded to no_sandbox and later restore from stale snapshots.
  • On Vercel reconnect (state.sandboxId), do not pass remainingTimeout=0 from stale state.expiresAt; that creates an immediately-expired local wrapper and can make the header/API checks flip to No sandbox even 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 in Pausing from 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 declaring connected, 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 snapshotUrl is persisted, resume/restore can race with no_snapshot, so unarchive/restore flows must gate on snapshot readiness (or surface a clear snapshot-in-progress state).
  • Client UI sandboxUiStatus must check server lifecycleTiming.state (from status poll) as primary source, not only local sandboxInfo; otherwise UI stays "Active" after server-side hibernation until the local timeout expires or user refreshes.
  • The isSandboxActive client flag must incorporate lifecycleTiming.state; local isSandboxValid(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 activeStreamId as 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/react Chat 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 stale input-available UI.
  • 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 activeStreamId resumable at all times: do not publish pre-registration ownership placeholders to activeStreamId (resume probes can clear them as stale), and gate onFinish writes on the atomic compare-and-set result that clears the currently owned token.
  • Usage analytics messageCount must represent assistant turns, not raw usage_events rows; 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 lastAssistantMessageAt when 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]/chats failures do not render an empty list on hard refresh.
  • For hydration-sensitive SWR endpoints (notably sidebar chat lists), use a dedicated no-store fetcher instead of changing the shared SWR fetcher globally; otherwise browser HTTP caching gets disabled across unrelated /api hooks (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.
  • hadInitialMessages is 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 submitted and streaming as in-flight. If only streaming is considered active, task/tool parts can be marked interrupted too early and stale Thinking... indicators can linger until a full page refresh.
  • In Streamdown, plugins.code.getThemes() overrides the shikiTheme prop; configure code themes in createCodePlugin(...) and pass actual custom theme objects for non-bundled themes (for example vercelLight/vercelDark) or highlighting can fall back to unstyled/plain tokens.
  • Shiki dual-theme TokensResult can encode dark variants inside semicolon-delimited fg/bg values and token htmlStyle fields (for example color + --shiki-dark); normalize these into Streamdown's color/bgColor fields 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 with target_id; (2) linked account but no installations -- installations/new/permissions?target_id={githubId} directly; (3) linked account with installations -- select_target for 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 both select_target and installations/new/permissions.
  • GitHub App must be made public for the org picker to appear during installation. While the app is private, /installations/select_target only 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_target instead of /installations/new for 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 code or installation_id must validate a server-stored state nonce 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 found errors 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:

typescript
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 only

getServerSession() 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:

typescript
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-48 that 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
const renderMessages = hasMounted ? messages : initialMessages;  // line 689
const lastMessage = renderMessages[renderMessages.length - 1];    // line 690
const showThinkingIndicator = status === "submitted" || ...       // line 691-697

During 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

  • CreateRepoDialog fetches GET /api/github/installations with raw fetch(), but RepoSelectorCompact fetches the same endpoint via SWR. The direct fetch() 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-form and @hookform/resolvers are in package.json but never imported in any application component.
  • app/chat-context.tsx exports ChatProvider/useChatContext but 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.