Packages
@surfjs/react
React SDK for Surf Live — real-time state sync hooks and components.
@surfjs/react
React SDK for Surf Live — connect your React app to a Surf WebSocket server and receive real-time state updates from AI agents.
Installation#
npm install @surfjs/reactPeer dependencies: react ^18.0.0 || ^19.0.0
SurfProvider#
Wrap your app with SurfProvider to establish a WebSocket connection:
import { SurfProvider } from '@surfjs/react' function App() { return ( <SurfProvider url="wss://myapp.com/surf/ws" auth="your-auth-token" channels={['project-123']} > <MyComponent /> </SurfProvider> )}Props
| Prop | Type | Description |
|------|------|-------------|
| url | string | WebSocket URL to connect to |
| auth | string? | Auth token sent on connect |
| channels | string[]? | Channels to subscribe to on connect |
| endpoint | string? | HTTP endpoint for manifest/execute fallback. Also registers window.surf |
Auto-Reconnect
The provider automatically reconnects with exponential backoff: 1s → 2s → 4s → 8s → max 30s.
Channel subscriptions are restored on reconnect.
useSurf#
Access the core Surf context — execute commands and check connection status:
import { useSurf } from '@surfjs/react' function Controls() { const { execute, connected, status } = useSurf() const addClip = async () => { const result = await execute('video.addClip', { url: 'clip.mp4' }) if (result.ok) console.log('Added:', result.result) } return ( <button onClick={addClip} disabled={!connected}> Add Clip </button> )}Returns
| Property | Type | Description |
|----------|------|-------------|
| execute | (command, params?) => Promise<SurfResult> | Execute a Surf command |
| connected | boolean | Whether WebSocket is connected |
| status | ConnectionStatus | 'connecting' \| 'connected' \| 'disconnected' \| 'reconnecting' |
| sessionId | string? | Current session ID |
| subscribe | (event, callback) => () => void | Subscribe to a Surf event |
| subscribeChannel | (channelId) => void | Subscribe to a Surf Live channel |
| unsubscribeChannel | (channelId) => void | Unsubscribe from a Surf Live channel |
| channels | ReadonlySet<string> | Currently subscribed channels |
useSurfEvent#
Subscribe to real-time events. Automatically cleans up on unmount:
import { useSurfEvent } from '@surfjs/react' function Timeline() { const [clips, setClips] = useState([]) useSurfEvent('timeline.updated', (data) => { setClips(data.clips) }) return <ClipList clips={clips} />}useSurfState#
Synced state that auto-updates from Surf Live broadcast events:
import { useSurfState } from '@surfjs/react' function Editor() { const [state, setState] = useSurfState('project-123', { timeline: { clips: [], playhead: 0 }, selectedClip: null, }) // `state` updates automatically when the server calls: // surf.live.setState('project-123', newState) // surf.live.patchState('project-123', partialUpdate) return ( <div> <p>Playhead: {state.timeline.playhead}s</p> <p>Clips: {state.timeline.clips.length}</p> </div> )}useSurfState handles both full state updates (surf:state) and patches (surf:patch). It uses version numbers to prevent stale updates.
useSurfChannel#
Dynamically manage channel subscriptions:
import { useSurfChannel } from '@surfjs/react' function ProjectSwitcher({ projectId }) { const { subscribe, unsubscribe, channels } = useSurfChannel() useEffect(() => { subscribe(projectId) return () => unsubscribe(projectId) }, [projectId]) return <p>Subscribed to: {[...channels].join(', ')}</p>}Connection Status#
import { useSurf } from '@surfjs/react' function StatusIndicator() { const { status } = useSurf() const colors = { connecting: 'yellow', connected: 'green', disconnected: 'red', reconnecting: 'orange', } return ( <span style={{ color: colors[status] }}> {status} </span> )}SurfBadge#
A small, premium badge that signals your app is Surf-enabled. It serves two purposes:
- For humans — a subtle trust signal, like a maker's mark or verification stamp
- For AI vision models — machine-readable context embedded in the DOM so agents can discover your Surf commands without downloading the manifest
When to use it
The badge is optional. Surf discovery works without it — agents and the CLI find your app via /.well-known/surf.json. The badge helps in situations where:
- A vision-based agent is browsing your site and needs to know it's Surf-enabled
- You want a visual indicator for users that AI agents can interact with your app
- You're showcasing Surf integration (demos, landing pages, portfolios)
You don't need it if your app is primarily used programmatically (API-to-API), or if the badge doesn't fit your design.
Usage
import { SurfBadge } from '@surfjs/react' function Layout({ children }) { return ( <> {children} <SurfBadge endpoint="https://myapp.com" name="My App" description="AI-enabled design tool" commands={[ { name: 'canvas.addShape', description: 'Add a shape to the canvas' }, { name: 'canvas.getState', description: 'Get current canvas state' }, ]} /> </> )}Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| endpoint | string | — | Your app's URL (used to link to the manifest) |
| name | string? | — | App name shown in the badge |
| description | string? | — | Short description for vision model context |
| commands | SurfBadgeCommand[]? | [] | Commands to embed for AI discovery |
| position | 'bottom-right' \| 'bottom-left' \| 'inline' | 'bottom-right' | Badge placement |
| theme | 'dark' \| 'light' \| 'auto' | 'auto' | Color theme (auto-detects by default) |
| className | string? | — | Additional CSS class |
How it works
The badge renders:
- A holographic seal SVG with guilloche patterns, precision ticks, and a wave glyph — styled to feel like a watermark on official documents
- A hidden
<div>containing a micro-manifest — the full list of commands, CLI entry points, and endpoint URL in plain text that vision models can read data-surf-*attributes for programmatic discovery by browser extensions or scripts- Proper
aria-labelfor screen readers
The seal adapts to light and dark mode automatically and has a slow ambient color drift that keeps it feeling alive without being distracting. On hover, it lifts slightly. Clicking opens your manifest JSON.
Without React
If you're not using React, you can achieve the same effect manually:
Plain HTML:
<!-- Hidden context for vision models --><div aria-hidden="true" data-surf-badge="true" data-surf-endpoint="https://myapp.com" data-surf-manifest="https://myapp.com/.well-known/surf.json" data-surf-commands="search,getProduct,addToCart" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)">[SURF PROTOCOL — AI AGENT INTERFACE]This website exposes structured commands for AI agents.MANIFEST: https://myapp.com/.well-known/surf.jsonCLI: surf exec myapp.com <command>COMMANDS (3): → search — Search products → getProduct — Get product details → addToCart — Add item to cart</div> <!-- Visible badge (style to match your site) --><a href="https://myapp.com/.well-known/surf.json" target="_blank" rel="noopener" title="Surf Protocol · 3 commands · myapp.com"> Surf-Enabled</a>Vue / Svelte / Other frameworks:
The key elements are:
- A hidden
<div>withdata-surf-*attributes and the micro-manifest as text content - An optional visible element linking to your manifest
- Both are static HTML — no framework-specific logic needed
useSurfCommands — Register Client-Side Handlers
Register local command handlers that run in the browser when an agent calls window.surf.execute(). This is how you make commands execute locally instead of going through the server.
useSurfCommandsis React sugar for@surfjs/web'sregisterCommand. If you're not using React, see the vanilla JS equivalent below or the@surfjs/webdocs.
Basic Usage
import { useSurfCommands } from '@surfjs/react' function CanvasApp() { useSurfCommands({ 'canvas.addCircle': { mode: 'local', run: (params) => { const circle = canvasStore.addCircle(params) return { ok: true, result: circle } } }, 'canvas.clear': { mode: 'local', run: () => { canvasStore.clear() return { ok: true } } }, }) return <CanvasRenderer />}When an agent calls window.surf.execute('canvas.addCircle', { x: 100 }), the local handler runs immediately — no network request, no server round-trip. The canvas updates instantly.
Modes
| Mode | Behavior | Use case |
|------|----------|----------|
| 'local' | Runs in browser only. No server contact. | UI-only commands, reads, local state |
| 'sync' | Runs locally first, then POSTs to server in background | Instant UI + server persistence |
useSurfCommands({ // Pure local — no server 'canvas.addCircle': { mode: 'local', run: (params) => { canvasStore.addCircle(params) return { ok: true } } }, // Local + background sync 'doc.save': { mode: 'sync', run: (params) => { docStore.updateLocal(params) return { ok: true } // After returning, Surf POSTs to server automatically // Server can broadcast via Surf Live to other clients } },})How It Relates to window.surf
useSurfCommands registers handlers in the window.surf local handler registry. When window.surf.execute() is called:
- Check if a local handler exists for this command
- Yes → run the local handler (instant)
- No → fall back to HTTP POST to server
This means useSurfCommands turns window.surf from an HTTP wrapper into a local runtime. Without it, all commands go to the server. With it, commands execute where you choose.
With and Without SurfProvider
- With
SurfProvider: Local handlers execute locally.mode: 'sync'commands sync through the WebSocket connection. Full Surf Live integration. - Without
SurfProvider(SurfBadge only): Local handlers still execute locally.mode: 'sync'commands sync via HTTP POST. No real-time broadcast to other clients.
Cleanup
Handlers are automatically deregistered when the component unmounts. This prevents stale handlers in single-page apps with conditional rendering.
Vanilla JS Alternative
Not using React? Use @surfjs/web directly — useSurfCommands is just a lifecycle wrapper around these functions:
import { initSurf, registerCommand } from '@surfjs/web' // Initialize window.surfinitSurf({ endpoint: 'https://myapp.com' }) // Register local handlers — same as useSurfCommands but framework-agnosticregisterCommand('canvas.addCircle', { mode: 'local', run: (params) => { const circle = canvasStore.addCircle(params) return { ok: true, result: circle } }}) registerCommand('doc.save', { mode: 'sync', run: (params) => { docStore.updateLocal(params) return { ok: true } }})See @surfjs/web for the full API.
window.surf — Browser Agent Interface
window.surf is a local execution runtime, not an HTTP wrapper. It maintains a registry of local handlers (registered via useSurfCommands) and dispatches commands to either local handlers or the server.
The Dispatch Flow
window.surf.execute('command', params) │ ▼ Local handler registered? │ │ YES NO │ │ ▼ ▼ Local run HTTP POST to server (0ms network) (network round-trip)How It's Registered
- With
SurfProvider: Commands without local handlers go through WebSocket (real-time, bidirectional). - With
SurfBadgeonly: Commands without local handlers go through HTTP POST. - Priority: If both are present,
SurfProvidertakes precedence.
// SurfProvider — WebSocket fallback for server commands<SurfProvider url="wss://myapp.com/surf/ws" endpoint="https://myapp.com"> <App /></SurfProvider> // SurfBadge — HTTP fallback for server commands<SurfBadge endpoint="https://myapp.com" commands={[...]} />API Reference
| Property | Type | Description |
|----------|------|-------------|
| execute(command, params?) | Promise<{ ok, result?, error? }> | Execute a command (local handler or server) |
| manifest() | Promise<{ name?, commands }> | Fetch the full manifest |
| commands | Record<string, { description, params?, hints? }> | Cached command list |
| status | 'connected' \| 'disconnected' \| 'connecting' | Connection status |
| version | string | Surf protocol version |
Example: Agent Interacting with a Canvas App
An agent navigates to a canvas app that uses useSurfCommands for local handlers:
// Discover what the app can doconst manifest = await window.surf.manifest()// => {// 'canvas.addCircle': { hints: { execution: 'browser' } },// 'canvas.getState': { hints: { execution: 'browser' } },// 'canvas.export': { hints: { execution: 'server' } },// } // Execute a local command — runs in-browser, canvas updates instantlyawait window.surf.execute('canvas.addCircle', { x: 200, y: 150, radius: 40, fill: '#3b82f6' })// => { ok: true, result: { id: 'c1' } }// Circle appears on canvas immediately // Read local stateconst state = await window.surf.execute('canvas.getState')// => { ok: true, result: { shapes: [{ id: 'c1', ... }] } } // Server command — proxied automaticallyconst png = await window.surf.execute('canvas.export', { width: 1920 })// => { ok: true, result: { url: '/exports/canvas-abc.png' } }Example: Testing from Browser DevTools
// Quick test during developmentawait window.surf.execute('cart.add', { sku: 'LAPTOP-01', quantity: 2 })// => { ok: true, result: { cartId: '...', itemCount: 2 } } // Check which commands have local handlers vs serverwindow.surf.commands// => { 'canvas.addCircle': { hints: { execution: 'browser' } }, ... }Cleanup
window.surf is removed when SurfProvider or SurfBadge unmounts. All local handlers registered via useSurfCommands are deregistered when their parent component unmounts.
TypeScript#
All hooks and components are fully typed. The SurfResult type:
interface SurfResult { ok: boolean result?: unknown error?: { code: string; message: string } state?: Record<string, unknown>}