add skills to docs (#576)

This commit is contained in:
Chris Tate
2026-03-01 09:02:06 -06:00
committed by GitHub
parent 14ec5b5ffa
commit 79d8dfe34c
19 changed files with 720 additions and 292 deletions
+1 -1
View File
@@ -42,7 +42,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tailwindcss-animate": "^1.0.7",
"typescript": "^5" "typescript": "^5"
} }
} }
+12 -8
View File
@@ -102,9 +102,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4 specifier: ^4
version: 4.1.18 version: 4.1.18
tw-animate-css: tailwindcss-animate:
specifier: ^1.4.0 specifier: ^1.0.7
version: 1.4.0 version: 1.0.7(tailwindcss@4.1.18)
typescript: typescript:
specifier: ^5 specifier: ^5
version: 5.9.3 version: 5.9.3
@@ -3467,6 +3467,11 @@ packages:
tailwind-merge@3.4.0: tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@4.1.18: tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
@@ -3521,9 +3526,6 @@ packages:
turndown@7.2.2: turndown@7.2.2:
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -7827,6 +7829,10 @@ snapshots:
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.18):
dependencies:
tailwindcss: 4.1.18
tailwindcss@4.1.18: {} tailwindcss@4.1.18: {}
tapable@2.3.0: {} tapable@2.3.0: {}
@@ -7891,8 +7897,6 @@ snapshots:
dependencies: dependencies:
'@mixmark-io/domino': 2.2.0 '@mixmark-io/domino': 2.2.0
tw-animate-css@1.4.0: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
+40
View File
@@ -0,0 +1,40 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { NextRequest, NextResponse } from "next/server";
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const docPath = searchParams.get("path");
if (!docPath) {
return NextResponse.json(
{ error: "Missing ?path= parameter" },
{ status: 400 },
);
}
const normalized = docPath
.replace(/^\//, "")
.replace(/\.\./g, "")
.replace(/[^a-zA-Z0-9/_-]/g, "");
const slug = normalized;
const filePath = slug
? join(process.cwd(), "src", "app", ...slug.split("/"), "page.mdx")
: join(process.cwd(), "src", "app", "page.mdx");
try {
const raw = await readFile(filePath, "utf-8");
const markdown = mdxToCleanMarkdown(raw);
return new NextResponse(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
} catch {
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
}
+158 -115
View File
@@ -1,117 +1,112 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @plugin "tailwindcss-animate";
@source "../../node_modules/streamdown/dist/index.js"; @source "../../node_modules/streamdown/dist/index.js";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", "Cascadia Mono", "Segoe UI Mono", Menlo, Consolas, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
:root { :root {
--radius: 0.5rem; --background: #fff;
--background: oklch(1.0 0 0); --foreground: #171717;
--foreground: oklch(0.1 0 0); --border: #e5e5e5;
--card: oklch(0.98 0 0); --muted: #f5f5f5;
--card-foreground: oklch(0.1 0 0); --muted-foreground: #737373;
--popover: oklch(0.98 0 0); --primary: #171717;
--popover-foreground: oklch(0.1 0 0); --primary-foreground: #fff;
--primary: oklch(0.1 0 0);
--primary-foreground: oklch(1.0 0 0);
--secondary: oklch(0.92 0 0);
--secondary-foreground: oklch(0.1 0 0);
--muted: oklch(0.92 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.92 0 0);
--accent-foreground: oklch(0.1 0 0);
--destructive: oklch(0.55 0.2 25);
--destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.85 0 0);
--input: oklch(0.85 0 0);
--ring: oklch(0.6 0 0);
--chat-bg: oklch(0.95 0 0);
} }
.dark { .dark {
--background: oklch(0.0 0 0); --background: #0a0a0a;
--foreground: oklch(0.98 0 0); --foreground: #f5f5f5;
--card: oklch(0.08 0 0); --border: #262626;
--card-foreground: oklch(0.98 0 0); --muted: #262626;
--popover: oklch(0.08 0 0); --muted-foreground: #a3a3a3;
--popover-foreground: oklch(0.98 0 0); --primary: #f5f5f5;
--primary: oklch(0.98 0 0); --primary-foreground: #0a0a0a;
--primary-foreground: oklch(0.0 0 0);
--secondary: oklch(0.15 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.15 0 0);
--muted-foreground: oklch(0.6 0 0);
--accent: oklch(0.15 0 0);
--accent-foreground: oklch(0.1 0 0);
--destructive: oklch(0.65 0.2 25);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.25 0 0);
--input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0);
--chat-bg: oklch(0.25 0 0);
} }
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
}
* {
border-color: var(--border);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist), system-ui, sans-serif;
}
/* Hide page scrollbar */
html { html {
scrollbar-width: none; scroll-behavior: smooth;
} }
html::-webkit-scrollbar { ::selection {
display: none; background-color: #000;
color: #fff;
}
@media (prefers-color-scheme: dark) {
::selection {
background-color: #fff;
color: #000;
}
}
/* Article tables */
article table {
width: 100%;
font-size: 0.875rem;
margin-bottom: 1rem;
border-collapse: collapse;
}
article th {
border-bottom: 1px solid #e5e5e5;
padding: 0.5rem 0.75rem;
text-align: left;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #737373;
}
article td {
border-bottom: 1px solid #f5f5f5;
padding: 0.5rem 0.75rem;
color: #525252;
}
:is(.dark) article th {
border-bottom-color: #262626;
color: #a3a3a3;
}
:is(.dark) article td {
border-bottom-color: rgba(38, 38, 38, 0.5);
color: #a3a3a3;
}
button {
cursor: pointer;
} }
/* Code blocks */ /* Code blocks */
pre { pre {
background: var(--card) !important;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
padding: 0.875rem; padding: 0.875rem;
overflow-x: auto; overflow-x: auto;
font-family: var(--font-geist-mono), monospace;
font-size: 0.8125rem; font-size: 0.8125rem;
line-height: 1.7; line-height: 1.7;
} }
pre:not(.shiki) {
background: var(--muted);
}
.code-block pre { .code-block pre {
margin: 0; margin: 0;
} }
@@ -127,17 +122,26 @@ pre {
} }
} }
code {
font-family: var(--font-geist-mono), monospace;
}
:not(pre) > code { :not(pre) > code {
background: var(--card); background: var(--muted);
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
border-radius: 3px; border-radius: 3px;
font-size: 0.875em; font-size: 0.875em;
} }
/* Shiki dual theme support */
.shiki,
.shiki span {
color: var(--shiki-light) !important;
background-color: var(--shiki-light-bg) !important;
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
/* Prose */ /* Prose */
.prose { .prose {
max-width: 100%; max-width: 100%;
@@ -145,9 +149,9 @@ code {
.prose h1 { .prose h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 500; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin-bottom: 0.5rem; margin-bottom: 1.5rem;
color: var(--foreground); color: var(--foreground);
} }
@@ -158,59 +162,102 @@ code {
} }
.prose h2 { .prose h2 {
font-size: 0.875rem; font-size: 1.125rem;
font-weight: 500; font-weight: 600;
letter-spacing: 0;
text-transform: uppercase;
color: var(--muted-foreground);
margin-top: 3rem; margin-top: 3rem;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--foreground);
}
.prose h2:first-child {
margin-top: 0;
} }
.prose h3 { .prose h3 {
font-size: 0.875rem; font-size: 1rem;
font-weight: 500; font-weight: 600;
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
color: var(--foreground); color: var(--foreground);
opacity: 0.85;
} }
.prose p { .prose p {
margin-bottom: 1.25rem; margin-bottom: 1rem;
line-height: 1.7; line-height: 1.65;
color: var(--muted-foreground); color: #525252;
font-size: 0.875rem; font-size: 0.875rem;
} }
:is(.dark) .prose p {
color: #a3a3a3;
}
.prose ul, .prose ol { .prose ul, .prose ol {
margin-bottom: 1.25rem; margin-bottom: 1rem;
padding-left: 1.25rem; padding-left: 1.25rem;
} }
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li { .prose li {
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
color: var(--muted-foreground); color: #525252;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.6; line-height: 1.6;
} }
:is(.dark) .prose li {
color: #a3a3a3;
}
.prose li strong { .prose li strong {
color: var(--foreground); color: var(--foreground);
opacity: 0.85;
font-weight: 500; font-weight: 500;
} }
.prose a { .prose a {
color: var(--foreground); color: var(--foreground);
text-decoration: underline; text-decoration: underline;
text-decoration-color: #d4d4d4;
text-underline-offset: 2px; text-underline-offset: 2px;
} }
.prose a:hover { .prose a:hover {
text-decoration-color: var(--foreground);
}
:is(.dark) .prose a {
text-decoration-color: #525252;
}
:is(.dark) .prose a:hover {
text-decoration-color: var(--foreground);
}
.prose strong {
font-weight: 500;
color: var(--foreground); color: var(--foreground);
} }
.prose blockquote {
margin-bottom: 1rem;
border-left: 2px solid #e5e5e5;
padding-left: 1rem;
font-size: 0.875rem;
color: #737373;
}
:is(.dark) .prose blockquote {
border-left-color: #525252;
color: #a3a3a3;
}
.prose table { .prose table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -284,7 +331,3 @@ code {
margin-top: 0.5em; margin-top: 0.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
button {
cursor: pointer;
}
+20 -19
View File
@@ -1,18 +1,19 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Inter, Geist_Mono } from "next/font/google";
import { GeistPixelSquare } from "geist/font/pixel"; import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { MobileNavProvider } from "@/components/mobile-nav-context";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Sidebar } from "@/components/sidebar"; import { DocsSidebar } from "@/components/docs-sidebar";
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { CopyPageButton } from "@/components/copy-page-button";
import { DocsChat } from "@/components/docs-chat"; import { DocsChat } from "@/components/docs-chat";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SpeedInsights } from "@vercel/speed-insights/next"; import { SpeedInsights } from "@vercel/speed-insights/next";
import { Analytics } from "@vercel/analytics/next"; import { Analytics } from "@vercel/analytics/next";
const geist = Geist({ const inter = Inter({
variable: "--font-geist", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
}); });
@@ -66,23 +67,23 @@ export default async function RootLayout({
)} )}
</head> </head>
<body <body
className={`${geist.variable} ${geistMono.variable} ${GeistPixelSquare.variable} antialiased bg-background text-foreground`} className={`${inter.variable} ${geistMono.variable} ${GeistPixelSquare.variable} bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100`}
> >
<ThemeProvider> <ThemeProvider>
<MobileNavProvider> <Header />
<Header /> <DocsMobileNav />
<div className="flex min-h-[calc(100vh-3.5rem)]"> <div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
<Sidebar /> <aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
<main className="flex-1 overflow-auto"> <DocsSidebar />
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12"> </aside>
<div className="prose"> <div className="flex-1 min-w-0 max-w-2xl pb-20">
{children} <div className="flex justify-end mb-4">
</div> <CopyPageButton />
</div> </div>
</main> <article className="prose">{children}</article>
</div> </div>
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} /> </div>
</MobileNavProvider> <DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
</ThemeProvider> </ThemeProvider>
<SpeedInsights /> <SpeedInsights />
<Analytics /> <Analytics />
+60
View File
@@ -0,0 +1,60 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("skills")
# Skills
agent-browser ships with skills that teach AI coding agents how to use it for specific workflows. Install a skill and your agent in Cursor, Claude Code, or Codex can automate browser tasks without manual guidance.
## Available Skills
- **agent-browser** — General browser automation: navigation, snapshots, forms, screenshots, data extraction, sessions, authentication, diffing, and the full command reference.
- **dogfood** — Systematic exploratory testing. Navigates an app like a real user, finds bugs and UX issues, and produces a structured report with screenshots and repro videos.
- **electron** — Automate any Electron app (VS Code, Slack, Discord, Figma, etc.) by connecting to its built-in Chrome DevTools Protocol port. This is how agent-browser drives native desktop apps like the Slack macOS app.
- **slack** — Browser-based Slack automation. Check unreads, navigate channels, search conversations, send messages, and extract data — no API tokens needed.
## Installation
```bash
npx skills add vercel-labs/agent-browser --skill agent-browser
npx skills add vercel-labs/agent-browser --skill dogfood
npx skills add vercel-labs/agent-browser --skill electron
npx skills add vercel-labs/agent-browser --skill slack
```
After installing, your AI agent will automatically activate the right skill when it encounters a matching request.
## agent-browser
The core skill. Teaches agents the full agent-browser API: the navigate-snapshot-interact-re-snapshot workflow, all commands, command chaining, authentication (auth vault and state persistence), sessions, diffing, JavaScript evaluation, annotated screenshots, semantic locators, and configuration.
Example agent interactions:
- "Open example.com and fill out the contact form"
- "Take a screenshot of the dashboard after logging in"
- "Compare staging and production versions of the homepage"
## dogfood
A structured workflow for exploratory testing. The agent opens a target URL, systematically explores the app (navigating pages, testing forms, clicking buttons, checking console errors), and documents every issue it finds with:
- Numbered repro steps
- Step-by-step screenshots
- Repro videos for interactive bugs
- Severity classification
The output is a markdown report in an output directory, ready to hand to the responsible team. Run it with a single prompt like "dogfood vercel.com" or "QA http://localhost:3000 — focus on the billing page".
## electron
Electron apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. This skill teaches agents how to launch or connect to any Electron app, then use the standard snapshot-interact workflow to automate it.
Electron apps are built on Chromium, so they expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. Launch the app with `--remote-debugging-port`, connect, and use the standard snapshot-interact workflow. This is the foundation that the **slack** skill builds on.
## slack
Browser-based Slack automation. Connects to an existing Slack session (via `agent-browser connect 9222`) or opens Slack in a new browser, then uses snapshots and element refs to navigate the UI. Covers checking unreads, navigating channels and DMs, searching conversations, extracting message data, and taking screenshots — all without needing Slack API tokens or bot setup.
## Source
All skill files are in the [`skills/`](https://github.com/vercel-labs/agent-browser/tree/main/skills) directory of the repository.
+4 -1
View File
@@ -10,7 +10,10 @@ export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
const trimmedCode = code.trim(); const trimmedCode = code.trim();
const html = await codeToHtml(trimmedCode, { const html = await codeToHtml(trimmedCode, {
lang, lang,
theme: "github-dark-default", themes: {
light: "github-light-default",
dark: "github-dark-default",
},
}); });
return ( return (
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
export function CopyPageButton() {
const pathname = usePathname();
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
const handleCopy = async () => {
setState("loading");
try {
const response = await fetch(
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
);
if (!response.ok) {
throw new Error("Failed to fetch markdown");
}
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);
setState("copied");
setTimeout(() => setState("idle"), 2000);
} catch {
setState("idle");
}
};
return (
<button
onClick={handleCopy}
disabled={state === "loading"}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
aria-label="Copy page as Markdown"
>
{state === "copied" ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
Copied
</>
) : (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy Page
</>
)}
</button>
);
}
+1 -1
View File
@@ -494,7 +494,7 @@ export function DocsChat({
{!open && ( {!open && (
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium" className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity text-sm font-medium"
aria-label="Ask AI" aria-label="Ask AI"
> >
Ask AI Ask AI
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import { navigation, allDocsPages } from "@/lib/docs-navigation";
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const currentPage = useMemo(() => {
const page = allDocsPages.find((p) => p.href === pathname);
return page ?? allDocsPages[0];
}, [pathname]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger className="lg:hidden sticky top-14 z-40 w-full px-6 py-3 bg-background/80 backdrop-blur-sm border-b border-border flex items-center justify-between focus:outline-none">
<div className="text-sm font-medium">{currentPage?.name}</div>
<div className="w-8 h-8 flex items-center justify-center">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</div>
</SheetTrigger>
<SheetContent side="left" showCloseButton={false} className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
<nav className="space-y-6">
{navigation.map((section, sectionIndex) => (
<div key={section.title ?? sectionIndex}>
{section.title && (
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</SheetContent>
</Sheet>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { navigation } from "@/lib/docs-navigation";
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="space-y-6 pb-8">
{navigation.map((section, sectionIndex) => (
<div key={section.title ?? sectionIndex}>
{section.title && (
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"text-sm transition-colors block py-1",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
{item.name}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
+11 -26
View File
@@ -1,14 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useMobileNav } from "./mobile-nav-context";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
export function Header() { export function Header() {
const { isOpen, toggle } = useMobileNav();
return ( return (
<header className="sticky top-0 z-50 bg-background/90 backdrop-blur-sm"> <header className="sticky top-0 z-50 bg-white/90 backdrop-blur-sm dark:bg-neutral-950/90">
<div className="flex h-14 items-center justify-between px-4 gap-6"> <div className="flex h-14 items-center justify-between px-4 gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="https://vercel.com" title="Made with love by Vercel"> <Link href="https://vercel.com" title="Made with love by Vercel">
@@ -25,10 +22,10 @@ export function Header() {
clipRule="evenodd" clipRule="evenodd"
d="M8 1L16 15H0L8 1Z" d="M8 1L16 15H0L8 1Z"
fill="currentColor" fill="currentColor"
></path> />
</svg> </svg>
</Link> </Link>
<span className="text-border"> <span className="text-neutral-300 dark:text-neutral-700">
<svg <svg
data-testid="geist-icon" data-testid="geist-icon"
height="16" height="16"
@@ -42,11 +39,14 @@ export function Header() {
clipRule="evenodd" clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
fill="currentColor" fill="currentColor"
></path> />
</svg> </svg>
</span> </span>
<Link href="/"> <Link href="/">
<span className="font-medium tracking-tight text-lg" style={{ fontFamily: "var(--font-geist-pixel-square)" }}> <span
className="font-medium tracking-tight text-lg"
style={{ fontFamily: "var(--font-geist-pixel-square)" }}
>
agent-browser agent-browser
</span> </span>
</Link> </Link>
@@ -56,7 +56,7 @@ export function Header() {
href="https://github.com/vercel-labs/agent-browser" href="https://github.com/vercel-labs/agent-browser"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" className="flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100"
> >
<svg <svg
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -66,32 +66,17 @@ export function Header() {
> >
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg> </svg>
<span>14k</span> <span>16k</span>
</a> </a>
<a <a
href="https://www.npmjs.com/package/agent-browser" href="https://www.npmjs.com/package/agent-browser"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors" className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100"
> >
npm npm
</a> </a>
<ThemeToggle /> <ThemeToggle />
<button
onClick={toggle}
className="lg:hidden p-2 -mr-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Toggle menu"
>
{isOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</nav> </nav>
</div> </div>
</header> </header>
@@ -1,45 +0,0 @@
"use client";
import { createContext, useContext, useState, useEffect } from "react";
import { usePathname } from "next/navigation";
type MobileNavContextType = {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
toggle: () => void;
};
const MobileNavContext = createContext<MobileNavContextType | null>(null);
export function MobileNavProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
useEffect(() => {
setIsOpen(false);
}, [pathname]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, []);
const toggle = () => setIsOpen(!isOpen);
return (
<MobileNavContext.Provider value={{ isOpen, setIsOpen, toggle }}>
{children}
</MobileNavContext.Provider>
);
}
export function useMobileNav() {
const context = useContext(MobileNavContext);
if (!context) {
throw new Error("useMobileNav must be used within a MobileNavProvider");
}
return context;
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMobileNav } from "./mobile-nav-context";
import { navigation } from "@/lib/docs-navigation";
export function Sidebar() {
const pathname = usePathname();
const { isOpen, setIsOpen } = useMobileNav();
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-background/80"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:sticky top-14 left-0 z-50 lg:z-auto
w-56 lg:w-48 h-[calc(100vh-3.5rem)]
bg-background
transform transition-transform duration-150 ease-out
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}
>
<div className="h-full overflow-y-auto py-5 pl-3 pr-5">
<nav className="space-y-4">
{navigation.map((section, sectionIndex) => (
<div key={section.title ?? sectionIndex}>
{section.title && (
<div className="px-2 pb-1 text-xs font-medium text-muted-foreground/60 uppercase tracking-wider">
{section.title}
</div>
)}
<div className="space-y-0.5">
{section.items.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`block px-2 py-1.5 text-sm transition-colors ${
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.name}
</Link>
);
})}
</div>
</div>
))}
</nav>
</div>
</aside>
</>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function ThemeToggle() {
return ( return (
<button <button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" className="w-8 h-8 flex items-center justify-center rounded-md text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100 dark:hover:bg-neutral-800"
aria-label="Toggle theme" aria-label="Toggle theme"
> >
{theme === "dark" ? ( {theme === "dark" ? (
+1
View File
@@ -15,6 +15,7 @@ export const navigation: NavSection[] = [
{ name: "Introduction", href: "/" }, { name: "Introduction", href: "/" },
{ name: "Installation", href: "/installation" }, { name: "Installation", href: "/installation" },
{ name: "Quick Start", href: "/quick-start" }, { name: "Quick Start", href: "/quick-start" },
{ name: "Skills", href: "/skills" },
], ],
}, },
{ {
+2 -9
View File
@@ -1,11 +1,8 @@
/** /**
* Converts raw MDX content to clean Markdown suitable for AI agents. * Converts raw MDX content to clean Markdown suitable for AI agents.
* *
* Transformations: * Strips export/import statements and standalone JSX divs with className
* - Remove `export` statements (metadata, etc.) * attributes, passing everything else through as valid Markdown.
* - Remove `import` statements
* - Strip standalone JSX divs with className attributes
* - Pass everything else through as-is (already valid Markdown)
*/ */
export function mdxToCleanMarkdown(raw: string): string { export function mdxToCleanMarkdown(raw: string): string {
const lines = raw.split("\n"); const lines = raw.split("\n");
@@ -16,12 +13,10 @@ export function mdxToCleanMarkdown(raw: string): string {
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
// Skip export and import statements
if (trimmed.startsWith("export ") || trimmed.startsWith("import ")) { if (trimmed.startsWith("export ") || trimmed.startsWith("import ")) {
continue; continue;
} }
// Track JSX blocks (like callout divs) and skip them
if ( if (
!inJsxBlock && !inJsxBlock &&
trimmed.startsWith("<div ") && trimmed.startsWith("<div ") &&
@@ -33,7 +28,6 @@ export function mdxToCleanMarkdown(raw: string): string {
} }
if (inJsxBlock) { if (inJsxBlock) {
// Count opening/closing div tags to handle nesting
const opens = (line.match(/<div[\s>]/g) || []).length; const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div>/g) || []).length; const closes = (line.match(/<\/div>/g) || []).length;
jsxDepth += opens - closes; jsxDepth += opens - closes;
@@ -47,7 +41,6 @@ export function mdxToCleanMarkdown(raw: string): string {
out.push(line); out.push(line);
} }
// Clean up leading blank lines
let result = out.join("\n"); let result = out.join("\n");
result = result.replace(/^\n+/, "\n").trim(); result = result.replace(/^\n+/, "\n").trim();
return result; return result;
+1
View File
@@ -2,6 +2,7 @@ export const PAGE_TITLES: Record<string, string> = {
"": "Headless Browser\nAutomation for AI", "": "Headless Browser\nAutomation for AI",
installation: "Installation", installation: "Installation",
"quick-start": "Quick Start", "quick-start": "Quick Start",
skills: "Skills",
commands: "Commands", commands: "Commands",
configuration: "Configuration", configuration: "Configuration",
selectors: "Selectors", selectors: "Selectors",
+212
View File
@@ -0,0 +1,212 @@
---
name: electron
description: Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using agent-browser via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include "automate Slack app", "control VS Code", "interact with Discord app", "test this Electron app", "connect to desktop app", or any task requiring automation of a native Electron application.
allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
---
# Electron App Automation
Automate any Electron desktop app using agent-browser. Electron apps are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages.
## Core Workflow
1. **Launch** the Electron app with remote debugging enabled
2. **Connect** agent-browser to the CDP port
3. **Snapshot** to discover interactive elements
4. **Interact** using element refs
5. **Re-snapshot** after navigation or state changes
```bash
# Launch an Electron app with remote debugging
open -a "Slack" --args --remote-debugging-port=9222
# Connect agent-browser to the app
agent-browser connect 9222
# Standard workflow from here
agent-browser snapshot -i
agent-browser click @e5
agent-browser screenshot slack-desktop.png
```
## Launching Electron Apps with CDP
Every Electron app supports the `--remote-debugging-port` flag since it's built into Chromium.
### macOS
```bash
# Slack
open -a "Slack" --args --remote-debugging-port=9222
# VS Code
open -a "Visual Studio Code" --args --remote-debugging-port=9223
# Discord
open -a "Discord" --args --remote-debugging-port=9224
# Figma
open -a "Figma" --args --remote-debugging-port=9225
# Notion
open -a "Notion" --args --remote-debugging-port=9226
# Spotify
open -a "Spotify" --args --remote-debugging-port=9227
```
### Linux
```bash
slack --remote-debugging-port=9222
code --remote-debugging-port=9223
discord --remote-debugging-port=9224
```
### Windows
```bash
"C:\Users\%USERNAME%\AppData\Local\slack\slack.exe" --remote-debugging-port=9222
"C:\Users\%USERNAME%\AppData\Local\Programs\Microsoft VS Code\Code.exe" --remote-debugging-port=9223
```
**Important:** If the app is already running, quit it first, then relaunch with the flag. The `--remote-debugging-port` flag must be present at launch time.
## Connecting
```bash
# Connect to a specific port
agent-browser connect 9222
# Or use --cdp on each command
agent-browser --cdp 9222 snapshot -i
# Auto-discover a running Chromium-based app
agent-browser --auto-connect snapshot -i
```
After `connect`, all subsequent commands target the connected app without needing `--cdp`.
## Tab Management
Electron apps often have multiple windows or webviews. Use tab commands to list and switch between them:
```bash
# List all available targets (windows, webviews, etc.)
agent-browser tab
# Switch to a specific tab by index
agent-browser tab 2
# Switch by URL pattern
agent-browser tab --url "*settings*"
```
## Common Patterns
### Inspect and Navigate an App
```bash
open -a "Slack" --args --remote-debugging-port=9222
sleep 3 # Wait for app to start
agent-browser connect 9222
agent-browser snapshot -i
# Read the snapshot output to identify UI elements
agent-browser click @e10 # Navigate to a section
agent-browser snapshot -i # Re-snapshot after navigation
```
### Take Screenshots of Desktop Apps
```bash
agent-browser connect 9222
agent-browser screenshot app-state.png
agent-browser screenshot --full full-app.png
agent-browser screenshot --annotate annotated-app.png
```
### Extract Data from a Desktop App
```bash
agent-browser connect 9222
agent-browser snapshot -i
agent-browser get text @e5
agent-browser snapshot --json > app-state.json
```
### Fill Forms in Desktop Apps
```bash
agent-browser connect 9222
agent-browser snapshot -i
agent-browser fill @e3 "search query"
agent-browser press Enter
agent-browser wait 1000
agent-browser snapshot -i
```
### Run Multiple Apps Simultaneously
Use named sessions to control multiple Electron apps at the same time:
```bash
# Connect to Slack
agent-browser --session slack connect 9222
# Connect to VS Code
agent-browser --session vscode connect 9223
# Interact with each independently
agent-browser --session slack snapshot -i
agent-browser --session vscode snapshot -i
```
## Color Scheme
Playwright overrides the color scheme to `light` by default when connecting via CDP. To preserve dark mode:
```bash
agent-browser connect 9222
agent-browser --color-scheme dark snapshot -i
```
Or set it globally:
```bash
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser connect 9222
```
## Troubleshooting
### "Connection refused" or "Cannot connect"
- Make sure the app was launched with `--remote-debugging-port=NNNN`
- If the app was already running, quit and relaunch with the flag
- Check that the port isn't in use by another process: `lsof -i :9222`
### App launches but connect fails
- Wait a few seconds after launch before connecting (`sleep 3`)
- Some apps take time to initialize their webview
### Elements not appearing in snapshot
- The app may use multiple webviews. Use `agent-browser tab` to list targets and switch to the right one
- Use `agent-browser snapshot -i -C` to include cursor-interactive elements (divs with onclick handlers)
### Cannot type in input fields
- Try `agent-browser keyboard type "text"` to type at the current focus without a selector
- Some Electron apps use custom input components; use `agent-browser keyboard inserttext "text"` to bypass key events
## Supported Apps
Any app built on Electron works, including:
- **Communication:** Slack, Discord, Microsoft Teams, Signal, Telegram Desktop
- **Development:** VS Code, GitHub Desktop, Postman, Insomnia
- **Design:** Figma, Notion, Obsidian
- **Media:** Spotify, Tidal
- **Productivity:** Todoist, Linear, 1Password
If an app is built with Electron, it supports `--remote-debugging-port` and can be automated with agent-browser.