Skip to content

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#

Bash
npm install @surfjs/react

Peer dependencies: react ^18.0.0 || ^19.0.0

SurfProvider#

Wrap your app with SurfProvider to establish a WebSocket connection:

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
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#

TSX
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:

  1. For humans — a subtle trust signal, like a maker's mark or verification stamp
  2. 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

TSX
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-label for 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:

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.json
CLI: 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:

  1. A hidden <div> with data-surf-* attributes and the micro-manifest as text content
  2. An optional visible element linking to your manifest
  3. 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.

useSurfCommands is React sugar for @surfjs/web's registerCommand. If you're not using React, see the vanilla JS equivalent below or the @surfjs/web docs.

Basic Usage

TSX
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 |

TSX
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:

  1. Check if a local handler exists for this command
  2. Yes → run the local handler (instant)
  3. 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:

JavaScript
import { initSurf, registerCommand } from '@surfjs/web'
 
// Initialize window.surf
initSurf({ endpoint: 'https://myapp.com' })
 
// Register local handlers — same as useSurfCommands but framework-agnostic
registerCommand('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

TypeScript
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 SurfBadge only: Commands without local handlers go through HTTP POST.
  • Priority: If both are present, SurfProvider takes precedence.
TSX
// 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:

JavaScript
// Discover what the app can do
const 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 instantly
await 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 state
const state = await window.surf.execute('canvas.getState')
// => { ok: true, result: { shapes: [{ id: 'c1', ... }] } }
 
// Server command — proxied automatically
const png = await window.surf.execute('canvas.export', { width: 1920 })
// => { ok: true, result: { url: '/exports/canvas-abc.png' } }

Example: Testing from Browser DevTools

JavaScript
// Quick test during development
await window.surf.execute('cart.add', { sku: 'LAPTOP-01', quantity: 2 })
// => { ok: true, result: { cartId: '...', itemCount: 2 } }
 
// Check which commands have local handlers vs server
window.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:

TypeScript
interface SurfResult {
ok: boolean
result?: unknown
error?: { code: string; message: string }
state?: Record<string, unknown>
}