Documentation
Surf lets any website expose typed commands for AI agents. Install the packages you need and start building in minutes.
Installation
Three packages — pick what you need:
Server-side — expose commands
npm install @surfjs/coreAgent/client-side — discover & execute
npm install @surfjs/clientDevUI — interactive inspector (optional)
npm install @surfjs/devuiCLI — inspect & test from terminal (optional)
npm install -g @surfjs/cliQuick Start
A complete Express server with Surf commands in under 20 lines:
import { createSurf } from '@surfjs/core'import express from 'express' const surf = createSurf({ name: 'My Store', commands: { search: { description: 'Search products', params: { query: { type: 'string', required: true }, limit: { type: 'number', default: 10 }, }, run: async ({ query, limit }) => { return db.products.search(query, { limit }) }, }, },}) const app = express()app.use(surf.middleware())app.listen(3000)Now any AI agent can discover and use your commands:
import { SurfClient } from '@surfjs/client' const client = await SurfClient.discover('http://localhost:3000') // List available commandsconsole.log(client.commands())// => { search: { description: 'Search products', params: {...} } } // Execute a commandconst result = await client.execute('search', { query: 'laptop' })// => { results: [...], total: 42 }How It Works
Surf follows a three-step protocol:
Discovery
Agents fetch /.well-known/surf.json to discover all available commands, their parameters, descriptions, and auth requirements. The manifest includes a SHA-256 checksum for drift detection via ETag/304.
Execution
Agents POST to /surf/execute with a command name and typed parameters. Surf validates params, runs middleware, and executes the handler.
Response
Results are returned as structured JSON. For long-running tasks, use SSE streaming. For multi-step workflows, use pipelines. For real-time events, connect via WebSocket.
createSurf(config)
Creates a new Surf instance. The config object defines your service name, commands, auth, events, and behavior.
import { createSurf } from '@surfjs/core' const surf = createSurf({ name: 'My Service', description: 'A Surf-enabled API', version: '1.0.0', commands: { /* ... */ }, events: { /* ... */ }, authVerifier: myVerifier, rateLimit: { windowMs: 60_000, maxRequests: 100 }, strict: true,}) // Returns a SurfInstance with:surf.manifest() // Get the generated manifestsurf.manifestHandler() // HTTP handler for /.well-known/surf.jsonsurf.httpHandler() // HTTP handler for /surf/executesurf.middleware() // Combined HTTP middleware (manifest + execute + sessions + pipeline)surf.wsHandler(server) // Attach WebSocket transportsurf.emit(event, data) // Emit an event to connected clientssurf.use(middleware) // Add a middleware functionsurf.events // Access the EventBussurf.sessions // Access the SessionStoresurf.commands // Access the CommandRegistryCommands
Commands are the core building block of a Surf API. Each command has a name, description, optional parameters, and a handler function.
const surf = createSurf({ name: 'My Store', commands: { search: { description: 'Search products by keyword', params: { query: { type: 'string', required: true, description: 'Search term' }, limit: { type: 'number', default: 10 }, }, returns: { type: 'object' }, tags: ['catalog', 'public'], hints: { idempotent: true, sideEffects: false, estimatedMs: 50, }, run: async ({ query, limit }) => { return db.products.search(query, { limit }) }, }, },})💡 Tip: The hints object helps AI agents make smart decisions — for example, knowing a command is idempotent means it's safe to retry on failure.
Namespacing
Organize related commands using dot notation. Nest command groups to create clean, discoverable APIs.
const surf = createSurf({ name: 'My Store', commands: { // Top-level command search: { description: 'Search products', params: { query: { type: 'string', required: true } }, run: async ({ query }) => db.search(query), }, // Nested namespace using groups cart: { add: { description: 'Add item to cart', params: { sku: { type: 'string', required: true } }, auth: 'required', run: async ({ sku }, ctx) => cart.add(ctx.sessionId, sku), }, remove: { description: 'Remove item from cart', params: { sku: { type: 'string', required: true } }, auth: 'required', run: async ({ sku }, ctx) => cart.remove(ctx.sessionId, sku), }, view: { description: 'View cart contents', auth: 'required', run: async (_, ctx) => cart.get(ctx.sessionId), }, }, // Deep nesting user: { profile: { get: { description: 'Get user profile', auth: 'required', run: async (_, ctx) => users.getProfile(ctx.claims?.userId), }, }, }, },})This creates commands accessible as search, cart.add, cart.remove, cart.view, and user.profile.get.
Deep Nesting
Surf supports arbitrary nesting depth — for namespaces, object parameters, and return types. This lets you model complex, real-world data structures while keeping everything typed and validated.
Nested Namespaces
Command groups can nest multiple levels deep. Each level creates a dot-separated namespace in the manifest.
const surf = createSurf({ name: 'My Store', commands: { cart: { items: { add: { description: 'Add an item to the cart', params: { sku: { type: 'string', required: true } }, auth: 'required', run: async ({ sku }, ctx) => cart.addItem(ctx.sessionId!, sku), }, remove: { description: 'Remove an item from the cart', params: { sku: { type: 'string', required: true } }, auth: 'required', run: async ({ sku }, ctx) => cart.removeItem(ctx.sessionId!, sku), }, list: { description: 'List all items in the cart', auth: 'required', run: async (_, ctx) => cart.listItems(ctx.sessionId!), }, }, checkout: { description: 'Checkout the current cart', auth: 'required', run: async (_, ctx) => cart.checkout(ctx.sessionId!), }, }, user: { profile: { get: { description: 'Get user profile', auth: 'required', run: async (_, ctx) => users.getProfile(ctx.claims?.userId), }, update: { description: 'Update user profile', auth: 'required', params: { name: { type: 'string' }, bio: { type: 'string' }, }, run: async (params, ctx) => users.updateProfile(ctx.claims?.userId, params), }, }, }, },})This produces commands: cart.items.add, cart.items.remove, cart.items.list, cart.checkout, user.profile.get, and user.profile.update.
Nested Object Parameters
Use type: 'object' with properties to define nested parameter structures. Nesting can go as deep as needed.
{ params: { address: { type: 'object', description: 'Shipping address', properties: { street: { type: 'string', required: true }, city: { type: 'string', required: true }, zip: { type: 'string' }, country: { type: 'string', default: 'US' }, coordinates: { type: 'object', properties: { lat: { type: 'number', required: true }, lng: { type: 'number', required: true }, }, }, }, }, },}Nested Array Parameters
Arrays use items to define element schemas — including nested objects and arrays of arrays.
{ params: { // Array of strings tags: { type: 'array', items: { type: 'string' }, }, // Array of objects lineItems: { type: 'array', items: { type: 'object', properties: { sku: { type: 'string', required: true }, quantity: { type: 'number', default: 1 }, options: { type: 'array', items: { type: 'string' }, }, }, }, }, },}Reusable Types with $ref
Define complex types once in types and reference them with $ref anywhere — in params, return schemas, or array items. This avoids duplication and keeps your manifest clean.
const surf = createSurf({ name: 'My Store', types: { Address: { type: 'object', description: 'A postal address', properties: { street: { type: 'string', required: true }, city: { type: 'string', required: true }, zip: { type: 'string' }, country: { type: 'string' }, }, }, LineItem: { type: 'object', description: 'A single item in an order', properties: { sku: { type: 'string', required: true }, quantity: { type: 'number', default: 1 }, price: { type: 'number', required: true }, }, }, }, commands: { 'order.create': { description: 'Place a new order', auth: 'required', params: { shippingAddress: { $ref: 'Address' }, billingAddress: { $ref: 'Address' }, items: { type: 'array', items: { $ref: 'LineItem' } }, notes: { type: 'string' }, }, returns: { type: 'object', properties: { orderId: { type: 'string' }, total: { type: 'number' }, items: { type: 'array', items: { $ref: 'LineItem' } }, }, }, run: async (params, ctx) => { return orders.create(ctx.claims!.userId, params) }, }, },})💡 Tip: Types referenced by $ref are included in the manifest under the types key, making them visible to agents for schema understanding.
Parameters
Parameters are validated automatically before your handler runs. Surf supports five types: string, number, boolean, object, and array.
{ params: { // Required string name: { type: 'string', required: true, description: 'User name' }, // String with enum constraint category: { type: 'string', enum: ['electronics', 'clothing', 'books'] }, // Number with default limit: { type: 'number', default: 10 }, // Boolean includeArchived: { type: 'boolean', default: false }, // Nested object shipping: { type: 'object', properties: { address: { type: 'string', required: true }, express: { type: 'boolean', default: false }, }, }, // Array with typed items tags: { type: 'array', items: { type: 'string' }, }, // Array with $ref items (reusable types) items: { type: 'array', items: { $ref: 'CartItem' }, }, },}You can define reusable types in the types config and reference them with $ref:
const surf = createSurf({ name: 'My Store', types: { CartItem: { type: 'object', description: 'An item in the shopping cart', properties: { sku: { type: 'string', required: true }, quantity: { type: 'number', default: 1 }, }, }, }, commands: { 'cart.add': { description: 'Add items to cart', params: { items: { type: 'array', items: { $ref: 'CartItem' } }, }, run: async ({ items }) => { /* ... */ }, }, },})Auth
Surf supports per-command auth levels: none (public), optional, required, or hidden. Auth is advertised in the manifest so agents know what credentials to provide. Hidden commands are excluded from the manifest entirely unless the request includes a valid auth token.
import { createSurf } from '@surfjs/core' const surf = createSurf({ name: 'My Store', auth: { type: 'bearer', description: 'JWT Bearer token' }, // Auth verifier — called automatically for commands that need auth authVerifier: async (token, command) => { const decoded = await verifyJWT(token) if (!decoded) return { valid: false, reason: 'Invalid token' } return { valid: true, claims: { userId: decoded.sub, role: decoded.role } } }, commands: { // Public — no auth needed search: { description: 'Search products', auth: 'none', // default params: { query: { type: 'string', required: true } }, run: async ({ query }) => db.search(query), }, // Auth required — returns 401 if no token, 403 if invalid 'order.create': { description: 'Create a new order', auth: 'required', run: async (params, ctx) => { // ctx.claims is populated by authVerifier return orders.create(ctx.claims!.userId, params) }, }, // Auth optional — works without token, enriched with token recommendations: { description: 'Get recommendations', auth: 'optional', run: async (_, ctx) => { if (ctx.claims) return personalized(ctx.claims.userId) return popular() }, }, // Hidden — not in manifest unless authed 'admin.analytics': { description: 'Dashboard analytics', auth: 'hidden', run: async (_, ctx) => getAnalytics(ctx.claims!.role), }, },})Surf also provides a built-in bearerVerifier for simple token validation:
import { createSurf, bearerVerifier } from '@surfjs/core' const surf = createSurf({ name: 'My API', authVerifier: bearerVerifier(['sk-secret-token-1', 'sk-secret-token-2']), commands: { /* ... */ },})Security Best Practices
When adding Surf to your website, follow these guidelines to avoid accidentally exposing internal functionality:
Only expose what's already public
- •Commands should mirror actions a regular user can already perform through the UI
- •Do not expose internal APIs, admin endpoints, database queries, or backend services
- •Use
auth: 'required'for any command that modifies data or acts on behalf of a user - •Use
auth: 'hidden'for admin or internal commands that should not appear in the public manifest - •When in doubt, leave it out — explicitly opt-in to each command you expose
- •Review your
surf.jsonmanifest to verify only intended commands are listed
Think of Surf commands as a curated public API — not a proxy to your entire backend. A good rule of thumb: if a user can't do it from the browser without logging in, it shouldn't be an unauthenticated Surf command.
Design for zero prior knowledge
Agents arrive with no context about your site — no IDs, slugs, or internal references. Design your commands so an agent can explore everything starting from just the manifest.
- •Every ID-based command (e.g.
article.get) must be reachable from a discovery command (e.g.article.list,search) - •List and search commands should return items with their IDs, so agents can discover then drill down
- •Good pattern: search → get details → take action (never require an ID without a way to find it)
- •Include feed or browse commands as entry points — don't assume the agent knows what to ask for
Rate Limiting
Built-in sliding-window rate limiting, configurable globally or per-command. Supports multiple key strategies.
const surf = createSurf({ name: 'My Store', // Global rate limit — applies to all commands rateLimit: { windowMs: 60_000, // 1-minute window maxRequests: 100, // 100 requests per window keyBy: 'ip', // Group by: 'ip' | 'session' | 'auth' | 'global' }, commands: { search: { description: 'Search products', // Per-command override — stricter limit rateLimit: { windowMs: 60_000, maxRequests: 30, keyBy: 'ip', }, params: { query: { type: 'string', required: true } }, run: async ({ query }) => db.search(query), }, 'order.create': { description: 'Create order', auth: 'required', // Rate limit by authenticated user rateLimit: { windowMs: 3600_000, // 1 hour maxRequests: 10, keyBy: 'auth', }, run: async (params, ctx) => { /* ... */ }, }, },})When rate limited, responses include a Retry-After header and return the RATE_LIMITED error code (HTTP 429).
Middleware
Middleware intercepts command execution. Each middleware receives a context and a next function. Use it for logging, tracing, analytics, or custom validation.
import { createSurf } from '@surfjs/core'import type { SurfMiddleware } from '@surfjs/core' // Logging middlewareconst logger: SurfMiddleware = async (ctx, next) => { const start = Date.now() console.log(`→ ${ctx.command}`, ctx.params) await next() const ms = Date.now() - start if (ctx.result) console.log(`← ${ctx.command} OK (${ms}ms)`) if (ctx.error) console.log(`← ${ctx.command} ERROR (${ms}ms)`)} const surf = createSurf({ name: 'My API', middleware: [logger], commands: { /* ... */ },}) // Or add middleware after creationsurf.use(async (ctx, next) => { // ctx.command — command name // ctx.params — validated parameters // ctx.context — execution context (sessionId, auth, claims, ip, state) // ctx.result — set by handler (available after next()) // ctx.error — set on error (short-circuits pipeline) await next()})⚠️ Note: Auth middleware is auto-installed when you provide an authVerifier. It runs before any custom middleware.
Strict Mode
Enable strict mode to validate command return values against their declared returns schema. This catches bugs before they reach your agents.
const surf = createSurf({ name: 'My API', strict: true, // enables validateReturns + other strict checks commands: { search: { description: 'Search products', params: { query: { type: 'string', required: true } }, returns: { type: 'object', properties: { results: { type: 'array', items: { type: 'object' } }, total: { type: 'number' }, }, }, run: async ({ query }) => { // Return value is validated against the schema above return { results: [...], total: 42 } }, }, },})💡 Tip: You can also enable return validation selectively with validateReturns: true instead of full strict mode.
Sessions
Sessions let agents maintain state across multiple requests — useful for carts, multi-step workflows, and personalization. Surf includes an in-memory session store out of the box.
// Agent starts a sessionPOST /surf/session/start// => { "ok": true, "sessionId": "sess_abc123" } // Execute commands with session contextPOST /surf/execute{ "command": "cart.add", "params": { "sku": "LAPTOP-01" }, "sessionId": "sess_abc123"} // The handler receives session state in ctx.stateconst surf = createSurf({ name: 'My Store', commands: { 'cart.add': { description: 'Add item to cart', params: { sku: { type: 'string', required: true } }, run: async ({ sku }, ctx) => { const cart = (ctx.state?.cart as string[]) ?? [] cart.push(sku) // Return state to persist it return { cart, added: sku } }, }, 'cart.view': { description: 'View cart', run: async (_, ctx) => { return { items: ctx.state?.cart ?? [] } }, }, },}) // End session when donePOST /surf/session/end{ "sessionId": "sess_abc123" }Session state is automatically persisted between requests when a sessionId is provided and the response includes state.
Pipelines
Pipelines let agents execute multiple commands in a single HTTP round-trip. Steps run sequentially, and each step can reference previous results using $prev or named aliases with as.
// Agent sends a pipeline requestPOST /surf/pipeline{ "steps": [ { "command": "search", "params": { "query": "laptop" }, "as": "searchResults" }, { "command": "cart.add", "params": { "sku": "$prev.results[0].sku" } }, { "command": "checkout" } ], "sessionId": "sess_abc123", "continueOnError": false} // Response{ "ok": true, "results": [ { "command": "search", "ok": true, "result": { "results": [...] } }, { "command": "cart.add", "ok": true, "result": { "added": "LAPTOP-01" } }, { "command": "checkout", "ok": true, "result": { "orderId": "ORD-789" } } ]}Set continueOnError: true to continue executing subsequent steps even if one fails. By default, the pipeline stops on the first error.
Pagination
For commands that return lists of items, Surf provides built-in pagination with a standard response envelope. Agents can detect paginated commands in the manifest and iterate automatically.
Declaring a Paginated Command
Set paginated: true for defaults, or pass a config object to customize limits and pagination style.
const surf = createSurf({ name: 'My Store', commands: { 'products.list': { description: 'List all products', paginated: true, // Defaults: { defaultLimit: 20, maxLimit: 100, style: 'cursor' } params: { category: { type: 'string' }, }, run: async ({ category, cursor, limit }) => { const products = await db.products.list({ category, cursor, limit }) return paginatedResult(products.items, { nextCursor: products.nextCursor, total: products.total, }) }, }, 'orders.history': { description: 'Get order history', auth: 'required', paginated: { defaultLimit: 10, maxLimit: 50, style: 'cursor', }, run: async ({ cursor, limit }, ctx) => { const orders = await db.orders.forUser(ctx.claims!.userId, { cursor, limit }) return paginatedResult(orders.items, { nextCursor: orders.nextCursor, total: orders.total, }) }, }, },})When paginated is set, Surf automatically injects cursor, limit, and offset into the command's accepted params. The limit is clamped to maxLimit and defaults to defaultLimit.
The paginatedResult Helper
Use paginatedResult() from @surfjs/core to build the standard response envelope. It derives hasMore from nextCursor automatically.
import { paginatedResult } from '@surfjs/core' // Basic usage — hasMore is derived from nextCursorpaginatedResult(items, { nextCursor: 'abc123' })// => { items: [...], nextCursor: 'abc123', hasMore: true } paginatedResult(items, { nextCursor: null })// => { items: [...], nextCursor: null, hasMore: false } // With total countpaginatedResult(items, { nextCursor: 'abc123', total: 142 })// => { items: [...], nextCursor: 'abc123', hasMore: true, total: 142 } // Explicit hasMore overridepaginatedResult(items, { hasMore: false })Response Envelope
Paginated commands return a standard envelope that agents can rely on:
// Response from a paginated command{ "ok": true, "result": { "items": [ { "id": "prod_1", "name": "Laptop", "price": 999 }, { "id": "prod_2", "name": "Mouse", "price": 29 } ], "nextCursor": "eyJpZCI6InByb2RfMiJ9", "hasMore": true, "total": 142 }}Client-Side Iteration
Agents iterate by passing the nextCursor from each response as the cursor param of the next request:
import { SurfClient } from '@surfjs/client' const client = await SurfClient.discover('https://shop.example.com') // Iterate through all pageslet cursor: string | undefinedconst allProducts = [] do { const page = await client.execute('products.list', { category: 'electronics', limit: 20, cursor, }) allProducts.push(...page.items) cursor = page.nextCursor ?? undefined} while (cursor) console.log(`Fetched ${allProducts.length} products`)Cursor vs Offset Style
Surf supports two pagination styles. Cursor-based is the default and recommended approach — it's stable under concurrent inserts and deletes. Offset-based is available for simpler use cases.
// Cursor-based (default) — stable, recommended{ paginated: { style: 'cursor', defaultLimit: 20 }}// Client sends: { cursor: "eyJ...", limit: 20 } // Offset-based — simpler, but can skip/duplicate items on mutations{ paginated: { style: 'offset', defaultLimit: 20, maxLimit: 100 }}// Client sends: { offset: 40, limit: 20 }⚠️ Note: Offset-based pagination can produce inconsistent results when items are inserted or deleted between requests. Prefer cursor-based pagination for production APIs.
SSE Streaming
For long-running operations, enable streaming on a command. The handler receives an emit() function on the context to send progressive chunks via Server-Sent Events.
Defining a Streaming Command
Set stream: true on the command definition. Inside the handler, use ctx.emit() to push chunks to the client. The return value becomes the final done event.
const surf = createSurf({ name: 'AI API', commands: { generate: { description: 'Generate text with AI', stream: true, params: { prompt: { type: 'string', required: true }, maxTokens: { type: 'number', default: 500 }, }, run: async ({ prompt, maxTokens }, ctx) => { const response = ai.stream(prompt, { maxTokens }) let totalTokens = 0 for await (const chunk of response) { totalTokens += chunk.tokens // Each emit() sends an SSE "chunk" event ctx.emit!({ text: chunk.text, tokens: chunk.tokens }) } // Return value is sent as the final "done" event return { finished: true, totalTokens } }, }, },})Client-Side SSE
When the client sends stream: true in the execute request, the response is an SSE stream instead of a single JSON body. Each chunk follows the StreamChunk protocol:
// Request streaming executionPOST /surf/executeContent-Type: application/json { "command": "generate", "params": { "prompt": "Explain SSE" }, "stream": true } // SSE response (Content-Type: text/event-stream):data: { "type": "chunk", "data": { "text": "Server-Sent", "tokens": 2 } } data: { "type": "chunk", "data": { "text": " Events are", "tokens": 3 } } data: { "type": "chunk", "data": { "text": " a standard...", "tokens": 4 } } data: { "type": "done", "result": { "finished": true, "totalTokens": 9 } }Consuming Streams with the Client SDK
The @surfjs/client SDK handles SSE parsing automatically. Use executeStream() to get an async iterator of chunks:
import { SurfClient } from '@surfjs/client' const client = await SurfClient.discover('https://ai.example.com') // Stream chunks as they arriveconst stream = client.executeStream('generate', { prompt: 'Hello world' }) for await (const chunk of stream) { if (chunk.type === 'chunk') { process.stdout.write(chunk.data.text) } if (chunk.type === 'done') { console.log('\nDone:', chunk.result) }}Pipeline Streaming
Streaming also works inside pipelines. If a pipeline step targets a streaming command and the pipeline request includes stream: true, chunks from that step are emitted in real-time while remaining steps execute normally after the stream completes.
// Pipeline with a streaming stepPOST /surf/pipeline{ "steps": [ { "command": "search", "params": { "query": "SSE tutorial" }, "as": "results" }, { "command": "generate", "params": { "prompt": "$prev.results[0].summary" } } ], "stream": true} // Step 1 executes normally, step 2 streams:data: { "type": "step", "index": 0, "result": { "results": [...] } } data: { "type": "chunk", "index": 1, "data": { "text": "Server-Sent..." } } data: { "type": "chunk", "index": 1, "data": { "text": " Events..." } } data: { "type": "done", "results": [...] }💡 Tip: Both the command definition must have stream: true and the client request must include stream: true for SSE to activate. If stream: true is sent for a non-streaming command, it executes normally and returns a standard JSON response.
WebSocket
For real-time bidirectional communication, attach the WebSocket transport. Agents can execute commands, receive events, and manage sessions over a persistent connection.
import { createSurf } from '@surfjs/core'import http from 'node:http' const surf = createSurf({ name: 'Realtime App', commands: { /* ... */ }, events: { 'order.updated': { description: 'Fired when an order status changes', data: { orderId: { type: 'string' }, status: { type: 'string' } }, }, },}) const server = http.createServer(/* express app or raw handler */) // Attach WebSocket — requires the 'ws' packagesurf.wsHandler(server) server.listen(3000) // Emit events from anywhere in your codesurf.emit('order.updated', { orderId: 'ORD-789', status: 'shipped' })WebSocket message protocol:
// Client → Server: execute a command{ "type": "execute", "id": "req-1", "command": "search", "params": { "query": "laptop" } } // Server → Client: command result{ "type": "result", "id": "req-1", "ok": true, "result": { ... } } // Client → Server: authenticate{ "type": "auth", "token": "Bearer sk-..." } // Client → Server: manage session{ "type": "session", "action": "start" }{ "type": "session", "action": "end", "sessionId": "sess_abc" } // Server → Client: event{ "type": "event", "event": "order.updated", "data": { ... } }⚠️ Note: WebSocket transport requires the ws package: npm install ws
Events & Scoping
Events let your server push real-time updates to connected clients. A key security feature is event scoping — controlling who receives each event.
const surf = createSurf({ name: 'My App', events: { // Session-scoped (default) — only the triggering session receives it 'cart.updated': { description: 'Cart contents changed', scope: 'session', data: { items: { type: 'array' } }, }, // Global — delivered to all subscribers (system announcements) 'maintenance.scheduled': { description: 'System maintenance upcoming', scope: 'global', data: { startsAt: { type: 'string' }, message: { type: 'string' } }, }, // Broadcast — delivered to all connected sessions 'product.restocked': { description: 'A product came back in stock', scope: 'broadcast', data: { sku: { type: 'string' }, name: { type: 'string' } }, }, }, commands: { /* ... */ },})| Scope | Delivery | Use Case |
|---|---|---|
| session | Only the session that triggered it | Cart updates, user-specific notifications |
| global | All subscribers (including server-side) | System announcements, maintenance alerts |
| broadcast | All connected sessions | Stock updates, live notifications |
You can also subscribe and emit events programmatically on the server:
// Subscribe to eventsconst unsubscribe = surf.events.on('cart.updated', (data) => { console.log('Cart updated:', data)}, sessionId) // Optional: scope to a session // Emit eventssurf.emit('maintenance.scheduled', { startsAt: '2025-03-21T02:00:00Z', message: 'Scheduled maintenance window',}) // Clean up session listeners on disconnectsurf.events.removeSession(sessionId)⚠️ Note: Session scoping is a security feature. By default, events are scoped to session, preventing one user from receiving another user's updates. Only use global or broadcast for non-sensitive data.
Express / Node.js
The built-in middleware() method returns a standard Node.js HTTP handler compatible with Express, Connect, and raw http.createServer.
import { createSurf } from '@surfjs/core'import express from 'express' const surf = createSurf({ name: 'Express App', commands: { /* ... */ },}) const app = express()app.use(express.json())app.use(surf.middleware()) // Mounts all Surf routes // Routes mounted:// GET /.well-known/surf.json — manifest with ETag/304// POST /surf/execute — command execution// POST /surf/pipeline — pipeline execution// POST /surf/session/start — start session// POST /surf/session/end — end session app.listen(3000)Next.js
Use Surf in Next.js App Router via route handlers:
// app/api/surf/[...path]/route.tsimport { createSurf } from '@surfjs/core' const surf = createSurf({ name: 'My Next.js App', commands: { /* ... */ },}) const handler = surf.middleware() // Wrap the Node handler for Next.js route handlersasync function handleRequest(req: Request) { const url = new URL(req.url) const body = req.method === 'POST' ? await req.json() : undefined return new Promise<Response>((resolve) => { const headers: Record<string, string> = {} req.headers.forEach((v, k) => { headers[k] = v }) handler( { method: req.method, url: url.pathname, headers, body }, { writeHead(status, h) { Object.assign(headers, h) }, end(b) { resolve(new Response(b, { status, headers: { 'Content-Type': 'application/json', ...headers }, })) }, } ) })} export const GET = handleRequestexport const POST = handleRequestFastify
Surf provides a native Fastify plugin that registers all routes with proper serialization and SSE support.
import Fastify from 'fastify'import { createSurf } from '@surfjs/core'import { fastifyPlugin } from '@surfjs/core/fastify' const surf = createSurf({ name: 'Fastify App', commands: { /* ... */ },}) const app = Fastify()app.register(fastifyPlugin(surf)) app.listen({ port: 3000 })💡 Tip: The Fastify adapter handles SSE streaming natively by writing directly to the raw Node response.
Hono
Surf provides a Hono sub-app and a standalone middleware handler for Cloudflare Workers and edge runtimes.
import { Hono } from 'hono'import { createSurf } from '@surfjs/core'import { honoApp } from '@surfjs/core/hono' const surf = createSurf({ name: 'Hono App', commands: { /* ... */ },}) // Mount as sub-appconst app = new Hono()app.route('/', honoApp(surf)) export default app // Or use as a Cloudflare Worker / standaloneimport { honoMiddleware } from '@surfjs/core/hono'export default { fetch: honoMiddleware(surf) }The Hono adapter uses Web Streams API for SSE, making it compatible with Cloudflare Workers, Deno, and Bun.
Client SDK — Discovery
The @surfjs/client package provides a full-featured SDK for interacting with any Surf-enabled website.
import { SurfClient } from '@surfjs/client' // Discover a Surf-enabled site (fetches manifest automatically)const client = await SurfClient.discover('https://shop.example.com') // Or with optionsconst client = await SurfClient.discover('https://shop.example.com', { auth: 'sk-my-token', discoverTimeout: 5000,}) // Inspect the manifestconsole.log(client.manifest.name) // 'My Store'console.log(client.manifest.version) // '1.0.0'console.log(client.manifest.checksum) // 'a1b2c3...' // List all commandsconst cmds = client.commands()// => { search: { description: '...', params: {...} }, 'cart.add': {...} } // Get a specific commandconst cmd = client.command('search')// => { description: '...', params: {...}, hints: {...} } // Create from existing manifest (skip discovery)const client2 = SurfClient.fromManifest(manifest, { baseUrl: 'https://...' })Execute
// Simple executionconst result = await client.execute('search', { query: 'laptop', limit: 5 })// => { results: [...], total: 42 } // Throws SurfClientError on failuretry { await client.execute('admin.reset', {})} catch (err) { if (err instanceof SurfClientError) { console.log(err.code) // 'AUTH_REQUIRED' console.log(err.message) // 'Surf error [AUTH_REQUIRED]: ...' console.log(err.statusCode) // 401 console.log(err.retryAfter) // seconds (for rate limiting) }}Pipeline
Execute multiple commands in a single round-trip. Returns results for each step.
const response = await client.pipeline( [ { command: 'search', params: { query: 'laptop' }, as: 'results' }, { command: 'cart.add', params: { sku: '$prev.results[0].sku' } }, { command: 'checkout' }, ], { sessionId: 'sess_abc', continueOnError: false }) // response.ok — overall success// response.results — array of per-step resultsfor (const step of response.results) { console.log(step.command, step.ok, step.result)}Typed Client
Create a typed proxy for full TypeScript autocomplete and type checking:
// Define your command typesinterface MyCommands { search: { params: { query: string; limit?: number } result: { results: Product[]; total: number } } 'cart.add': { params: { sku: string; quantity?: number } result: { added: string } }} // Create typed clientconst typed = client.typed<MyCommands>() // Full autocomplete and type safetyconst { results, total } = await typed.search({ query: 'laptop' })// ^ Product[] ^ numberClient Sessions
The client SDK provides a clean session API that manages state automatically:
// Start a sessionconst session = await client.startSession() // Execute commands within the sessionawait session.execute('cart.add', { sku: 'LAPTOP-01' })await session.execute('cart.add', { sku: 'MOUSE-05' }) // Session state is tracked automaticallyconsole.log(session.id) // 'sess_abc123'console.log(session.state) // { cart: ['LAPTOP-01', 'MOUSE-05'] } // End the sessionawait session.end()Client WebSocket
Connect via WebSocket for real-time command execution and event subscriptions:
// Connect to WebSocketconst ws = await client.connect() // Execute commands over WebSocketconst result = await ws.execute('search', { query: 'laptop' }) // Listen for eventsws.on('order.updated', (data) => { console.log('Order updated:', data)}) // Disconnect when doneclient.disconnect()Retry & Cache
The client supports automatic retry with exponential backoff and an LRU response cache.
const client = await SurfClient.discover(url, { // Retry configuration retry: { maxAttempts: 3, backoffMs: 500, backoffMultiplier: 2, retryOn: [429, 502, 503, 504], }, // Response caching (skips commands with sideEffects: true) cache: { ttlMs: 60_000, // 60s TTL maxSize: 100, // Max 100 cached entries },}) // Execute — automatically retries and cachesconst result = await client.execute('search', { query: 'laptop' }) // Clear cache for a specific commandclient.clearCache('search') // Clear all cachesclient.clearCache() // Check for manifest updates (checksum comparison)const update = await client.checkForUpdates()if (update.changed) { console.log('Manifest updated:', update.manifest)}CLI
The @surfjs/cli package provides terminal commands to inspect and test Surf-enabled websites.
# Check if a site is Surf-enabledsurf ping https://shop.example.com# ✅ https://shop.example.com is Surf-enabled (42ms) # Inspect all commandssurf inspect https://shop.example.com# 🏄 My Store (Surf v1.0)# 6 commands available:## search(query: string, limit?: number)# Search products## cart.add(sku: string, quantity?: number) 🔐# Add item to cart # Inspect with full parameter detailssurf inspect https://shop.example.com --verbose # Execute a command interactivelysurf test https://shop.example.com search --query "laptop"# Executing search on https://shop.example.com...# OK# { "results": [...], "total": 42 }# ⏱ 47ms execute / 89ms total # Machine-readable JSON outputsurf test https://shop.example.com search --query "laptop" --json # With authenticationsurf test https://shop.example.com order.create --auth sk-token --sku LAPTOP-01The CLI prompts for missing required parameters interactively and coerces values to the correct type.
DevUI
A drop-in interactive dashboard for exploring and testing your Surf commands in the browser. Ships as a self-contained HTML page with no external dependencies.
import { createSurf } from '@surfjs/core'import { createDevUI } from '@surfjs/devui' const surf = createSurf({ name: 'My App', commands: { /* ... */ },}) const devui = createDevUI(surf, { port: 4242, // Standalone server port (default: 4242) host: 'localhost', // Bind host (default: 'localhost') path: '/__surf', // Mount path (default: '/__surf') title: 'My App Dev', // Override title}) // Option 1: Standalone serverconst { url } = await devui.start()console.log(`DevUI at ${url}`)// => DevUI at http://localhost:4242/__surf // Option 2: Mount as Express middlewareimport express from 'express'const app = express()app.use(devui.middleware()) // Mounts at /__surfapp.use(surf.middleware()) // Surf routesapp.listen(3000) // Stop standalone serverawait devui.stop()The DevUI includes:
- ✓Command sidebar with search and namespaced grouping
- ✓Parameter form with type-aware inputs (select for enums, checkbox for booleans)
- ✓One-click command execution with auth token support
- ✓Request log with syntax-highlighted JSON and timing
- ✓Keyboard shortcuts: / to search, ⌘Enter to execute
Manifest
The manifest is auto-generated from your config and served at /.well-known/surf.json. It includes a SHA-256 checksum for cache validation and drift detection.
// GET /.well-known/surf.json{ "surf": "1.0", "name": "My Store", "description": "A Surf-enabled e-commerce API", "version": "1.0.0", "baseUrl": "https://shop.example.com", "auth": { "type": "bearer", "description": "JWT Bearer token" }, "commands": { "search": { "description": "Search products", "params": { "query": { "type": "string", "required": true }, "limit": { "type": "number", "default": 10 } }, "returns": { "type": "object" }, "tags": ["catalog"], "hints": { "idempotent": true, "sideEffects": false } }, "cart.add": { "description": "Add item to cart", "params": { "sku": { "type": "string", "required": true } }, "auth": "required" } }, "events": { "cart.updated": { "description": "Cart contents changed", "data": { "items": { "type": "array" } } } }, "types": { "CartItem": { "type": "object", "properties": { "sku": { "type": "string" }, "quantity": { "type": "number" } } } }, "checksum": "a1b2c3d4e5f6...", "updatedAt": "2025-03-20T18:00:00.000Z"}The manifest supports ETag-based caching. Clients send If-None-Match with the checksum, and Surf returns 304 Not Modified if unchanged. The Cache-Control header is set to public, max-age=300 by default.
Protocol Spec
The full Surf protocol specification is available on GitHub:
View SPEC.md on GitHub →Error Codes
| Code | HTTP | Meaning |
|---|---|---|
| UNKNOWN_COMMAND | 404 | Command not found in manifest |
| INVALID_PARAMS | 400 | Missing required param or wrong type |
| AUTH_REQUIRED | 401 | Command requires auth but none provided |
| AUTH_FAILED | 403 | Token is invalid or expired |
| SESSION_EXPIRED | 410 | Session ID no longer valid |
| RATE_LIMITED | 429 | Too many requests (check Retry-After) |
| INTERNAL_ERROR | 500 | Unexpected server error |
| NOT_SUPPORTED | 501 | Feature not available |
Examples
Explore real-world implementation guides: