diff --git a/.gitignore b/.gitignore index de4d863e..4805dea9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ Cargo.lock .agents/ .claude/ .opencode/ +.ralph/ # Example temp files .tmp-upload/ diff --git a/docker/test-agent/Dockerfile b/docker/test-agent/Dockerfile index 67888b33..1920c1a4 100644 --- a/docker/test-agent/Dockerfile +++ b/docker/test-agent/Dockerfile @@ -47,6 +47,17 @@ RUN apt-get update -qq && \ xauth \ fonts-dejavu-core \ xterm \ + chromium \ + libnss3 \ + libatk-bridge2.0-0 \ + libdrm2 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpangocairo-1.0-0 \ + libgtk-3-0 \ > /dev/null 2>&1 && \ rm -rf /var/lib/apt/lists/* diff --git a/docs/browser-feature-matrix.mdx b/docs/browser-feature-matrix.mdx new file mode 100644 index 00000000..f2c7202c --- /dev/null +++ b/docs/browser-feature-matrix.mdx @@ -0,0 +1,148 @@ +--- +title: "Feature Matrix" +description: "Compare Sandbox Agent's capabilities against other sandbox and browser automation providers." +sidebarTitle: "Feature Matrix" +icon: "table-columns" +--- + +A comparison of Sandbox Agent's features against Daytona, E2B, Cloudflare (Browser Rendering), Browserbase, and common agent-browser tools (Steel, Stagehand, Browser Use). + +## Sandbox Lifecycle + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| Create sandbox | ✓ | ✓ | ✓ | ✓ | ✓ | +| Destroy/delete | ✓ | ✓ | ✓ | ✓ | ✓ | +| List sandboxes | ✓ | ✓ | ✓ | - | ✓ | +| Start/stop | ✓ | ✓ | ✓ | ✓ | - | +| Pause/resume | - | - | ✓ | - | - | +| Snapshots/templates | - | ✓ | ✓ | - | ✓ | +| Auto-stop timeout | - | ✓ | ✓ | ✓ | ✓ | +| Region selection | - | - | - | ✓ | ✓ | + +## Filesystem + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| Read file | ✓ | ✓ | ✓ | - | - | +| Write file | ✓ | ✓ | ✓ | - | - | +| List directory (recursive) | ✓ | ✓ | ✓ | - | - | +| Delete file/dir | ✓ | - | ✓ | - | - | +| Move/rename | ✓ | - | ✓ | - | - | +| Mkdir | ✓ | - | ✓ | - | - | +| File stat/metadata | ✓ | - | - | - | - | +| Batch upload (tar) | ✓ | - | - | - | - | +| File watch/events | - | - | ✓ | - | - | + +## Process Management + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| One-shot exec | ✓ | ✓ | ✓ | - | - | +| Background processes | ✓ | - | ✓ | - | - | +| Stream stdout/stderr | ✓ | - | ✓ | - | - | +| Interactive PTY (WebSocket) | ✓ | ✓ | ✓ | - | - | +| Terminal resize | ✓ | ✓ | ✓ | - | - | +| Send stdin | ✓ | - | ✓ | - | - | +| Kill/stop process | ✓ | - | ✓ | - | - | +| List processes | ✓ | - | ✓ | - | - | +| Process config (limits) | ✓ | - | - | - | - | + +## Desktop / Computer-Use + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| Virtual desktop | ✓ | - | ✓ | - | - | +| Screenshot (full) | ✓ | - | ✓ | ✓ | - | +| Screenshot (region) | ✓ | - | - | - | - | +| Mouse (move/click/drag/scroll) | ✓ | - | ✓ | - | - | +| Keyboard (type/press) | ✓ | - | ✓ | - | - | +| Window management | ✓ | - | - | - | - | +| Clipboard read/write | ✓ | - | - | - | - | +| Launch application | ✓ | - | - | - | - | +| Display info / DPI config | ✓ | - | - | - | - | +| Desktop recording | ✓ | - | - | - | ✓ | + +## Live Streaming + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| Live desktop stream | ✓ | - | ✓ | - | - | +| Protocol | WebRTC (Neko) | - | VNC | - | CDP screencast | +| Video codecs | VP8, VP9, H.264 | - | - | - | JPEG | +| Audio streaming | ✓ (Opus, G.722) | - | - | - | - | +| Interactive input via stream | ✓ | - | ✓ | - | Limited | +| Configurable FPS (1-60) | ✓ | - | - | - | - | +| Multi-viewer | ✓ | - | - | - | ✓ | +| Typical latency | 50-150ms | - | 100-500ms | - | 200-1000ms | + +## Browser Automation + +| Feature | Sandbox Agent | Cloudflare | Browserbase | Steel | Stagehand | +|---------|:---:|:---:|:---:|:---:|:---:| +| Start/stop browser | ✓ | ✓ | ✓ | ✓ | ✓ | +| CDP WebSocket access | ✓ | ✓ | ✓ | ✓ | - | +| Navigate / back / forward | ✓ | ✓ | ✓ | ✓ | ✓ | +| Tab management | ✓ | - | ✓ | - | - | +| Click / type / scroll (selector) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Screenshot (browser-level) | ✓ | ✓ | ✓ | ✓ | ✓ | +| PDF generation | ✓ | ✓ | ✓ | ✓ | - | +| Get page HTML | ✓ | ✓ | ✓ | - | - | +| Get page as Markdown | ✓ | ✓ | - | - | - | +| Scrape elements (selectors) | ✓ | ✓ | ✓ | - | - | +| Extract all links | ✓ | ✓ | - | - | - | +| Accessibility tree snapshot | ✓ | - | - | - | ✓ | +| Execute JavaScript | ✓ | - | ✓ | ✓ | - | +| Console log capture | ✓ | - | ✓ | - | - | +| Network request capture | ✓ | - | ✓ | - | - | +| Web crawling | ✓ | ✓ | - | - | - | +| Persistent browser profiles | ✓ | - | ✓ | ✓ | - | +| Cookie management | ✓ | - | ✓ | ✓ | - | +| File upload to input | ✓ | - | ✓ | ✓ | - | +| Dialog handling | ✓ | - | ✓ | - | - | +| Live browser streaming | ✓ (WebRTC) | - | ✓ (CDP) | ✓ | - | +| Anti-detection/stealth | - | - | ✓ | ✓ | - | +| Proxy support | - | - | ✓ | ✓ | - | +| CAPTCHA solving | - | - | ✓ | - | - | +| Browser extensions | - | - | ✓ | - | - | + +## Agent Integration + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| Agent Client Protocol (ACP) | ✓ | - | - | - | - | +| MCP server config | ✓ | - | - | - | - | +| Skills config | ✓ | - | - | - | - | +| Agent install/management | ✓ | - | - | - | - | +| Session persistence | ✓ | - | - | - | - | +| Permission system | ✓ | - | - | - | - | +| Code interpreter | - | - | ✓ | - | - | + +## SDKs and Tooling + +| Feature | Sandbox Agent | Daytona | E2B | Cloudflare | Browserbase | +|---------|:---:|:---:|:---:|:---:|:---:| +| TypeScript SDK | ✓ | ✓ | ✓ | ✓ | ✓ | +| Python SDK | - | ✓ | ✓ | - | ✓ | +| React components | ✓ | - | - | - | - | +| Inspector UI | ✓ | - | - | - | - | +| Provider abstraction (7+) | ✓ | - | - | - | - | +| WebRTC client library | ✓ | - | - | - | - | +| CLI | ✓ | ✓ | ✓ | ✓ | - | + +## Streaming Technology Comparison + +For platforms that support live desktop/browser streaming, here is how the underlying technologies compare: + +| Dimension | WebRTC (Neko) | VNC (noVNC) | CDP Screencast | WebSocket + JPEG | +|-----------|:---:|:---:|:---:|:---:| +| Typical latency | 50-150ms | 100-500ms | 200-1000ms | 150-400ms | +| Frame rate | 30-60 fps | 10-30 fps | 1-15 fps | 5-20 fps | +| Video quality | High | Medium | Low-Medium | Medium | +| Audio support | Yes | No | No | No | +| Interactive input | Full | Full | Limited | Limited | +| Bandwidth (adaptive) | Yes | No | No | No | +| Used by | Sandbox Agent | E2B, Gitpod | Browserbase | Various | + +Sandbox Agent uses [Neko](https://github.com/m1k1o/neko) (WebRTC) for streaming, which provides the lowest latency and best interactivity of any approach. The same stream serves both the full desktop and browser automation modes. + diff --git a/docs/docs.json b/docs/docs.json index 0c2b19aa..c9ebb0fe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -103,6 +103,7 @@ { "group": "More", "pages": [ + "browser-feature-matrix", "daemon", "cors", "session-restoration", diff --git a/docs/openapi.json b/docs/openapi.json index 11ff9566..9acd1ef0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -396,43 +396,35 @@ } } }, - "/v1/config/mcp": { - "get": { + "/v1/browser/back": { + "post": { "tags": ["v1"], - "operationId": "get_v1_config_mcp", - "parameters": [ - { - "name": "directory", - "in": "query", - "description": "Target directory", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "mcpName", - "in": "query", - "description": "MCP entry name", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "Navigate the browser back in history.", + "description": "Sends a CDP `Page.navigateToHistoryEntry` command with the previous\nhistory entry and returns the resulting page URL and title.", + "operationId": "post_v1_browser_back", "responses": { "200": { - "description": "MCP entry", + "description": "Page info after navigating back", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/McpServerConfig" + "$ref": "#/components/schemas/BrowserPageInfo" } } } }, - "404": { - "description": "Entry not found", + "409": { + "description": "Browser runtime is not active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -442,113 +434,142 @@ } } } - }, - "put": { + } + }, + "/v1/browser/cdp": { + "get": { "tags": ["v1"], - "operationId": "put_v1_config_mcp", - "parameters": [ - { - "name": "directory", - "in": "query", - "description": "Target directory", - "required": true, - "schema": { - "type": "string" + "summary": "Open a CDP WebSocket proxy session.", + "description": "Upgrades the connection to a WebSocket that relays bidirectionally to\nChromium's internal CDP WebSocket endpoint. External tools like Playwright\nor Puppeteer can connect via `ws://sandbox-host:2468/v1/browser/cdp`.", + "operationId": "get_v1_browser_cdp_ws", + "responses": { + "101": { + "description": "WebSocket upgraded" + }, + "409": { + "description": "Browser runtime is not active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - { - "name": "mcpName", - "in": "query", - "description": "MCP entry name", - "required": true, - "schema": { - "type": "string" + "502": { + "description": "CDP connection failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - ], + } + } + }, + "/v1/browser/click": { + "post": { + "tags": ["v1"], + "summary": "Click an element in the browser page.", + "description": "Finds the element matching `selector`, computes its center point via\n`DOM.getBoxModel`, and dispatches mouse events through `Input.dispatchMouseEvent`.", + "operationId": "post_v1_browser_click", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/McpServerConfig" + "$ref": "#/components/schemas/BrowserClickRequest" } } }, "required": true }, "responses": { - "204": { - "description": "Stored" - } - } - }, - "delete": { - "tags": ["v1"], - "operationId": "delete_v1_config_mcp", - "parameters": [ - { - "name": "directory", - "in": "query", - "description": "Target directory", - "required": true, - "schema": { - "type": "string" + "200": { + "description": "Click performed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserActionResponse" + } + } } }, - { - "name": "mcpName", - "in": "query", - "description": "MCP entry name", - "required": true, - "schema": { - "type": "string" + "404": { + "description": "Element not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Browser runtime is not active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "CDP command failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } - } - ], - "responses": { - "204": { - "description": "Deleted" } } } }, - "/v1/config/skills": { + "/v1/browser/console": { "get": { "tags": ["v1"], - "operationId": "get_v1_config_skills", + "summary": "Get browser console messages.", + "description": "Returns console messages captured from the browser, optionally filtered by\nlevel (log, debug, info, warning, error) and limited in count.", + "operationId": "get_v1_browser_console", "parameters": [ { - "name": "directory", + "name": "level", "in": "query", - "description": "Target directory", - "required": true, + "required": false, "schema": { - "type": "string" + "type": "string", + "nullable": true } }, { - "name": "skillName", + "name": "limit", "in": "query", - "description": "Skill entry name", - "required": true, + "required": false, "schema": { - "type": "string" + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 } } ], "responses": { "200": { - "description": "Skills entry", + "description": "Console messages retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillsConfig" + "$ref": "#/components/schemas/BrowserConsoleResponse" } } } }, - "404": { - "description": "Entry not found", + "409": { + "description": "Browser not active", "content": { "application/json": { "schema": { @@ -556,87 +577,29 @@ } } } - } - } - }, - "put": { - "tags": ["v1"], - "operationId": "put_v1_config_skills", - "parameters": [ - { - "name": "directory", - "in": "query", - "description": "Target directory", - "required": true, - "schema": { - "type": "string" - } }, - { - "name": "skillName", - "in": "query", - "description": "Skill entry name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SkillsConfig" + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } - }, - "required": true - }, - "responses": { - "204": { - "description": "Stored" - } - } - }, - "delete": { - "tags": ["v1"], - "operationId": "delete_v1_config_skills", - "parameters": [ - { - "name": "directory", - "in": "query", - "description": "Target directory", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "skillName", - "in": "query", - "description": "Skill entry name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" } } } }, - "/v1/desktop/clipboard": { + "/v1/browser/content": { "get": { "tags": ["v1"], - "summary": "Read the desktop clipboard.", - "description": "Returns the current text content of the X11 clipboard.", - "operationId": "get_v1_desktop_clipboard", + "summary": "Get the HTML content of the current browser page.", + "description": "Returns the outerHTML of the page or a specific element selected by a CSS\nselector, along with the current URL and title.", + "operationId": "get_v1_browser_content", "parameters": [ { - "name": "selection", + "name": "selector", "in": "query", "required": false, "schema": { @@ -647,17 +610,17 @@ ], "responses": { "200": { - "description": "Clipboard contents", + "description": "Page HTML content", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopClipboardResponse" + "$ref": "#/components/schemas/BrowserContentResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -666,8 +629,8 @@ } } }, - "500": { - "description": "Clipboard read failed", + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -677,35 +640,106 @@ } } } - }, - "post": { + } + }, + "/v1/browser/contexts": { + "get": { "tags": ["v1"], - "summary": "Write to the desktop clipboard.", - "description": "Sets the text content of the X11 clipboard.", - "operationId": "post_v1_desktop_clipboard", + "summary": "List browser contexts (persistent profiles).", + "description": "Returns all browser context directories with their name, creation date,\nand on-disk size.", + "operationId": "get_v1_browser_contexts", + "responses": { + "200": { + "description": "Browser contexts listed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserContextListResponse" + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": ["v1"], + "summary": "Create a browser context (persistent profile).", + "description": "Creates a new browser context directory that can be passed as contextId\nto the browser start endpoint for persistent cookies and storage.", + "operationId": "post_v1_browser_contexts", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopClipboardWriteRequest" + "$ref": "#/components/schemas/BrowserContextCreateRequest" } } }, "required": true }, + "responses": { + "201": { + "description": "Browser context created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserContextInfo" + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/browser/contexts/{context_id}": { + "delete": { + "tags": ["v1"], + "summary": "Delete a browser context (persistent profile).", + "description": "Removes the browser context directory and all stored data (cookies,\nlocal storage, cache, etc.).", + "operationId": "delete_v1_browser_context", + "parameters": [ + { + "name": "context_id", + "in": "path", + "description": "Browser context ID", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "Clipboard updated", + "description": "Browser context deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopActionResponse" + "$ref": "#/components/schemas/BrowserActionResponse" } } } }, - "409": { - "description": "Desktop runtime is not ready", + "404": { + "description": "Browser context not found", "content": { "application/json": { "schema": { @@ -715,7 +749,7 @@ } }, "500": { - "description": "Clipboard write failed", + "description": "Internal error", "content": { "application/json": { "schema": { @@ -727,25 +761,36 @@ } } }, - "/v1/desktop/display/info": { + "/v1/browser/cookies": { "get": { "tags": ["v1"], - "summary": "Get desktop display information.", - "description": "Performs a health-gated display query against the managed desktop and\nreturns the current display identifier and resolution.", - "operationId": "get_v1_desktop_display_info", + "summary": "Get browser cookies.", + "description": "Returns cookies from the browser, optionally filtered by URL.\nUses CDP Network.getCookies.", + "operationId": "get_v1_browser_cookies", + "parameters": [ + { + "name": "url", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], "responses": { "200": { - "description": "Desktop display information", + "description": "Cookies retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopDisplayInfoResponse" + "$ref": "#/components/schemas/BrowserCookiesResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser not active", "content": { "application/json": { "schema": { @@ -754,8 +799,8 @@ } } }, - "503": { - "description": "Desktop runtime health or display query failed", + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -765,19 +810,17 @@ } } } - } - }, - "/v1/desktop/keyboard/down": { + }, "post": { "tags": ["v1"], - "summary": "Press and hold a desktop keyboard key.", - "description": "Performs a health-gated `xdotool keydown` operation against the managed\ndesktop.", - "operationId": "post_v1_desktop_keyboard_down", + "summary": "Set browser cookies.", + "description": "Sets one or more cookies in the browser via CDP Network.setCookies.", + "operationId": "post_v1_browser_cookies", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopKeyboardDownRequest" + "$ref": "#/components/schemas/BrowserSetCookiesRequest" } } }, @@ -785,17 +828,27 @@ }, "responses": { "200": { - "description": "Desktop keyboard action result", + "description": "Cookies set", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopActionResponse" + "$ref": "#/components/schemas/BrowserActionResponse" } } } }, - "400": { - "description": "Invalid keyboard down request", + "409": { + "description": "Browser not active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -803,9 +856,47 @@ } } } + } + } + }, + "delete": { + "tags": ["v1"], + "summary": "Delete browser cookies.", + "description": "Deletes cookies matching the given name and/or domain. If no filters are\nprovided, clears all browser cookies.", + "operationId": "delete_v1_browser_cookies", + "parameters": [ + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Cookies deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserActionResponse" + } + } + } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser not active", "content": { "application/json": { "schema": { @@ -815,7 +906,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -827,17 +918,17 @@ } } }, - "/v1/desktop/keyboard/press": { + "/v1/browser/crawl": { "post": { "tags": ["v1"], - "summary": "Press a desktop keyboard shortcut.", - "description": "Performs a health-gated `xdotool key` operation against the managed\ndesktop.", - "operationId": "post_v1_desktop_keyboard_press", + "summary": "Crawl multiple pages starting from a URL.", + "description": "Performs a breadth-first crawl: navigates to each page, extracts content in\nthe requested format, collects links, and follows them within the configured\ndomain and depth limits.", + "operationId": "post_v1_browser_crawl", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopKeyboardPressRequest" + "$ref": "#/components/schemas/BrowserCrawlRequest" } } }, @@ -845,27 +936,17 @@ }, "responses": { "200": { - "description": "Desktop keyboard action result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopActionResponse" - } - } - } - }, - "400": { - "description": "Invalid keyboard press request", + "description": "Crawl results", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserCrawlResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -875,7 +956,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -887,17 +968,17 @@ } } }, - "/v1/desktop/keyboard/type": { + "/v1/browser/dialog": { "post": { "tags": ["v1"], - "summary": "Type desktop keyboard text.", - "description": "Performs a health-gated `xdotool type` operation against the managed\ndesktop.", - "operationId": "post_v1_desktop_keyboard_type", + "summary": "Handle a JavaScript dialog (alert, confirm, prompt) in the browser.", + "description": "Accepts or dismisses the currently open dialog using\n`Page.handleJavaScriptDialog`, optionally providing prompt text.", + "operationId": "post_v1_browser_dialog", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopKeyboardTypeRequest" + "$ref": "#/components/schemas/BrowserDialogRequest" } } }, @@ -905,27 +986,17 @@ }, "responses": { "200": { - "description": "Desktop keyboard action result", + "description": "Dialog handled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopActionResponse" - } - } - } - }, - "400": { - "description": "Invalid keyboard type request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserActionResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -935,7 +1006,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -947,17 +1018,17 @@ } } }, - "/v1/desktop/keyboard/up": { + "/v1/browser/execute": { "post": { "tags": ["v1"], - "summary": "Release a desktop keyboard key.", - "description": "Performs a health-gated `xdotool keyup` operation against the managed\ndesktop.", - "operationId": "post_v1_desktop_keyboard_up", + "summary": "Execute a JavaScript expression in the browser.", + "description": "Evaluates the given expression via CDP `Runtime.evaluate` and returns the\nresult value and its type. Set `awaitPromise` to resolve async expressions.", + "operationId": "post_v1_browser_execute", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopKeyboardUpRequest" + "$ref": "#/components/schemas/BrowserExecuteRequest" } } }, @@ -965,27 +1036,17 @@ }, "responses": { "200": { - "description": "Desktop keyboard action result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopActionResponse" - } - } - } - }, - "400": { - "description": "Invalid keyboard up request", + "description": "Execution result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserExecuteResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -995,7 +1056,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1007,35 +1068,25 @@ } } }, - "/v1/desktop/launch": { + "/v1/browser/forward": { "post": { "tags": ["v1"], - "summary": "Launch a desktop application.", - "description": "Launches an application by name on the managed desktop, optionally waiting\nfor its window to appear.", - "operationId": "post_v1_desktop_launch", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopLaunchRequest" - } - } - }, - "required": true - }, + "summary": "Navigate the browser forward in history.", + "description": "Sends a CDP `Page.navigateToHistoryEntry` command with the next\nhistory entry and returns the resulting page URL and title.", + "operationId": "post_v1_browser_forward", "responses": { "200": { - "description": "Application launched", + "description": "Page info after navigating forward", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopLaunchResponse" + "$ref": "#/components/schemas/BrowserPageInfo" } } } }, - "404": { - "description": "Application not found", + "409": { + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1044,8 +1095,8 @@ } } }, - "409": { - "description": "Desktop runtime is not ready", + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1057,17 +1108,17 @@ } } }, - "/v1/desktop/mouse/click": { + "/v1/browser/hover": { "post": { "tags": ["v1"], - "summary": "Click on the desktop.", - "description": "Performs a health-gated pointer move and click against the managed desktop\nand returns the resulting mouse position.", - "operationId": "post_v1_desktop_mouse_click", + "summary": "Hover over an element.", + "description": "Finds the element matching `selector`, computes its center via `DOM.getBoxModel`,\nand dispatches a `mouseMoved` event.", + "operationId": "post_v1_browser_hover", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopMouseClickRequest" + "$ref": "#/components/schemas/BrowserHoverRequest" } } }, @@ -1075,17 +1126,17 @@ }, "responses": { "200": { - "description": "Desktop mouse position after click", + "description": "Hover performed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" + "$ref": "#/components/schemas/BrowserActionResponse" } } } }, - "400": { - "description": "Invalid mouse click request", + "404": { + "description": "Element not found", "content": { "application/json": { "schema": { @@ -1095,7 +1146,7 @@ } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1105,7 +1156,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1117,45 +1168,25 @@ } } }, - "/v1/desktop/mouse/down": { - "post": { + "/v1/browser/links": { + "get": { "tags": ["v1"], - "summary": "Press and hold a desktop mouse button.", - "description": "Performs a health-gated optional pointer move followed by `xdotool mousedown`\nand returns the resulting mouse position.", - "operationId": "post_v1_desktop_mouse_down", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMouseDownRequest" - } - } - }, - "required": true - }, + "summary": "Get all links on the current page.", + "description": "Extracts all anchor elements from the page via CDP and returns their href\nand text content.", + "operationId": "get_v1_browser_links", "responses": { "200": { - "description": "Desktop mouse position after button press", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" - } - } - } - }, - "400": { - "description": "Invalid mouse down request", + "description": "Links on the page", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserLinksResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1165,7 +1196,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1177,45 +1208,25 @@ } } }, - "/v1/desktop/mouse/drag": { - "post": { + "/v1/browser/markdown": { + "get": { "tags": ["v1"], - "summary": "Drag the desktop mouse.", - "description": "Performs a health-gated drag gesture against the managed desktop and\nreturns the resulting mouse position.", - "operationId": "post_v1_desktop_mouse_drag", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMouseDragRequest" - } - } - }, - "required": true - }, + "summary": "Get the page content as Markdown.", + "description": "Extracts the DOM HTML via CDP, strips navigation/footer/aside elements, and\nconverts the remaining content to Markdown using html2md.", + "operationId": "get_v1_browser_markdown", "responses": { "200": { - "description": "Desktop mouse position after drag", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" - } - } - } - }, - "400": { - "description": "Invalid mouse drag request", + "description": "Page content as Markdown", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserMarkdownResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1225,7 +1236,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1237,17 +1248,17 @@ } } }, - "/v1/desktop/mouse/move": { + "/v1/browser/navigate": { "post": { "tags": ["v1"], - "summary": "Move the desktop mouse.", - "description": "Performs a health-gated absolute pointer move on the managed desktop and\nreturns the resulting mouse position.", - "operationId": "post_v1_desktop_mouse_move", + "summary": "Navigate the browser to a URL.", + "description": "Sends a CDP `Page.navigate` command and optionally waits for a lifecycle\nevent before returning the resulting page URL, title, and HTTP status.", + "operationId": "post_v1_browser_navigate", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopMouseMoveRequest" + "$ref": "#/components/schemas/BrowserNavigateRequest" } } }, @@ -1255,17 +1266,27 @@ }, "responses": { "200": { - "description": "Desktop mouse position after move", + "description": "Navigation result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" + "$ref": "#/components/schemas/BrowserPageInfo" } } } }, - "400": { - "description": "Invalid mouse move request", + "409": { + "description": "Browser runtime is not active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1273,9 +1294,51 @@ } } } + } + } + } + }, + "/v1/browser/network": { + "get": { + "tags": ["v1"], + "summary": "Get browser network requests.", + "description": "Returns network requests captured from the browser, optionally filtered by\nURL pattern and limited in count.", + "operationId": "get_v1_browser_network", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + } + }, + { + "name": "urlPattern", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Network requests retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserNetworkResponse" + } + } + } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser not active", "content": { "application/json": { "schema": { @@ -1284,8 +1347,8 @@ } } }, - "502": { - "description": "Desktop runtime health or input failed", + "500": { + "description": "Internal error", "content": { "application/json": { "schema": { @@ -1297,25 +1360,61 @@ } } }, - "/v1/desktop/mouse/position": { + "/v1/browser/pdf": { "get": { "tags": ["v1"], - "summary": "Get the current desktop mouse position.", - "description": "Performs a health-gated mouse position query against the managed desktop.", - "operationId": "get_v1_desktop_mouse_position", - "responses": { - "200": { - "description": "Desktop mouse position", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" + "summary": "Generate a PDF of the current browser page.", + "description": "Generates a PDF document from the current page via CDP `Page.printToPDF`\nand returns the PDF bytes.", + "operationId": "get_v1_browser_pdf", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BrowserPdfFormat" } - } + ], + "nullable": true + } + }, + { + "name": "landscape", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "printBackground", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "scale", + "in": "query", + "required": false, + "schema": { + "type": "number", + "format": "float", + "nullable": true } + } + ], + "responses": { + "200": { + "description": "Browser page as PDF bytes" }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1325,7 +1424,7 @@ } }, "502": { - "description": "Desktop runtime health or input check failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1337,17 +1436,17 @@ } } }, - "/v1/desktop/mouse/scroll": { + "/v1/browser/reload": { "post": { "tags": ["v1"], - "summary": "Scroll the desktop mouse wheel.", - "description": "Performs a health-gated scroll gesture at the requested coordinates and\nreturns the resulting mouse position.", - "operationId": "post_v1_desktop_mouse_scroll", + "summary": "Reload the current browser page.", + "description": "Sends a CDP `Page.reload` command with an optional cache bypass flag\nand returns the resulting page URL and title.", + "operationId": "post_v1_browser_reload", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopMouseScrollRequest" + "$ref": "#/components/schemas/BrowserReloadRequest" } } }, @@ -1355,27 +1454,17 @@ }, "responses": { "200": { - "description": "Desktop mouse position after scroll", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" - } - } - } - }, - "400": { - "description": "Invalid mouse scroll request", + "description": "Page info after reload", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserPageInfo" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1385,7 +1474,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1397,17 +1486,17 @@ } } }, - "/v1/desktop/mouse/up": { + "/v1/browser/scrape": { "post": { "tags": ["v1"], - "summary": "Release a desktop mouse button.", - "description": "Performs a health-gated optional pointer move followed by `xdotool mouseup`\nand returns the resulting mouse position.", - "operationId": "post_v1_desktop_mouse_up", + "summary": "Scrape structured data from the current page using CSS selectors.", + "description": "For each key in the `selectors` map, runs `querySelectorAll` with the CSS\nselector value and collects `textContent` from every match. If `url` is\nprovided the browser navigates there first.", + "operationId": "post_v1_browser_scrape", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopMouseUpRequest" + "$ref": "#/components/schemas/BrowserScrapeRequest" } } }, @@ -1415,27 +1504,17 @@ }, "responses": { "200": { - "description": "Desktop mouse position after button release", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopMousePositionResponse" - } - } - } - }, - "400": { - "description": "Invalid mouse up request", + "description": "Scraped data", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BrowserScrapeResponse" } } } }, "409": { - "description": "Desktop runtime is not ready", + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1445,7 +1524,7 @@ } }, "502": { - "description": "Desktop runtime health or input failed", + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1457,35 +1536,72 @@ } } }, - "/v1/desktop/open": { - "post": { + "/v1/browser/screenshot": { + "get": { "tags": ["v1"], - "summary": "Open a file or URL with the default handler.", - "description": "Opens a file path or URL using xdg-open on the managed desktop.", - "operationId": "post_v1_desktop_open", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopOpenRequest" - } + "summary": "Capture a browser page screenshot.", + "description": "Captures a screenshot of the current browser page via CDP\n`Page.captureScreenshot` and returns the image bytes with the appropriate\nContent-Type header.", + "operationId": "get_v1_browser_screenshot", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BrowserScreenshotFormat" + } + ], + "nullable": true } }, - "required": true - }, + { + "name": "quality", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + } + }, + { + "name": "fullPage", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "selector", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], "responses": { "200": { - "description": "Target opened", + "description": "Browser screenshot as image bytes" + }, + "409": { + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopOpenResponse" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "409": { - "description": "Desktop runtime is not ready", + "502": { + "description": "CDP command failed", "content": { "application/json": { "schema": { @@ -1497,17 +1613,17 @@ } } }, - "/v1/desktop/recording/start": { + "/v1/browser/scroll": { "post": { "tags": ["v1"], - "summary": "Start desktop recording.", - "description": "Starts an ffmpeg x11grab recording against the managed desktop and returns\nthe created recording metadata.", - "operationId": "post_v1_desktop_recording_start", + "summary": "Scroll the page or a specific element.", + "description": "If a `selector` is provided, scrolls that element. Otherwise scrolls the\npage window by the given `x` and `y` pixel offsets.", + "operationId": "post_v1_browser_scroll", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopRecordingStartRequest" + "$ref": "#/components/schemas/BrowserScrollRequest" } } }, @@ -1515,17 +1631,17 @@ }, "responses": { "200": { - "description": "Desktop recording started", + "description": "Scroll performed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DesktopRecordingInfo" + "$ref": "#/components/schemas/BrowserActionResponse" } } } }, - "409": { - "description": "Desktop runtime is not ready or a recording is already active", + "404": { + "description": "Element not found", "content": { "application/json": { "schema": { @@ -1534,8 +1650,8 @@ } } }, - "502": { - "description": "Desktop recording failed", + "409": { + "description": "Browser runtime is not active", "content": { "application/json": { "schema": { @@ -1543,29 +1659,59 @@ } } } - } - } - } + }, + "502": { + "description": "CDP command failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } }, - "/v1/desktop/recording/stop": { + "/v1/browser/select": { "post": { "tags": ["v1"], - "summary": "Stop desktop recording.", - "description": "Stops the active desktop recording and returns the finalized recording\nmetadata.", - "operationId": "post_v1_desktop_recording_stop", + "summary": "Select an option in a ` setWidth(e.target.value)} inputMode="numeric" /> + +
+ + setHeight(e.target.value)} inputMode="numeric" /> +
+
+ + setStartUrl(e.target.value)} placeholder="https://example.com" /> +
+
+ + +
+ + +
+ {isActive ? ( + + ) : ( + + )} +
+ + + {/* ========== Missing Dependencies ========== */} + {status?.missingDependencies && status.missingDependencies.length > 0 && ( +
+
+ Missing Dependencies +
+
+ {status.missingDependencies.map((dep) => ( + + {dep} + + ))} +
+ {status.installCommand && ( + <> +
+ Install command +
+
{status.installCommand}
+ + )} +
+ )} + + {/* ========== Live View Section ========== */} +
+
+ + + Live View + + {isActive && ( + + )} +
+ + {liveViewError && ( +
+ {liveViewError} +
+ )} + + {!isActive &&
Start the browser runtime to enable live view.
} + + {isActive && liveViewActive && ( + <> + {/* Navigation Bar */} +
+ + + + setNavUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleNavigate(navUrl); + } + }} + placeholder="Enter URL..." + style={{ flex: 1, fontSize: 11 }} + /> +
+ + + + {status?.url && ( +
+ {status.url} +
+ )} + + )} + + {isActive && !liveViewActive &&
Click "Start Stream" for live browser view.
} +
+ + {/* ========== Screenshot Section ========== */} + {isActive && ( +
+
+ + + Screenshot + + +
+ +
+
+ + +
+ {screenshotFormat !== "png" && ( +
+ + setScreenshotQuality(e.target.value)} + inputMode="numeric" + style={{ maxWidth: 60 }} + /> +
+ )} + +
+ + setScreenshotSelector(e.target.value)} + placeholder="e.g. #main" + style={{ maxWidth: 140 }} + /> +
+
+ + {screenshotError && ( +
+ {screenshotError} +
+ )} + + {screenshotUrl ? ( +
+ Browser screenshot +
+ ) : ( +
Click "Capture" to take a browser screenshot.
+ )} +
+ )} + + {/* ========== Tabs Section ========== */} + {isActive && ( +
+
+ + + Tabs + + +
+ + {tabsError && ( +
+ {tabsError} +
+ )} + + {tabs.length > 0 ? ( +
+ {tabs.map((tab) => ( +
+
+
+ {tab.title || "(untitled)"} + {tab.active && ( + + active + + )} +
+ {tab.url} +
+
+
+ {!tab.active && ( + + )} + +
+
+
+ ))} +
+ ) : ( +
No tabs open.
+ )} + +
+ setNewTabUrl(e.target.value)} + placeholder="https://example.com" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateTab(); + }} + style={{ flex: 1, fontSize: 11 }} + /> + +
+
+ )} + + {/* ========== Console Section ========== */} + {isActive && ( +
+
+ + + Console + +
+ +
+
+ + {/* Level filter pills */} +
+ {CONSOLE_LEVELS.map((level) => ( + + ))} +
+ + {consoleError && ( +
+ {consoleError} +
+ )} + + {consoleMessages.length > 0 ? ( +
+ {consoleMessages.map((msg, idx) => ( +
+ + {msg.level} + {msg.text} + {new Date(msg.timestamp).toLocaleTimeString()} +
+ ))} +
+
+ ) : ( +
No console messages.
+ )} +
+ )} + + {/* ========== Network Section ========== */} + {isActive && ( +
+
+ + + +
+ +
+ setNetworkUrlPattern(e.target.value)} + placeholder="Filter by URL pattern..." + style={{ width: "100%", fontSize: 11 }} + /> +
+ + {networkError && ( +
+ {networkError} +
+ )} + + {networkRequests.length > 0 ? ( +
+ {networkRequests.map((req, idx) => ( +
+ + {req.method} + + = 400 + ? "var(--danger, #ef4444)" + : req.status && req.status >= 300 + ? "var(--warning, #f59e0b)" + : "var(--success, #22c55e)", + }} + > + {req.status ?? "..."} + + {req.url} + {req.responseSize != null ? formatBytes(req.responseSize) : ""} + + {req.duration != null ? `${req.duration}ms` : ""} + +
+ ))} +
+ ) : ( +
No network requests captured.
+ )} +
+ )} + + {/* ========== Content Tools Section ========== */} + {isActive && ( +
+
+ + + Content Tools + +
+ +
+ {(["html", "markdown", "links", "snapshot"] as const).map((type) => ( + + ))} +
+ + {contentError && ( +
+ {contentError} +
+ )} + + {contentOutput ? ( +