next.js example (#694)

* next.js guide

* better

* shadcn

* fixes

* fix: correct screenshot test assertion to check path instead of base64

The daemon returns { path: savePath } for screenshot commands, not base64.

* fix: cross-platform Chrome detection and gitignore hardening

- Replace hardcoded macOS Chrome path with findLocalChrome() that
  searches common paths on macOS, Linux, and WSL, with a clear error
  message when no Chrome is found.
- Add .env and .env*.local to .gitignore to prevent accidental
  secret commits.

* fix: correct Vercel deploy button repo URL to vercel-labs/agent-browser

* clean up

* demo

* next page

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-03-09 15:24:44 -05:00
committed by GitHub
co-authored by ctate
parent 3649787268
commit cc3c70dc86
35 changed files with 6764 additions and 2 deletions
+9
View File
@@ -0,0 +1,9 @@
# --- Serverless function mode (@sparticuz/chromium) ---
# Optional: path to Chromium binary for local development.
# On Vercel, @sparticuz/chromium provides this automatically.
# CHROMIUM_PATH=/usr/bin/chromium-browser
# --- Vercel Sandbox mode ---
# Snapshot ID with agent-browser + Chromium pre-installed (optional, speeds up startup)
# Create one with: npx tsx scripts/create-snapshot.ts
# AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.next/
.env
.env.local
.env*.local
+53
View File
@@ -0,0 +1,53 @@
# agent-browser Demo
A visual demo of agent-browser's core capabilities. Enter a URL, pick a compute environment, and take a screenshot or accessibility snapshot.
## Environments
- **Serverless Function** -- `@sparticuz/chromium` + `puppeteer-core` running directly inside a Vercel serverless function
- **Vercel Sandbox** -- agent-browser + Chrome in an ephemeral Linux microVM
## Getting Started
```bash
cd examples/demo
pnpm install
pnpm dev
```
## Serverless Function
Runs headless Chrome directly in the serverless function. On Vercel, `@sparticuz/chromium` provides the binary automatically. Locally, the app finds your system Chrome or uses `CHROMIUM_PATH`.
## Vercel Sandbox
Spins up a Linux microVM on demand, installs agent-browser + Chrome, runs the commands, and shuts down. No binary size limits. Create a snapshot to make startup sub-second:
```bash
npx tsx scripts/create-snapshot.ts
# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
```
Add the snapshot ID to your Vercel environment variables or `.env.local`.
## Environment Variables
| Variable | Environment | Description |
|---|---|---|
| `CHROMIUM_PATH` | Serverless | Path to local Chrome/Chromium binary (auto-detected on Vercel) |
| `AGENT_BROWSER_SNAPSHOT_ID` | Sandbox | Pre-built snapshot ID for fast startup |
## Project Structure
```
examples/demo/
app/
page.tsx # Demo UI
actions/browse.ts # Server actions (all environments)
api/browse/route.ts # API route for programmatic access
lib/
agent-browser.ts # Serverless: @sparticuz/chromium + puppeteer-core
agent-browser-sandbox.ts # Sandbox: Vercel Sandbox client
scripts/
create-snapshot.ts # Create sandbox snapshot
```
+98
View File
@@ -0,0 +1,98 @@
"use server";
import * as serverless from "@/lib/agent-browser";
import * as sandbox from "@/lib/agent-browser-sandbox";
export type EnvStatus = {
serverless: {
hasChromiumPath: boolean;
isVercel: boolean;
};
sandbox: {
hasSnapshot: boolean;
};
};
export async function getEnvStatus(): Promise<EnvStatus> {
return {
serverless: {
hasChromiumPath: !!process.env.CHROMIUM_PATH,
isVercel:
!!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME,
},
sandbox: {
hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID,
},
};
}
export type ScreenshotResult = {
ok: boolean;
screenshot?: string;
title?: string;
error?: string;
};
export type SnapshotResult = {
ok: boolean;
snapshot?: string;
title?: string;
error?: string;
};
export type Mode = "serverless" | "sandbox";
/**
* Server action: screenshot a URL.
*
* mode="serverless" -- runs @sparticuz/chromium + puppeteer-core in the function
* mode="sandbox" -- runs agent-browser inside a Vercel Sandbox microVM
*/
export async function takeScreenshot(
url: string,
mode: Mode = "serverless",
): Promise<ScreenshotResult> {
try {
if (mode === "sandbox") {
const { screenshot, title } = await sandbox.screenshotUrl(url);
return { ok: true, screenshot, title };
}
const { screenshot, title } = await serverless.screenshotUrl(url);
return { ok: true, screenshot, title };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Server action: snapshot a URL (accessibility tree).
*
* mode="serverless" -- runs @sparticuz/chromium + puppeteer-core in the function
* mode="sandbox" -- runs agent-browser inside a Vercel Sandbox microVM
*/
export async function takeSnapshot(
url: string,
mode: Mode = "serverless",
): Promise<SnapshotResult> {
try {
if (mode === "sandbox") {
const { snapshot, title } = await sandbox.snapshotUrl(url, {
interactive: true,
compact: true,
});
return { ok: true, snapshot, title };
}
const { snapshot, title } = await serverless.snapshotUrl(url);
return { ok: true, snapshot, title };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import * as ab from "@/lib/agent-browser";
/**
* POST /api/browse
*
* Programmatic API route for browser automation.
* Uses @sparticuz/chromium + puppeteer-core directly in the function.
*
* Body: { "action": "screenshot", "url": "https://example.com" }
* Or: { "action": "snapshot", "url": "https://example.com" }
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const url = body.url;
if (!url) {
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
}
if (body.action === "screenshot") {
const result = await ab.screenshotUrl(url, {
fullPage: body.fullPage,
});
return NextResponse.json(result);
}
if (body.action === "snapshot") {
const result = await ab.snapshotUrl(url, {
selector: body.selector,
});
return NextResponse.json(result);
}
return NextResponse.json(
{ error: "Provide 'action' as 'screenshot' or 'snapshot'" },
{ status: 400 },
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 502 });
}
}
+139
View File
@@ -0,0 +1,139 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme {
--color-surface: #fafafa;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
}
html {
@apply font-sans;
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--surface: oklch(0.985 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--surface: oklch(0.205 0 0);
}
@theme inline {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular",
"Roboto Mono", monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-surface: var(--surface);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
export const metadata: Metadata = {
title: "agent-browser Demo",
description: "A visual demo of agent-browser's core capabilities",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="min-h-screen font-sans antialiased">{children}</body>
</html>
);
}
+468
View File
@@ -0,0 +1,468 @@
"use client";
import { useState, useEffect, useSyncExternalStore } from "react";
import { takeScreenshot, takeSnapshot, getEnvStatus } from "./actions/browse";
import type {
ScreenshotResult,
SnapshotResult,
Mode,
EnvStatus,
} from "./actions/browse";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Loader2, Monitor, TriangleAlert, CircleX } from "lucide-react";
const MOBILE_QUERY = "(max-width: 767px)";
const subscribe = (cb: () => void) => {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
};
const getSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;
const getServerSnapshot = () => false;
function useIsMobile() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
type Action = "screenshot" | "snapshot";
function formatError(raw: string): string {
let cleaned = raw.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const match = cleaned.match(/(?:error|Error)[:\s]*(.{1,200})/);
if (match) cleaned = match[1].trim();
if (cleaned.length > 300) cleaned = cleaned.slice(0, 300) + "...";
return cleaned || raw.slice(0, 300);
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div className="inline-flex rounded-lg border border-input bg-muted p-0.5 w-full">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`
flex-1 px-3 py-1.5 text-[13px] font-medium rounded-md transition-all cursor-pointer
${
value === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}
`}
>
{opt.label}
</button>
))}
</div>
);
}
function EnvBadge({
label,
value,
status,
}: {
label: string;
value?: string;
status: "ok" | "warn" | "missing";
}) {
const variant =
status === "ok"
? "outline"
: status === "warn"
? "secondary"
: "destructive";
const icon =
status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717";
return (
<Badge variant={variant} className="gap-1 font-mono text-[10px]">
<span>{icon}</span>
{label}
{value && <span className="opacity-60">{value}</span>}
</Badge>
);
}
function ErrorDisplay({ error }: { error: string }) {
const isHtml = /<[a-z][\s\S]*>/i.test(error);
const message = isHtml ? formatError(error) : error;
const showRaw = isHtml && error.length > 100;
return (
<div className="w-full max-w-2xl space-y-0">
<Alert variant="destructive">
<CircleX className="size-4" />
<AlertTitle>Request failed</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
{showRaw && (
<details className="border border-t-0 border-border rounded-b-lg overflow-hidden">
<summary className="px-4 py-2 text-[11px] font-medium text-muted-foreground cursor-pointer hover:bg-muted transition-colors">
Show raw response
</summary>
<pre className="px-4 py-3 text-[11px] leading-relaxed text-muted-foreground font-mono overflow-auto max-h-[200px] bg-muted/50">
{error}
</pre>
</details>
)}
</div>
);
}
function ModeCard({
selected,
onSelect,
title,
description,
badges,
}: {
selected: boolean;
onSelect: () => void;
title: string;
description: string;
badges?: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onSelect}
className={`
w-full text-left rounded-lg border p-3 transition-all cursor-pointer
${
selected
? "border-ring bg-background ring-1 ring-ring/20"
: "border-input bg-background hover:border-ring/50"
}
`}
>
<div className="flex items-center gap-2 mb-1.5">
<div
className={`
size-3.5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors
${selected ? "border-foreground" : "border-muted-foreground/30"}
`}
>
{selected && (
<div className="size-1.5 rounded-full bg-foreground" />
)}
</div>
<span className="text-[13px] font-semibold">{title}</span>
</div>
<p className="text-[12px] text-muted-foreground leading-relaxed pl-[22px] mb-2">
{description}
</p>
{badges && (
<div className="flex flex-wrap gap-1.5 pl-[22px]">{badges}</div>
)}
</button>
);
}
export default function Home() {
const isMobile = useIsMobile();
const [url, setUrl] = useState("https://example.com");
const [loading, setLoading] = useState(false);
const [action, setAction] = useState<Action>("screenshot");
const [mode, setMode] = useState<Mode>("serverless");
const [screenshotResult, setScreenshotResult] =
useState<ScreenshotResult | null>(null);
const [snapshotResult, setSnapshotResult] =
useState<SnapshotResult | null>(null);
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null);
useEffect(() => {
getEnvStatus().then(setEnvStatus);
}, []);
function clearResults() {
setScreenshotResult(null);
setSnapshotResult(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setScreenshotResult(null);
setSnapshotResult(null);
try {
if (action === "screenshot") {
const result = await takeScreenshot(url, mode);
setScreenshotResult(result);
} else {
const result = await takeSnapshot(url, mode);
setSnapshotResult(result);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (action === "screenshot") {
setScreenshotResult({ ok: false, error: message });
} else {
setSnapshotResult({ ok: false, error: message });
}
} finally {
setLoading(false);
}
}
const hasResult = screenshotResult || snapshotResult;
const envWarning =
envStatus &&
mode === "serverless" &&
!envStatus.serverless.isVercel &&
!envStatus.serverless.hasChromiumPath
? "Running locally without CHROMIUM_PATH. The app will try to use your system Chrome. Set CHROMIUM_PATH if Chrome is not in the default location."
: null;
const controlsForm = (
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="space-y-1.5">
<Label
htmlFor="url-input"
className="text-[11px] text-muted-foreground uppercase tracking-wider"
>
URL
</Label>
<Input
id="url-input"
type="url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
clearResults();
}}
placeholder="https://example.com"
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
Action
</Label>
<SegmentedControl<Action>
value={action}
onChange={(v) => {
setAction(v);
clearResults();
}}
options={[
{ value: "screenshot", label: "Screenshot" },
{ value: "snapshot", label: "Snapshot" },
]}
/>
<p className="text-[11px] text-muted-foreground">
{action === "screenshot"
? "Captures a full-page PNG image"
: "Returns the accessibility tree"}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
Environment
</Label>
<div className="space-y-2">
<ModeCard
selected={mode === "serverless"}
onSelect={() => {
setMode("serverless");
clearResults();
}}
title="Serverless Function"
description="Runs @sparticuz/chromium + puppeteer-core directly in a Vercel function."
badges={
envStatus && (
<EnvBadge
label="@sparticuz/chromium"
status={
envStatus.serverless.isVercel
? "ok"
: envStatus.serverless.hasChromiumPath
? "ok"
: "warn"
}
value={
envStatus.serverless.isVercel
? "auto"
: envStatus.serverless.hasChromiumPath
? "CHROMIUM_PATH"
: "system Chrome"
}
/>
)
}
/>
<ModeCard
selected={mode === "sandbox"}
onSelect={() => {
setMode("sandbox");
clearResults();
}}
title="Vercel Sandbox"
description="Ephemeral microVM with agent-browser + Chrome. No binary size limits."
badges={
envStatus && (
<EnvBadge
label="AGENT_BROWSER_SNAPSHOT_ID"
status={
envStatus.sandbox.hasSnapshot ? "ok" : "warn"
}
/>
)
}
/>
</div>
</div>
{envWarning && (
<Alert>
<TriangleAlert className="size-4" />
<AlertTitle>Local development</AlertTitle>
<AlertDescription>{envWarning}</AlertDescription>
</Alert>
)}
<Button
type="submit"
disabled={loading}
className="w-full"
size="lg"
>
{loading && <Loader2 className="size-4 animate-spin" />}
{loading ? "Running..." : "Run"}
</Button>
</form>
);
const resultContent = loading ? (
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<p className="text-sm">Taking {action}...</p>
</div>
) : hasResult ? (
<div className="flex flex-col items-center p-6 lg:p-10">
{screenshotResult &&
(screenshotResult.ok ? (
<div className="w-full max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold truncate mr-3">
{screenshotResult.title}
</h2>
<Badge variant="outline" className="font-mono text-[11px] shrink-0">
screenshot
</Badge>
</div>
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<img
src={`data:image/png;base64,${screenshotResult.screenshot}`}
alt={screenshotResult.title}
className="w-full block"
/>
</div>
</div>
) : (
<ErrorDisplay
error={screenshotResult.error ?? "Unknown error"}
/>
))}
{snapshotResult &&
(snapshotResult.ok ? (
<div className="w-full max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold truncate mr-3">
{snapshotResult.title}
</h2>
<Badge variant="outline" className="font-mono text-[11px] shrink-0">
snapshot
</Badge>
</div>
<pre className="bg-card rounded-xl border border-border p-5 overflow-auto text-[13px] leading-relaxed font-mono max-h-[calc(100vh-12rem)]">
{snapshotResult.snapshot}
</pre>
</div>
) : (
<ErrorDisplay
error={snapshotResult.error ?? "Unknown error"}
/>
))}
</div>
) : (
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center text-muted-foreground">
<Monitor className="size-12 mb-4 opacity-30" strokeWidth={1} />
<p className="text-sm font-medium mb-1">No result yet</p>
<p className="text-[13px]">Enter a URL and click Run</p>
</div>
);
return (
<div className="h-screen flex flex-col">
<header className="border-b border-border shrink-0">
<div className="px-4 md:px-6 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold tracking-tight">
agent-browser
</span>
<span className="text-muted-foreground text-sm hidden sm:inline">/</span>
<span className="text-sm text-muted-foreground hidden sm:inline">
Demo
</span>
</div>
<a
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fdemo&env=CHROMIUM_PATH&envDescription=Optional%20path%20to%20Chromium%20binary.%20Not%20needed%20on%20Vercel.&envLink=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fdemo%23environment-variables&project-name=agent-browser-demo&repository-name=agent-browser-demo"
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://vercel.com/button"
alt="Deploy with Vercel"
className="h-8"
/>
</a>
</div>
</header>
{isMobile ? (
<div className="flex-1 overflow-auto">
<div className="border-b border-border">{controlsForm}</div>
<div className="bg-surface">{resultContent}</div>
</div>
) : (
<ResizablePanelGroup orientation="horizontal" className="flex-1">
<ResizablePanel defaultSize="30%" minSize="20%" maxSize="50%">
<aside className="h-full overflow-y-auto">{controlsForm}</aside>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="70%">
<main className="h-full overflow-auto bg-surface">
{resultContent}
</main>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+60
View File
@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default: "h-8 min-w-8 px-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
lg: "h-9 min-w-9 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+164
View File
@@ -0,0 +1,164 @@
/**
* Run agent-browser inside a Vercel Sandbox.
*
* No external server needed -- a Linux microVM spins up on demand,
* runs agent-browser + headless Chrome, and shuts down when done.
*
* For production, create a snapshot with agent-browser and Chromium
* pre-installed so startup is sub-second instead of ~30s.
*/
import { Sandbox } from "@vercel/sandbox";
export type SandboxResult = {
exitCode: number;
stdout: string;
stderr: string;
};
const SNAPSHOT_ID = process.env.AGENT_BROWSER_SNAPSHOT_ID;
async function createSandbox(): Promise<InstanceType<typeof Sandbox>> {
if (SNAPSHOT_ID) {
return Sandbox.create({
source: { type: "snapshot", snapshotId: SNAPSHOT_ID },
timeout: 120_000,
});
}
const sandbox = await Sandbox.create({
runtime: "node24",
timeout: 120_000,
});
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
await sandbox.runCommand("npx", ["agent-browser", "install"]);
return sandbox;
}
async function exec(
sandbox: InstanceType<typeof Sandbox>,
cmd: string,
args: string[],
): Promise<SandboxResult> {
const result = await sandbox.runCommand(cmd, args);
return {
exitCode: result.exitCode,
stdout: await result.stdout(),
stderr: await result.stderr(),
};
}
/**
* Screenshot a URL using agent-browser inside a Vercel Sandbox.
* Returns base64-encoded PNG.
*/
export async function screenshotUrl(
url: string,
opts: { fullPage?: boolean } = {},
): Promise<{ screenshot: string; title: string }> {
const sandbox = await createSandbox();
try {
await exec(sandbox, "agent-browser", ["open", url]);
const titleResult = await exec(sandbox, "agent-browser", [
"get",
"title",
"--json",
]);
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
const screenshotArgs = ["screenshot", "--json"];
if (opts.fullPage) screenshotArgs.push("--full");
const ssResult = await exec(sandbox, "agent-browser", screenshotArgs);
const screenshot = tryParseJson(ssResult.stdout)?.data?.base64 || "";
await exec(sandbox, "agent-browser", ["close"]);
return { screenshot, title };
} finally {
await sandbox.stop();
}
}
/**
* Snapshot a URL (accessibility tree) using agent-browser inside a Vercel Sandbox.
*/
export async function snapshotUrl(
url: string,
opts: { interactive?: boolean; compact?: boolean } = {},
): Promise<{ snapshot: string; title: string }> {
const sandbox = await createSandbox();
try {
await exec(sandbox, "agent-browser", ["open", url]);
const titleResult = await exec(sandbox, "agent-browser", [
"get",
"title",
"--json",
]);
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
const snapshotArgs = ["snapshot"];
if (opts.interactive) snapshotArgs.push("-i");
if (opts.compact) snapshotArgs.push("-c");
const snapResult = await exec(sandbox, "agent-browser", snapshotArgs);
await exec(sandbox, "agent-browser", ["close"]);
return { snapshot: snapResult.stdout, title };
} finally {
await sandbox.stop();
}
}
/**
* Run arbitrary agent-browser commands inside a Vercel Sandbox.
* Each command is a string array like ["open", "https://example.com"].
*/
export async function runCommands(
commands: string[][],
): Promise<SandboxResult[]> {
const sandbox = await createSandbox();
try {
const results: SandboxResult[] = [];
for (const args of commands) {
const result = await exec(sandbox, "agent-browser", args);
results.push(result);
if (result.exitCode !== 0) break;
}
return results;
} finally {
await sandbox.stop();
}
}
/**
* Create a reusable snapshot with agent-browser + Chromium pre-installed.
* Run this once, then set AGENT_BROWSER_SNAPSHOT_ID for fast startup.
*/
export async function createSnapshot(): Promise<string> {
const sandbox = await Sandbox.create({
runtime: "node24",
timeout: 300_000,
});
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
await sandbox.runCommand("npx", ["agent-browser", "install"]);
const snapshot = await sandbox.snapshot();
return snapshot.snapshotId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tryParseJson(str: string): any {
try {
return JSON.parse(str);
} catch {
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Run browser automation directly in a Vercel serverless function
* using @sparticuz/chromium + puppeteer-core.
*
* In development, uses the local Chrome/Chromium installation.
* In production (Vercel), uses @sparticuz/chromium's bundled binary.
*/
import puppeteer from "puppeteer-core";
import chromium from "@sparticuz/chromium";
import fs from "node:fs";
const CHROME_PATHS = [
// macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
// Linux
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
// Windows (WSL / common locations)
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
];
function findLocalChrome(): string {
for (const p of CHROME_PATHS) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Chrome not found. Set CHROMIUM_PATH to your Chrome/Chromium binary. Searched: ${CHROME_PATHS.join(", ")}`,
);
}
async function launchBrowser() {
const isLambda =
!!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME;
const executablePath = isLambda
? await chromium.executablePath()
: process.env.CHROMIUM_PATH || findLocalChrome();
const args = isLambda
? chromium.args
: ["--no-sandbox", "--disable-setuid-sandbox"];
return puppeteer.launch({
args,
executablePath,
headless: true,
defaultViewport: { width: 1280, height: 720 },
});
}
export async function screenshotUrl(
url: string,
opts: { fullPage?: boolean } = {},
): Promise<{ screenshot: string; title: string }> {
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
const title = await page.title();
const screenshot = await page.screenshot({
fullPage: opts.fullPage,
encoding: "base64",
});
return {
title: title || url,
screenshot: screenshot as string,
};
} finally {
await browser.close();
}
}
export async function snapshotUrl(
url: string,
opts: { selector?: string } = {},
): Promise<{ snapshot: string; title: string }> {
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
const title = await page.title();
const snapshot = await page.accessibility.snapshot({
root: opts.selector
? (await page.$(opts.selector)) ?? undefined
: undefined,
});
return {
title: title || url,
snapshot: JSON.stringify(snapshot, null, 2),
};
} finally {
await browser.close();
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
import path from "node:path";
const nextConfig: NextConfig = {
outputFileTracingRoot: path.resolve(import.meta.dirname, "../../"),
};
export default nextConfig;
+37
View File
@@ -0,0 +1,37 @@
{
"name": "agent-browser-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@sparticuz/chromium": "^143.0.4",
"@tailwindcss/postcss": "^4.2.1",
"@vercel/sandbox": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^0.577.0",
"next": "^16.1.6",
"postcss": "^8.5.8",
"puppeteer-core": "^24.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-resizable-panels": "^4.7.2",
"shadcn": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^5.6.0"
}
}
+4411
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
/**
* Create a Vercel Sandbox snapshot with agent-browser + Chromium pre-installed.
*
* Run once: npx tsx scripts/create-snapshot.ts
* Then set: AGENT_BROWSER_SNAPSHOT_ID=<output id>
*
* This makes sandbox creation sub-second instead of ~30s.
*/
import { createSnapshot } from "../lib/agent-browser-sandbox";
async function main() {
console.log("Creating Vercel Sandbox with agent-browser + Chromium...");
console.log("This takes ~30-60 seconds on first run.\n");
const snapshotId = await createSnapshot();
console.log("\nSnapshot created successfully!");
console.log(`\n AGENT_BROWSER_SNAPSHOT_ID=${snapshotId}\n`);
console.log("Add this to your .env.local or Vercel environment variables.");
}
main().catch((err) => {
console.error("Failed to create snapshot:", err.message || err);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"server"
]
}