diff --git a/.env.example b/.env.example index 0e0d1d4..96f9377 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# Kernel API Key -# Get your API key from the Vercel Marketplace Kernel integration -# or from https://dashboard.onkernel.com +# KERNEL API key +# create one at https://dashboard.onkernel.com, or install the KERNEL +# integration from the vercel marketplace and let it set this for you KERNEL_API_KEY= -# OpenAI API Key -# Get your API key from https://platform.openai.com/api-keys -# Required for AI-powered browser automation +# OpenAI API key +# https://platform.openai.com/api-keys +# the agent runs on gpt-5.4 through the vercel ai sdk OPENAI_API_KEY= diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 15b1ed9..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next" -} diff --git a/README.md b/README.md index 733442a..5603a68 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,95 @@ -# Kernel + Vercel Template +# KERNEL next.js template -Next.js + Kernel template for running AI-powered browser automations with natural language on Vercel. +![the template running](./public/template-preview.png) -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fonkernel%2Fkernel-nextjs-template&env=OPENAI_API_KEY&project-name=kernel-nextjs-template&repository-name=kernel-nextjs-template&products=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22kernel%22%2C%22productSlug%22%3A%22kernel%22%2C%22protocol%22%3A%22other%22%7D%5D) +one page that creates a KERNEL cloud browser, lets a gpt-5.4 agent write and run playwright against it, and keeps the browser and the generated code side by side. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fkernel%2Fkernel-nextjs-template&env=OPENAI_API_KEY&project-name=kernel-nextjs-template&repository-name=kernel-nextjs-template&products=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22kernel%22%2C%22productSlug%22%3A%22kernel%22%2C%22protocol%22%3A%22other%22%7D%5D) -## Overview +## what this shows -This template shows how to: +- creating a browser session with the KERNEL sdk, and streaming the live view into the page +- an AI SDK agent (`ToolLoopAgent` on gpt-5.4) whose only tool is playwright execution +- the agent writing playwright, running it in the browser vm, and returning a value to the model +- every step streamed into a sidebar with the code, the execution time, the return value, and failures -- Create serverless browsers with live view using the Kernel SDK -- Describe browser tasks in natural language -- Use an AI agent to execute browser automation code via AI SDK tools in Next.js API routes -- Display live browser view and automation results in a modern Next.js UI +## quick start -## Tech Stack - -- **Framework**: Next.js 15 with App Router -- **Styling**: Tailwind CSS v4 -- **UI Components**: shadcn/ui -- **AI**: Vercel AI SDK with OpenAI GPT-5.1 -- **Browser Automation**: Kernel SDK + Kernel AI SDK (`@onkernel/ai-sdk`) -- **Package Manager**: Bun -- **Deployment**: Vercel - -## Getting Started - -### Prerequisites - -- Node.js 18+ -- [Bun](https://bun.sh) (package manager) -- A Kernel account and API key -- An OpenAI API key -- Vercel account (optional, for deployment) - -### Installation - -1. **Clone the repository**: - - ```bash - git clone - cd nextjs-kernel-template - ``` - -2. **Install dependencies**: - - ```bash - bun install - ``` - -3. **Set up Kernel**: - - Get your Kernel API key from one of these sources: - - - **Option 1 (Recommended)**: Install the [Kernel integration](https://vercel.com/integrations/kernel) from the Vercel Marketplace - - **Option 2**: Get your API key from [https://dashboard.onkernel.com](https://dashboard.onkernel.com) - -4. **Configure environment variables**: - - Create a `.env` file: - - ```bash - touch .env.local - ``` +```bash +bun install +cp .env.example .env.local # add KERNEL_API_KEY and OPENAI_API_KEY +bun dev +``` - Add your API keys: +open http://localhost:3000, create a browser, and describe a task. - ``` - KERNEL_API_KEY=your_kernel_api_key_here - OPENAI_API_KEY=your_openai_api_key_here - ``` +## the three calls -5. **Run the development server**: +the whole browser layer is three methods on the KERNEL client. the template calls them from route handlers. - ```bash - bun dev - ``` +```ts +// app/api/create-browser/route.ts +const browser = await kernel.browsers.create({ stealth: true, headless: false }); +browser.browser_live_view_url; // stream this into an iframe +browser.cdp_ws_url; // or attach your own playwright client +browser.session_id; -6. **Open** [http://localhost:3000](http://localhost:3000) in your browser +// lib/playwright-tool.ts — what the model calls, one call per step +await kernel.browsers.playwright.execute(sessionId, { code, timeout_sec }); -## How It Works +// app/api/delete-browser/route.ts +await kernel.browsers.deleteByID(sessionId); +``` -1. **Create Browser**: Click "Create Browser" to provision a serverless Kernel browser with live view capabilities -2. **Describe Your Task**: Enter what you want the browser to do in natural language (e.g., "Go to Hacker News and get the top article title") -3. **Watch AI Execute**: The AI agent interprets your task and uses Kernel's AI SDK-compatible browser automation tool to execute it in real-time -4. **View Results**: See the agent's response, step count, and click "View Steps" to inspect the generated code and execution details +`playwright.execute` runs the code in the same vm as the browser, with `page`, `context`, `browser`, and `webmcp` in scope, and returns whatever the code returns. that return value is what the model sees, so `return` the data the task asks for. -## Code Structure +## code map ``` app/ ├── api/ -│ ├── agent/ -│ │ └── route.ts # AI agent endpoint with browser automation tool -│ ├── create-browser/ -│ │ └── route.ts # Creates a serverless Kernel browser -│ └── delete-browser/ -│ └── route.ts # Closes browser session -├── page.tsx # Main UI with live view and controls -├── layout.tsx # Root layout -└── globals.css # Global styles - +│ ├── agent/route.ts # ToolLoopAgent + streaming ui message response +│ ├── create-browser/route.ts # browser session, live view url, spin-up time +│ └── delete-browser/route.ts # closes the session +├── page.tsx # hero, split view, footer +├── layout.tsx # fonts and metadata +└── globals.css # KERNEL design tokens components/ -├── Header.tsx # App header with branding -├── StepsOverlay.tsx # Modal showing agent execution steps -└── ui/ # shadcn/ui components - ├── button.tsx - ├── card.tsx - ├── textarea.tsx - └── ... - +├── AgentStepsSidebar.tsx # streamed steps, task composer +├── BrowserPanel.tsx # live view, session details +├── HowItWorks.tsx # the three calls with this session's numbers +├── Header.tsx +├── ai-elements/ # code block and stack trace, styled to the design system +└── ui/ # shadcn/ui primitives lib/ -└── utils.ts # Utility functions -``` - -### Key Code Example - -**Step 1: Create Browser** (`app/api/create-browser/route.ts`) - -```typescript -import { Kernel } from "@onkernel/sdk"; - -// Initialize Kernel client -const kernel = new Kernel({ apiKey: process.env.KERNEL_API_KEY }); - -// Create a serverless browser with live view -const browser = await kernel.browsers.create({ - stealth: true, - headless: false, -}); - -// Return browser details to client -return { - sessionId: browser.session_id, - liveViewUrl: browser.browser_live_view_url, - cdpWsUrl: browser.cdp_ws_url, -}; -``` - -**Step 2: Run AI Agent** (`app/api/agent/route.ts`) - -```typescript -import { openai } from "@ai-sdk/openai"; -import { playwrightExecuteTool } from "@onkernel/ai-sdk"; -import { Kernel } from "@onkernel/sdk"; -import { Experimental_Agent as Agent, stepCountIs } from "ai"; - -// Initialize Kernel instance -const kernel = new Kernel({ apiKey: process.env.KERNEL_API_KEY }); - -// Initialize the AI agent with GPT-5.1 and Kernel's AI SDK-compatible browser automation tool -const agent = new Agent({ - model: openai("gpt-5.1"), - tools: { - playwright_execute: playwrightExecuteTool({ - client: kernel, - sessionId: sessionId, - }), - }, - stopWhen: stepCountIs(20), - system: `You are a browser automation expert with access to a Playwright execution tool...`, -}); - -// Execute the agent with the user's task -const { text, steps } = await agent.generate({ - prompt: task, // e.g., "Go to news.ycombinator.com and get the first article title" -}); +├── constants.ts # shared agent step-count limit +├── deploy-url.ts # deploy-with-vercel clone url +├── playwright-tool.ts # the playwright_execute tool +├── shiki.ts # syntax highlighting +└── types.ts # shared types ``` -## Deployment - -### Deploy to Vercel - -1. **Push to GitHub** - -2. **Connect to Vercel**: - - - Go to [vercel.com](https://vercel.com) - - Import your GitHub repository - - Add your environment variables (`KERNEL_API_KEY` and `OPENAI_API_KEY`) - - Deploy! +## how the streaming works -3. **Using Vercel Marketplace Integration**: - - Install [Kernel from Vercel Marketplace](https://vercel.com/integrations/kernel) - - The integration will automatically add the Kernel API key to your project - - Add your `OPENAI_API_KEY` manually - - Deploy your project +`/api/agent` builds a `ToolLoopAgent` and returns `toUIMessageStreamResponse()`, so tool calls and results reach the browser as they happen. the sidebar renders the `tool-playwright_execute` parts from `useChat`, which is where the per-step status, code, and return value come from. you see the run while it runs. -### Environment Variables +## environment -Make sure to add these environment variables in your Vercel project settings: +| variable | where it comes from | +| --- | --- | +| `KERNEL_API_KEY` | [dashboard.onkernel.com](https://dashboard.onkernel.com), or the KERNEL integration in the vercel marketplace | +| `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com/api-keys) | -- `KERNEL_API_KEY` - Your Kernel API key -- `OPENAI_API_KEY` - Your OpenAI API key +## deploy -## Learn More +push to github and import the repo at [vercel.com/new](https://vercel.com/new), or use the deploy button above. install the [KERNEL integration](https://vercel.com/integrations/kernel) to have `KERNEL_API_KEY` set for you, then add `OPENAI_API_KEY` yourself. -- [Kernel Documentation](https://docs.onkernel.com) -- [Kernel AI SDK](https://www.onkernel.com/docs/integrations/vercel/ai-sdk) -- [Vercel AI SDK Documentation](https://sdk.vercel.ai/docs) -- [Next.js Documentation](https://nextjs.org/docs) +> [!WARNING] +> `/api/agent` accepts any `sessionId` and runs whatever playwright the model writes against it, with no auth or rate limiting. a public deploy runs on your keys for anyone who finds the url - add access control before sharing a deployed link. ---- +## links -Built with [Kernel](https://dashboard.onkernel.com), [Vercel AI SDK](https://sdk.vercel.ai), and [Vercel](https://vercel.com) +- [KERNEL docs](https://kernel.sh/docs) +- [playwright execution](https://kernel.sh/docs/browsers/playwright-execution) +- [vercel ai sdk](https://ai-sdk.dev) diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 5aac1e4..21636eb 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -1,149 +1,80 @@ import { openai } from "@ai-sdk/openai"; -import { playwrightExecuteTool } from "@onkernel/ai-sdk"; import { Kernel } from "@onkernel/sdk"; -import { Experimental_Agent as Agent, stepCountIs, tool } from "ai"; -import { z } from "zod"; +import { ToolLoopAgent, convertToModelMessages, stepCountIs } from "ai"; +import { AGENT_STEP_LIMIT } from "@/lib/constants"; +import { playwrightExecuteTool } from "@/lib/playwright-tool"; +import type { AgentUIMessage } from "@/lib/types"; -export const maxDuration = 300; // 5 minutes timeout for long-running agent operations +export const maxDuration = 300; -export async function POST(req: Request) { - try { - const { sessionId, task } = await req.json(); - - if (!sessionId || !task) { - return Response.json( - { error: "Missing sessionId or task" }, - { status: 400 } - ); - } - - const apiKey = process.env.KERNEL_API_KEY; - const openaiKey = process.env.OPENAI_API_KEY; - - if (!apiKey) { - return Response.json( - { error: "KERNEL_API_KEY environment variable is not set" }, - { status: 400 } - ); - } - - if (!openaiKey) { - return Response.json( - { error: "OPENAI_API_KEY environment variable is not set" }, - { status: 400 } - ); - } - - const kernel = new Kernel({ apiKey }); - - // Initialize the AI agent with GPT-5.1 - const agent = new Agent({ - model: openai("gpt-5.1"), - tools: { - playwright_execute: playwrightExecuteTool({ - client: kernel, - sessionId: sessionId, - }), - }, - stopWhen: stepCountIs(20), - system: `You are a browser automation expert with access to a Playwright execution tool. +const INSTRUCTIONS = `you drive a KERNEL cloud browser by writing playwright code. -Available tools: -- playwright_execute: Executes JavaScript/Playwright code in the browser. Has access to 'page', 'context', and 'browser' objects. Returns the result of your code. +the browser session already exists and starts on about:blank. inside the execution tool you have \`page\`, \`context\`, \`browser\`, and \`webmcp\` in scope. the tool runs in the same vm as the browser. -When given a task: -1. If no URL is provided, FIRST get the current page context: - return { url: page.url(), title: await page.title() } -2. If a URL is provided, navigate to it using page.goto() -3. Use appropriate selectors (page.locator, page.getByRole, etc.) to interact with elements -4. Always return the requested data from your code execution +how to work: +- one atomic step per call: navigate, then inspect, then act, then extract. short snippets beat long scripts. +- the return value is the only thing you get back, so return the data the task asks for. +- when a selector misses, inspect the page instead of guessing the same selector again. +- finish with one or two sentences of plain prose. no preamble, no restating the task. +- you have a hard budget of ${AGENT_STEP_LIMIT} tool calls for this task. if you can tell you won't finish in time, say so plainly in your closing sentence instead of trailing off mid-task. -Important: Write concise code that solves one atomic step at a time. Break complex tasks into small, focused executions rather than writing long scripts. +timeouts: +- playwright waits 30 seconds before every locator action gives up, which is far longer than anyone is watching. never leave that default in place. +- open a snippet that touches a selector with \`page.setDefaultTimeout(5000)\`, or pass \`{ timeout: 5000 }\` to the action itself. use up to 15000 for \`page.goto\` on a heavy site, and nothing higher unless the task says otherwise. +- keep waits you write yourself short too: \`waitForSelector(selector, { timeout: 5000 })\`. +- to read a value that may not be there, check first (\`await locator.count()\`, \`isVisible()\`) and skip the row, instead of awaiting the text and catching the failure. a \`.catch()\` does not shorten the 30 second wait it is wrapping.`; -Execute tasks autonomously without asking clarifying questions. Make reasonable assumptions and proceed.`, - }); - - // Execute the agent with the user's task - const { text, steps, usage } = await agent.generate({ - prompt: task, - }); - - // Extract detailed step information from step.content[] array - const detailedSteps = steps.map((step, index) => { - const stepData = step as any; - const content = stepData.content || []; +export async function POST(req: Request) { + const body = (await req.json().catch(() => null)) as { + messages?: AgentUIMessage[]; + sessionId?: string; + } | null; - console.log(content); + if (!body?.sessionId) { + return Response.json({ error: "missing sessionId" }, { status: 400 }); + } - // Process each content item based on its type - const processedContent = content.map((item: any) => { - if (item.type === "tool-call") { - return { - type: "tool-call" as const, - toolCallId: item.toolCallId, - toolName: item.toolName, - code: item.input?.code || null, - }; - } else if (item.type === "tool-result") { - return { - type: "tool-result" as const, - toolCallId: item.toolCallId, - toolName: item.toolName, - result: item.result?.result, - success: item.result?.success ?? true, - error: item.result?.error, - }; - } else if (item.type === "text") { - return { - type: "text" as const, - text: item.text, - }; - } - return item; - }); + const { messages, sessionId } = body; - return { - stepNumber: index + 1, - finishReason: stepData.finishReason || null, - content: processedContent, - }; - }); + const apiKey = process.env.KERNEL_API_KEY; - // Collect all executed code from the steps (for backward compatibility) - const executedCodes = detailedSteps.flatMap((step) => - step.content - .filter((item: any) => item.type === "tool-call" && item.code) - .map((item: any) => { - // Find matching result - const result = step.content.find( - (r: any) => - r.type === "tool-result" && r.toolCallId === item.toolCallId - ); - return { - code: item.code, - success: result?.success ?? true, - result: result?.result, - error: result?.error, - }; - }) + if (!apiKey) { + return Response.json( + { error: "KERNEL_API_KEY environment variable is not set" }, + { status: 400 }, ); + } - return Response.json({ - success: true, - response: text, - executedCodes, - detailedSteps, - stepCount: steps.length, - usage, - }); - } catch (error: any) { - console.error("Agent execution error:", error); + if (!process.env.OPENAI_API_KEY) { return Response.json( - { - success: false, - error: error.message || "Failed to execute agent", - }, - { status: 500 } + { error: "OPENAI_API_KEY environment variable is not set" }, + { status: 400 }, ); } + + const kernel = new Kernel({ apiKey }); + + const agent = new ToolLoopAgent({ + model: openai("gpt-5.4"), + instructions: INSTRUCTIONS, + tools: { + playwright_execute: playwrightExecuteTool({ client: kernel, sessionId }), + }, + stopWhen: stepCountIs(AGENT_STEP_LIMIT), + }); + + // a stopped run leaves a tool call without a result, which the model would + // reject on the next turn + const result = await agent.stream({ + messages: await convertToModelMessages(messages ?? [], { + ignoreIncompleteToolCalls: true, + }), + abortSignal: req.signal, + }); + + // this template runs on the deployer's own keys, so the real error is safe + // to show them (the ai sdk otherwise masks every failure as one generic string) + return result.toUIMessageStreamResponse({ + onError: (error) => (error instanceof Error ? error.message : "an unexpected error occurred"), + }); } diff --git a/app/api/create-browser/route.ts b/app/api/create-browser/route.ts index e208703..7ca55e5 100644 --- a/app/api/create-browser/route.ts +++ b/app/api/create-browser/route.ts @@ -1,52 +1,54 @@ -import { NextResponse } from "next/server"; import { Kernel } from "@onkernel/sdk"; +import { NextResponse } from "next/server"; +import { DEPLOY_URL } from "@/lib/deploy-url"; export async function POST() { - try { - const apiKey = process.env.KERNEL_API_KEY; - - if (!apiKey) { - return NextResponse.json( - { - error: "MISSING_API_KEY", - message: "KERNEL_API_KEY environment variable is not set", - deployUrl: - "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fonkernel%2Fkernel-nextjs-template&env=OPENAI_API_KEY&project-name=kernel-nextjs-template&repository-name=kernel-nextjs-template&products=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22kernel%22%2C%22productSlug%22%3A%22kernel%22%2C%22protocol%22%3A%22other%22%7D%5D", - }, - { status: 400 } - ); - } + const apiKey = process.env.KERNEL_API_KEY; - const startTime = Date.now(); + if (!apiKey) { + return NextResponse.json( + { + error: "MISSING_API_KEY", + message: "KERNEL_API_KEY environment variable is not set", + deployUrl: DEPLOY_URL, + }, + { status: 400 }, + ); + } - // Initialize Kernel client + try { const kernel = new Kernel({ apiKey }); + const startTime = Date.now(); - // Create a headful, stealth browser - console.log("Creating Kernel browser..."); const browser = await kernel.browsers.create({ stealth: true, headless: false, + // a smaller window than the 1920x1080 default keeps the live view readable + viewport: { width: 1280, height: 800, refresh_rate: 60 }, + // /api/agent's own maxDuration is 300s, so a session timeout of the same + // length can expire mid-run for a task started any time after creation. + // give it much more headroom than a single run needs. + timeout_seconds: 1800, }); - console.log(`Browser created: ${browser.session_id}`); - - const spinUpTime = Date.now() - startTime; return NextResponse.json({ success: true, sessionId: browser.session_id, liveViewUrl: browser.browser_live_view_url, cdpWsUrl: browser.cdp_ws_url, - spinUpTime, + region: browser.region, + stealth: browser.stealth, + spinUpTime: Date.now() - startTime, }); } catch (error) { - console.error("Error creating browser:", error); + console.error("failed to create browser", error); + return NextResponse.json( { error: "Failed to create browser", details: error instanceof Error ? error.message : String(error), }, - { status: 500 } + { status: 500 }, ); } } diff --git a/app/api/delete-browser/route.ts b/app/api/delete-browser/route.ts index 939b8cf..98d9643 100644 --- a/app/api/delete-browser/route.ts +++ b/app/api/delete-browser/route.ts @@ -1,54 +1,43 @@ import { Kernel, NotFoundError } from "@onkernel/sdk"; export async function POST(req: Request) { - try { - const { sessionId } = await req.json(); + const body = (await req.json().catch(() => null)) as { sessionId?: string } | null; - if (!sessionId) { - return Response.json( - { error: "Missing sessionId" }, - { status: 400 } - ); - } + if (!body?.sessionId) { + return Response.json({ error: "missing sessionId" }, { status: 400 }); + } - const apiKey = process.env.KERNEL_API_KEY; + const { sessionId } = body; - if (!apiKey) { - return Response.json( - { error: "KERNEL_API_KEY not configured" }, - { status: 500 } - ); - } + const apiKey = process.env.KERNEL_API_KEY; - const kernel = new Kernel({ apiKey }); - - try { - await kernel.browsers.deleteByID(sessionId); - - return Response.json({ - success: true, - message: "Browser session closed successfully", - }); - } catch (error) { - // Handle 404 gracefully - browser was already deleted or doesn't exist - if (error instanceof NotFoundError) { - return Response.json({ - success: true, - message: "Browser session already closed or not found", - }); - } - - // Re-throw other errors - throw error; + if (!apiKey) { + // a missing server env var is a server misconfiguration, not a bad request + return Response.json( + { error: "KERNEL_API_KEY environment variable is not set" }, + { status: 500 }, + ); + } + + const kernel = new Kernel({ apiKey }); + + try { + await kernel.browsers.deleteByID(sessionId); + + return Response.json({ success: true }); + } catch (error) { + // the session may have timed out already, which is not an error for us + if (error instanceof NotFoundError) { + return Response.json({ success: true }); } - } catch (error: any) { - console.error("Browser deletion error:", error); + + console.error("failed to close browser", error); + return Response.json( { - success: false, - error: error.message || "Failed to close browser session", + error: error instanceof Error ? error.message : "failed to close browser", }, - { status: 500 } + { status: 500 }, ); } } diff --git a/app/globals.css b/app/globals.css index d22072d..b4e3b1d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,100 +1,232 @@ @import "tailwindcss"; @import "tw-animate-css"; -@custom-variant dark (&:is(.dark *)); +/* + * KERNEL design tokens. The palette is closed on purpose: `--color-*: initial` + * drops every default Tailwind color so nothing off-brand can be used by accident. + */ +@theme { + --color-*: initial; + --color-kernel-green: #81b300; + --color-beige: #f2f0e7; + --color-beige-light: #faf9f2; + --color-beige-muted: #e1dccf; + --color-grey-light-03: #f0f0f3; + --color-grey-light-05: #e0e1e6; + --color-grey-light-07: #d0d2d9; + --color-grey-light-11: #60646c; + --color-grey-light-12: #1c2024; + --color-grey-dark-03: #212225; + --color-grey-dark-12: #edeef0; + --color-charcoal: #212225; + --color-gold: #cab168; + --color-white: #ffffff; + --color-black: #000000; + --color-transparent: transparent; + --color-current: currentColor; + --color-inherit: inherit; + + --font-*: initial; + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular, monospace; + + /* square corners are the default visual language */ + --radius-*: initial; + + --breakpoint-sm: 414px; + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; + --breakpoint-xl: 1440px; +} + +/* Bridge the tokens the shadcn/ui components read from. */ :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.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --radius: 0.625rem; -} - -.dark { - --background: oklch(0.12 0.01 264); - --foreground: oklch(0.985 0 0); - --card: oklch(0.15 0.01 264); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.12 0.01 264); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 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.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); -} - -@theme inline { - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --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); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --background: var(--color-beige); + --foreground: var(--color-charcoal); + --card: var(--color-beige-light); + --card-foreground: var(--color-charcoal); + --primary: var(--color-charcoal); + --primary-foreground: var(--color-beige); + --secondary: var(--color-beige-muted); + --secondary-foreground: var(--color-charcoal); + --muted: var(--color-beige-muted); + --muted-foreground: var(--color-grey-light-11); + --accent: var(--color-kernel-green); + --accent-foreground: var(--color-charcoal); + --border: var(--color-grey-light-07); + --input: var(--color-grey-light-07); + --ring: var(--color-kernel-green); + + ::selection { + background-color: var(--color-kernel-green); + color: var(--color-charcoal); + } } @layer base { * { - @apply border-border outline-ring/50; + border-color: var(--border); } + + html { + -webkit-font-smoothing: antialiased; + } + body { - @apply bg-background text-foreground; + background-color: var(--color-beige); + color: var(--color-charcoal); + font-family: var(--font-sans); + font-weight: 250; + text-transform: lowercase; + } + + /* machine-sensitive strings keep their case */ + code, + pre, + kbd, + samp, + input, + textarea, + [data-preserve-case] { + text-transform: none; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 250; + line-height: 1.2; + margin: 0; } - body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-image: - linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px); - background-size: 40px 40px; - pointer-events: none; - z-index: 0; + + a { + text-underline-offset: 2px; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: var(--color-beige-muted); + } + + ::-webkit-scrollbar-thumb { + background: var(--color-grey-light-07); + } +} + +/* + * Type scale. Use these instead of hand-mixed font-size/leading/tracking. + * Declared with @utility, not @layer components, so Tailwind generates + * variants for them (md:text-heading-02, etc.) the same as any other utility. + */ +@utility text-heading-01 { + font-size: 100px; + line-height: 1.1; + letter-spacing: 2px; +} + +@utility text-heading-02 { + font-size: 67px; + line-height: 1.2; + letter-spacing: 1.3px; +} + +@utility text-heading-03 { + font-size: 50px; + line-height: 1.2; + letter-spacing: 1px; +} + +@utility text-heading-04 { + font-size: 42px; + line-height: 1.2; + letter-spacing: 0.84px; +} + +@utility text-heading-05 { + font-size: 33px; + line-height: 1.1; + letter-spacing: 0.66px; +} + +@utility text-heading-06 { + font-size: 25px; + line-height: 1.2; + letter-spacing: 0.5px; +} + +@utility text-body-01 { + font-size: 21px; + line-height: 1.2; + letter-spacing: 0.3px; +} + +@utility text-body-02 { + font-size: 17px; + line-height: 1.2; +} + +@utility text-body-03 { + font-size: 15px; + line-height: 1.2; +} + +@utility text-body-expanded-01 { + font-size: 21px; + line-height: 1.6; + letter-spacing: 0.3px; +} + +@utility text-label-01 { + font-size: 21px; + line-height: 1.2; + letter-spacing: 0.3px; + font-weight: 350; +} + +@utility text-label-02 { + font-size: 17px; + line-height: 1.2; + font-weight: 350; +} + +@utility text-mono-01 { + font-family: var(--font-mono); + font-size: 17px; + line-height: 1.2; + letter-spacing: 0.24px; + font-weight: 350; +} + +@utility text-mono-02 { + font-family: var(--font-mono); + font-size: 15px; + line-height: 1.2; + letter-spacing: 0.21px; + font-weight: 350; +} + +@utility text-tag { + font-size: 10px; + line-height: 1.2; + font-weight: 350; + letter-spacing: 0.2px; + text-transform: uppercase; +} + +@layer components { + /* shiki output sits inside a charcoal panel */ + .code-surface .shiki { + background-color: transparent !important; + padding: 0; + white-space: pre-wrap; + word-break: break-word; } } diff --git a/app/icon.svg b/app/icon.svg index b9a86da..32fc3b0 100644 --- a/app/icon.svg +++ b/app/icon.svg @@ -1,4 +1,8 @@ - - - + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx index de52d3d..84bdeb4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,23 @@ import type { Metadata } from "next"; -import { GeistSans } from "geist/font/sans"; -import { GeistMono } from "geist/font/mono"; +import { IBM_Plex_Mono, Inter } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +const plexMono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["300", "400"], + variable: "--font-plex-mono", +}); + export const metadata: Metadata = { - title: "Kernel + Vercel Template", - description: "Example Next.js app showing how to use Kernel SDK with Playwright in Vercel functions", + title: "KERNEL next.js template", + description: + "create cloud browsers with the KERNEL sdk and drive them from a vercel ai sdk agent that writes and runs playwright.", }; export default function RootLayout({ @@ -15,8 +26,8 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - + + {children} diff --git a/app/page.tsx b/app/page.tsx index 89421b9..48c8a8e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,586 +1,276 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; -import { Loader2, CheckCircle2, XCircle, Clock, Monitor, Terminal, Zap, ListTree } from "lucide-react"; -import { useState } from "react"; +import { DefaultChatTransport } from "ai"; +import { useChat } from "@ai-sdk/react"; +import { Loader2 } from "lucide-react"; import Image from "next/image"; -import Link from "next/link"; -import { Header } from "@/components/Header"; -import { StepsOverlay } from "@/components/StepsOverlay"; +import { useEffect, useMemo, useState } from "react"; -interface BrowserSession { - sessionId: string; - liveViewUrl: string; - cdpWsUrl: string; - spinUpTime: number; -} - -interface ExecutedCode { - code: string; - success: boolean; - result?: any; - error?: string; -} - -interface StepContentItem { - type: "tool-call" | "tool-result" | "text"; - toolCallId?: string; - toolName?: string; - code?: string; - result?: any; - success?: boolean; - text?: string; -} +import { AgentStepsSidebar } from "@/components/AgentStepsSidebar"; +import { BrowserPanel } from "@/components/BrowserPanel"; +import { Header } from "@/components/Header"; +import { HowItWorks } from "@/components/HowItWorks"; +import { StackTrace } from "@/components/ai-elements/stack-trace"; +import { Button } from "@/components/ui/button"; +import type { AgentUIMessage, BrowserSession } from "@/lib/types"; +import { cn } from "@/lib/utils"; -interface DetailedStep { - stepNumber: number; - finishReason: string | null; - content: StepContentItem[]; -} +const transport = new DefaultChatTransport({ + api: "/api/agent", +}); -interface AutomationResult { - success: boolean; - response?: string; - executedCodes?: ExecutedCode[]; - detailedSteps?: DetailedStep[]; - stepCount?: number; - timestamp: number; - error?: string; - task?: string; -} +// a refresh would otherwise strand the live session with no way back to it +const SESSION_STORAGE_KEY = "kernel-browser-session"; export default function HomePage() { - const [creatingBrowser, setCreatingBrowser] = useState(false); - const [runningAutomation, setRunningAutomation] = useState(false); - const [closingBrowser, setClosingBrowser] = useState(false); - const [browserSession, setBrowserSession] = useState( - null - ); - const [automationResults, setAutomationResults] = useState< - AutomationResult[] - >([]); + const [session, setSession] = useState(null); + const [creating, setCreating] = useState(false); + const [closing, setClosing] = useState(false); const [error, setError] = useState(null); const [deployUrl, setDeployUrl] = useState(null); - const [task, setTask] = useState("Go to https://news.ycombinator.com/ and get the first article title"); - const [stepsOverlayResult, setStepsOverlayResult] = useState(null); + + const { + messages, + sendMessage, + status, + error: chatError, + stop, + setMessages, + } = useChat({ transport }); + + useEffect(() => { + const stored = sessionStorage.getItem(SESSION_STORAGE_KEY); + if (!stored) return; + + try { + // one-time restore from sessionStorage on mount, not a derived value + // eslint-disable-next-line react-hooks/set-state-in-effect + setSession(JSON.parse(stored) as BrowserSession); + } catch { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } + }, []); + + useEffect(() => { + if (session) { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); + } else { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } + }, [session]); + + const stats = useMemo(() => { + let executions = 0; + let executionMs = 0; + + for (const message of messages) { + for (const part of message.parts) { + if (part.type !== "tool-playwright_execute") continue; + if (part.state !== "output-available") continue; + executions += 1; + executionMs += part.output.durationMs; + } + } + + return { executions, executionMs }; + }, [messages]); const createBrowser = async () => { - setCreatingBrowser(true); + setCreating(true); setError(null); setDeployUrl(null); try { - const response = await fetch("/api/create-browser", { - method: "POST", - }); + const response = await fetch("/api/create-browser", { method: "POST" }); + const data = await response.json().catch(() => null); - const data = await response.json(); + if (!data) { + setError(`create-browser failed with status ${response.status}`); + return; + } if (data.success) { - setBrowserSession({ + setMessages([]); + setSession({ sessionId: data.sessionId, liveViewUrl: data.liveViewUrl, cdpWsUrl: data.cdpWsUrl, spinUpTime: data.spinUpTime, + region: data.region, + stealth: data.stealth, }); } else { if (data.error === "MISSING_API_KEY" && data.deployUrl) { setDeployUrl(data.deployUrl); } - setError(data.message || data.error || "Failed to create browser"); - } - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to connect to API" - ); - } finally { - setCreatingBrowser(false); - } - }; - - const runAutomation = async () => { - if (!browserSession || !task.trim()) return; - - setRunningAutomation(true); - - try { - const response = await fetch("/api/agent", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sessionId: browserSession.sessionId, - task: task.trim(), - }), - }); - - const data = await response.json(); - - const result: AutomationResult = { - success: data.success, - response: data.response, - executedCodes: data.executedCodes, - detailedSteps: data.detailedSteps, - stepCount: data.stepCount, - error: data.error, - task: task.trim(), - timestamp: Date.now(), - }; - - setAutomationResults((prev) => [result, ...prev]); - - // Clear the task input after successful execution - if (data.success) { - setTask(""); + setError(data.message ?? data.error ?? "failed to create browser"); } - } catch (err) { - const result: AutomationResult = { - success: false, - error: "Failed to run AI agent", - task: task.trim(), - timestamp: Date.now(), - }; - setAutomationResults((prev) => [result, ...prev]); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "failed to reach the api"); } finally { - setRunningAutomation(false); + setCreating(false); } }; const closeBrowser = async () => { - if (!browserSession) return; + if (!session) return; - setClosingBrowser(true); + // a run in flight would keep executing against a session we are about to delete + stop(); + setClosing(true); try { const response = await fetch("/api/delete-browser", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sessionId: browserSession.sessionId, - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: session.sessionId }), }); + const data = await response.json().catch(() => null); - const data = await response.json(); - - if (data.success) { - // Clear browser session and reset state - setBrowserSession(null); - setAutomationResults([]); - setTask("Go to https://news.ycombinator.com/ and extract the first article title"); - } else { - setError(data.error || "Failed to close browser"); + if (!data?.success) { + setError( + data?.error ?? `delete-browser failed with status ${response.status}`, + ); + return; } - } catch (err) { + + setError(null); + setSession(null); + setMessages([]); + } catch (caught) { setError( - err instanceof Error ? err.message : "Failed to close browser" + caught instanceof Error ? caught.message : "failed to close the browser", ); } finally { - setClosingBrowser(false); + setClosing(false); } }; return ( -
- {/* Radial Glow Effect */} -
-
-
- - {/* Header */} -
-
-
+
+
+ +
+ {session ? ( +
+ {error && ( + + )} - {/* Main Content */} -
-
-
- {/* Hero Section */} -
-

- AI-Powered Browser Automation with - Kernel -

-

- Describe what you want to do in natural language, and watch as an AI agent executes browser automation in a serverless browser. -

+
+ + + sendMessage( + { text: task }, + { body: { sessionId: session.sessionId } }, + ) + } + onStop={stop} + />
- - {/* Step 1: Create Browser */} - {!browserSession && ( -
-
+ ) : ( +
+
+

+ instant browser infra for your next.js agent +

+

+ one call gives you a browser vm session with a live view. describe + a task, and a gpt-5.4 agent writes playwright, runs it in the + same vm, and returns the value. every generated line stays + visible next to the browser it ran in. +

+
+ -

Click to create serverless browser

+
- )} - - {/* Error Display */} - {error && !browserSession && ( - - -
-
- - Error -
-

{error}

- {deployUrl && ( -
-

- Deploy this template with the Kernel integration to get started: -

- - Deploy with Vercel - -
- )} -
-
-
- )} - - {/* Live View and Automation Controls */} - {browserSession && ( -
- {/* Live View */} - - -
-
-
- - Browser Live View -
- -
-
-