├── .env.example ├── app ├── favicon.ico ├── opengraph-image.png ├── twitter-image.png ├── api │ ├── kill-desktop │ │ └── route.ts │ └── chat │ │ └── route.ts ├── layout.tsx ├── globals.css └── page.tsx ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── eslint.config.mjs ├── components.json ├── .gitignore ├── tsconfig.json ├── components ├── ui │ ├── label.tsx │ ├── sonner.tsx │ ├── input.tsx │ ├── resizable.tsx │ └── button.tsx ├── prompt-suggestions.tsx ├── input.tsx ├── project-info.tsx ├── icons.tsx └── message.tsx ├── lib ├── use-scroll-to-bottom.tsx ├── utils.ts └── e2b │ ├── utils.ts │ └── tool.ts ├── next.config.ts ├── package.json └── README.md /.env.example: -------------------------------------------------------------------------------- 1 | ANTHROPIC_API_KEY= 2 | E2B_API_KEY= 3 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-computer-use/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-computer-use/HEAD/app/opengraph-image.png -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-computer-use/HEAD/app/twitter-image.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env 35 | .env.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | .env*.local 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /lib/use-scroll-to-bottom.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, type RefObject } from 'react'; 2 | 3 | export function useScrollToBottom(): [ 4 | RefObject, 5 | RefObject, 6 | ] { 7 | const containerRef = useRef(null); 8 | const endRef = useRef(null); 9 | 10 | useEffect(() => { 11 | const container = containerRef.current; 12 | const end = endRef.current; 13 | 14 | if (container && end) { 15 | const observer = new MutationObserver(() => { 16 | end.scrollIntoView({ behavior: 'instant', block: 'end' }); 17 | }); 18 | 19 | observer.observe(container, { 20 | childList: true, 21 | subtree: true, 22 | attributes: true, 23 | characterData: true, 24 | }); 25 | 26 | return () => observer.disconnect(); 27 | } 28 | }, []); 29 | 30 | // @ts-expect-error error 31 | return [containerRef, endRef]; 32 | } -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /app/api/kill-desktop/route.ts: -------------------------------------------------------------------------------- 1 | import { killDesktop } from "@/lib/e2b/utils"; 2 | 3 | // Common handler for both GET and POST requests 4 | async function handleKillDesktop(request: Request) { 5 | // Enable CORS to ensure this works across all browsers 6 | 7 | const { searchParams } = new URL(request.url); 8 | const sandboxId = searchParams.get("sandboxId"); 9 | 10 | console.log(`Kill desktop request received via ${request.method} for ID: ${sandboxId}`); 11 | 12 | if (!sandboxId) { 13 | return new Response("No sandboxId provided", { status: 400 }); 14 | } 15 | 16 | try { 17 | await killDesktop(sandboxId); 18 | return new Response("Desktop killed successfully", { status: 200 }); 19 | } catch (error) { 20 | console.error(`Failed to kill desktop with ID: ${sandboxId}`, error); 21 | return new Response("Failed to kill desktop", { status: 500 }); 22 | } 23 | } 24 | 25 | // Handle POST requests 26 | export async function POST(request: Request) { 27 | return handleKillDesktop(request); 28 | } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import { Analytics } from "@vercel/analytics/react" 6 | 7 | const geistSans = Geist({ 8 | variable: "--font-geist-sans", 9 | subsets: ["latin"], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: "--font-geist-mono", 14 | subsets: ["latin"], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "AI SDK Computer Use Demo", 19 | description: "A Next.js app that uses the AI SDK and Anthropic to create a computer using agent.", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 32 | {children} 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | async headers() { 6 | return [ 7 | { 8 | source: "/:path*", 9 | headers: [ 10 | { 11 | key: "Content-Security-Policy", 12 | value: [ 13 | "default-src 'self'", 14 | "frame-src https://*.e2b.dev https://*.e2b.app https://va.vercel-scripts.com", 15 | "frame-ancestors 'self' https://*.e2b.dev https://*.e2b.app", 16 | "connect-src 'self' https://*.e2b.dev https://*.e2b.app", 17 | "img-src 'self' data: https://*.e2b.dev https://*.e2b.app", 18 | "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.e2b.dev https://*.e2b.app https://va.vercel-scripts.com", 19 | "style-src 'self' 'unsafe-inline'", 20 | ].join("; "), 21 | }, 22 | { 23 | key: "X-Frame-Options", 24 | value: "SAMEORIGIN", 25 | }, 26 | ], 27 | }, 28 | ]; 29 | }, 30 | }; 31 | 32 | export default nextConfig; 33 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { UIMessage } from "ai"; 2 | import { clsx, type ClassValue } from "clsx"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export const ABORTED = "User aborted"; 10 | 11 | export const prunedMessages = (messages: UIMessage[]): UIMessage[] => { 12 | if (messages.at(-1)?.role === "assistant") { 13 | return messages; 14 | } 15 | 16 | return messages.map((message) => { 17 | // check if last message part is a tool invocation in a call state, then append a part with the tool result 18 | message.parts = message.parts.map((part) => { 19 | if (part.type === "tool-invocation") { 20 | if ( 21 | part.toolInvocation.toolName === "computer" && 22 | part.toolInvocation.args.action === "screenshot" 23 | ) { 24 | return { 25 | ...part, 26 | toolInvocation: { 27 | ...part.toolInvocation, 28 | result: { 29 | type: "text", 30 | text: "Image redacted to save input tokens", 31 | }, 32 | }, 33 | }; 34 | } 35 | return part; 36 | } 37 | return part; 38 | }); 39 | return message; 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /lib/e2b/utils.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { Sandbox } from "@e2b/desktop"; 4 | import { resolution } from "./tool"; 5 | 6 | export const getDesktop = async (id?: string) => { 7 | try { 8 | if (id) { 9 | const connected = await Sandbox.connect(id); 10 | const isRunning = await connected.isRunning(); 11 | if (isRunning) { 12 | // await connected.stream.start(); 13 | return connected; 14 | } 15 | } 16 | 17 | const desktop = await Sandbox.create({ 18 | resolution: [resolution.x, resolution.y], // Custom resolution 19 | timeoutMs: 300000, // Container timeout in milliseconds 20 | }); 21 | await desktop.stream.start(); 22 | return desktop; 23 | } catch (error) { 24 | console.error("Error in getDesktop:", error); 25 | throw error; 26 | } 27 | }; 28 | 29 | export const getDesktopURL = async (id?: string) => { 30 | try { 31 | const desktop = await getDesktop(id); 32 | const streamUrl = desktop.stream.getUrl(); 33 | 34 | return { streamUrl, id: desktop.sandboxId }; 35 | } catch (error) { 36 | console.error("Error in getDesktopURL:", error); 37 | throw error; 38 | } 39 | }; 40 | 41 | export const killDesktop = async (id: string = "desktop") => { 42 | const desktop = await getDesktop(id); 43 | await desktop.kill(); 44 | }; 45 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-sdk-computer-use", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/anthropic": "^1.1.15", 13 | "@ai-sdk/react": "^1.1.21", 14 | "@e2b/desktop": "^1.7.3", 15 | "@radix-ui/react-label": "^2.1.2", 16 | "@radix-ui/react-slot": "^1.1.2", 17 | "@vercel/analytics": "^1.5.0", 18 | "ai": "^4.1.54", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.1", 21 | "fast-deep-equal": "^3.1.3", 22 | "lucide-react": "^0.479.0", 23 | "motion": "^12.4.10", 24 | "next": "15.2.6", 25 | "next-themes": "^0.4.5", 26 | "react": "^19.0.1", 27 | "react-dom": "^19.0.1", 28 | "react-resizable-panels": "^2.1.7", 29 | "sonner": "^2.0.1", 30 | "streamdown": "^1.6.7", 31 | "tailwind-merge": "^3.0.2", 32 | "tailwindcss-animate": "^1.0.7", 33 | "zod": "^3.24.2" 34 | }, 35 | "devDependencies": { 36 | "@eslint/eslintrc": "^3", 37 | "@tailwindcss/postcss": "^4", 38 | "@tailwindcss/typography": "^0.5.16", 39 | "@types/node": "^20", 40 | "@types/react": "^19", 41 | "@types/react-dom": "^19", 42 | "eslint": "^9", 43 | "eslint-config-next": "15.2.6", 44 | "tailwindcss": "^4", 45 | "typescript": "^5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/prompt-suggestions.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUpRight } from "lucide-react"; 2 | import { Button } from "./ui/button"; 3 | 4 | const suggestions = [ 5 | { 6 | text: "Get the latest Vercel blog post", 7 | prompt: "Go to vercel.com/blog and get the latest post", 8 | }, 9 | // { 10 | // text: "search google for cute dogs", 11 | // prompt: "Launch browser and search Google for labradoodle puppies. Show me images.", 12 | // }, 13 | { 14 | text: "Create a new text file", 15 | prompt: "Open a text editor and create a new file called notes.txt and write 'we are so back!'", 16 | }, 17 | // { 18 | // text: "Check system memory usage", 19 | // prompt: "Run the top command to show system resource usage", 20 | // }, 21 | { 22 | text: "Get the latest rauchg tweet", 23 | prompt: "Go to twitter.com/rauchg and get the latest tweet", 24 | }, 25 | // { 26 | // text: "What do you see", 27 | // prompt: 28 | // "Capture a screenshot of the current screen and tell me what you see", 29 | // }, 30 | ]; 31 | 32 | export const PromptSuggestions = ({ 33 | submitPrompt, 34 | disabled, 35 | }: { 36 | submitPrompt: (prompt: string) => void; 37 | disabled: boolean; 38 | }) => { 39 | return ( 40 |
41 | {suggestions.map((suggestion, index) => ( 42 | 56 | ))} 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { anthropic } from "@ai-sdk/anthropic"; 2 | import { streamText, UIMessage } from "ai"; 3 | import { killDesktop } from "@/lib/e2b/utils"; 4 | import { bashTool, computerTool } from "@/lib/e2b/tool"; 5 | import { prunedMessages } from "@/lib/utils"; 6 | 7 | // Allow streaming responses up to 30 seconds 8 | export const maxDuration = 300; 9 | 10 | export async function POST(req: Request) { 11 | const { messages, sandboxId }: { messages: UIMessage[]; sandboxId: string } = 12 | await req.json(); 13 | try { 14 | const result = streamText({ 15 | model: anthropic("claude-3-7-sonnet-20250219"), // Using Sonnet for computer use 16 | system: 17 | "You are a helpful assistant with access to a computer. " + 18 | "Use the computer tool to help the user with their requests. " + 19 | "Use the bash tool to execute commands on the computer. You can create files and folders using the bash tool. Always prefer the bash tool where it is viable for the task. " + 20 | "Be sure to advise the user when waiting is necessary. " + 21 | "If the browser opens with a setup wizard, YOU MUST IGNORE IT and move straight to the next step (e.g. input the url in the search bar).", 22 | messages: prunedMessages(messages), 23 | tools: { computer: computerTool(sandboxId), bash: bashTool(sandboxId) }, 24 | providerOptions: { 25 | anthropic: { cacheControl: { type: "ephemeral" } }, 26 | }, 27 | }); 28 | 29 | // Create response stream 30 | const response = result.toDataStreamResponse({ 31 | // @ts-expect-error eheljfe 32 | getErrorMessage(error) { 33 | console.error(error); 34 | return error; 35 | }, 36 | }); 37 | 38 | return response; 39 | } catch (error) { 40 | console.error("Chat API error:", error); 41 | await killDesktop(sandboxId); // Force cleanup on error 42 | return new Response(JSON.stringify({ error: "Internal Server Error" }), { 43 | status: 500, 44 | headers: { "Content-Type": "application/json" }, 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/ui/resizable.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { GripVerticalIcon } from "lucide-react" 5 | import * as ResizablePrimitive from "react-resizable-panels" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function ResizablePanelGroup({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | ) 23 | } 24 | 25 | function ResizablePanel({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function ResizableHandle({ 32 | withHandle, 33 | className, 34 | ...props 35 | }: React.ComponentProps & { 36 | withHandle?: boolean 37 | }) { 38 | return ( 39 | div]:rotate-90", 43 | className 44 | )} 45 | {...props} 46 | > 47 | {withHandle && ( 48 |
49 | 50 |
51 | )} 52 |
53 | ) 54 | } 55 | 56 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle } 57 | -------------------------------------------------------------------------------- /components/input.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUp } from "lucide-react"; 2 | import { Input as ShadcnInput } from "./ui/input"; 3 | 4 | interface InputProps { 5 | input: string; 6 | handleInputChange: (event: React.ChangeEvent) => void; 7 | isInitializing: boolean; 8 | isLoading: boolean; 9 | status: string; 10 | stop: () => void; 11 | } 12 | 13 | export const Input = ({ 14 | input, 15 | handleInputChange, 16 | isInitializing, 17 | isLoading, 18 | status, 19 | stop, 20 | }: InputProps) => { 21 | return ( 22 |
23 | 31 | {status === "streaming" || status === "submitted" ? ( 32 | 56 | ) : ( 57 | 64 | )} 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | pill: "flex items-center justify-between px-2 rounded-lg py-1 bg-secondary text-sm hover:opacity-70 group transition-opacity duration-200 font-normal" 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | pill: "h-8 text-sm" 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean 48 | }) { 49 | const Comp = asChild ? Slot : "button" 50 | 51 | return ( 52 | 57 | ) 58 | } 59 | 60 | export { Button, buttonVariants } 61 | -------------------------------------------------------------------------------- /components/project-info.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "motion/react"; 2 | import { VercelIcon } from "./icons"; 3 | import { ComputerIcon } from "lucide-react"; 4 | import Link from "next/link"; 5 | 6 | export const ProjectInfo = () => { 7 | return ( 8 | 9 |
10 |

11 | 12 | + 13 | 14 |

15 |

Computer Use Agent

16 |

17 | This demo showcases a Computer Use Agent built with the{" "} 18 | AI SDK,{" "} 19 | 20 | Anthropic Claude Sonnet 3.7 21 | 22 | , and e2b desktop. 23 |

24 |

25 | {" "} 26 | Learn more about{" "} 27 | 32 | Computer Use{" "} 33 | 34 | with the AI SDK. 35 |

36 |
37 |
38 | ); 39 | }; 40 | 41 | const StyledLink = ({ 42 | children, 43 | href, 44 | }: { 45 | children: React.ReactNode; 46 | href: string; 47 | }) => { 48 | return ( 49 | 54 | {children} 55 | 56 | ); 57 | }; 58 | 59 | // const Code = ({ text }: { text: string }) => { 60 | // return {text}; 61 | // }; 62 | 63 | export const DeployButton = () => { 64 | return ( 65 | 70 | 71 | Deploy 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

AI SDK Computer Use Demo

3 |
4 | 5 |

6 | An open-source AI chatbot app template demonstrating Anthropic Claude 3.7 Sonnet's computer use capabilities, built with Next.js and the AI SDK by Vercel. 7 |

8 | 9 |

10 | Features · 11 | Deploy Your Own · 12 | Running Locally · 13 | Authors 14 |

15 |
16 | 17 | ## Features 18 | 19 | - Streaming text responses powered by the [AI SDK by Vercel](https://sdk.vercel.ai/docs), allowing multiple AI providers to be used interchangeably with just a few lines of code. 20 | - Integration with Anthropic Claude 3.7 Sonnet's computer use tool and bash tool capabilities. 21 | - Sandbox environment with [e2b](https://e2b.dev) for secure execution. 22 | - [shadcn/ui](https://ui.shadcn.com/) components for a modern, responsive UI powered by [Tailwind CSS](https://tailwindcss.com). 23 | - Built with the latest [Next.js](https://nextjs.org) App Router. 24 | 25 | ## Deploy Your Own 26 | 27 | You can deploy your own version to Vercel by clicking the button below: 28 | 29 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?project-name=AI+SDK+Computer+Use+Demo&repository-name=ai-sdk-computer-use&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-computer-use&demo-title=AI+SDK+Computer+Use+Demo&demo-url=https%3A%2F%2Fai-sdk-computer-use.vercel.app%2F&demo-description=A+chatbot+application+built+with+Next.js+demonstrating+Anthropic+Claude+3.7+Sonnet%27s+computer+use+capabilities&env=ANTHROPIC_API_KEY,E2B_API_KEY) 30 | 31 | ## Running Locally 32 | 33 | 1. Clone the repository and install dependencies: 34 | 35 | ```bash 36 | npm install 37 | # or 38 | yarn install 39 | # or 40 | pnpm install 41 | ``` 42 | 43 | 2. Install the [Vercel CLI](https://vercel.com/docs/cli): 44 | 45 | ```bash 46 | npm i -g vercel 47 | # or 48 | yarn global add vercel 49 | # or 50 | pnpm install -g vercel 51 | ``` 52 | 53 | Once installed, link your local project to your Vercel project: 54 | 55 | ```bash 56 | vercel link 57 | ``` 58 | 59 | After linking, pull your environment variables: 60 | 61 | ```bash 62 | vercel env pull 63 | ``` 64 | 65 | This will create a `.env.local` file with all the necessary environment variables. 66 | 67 | 3. Run the development server: 68 | 69 | ```bash 70 | npm run dev 71 | # or 72 | yarn dev 73 | # or 74 | pnpm dev 75 | ``` 76 | 77 | 4. Open [http://localhost:3000](http://localhost:3000) to view your new AI chatbot application. 78 | 79 | ## Authors 80 | 81 | This repository is maintained by the [Vercel](https://vercel.com) team and community contributors. 82 | 83 | Contributions are welcome! Feel free to open issues or submit pull requests to enhance functionality or fix bugs. 84 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @source "../node_modules/streamdown/dist/*.js"; 3 | 4 | @plugin "tailwindcss-animate"; 5 | @plugin "@tailwindcss/typography"; 6 | 7 | @custom-variant dark (&:is(.dark *)); 8 | 9 | @theme inline { 10 | --color-background: var(--background); 11 | --color-foreground: var(--foreground); 12 | --font-sans: var(--font-geist-sans); 13 | --font-mono: var(--font-geist-mono); 14 | --color-sidebar-ring: var(--sidebar-ring); 15 | --color-sidebar-border: var(--sidebar-border); 16 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 17 | --color-sidebar-accent: var(--sidebar-accent); 18 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 19 | --color-sidebar-primary: var(--sidebar-primary); 20 | --color-sidebar-foreground: var(--sidebar-foreground); 21 | --color-sidebar: var(--sidebar); 22 | --color-chart-5: var(--chart-5); 23 | --color-chart-4: var(--chart-4); 24 | --color-chart-3: var(--chart-3); 25 | --color-chart-2: var(--chart-2); 26 | --color-chart-1: var(--chart-1); 27 | --color-ring: var(--ring); 28 | --color-input: var(--input); 29 | --color-border: var(--border); 30 | --color-destructive-foreground: var(--destructive-foreground); 31 | --color-destructive: var(--destructive); 32 | --color-accent-foreground: var(--accent-foreground); 33 | --color-accent: var(--accent); 34 | --color-muted-foreground: var(--muted-foreground); 35 | --color-muted: var(--muted); 36 | --color-secondary-foreground: var(--secondary-foreground); 37 | --color-secondary: var(--secondary); 38 | --color-primary-foreground: var(--primary-foreground); 39 | --color-primary: var(--primary); 40 | --color-popover-foreground: var(--popover-foreground); 41 | --color-popover: var(--popover); 42 | --color-card-foreground: var(--card-foreground); 43 | --color-card: var(--card); 44 | --radius-sm: calc(var(--radius) - 4px); 45 | --radius-md: calc(var(--radius) - 2px); 46 | --radius-lg: var(--radius); 47 | --radius-xl: calc(var(--radius) + 4px); 48 | } 49 | 50 | :root { 51 | --background: oklch(1 0 0); 52 | --foreground: oklch(0.141 0.005 285.823); 53 | --card: oklch(1 0 0); 54 | --card-foreground: oklch(0.141 0.005 285.823); 55 | --popover: oklch(1 0 0); 56 | --popover-foreground: oklch(0.141 0.005 285.823); 57 | --primary: oklch(0.21 0.006 285.885); 58 | --primary-foreground: oklch(0.985 0 0); 59 | --secondary: oklch(0.967 0.001 286.375); 60 | --secondary-foreground: oklch(0.21 0.006 285.885); 61 | --muted: oklch(0.967 0.001 286.375); 62 | --muted-foreground: oklch(0.552 0.016 285.938); 63 | --accent: oklch(0.967 0.001 286.375); 64 | --accent-foreground: oklch(0.21 0.006 285.885); 65 | --destructive: oklch(0.577 0.245 27.325); 66 | --destructive-foreground: oklch(0.577 0.245 27.325); 67 | --border: oklch(0.92 0.004 286.32); 68 | --input: oklch(0.92 0.004 286.32); 69 | --ring: oklch(0.705 0.015 286.067); 70 | --chart-1: oklch(0.646 0.222 41.116); 71 | --chart-2: oklch(0.6 0.118 184.704); 72 | --chart-3: oklch(0.398 0.07 227.392); 73 | --chart-4: oklch(0.828 0.189 84.429); 74 | --chart-5: oklch(0.769 0.188 70.08); 75 | --radius: 0.625rem; 76 | --sidebar: oklch(0.985 0 0); 77 | --sidebar-foreground: oklch(0.141 0.005 285.823); 78 | --sidebar-primary: oklch(0.21 0.006 285.885); 79 | --sidebar-primary-foreground: oklch(0.985 0 0); 80 | --sidebar-accent: oklch(0.967 0.001 286.375); 81 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885); 82 | --sidebar-border: oklch(0.92 0.004 286.32); 83 | --sidebar-ring: oklch(0.705 0.015 286.067); 84 | } 85 | 86 | .dark { 87 | --background: oklch(0.141 0.005 285.823); 88 | --foreground: oklch(0.985 0 0); 89 | --card: oklch(0.141 0.005 285.823); 90 | --card-foreground: oklch(0.985 0 0); 91 | --popover: oklch(0.141 0.005 285.823); 92 | --popover-foreground: oklch(0.985 0 0); 93 | --primary: oklch(0.985 0 0); 94 | --primary-foreground: oklch(0.21 0.006 285.885); 95 | --secondary: oklch(0.274 0.006 286.033); 96 | --secondary-foreground: oklch(0.985 0 0); 97 | --muted: oklch(0.274 0.006 286.033); 98 | --muted-foreground: oklch(0.705 0.015 286.067); 99 | --accent: oklch(0.274 0.006 286.033); 100 | --accent-foreground: oklch(0.985 0 0); 101 | --destructive: oklch(0.396 0.141 25.723); 102 | --destructive-foreground: oklch(0.637 0.237 25.331); 103 | --border: oklch(0.274 0.006 286.033); 104 | --input: oklch(0.274 0.006 286.033); 105 | --ring: oklch(0.442 0.017 285.786); 106 | --chart-1: oklch(0.488 0.243 264.376); 107 | --chart-2: oklch(0.696 0.17 162.48); 108 | --chart-3: oklch(0.769 0.188 70.08); 109 | --chart-4: oklch(0.627 0.265 303.9); 110 | --chart-5: oklch(0.645 0.246 16.439); 111 | --sidebar: oklch(0.21 0.006 285.885); 112 | --sidebar-foreground: oklch(0.985 0 0); 113 | --sidebar-primary: oklch(0.488 0.243 264.376); 114 | --sidebar-primary-foreground: oklch(0.985 0 0); 115 | --sidebar-accent: oklch(0.274 0.006 286.033); 116 | --sidebar-accent-foreground: oklch(0.985 0 0); 117 | --sidebar-border: oklch(0.274 0.006 286.033); 118 | --sidebar-ring: oklch(0.442 0.017 285.786); 119 | } 120 | 121 | @layer base { 122 | * { 123 | @apply border-border outline-ring/50; 124 | } 125 | body { 126 | @apply bg-background text-foreground; 127 | } 128 | } 129 | 130 | /* Hide scrollbar for Chrome, Safari and Opera */ 131 | /* ::-webkit-scrollbar { 132 | display: none; 133 | } */ 134 | 135 | /* Hide scrollbar for IE, Edge and Firefox */ 136 | /* * { 137 | -ms-overflow-style: none; 138 | scrollbar-width: none; 139 | } */ 140 | -------------------------------------------------------------------------------- /lib/e2b/tool.ts: -------------------------------------------------------------------------------- 1 | import { anthropic } from "@ai-sdk/anthropic"; 2 | import { getDesktop } from "./utils"; 3 | 4 | const wait = async (seconds: number) => { 5 | await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); 6 | }; 7 | 8 | export const resolution = { x: 1024, y: 768 }; 9 | 10 | export const computerTool = (sandboxId: string) => 11 | anthropic.tools.computer_20250124({ 12 | displayWidthPx: resolution.x, 13 | displayHeightPx: resolution.y, 14 | displayNumber: 1, 15 | execute: async ({ 16 | action, 17 | coordinate, 18 | text, 19 | duration, 20 | scroll_amount, 21 | scroll_direction, 22 | start_coordinate, 23 | }) => { 24 | const desktop = await getDesktop(sandboxId); 25 | 26 | switch (action) { 27 | case "screenshot": { 28 | const image = await desktop.screenshot(); 29 | // Convert image data to base64 immediately 30 | const base64Data = Buffer.from(image).toString("base64"); 31 | return { 32 | type: "image" as const, 33 | data: base64Data, 34 | }; 35 | } 36 | case "wait": { 37 | if (!duration) throw new Error("Duration required for wait action"); 38 | const actualDuration = Math.min(duration, 2); 39 | await wait(actualDuration); 40 | return { 41 | type: "text" as const, 42 | text: `Waited for ${actualDuration} seconds`, 43 | }; 44 | } 45 | case "left_click": { 46 | if (!coordinate) 47 | throw new Error("Coordinate required for left click action"); 48 | const [x, y] = coordinate; 49 | await desktop.moveMouse(x, y); 50 | await desktop.leftClick(); 51 | return { type: "text" as const, text: `Left clicked at ${x}, ${y}` }; 52 | } 53 | case "double_click": { 54 | if (!coordinate) 55 | throw new Error("Coordinate required for double click action"); 56 | const [x, y] = coordinate; 57 | await desktop.moveMouse(x, y); 58 | await desktop.doubleClick(); 59 | return { 60 | type: "text" as const, 61 | text: `Double clicked at ${x}, ${y}`, 62 | }; 63 | } 64 | case "right_click": { 65 | if (!coordinate) 66 | throw new Error("Coordinate required for right click action"); 67 | const [x, y] = coordinate; 68 | await desktop.moveMouse(x, y); 69 | await desktop.rightClick(); 70 | return { type: "text" as const, text: `Right clicked at ${x}, ${y}` }; 71 | } 72 | case "mouse_move": { 73 | if (!coordinate) 74 | throw new Error("Coordinate required for mouse move action"); 75 | const [x, y] = coordinate; 76 | await desktop.moveMouse(x, y); 77 | return { type: "text" as const, text: `Moved mouse to ${x}, ${y}` }; 78 | } 79 | case "type": { 80 | if (!text) throw new Error("Text required for type action"); 81 | await desktop.write(text); 82 | return { type: "text" as const, text: `Typed: ${text}` }; 83 | } 84 | case "key": { 85 | if (!text) throw new Error("Key required for key action"); 86 | await desktop.press(text === "Return" ? "enter" : text); 87 | return { type: "text" as const, text: `Pressed key: ${text}` }; 88 | } 89 | case "scroll": { 90 | if (!scroll_direction) 91 | throw new Error("Scroll direction required for scroll action"); 92 | if (!scroll_amount) 93 | throw new Error("Scroll amount required for scroll action"); 94 | 95 | await desktop.scroll( 96 | scroll_direction as "up" | "down", 97 | scroll_amount, 98 | ); 99 | return { type: "text" as const, text: `Scrolled ${text}` }; 100 | } 101 | case "left_click_drag": { 102 | if (!start_coordinate || !coordinate) 103 | throw new Error("Coordinate required for mouse move action"); 104 | const [startX, startY] = start_coordinate; 105 | const [endX, endY] = coordinate; 106 | 107 | await desktop.drag([startX, startY], [endX, endY]); 108 | return { 109 | type: "text" as const, 110 | text: `Dragged mouse from ${startX}, ${startY} to ${endX}, ${endY}`, 111 | }; 112 | } 113 | default: 114 | throw new Error(`Unsupported action: ${action}`); 115 | } 116 | }, 117 | experimental_toToolResultContent(result) { 118 | if (typeof result === "string") { 119 | return [{ type: "text", text: result }]; 120 | } 121 | if (result.type === "image" && result.data) { 122 | return [ 123 | { 124 | type: "image", 125 | data: result.data, 126 | mimeType: "image/png", 127 | }, 128 | ]; 129 | } 130 | if (result.type === "text" && result.text) { 131 | return [{ type: "text", text: result.text }]; 132 | } 133 | throw new Error("Invalid result format"); 134 | }, 135 | }); 136 | 137 | export const bashTool = (sandboxId?: string) => 138 | anthropic.tools.bash_20250124({ 139 | execute: async ({ command }) => { 140 | const desktop = await getDesktop(sandboxId); 141 | 142 | try { 143 | const result = await desktop.commands.run(command); 144 | return ( 145 | result.stdout || "(Command executed successfully with no output)" 146 | ); 147 | } catch (error) { 148 | console.error("Bash command failed:", error); 149 | if (error instanceof Error) { 150 | return `Error executing command: ${error.message}`; 151 | } else { 152 | return `Error executing command: ${String(error)}`; 153 | } 154 | } 155 | }, 156 | }); 157 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export const BotIcon = () => { 4 | return ( 5 | 12 | 18 | 19 | ); 20 | }; 21 | 22 | export const AISDKLogo = () => { 23 | return ( 24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 | 39 | 43 | 47 | 51 | 52 |
53 |
54 | AI{" "} 55 | 56 | SDK 57 | 58 |
59 |
60 | 61 |
62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export const UserIcon = () => { 69 | return ( 70 | 78 | 84 | 85 | ); 86 | }; 87 | 88 | export const VercelIcon = ({ size = 16 }: { size: number }) => { 89 | return ( 90 | 97 | 103 | 104 | ); 105 | }; 106 | 107 | export const ObjectIcon = () => { 108 | return ( 109 | 116 | 122 | 123 | ); 124 | }; 125 | 126 | export const GitIcon = () => { 127 | return ( 128 | 135 | 136 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | ); 150 | }; 151 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PreviewMessage } from "@/components/message"; 4 | import { getDesktopURL } from "@/lib/e2b/utils"; 5 | import { useScrollToBottom } from "@/lib/use-scroll-to-bottom"; 6 | import { useChat } from "@ai-sdk/react"; 7 | import { useEffect, useState } from "react"; 8 | import { Input } from "@/components/input"; 9 | import { Button } from "@/components/ui/button"; 10 | import { toast } from "sonner"; 11 | import { DeployButton, ProjectInfo } from "@/components/project-info"; 12 | import { AISDKLogo } from "@/components/icons"; 13 | import { PromptSuggestions } from "@/components/prompt-suggestions"; 14 | import { 15 | ResizableHandle, 16 | ResizablePanel, 17 | ResizablePanelGroup, 18 | } from "@/components/ui/resizable"; 19 | import { ABORTED } from "@/lib/utils"; 20 | 21 | export default function Chat() { 22 | // Create separate refs for mobile and desktop to ensure both scroll properly 23 | const [desktopContainerRef, desktopEndRef] = useScrollToBottom(); 24 | const [mobileContainerRef, mobileEndRef] = useScrollToBottom(); 25 | 26 | const [isInitializing, setIsInitializing] = useState(true); 27 | const [streamUrl, setStreamUrl] = useState(null); 28 | const [sandboxId, setSandboxId] = useState(null); 29 | 30 | const { 31 | messages, 32 | input, 33 | handleInputChange, 34 | handleSubmit, 35 | status, 36 | stop: stopGeneration, 37 | append, 38 | setMessages, 39 | } = useChat({ 40 | api: "/api/chat", 41 | id: sandboxId ?? undefined, 42 | body: { 43 | sandboxId, 44 | }, 45 | maxSteps: 30, 46 | onError: (error) => { 47 | console.error(error); 48 | toast.error("There was an error", { 49 | description: "Please try again later.", 50 | richColors: true, 51 | position: "top-center", 52 | }); 53 | }, 54 | }); 55 | 56 | const stop = () => { 57 | stopGeneration(); 58 | 59 | const lastMessage = messages.at(-1); 60 | const lastMessageLastPart = lastMessage?.parts.at(-1); 61 | if ( 62 | lastMessage?.role === "assistant" && 63 | lastMessageLastPart?.type === "tool-invocation" 64 | ) { 65 | setMessages((prev) => [ 66 | ...prev.slice(0, -1), 67 | { 68 | ...lastMessage, 69 | parts: [ 70 | ...lastMessage.parts.slice(0, -1), 71 | { 72 | ...lastMessageLastPart, 73 | toolInvocation: { 74 | ...lastMessageLastPart.toolInvocation, 75 | state: "result", 76 | result: ABORTED, 77 | }, 78 | }, 79 | ], 80 | }, 81 | ]); 82 | } 83 | }; 84 | 85 | const isLoading = status !== "ready"; 86 | 87 | const refreshDesktop = async () => { 88 | try { 89 | setIsInitializing(true); 90 | const { streamUrl, id } = await getDesktopURL(sandboxId || undefined); 91 | // console.log("Refreshed desktop connection with ID:", id); 92 | setStreamUrl(streamUrl); 93 | setSandboxId(id); 94 | } catch (err) { 95 | console.error("Failed to refresh desktop:", err); 96 | } finally { 97 | setIsInitializing(false); 98 | } 99 | }; 100 | 101 | // Kill desktop on page close 102 | useEffect(() => { 103 | if (!sandboxId) return; 104 | 105 | // Function to kill the desktop - just one method to reduce duplicates 106 | const killDesktop = () => { 107 | if (!sandboxId) return; 108 | 109 | // Use sendBeacon which is best supported across browsers 110 | navigator.sendBeacon( 111 | `/api/kill-desktop?sandboxId=${encodeURIComponent(sandboxId)}`, 112 | ); 113 | }; 114 | 115 | // Detect iOS / Safari 116 | const isIOS = 117 | /iPad|iPhone|iPod/.test(navigator.userAgent) || 118 | (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 119 | const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 120 | 121 | // Choose exactly ONE event handler based on the browser 122 | if (isIOS || isSafari) { 123 | // For Safari on iOS, use pagehide which is most reliable 124 | window.addEventListener("pagehide", killDesktop); 125 | 126 | return () => { 127 | window.removeEventListener("pagehide", killDesktop); 128 | // Also kill desktop when component unmounts 129 | killDesktop(); 130 | }; 131 | } else { 132 | // For all other browsers, use beforeunload 133 | window.addEventListener("beforeunload", killDesktop); 134 | 135 | return () => { 136 | window.removeEventListener("beforeunload", killDesktop); 137 | // Also kill desktop when component unmounts 138 | killDesktop(); 139 | }; 140 | } 141 | }, [sandboxId]); 142 | 143 | useEffect(() => { 144 | // Initialize desktop and get stream URL when the component mounts 145 | const init = async () => { 146 | try { 147 | setIsInitializing(true); 148 | 149 | // Use the provided ID or create a new one 150 | const { streamUrl, id } = await getDesktopURL(sandboxId ?? undefined); 151 | 152 | setStreamUrl(streamUrl); 153 | setSandboxId(id); 154 | } catch (err) { 155 | console.error("Failed to initialize desktop:", err); 156 | toast.error("Failed to initialize desktop"); 157 | } finally { 158 | setIsInitializing(false); 159 | } 160 | }; 161 | 162 | init(); 163 | // eslint-disable-next-line react-hooks/exhaustive-deps 164 | }, []); 165 | 166 | return ( 167 |
168 | {/* Mobile/tablet banner */} 169 |
170 | Headless mode 171 |
172 | 173 | {/* Resizable Panels */} 174 |
175 | 176 | {/* Desktop Stream Panel */} 177 | 182 | {streamUrl ? ( 183 | <> 184 |