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

terminal
npm install @surfjs/core

Agent/client-side — discover & execute

terminal
npm install @surfjs/client

DevUI — interactive inspector (optional)

terminal
npm install @surfjs/devui

CLI — inspect & test from terminal (optional)

terminal
npm install -g @surfjs/cli

Quick Start

A complete Express server with Surf commands in under 20 lines:

server.ts
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:

agent.ts
import { SurfClient } from '@surfjs/client'
 
const client = await SurfClient.discover('http://localhost:3000')
 
// List available commands
console.log(client.commands())
// => { search: { description: 'Search products', params: {...} } }
 
// Execute a command
const result = await client.execute('search', { query: 'laptop' })
// => { results: [...], total: 42 }

How It Works

Surf follows a three-step protocol:

1

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.

2

Execution

Agents POST to /surf/execute with a command name and typed parameters. Surf validates params, runs middleware, and executes the handler.

3

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.

config.ts
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 manifest
surf.manifestHandler() // HTTP handler for /.well-known/surf.json
surf.httpHandler() // HTTP handler for /surf/execute
surf.middleware() // Combined HTTP middleware (manifest + execute + sessions + pipeline)
surf.wsHandler(server) // Attach WebSocket transport
surf.emit(event, data) // Emit an event to connected clients
surf.use(middleware) // Add a middleware function
surf.events // Access the EventBus
surf.sessions // Access the SessionStore
surf.commands // Access the CommandRegistry

Commands

Commands are the core building block of a Surf API. Each command has a name, description, optional parameters, and a handler function.

commands.ts
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.

namespacing.ts
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.

deep-namespaces.ts
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.

nested-params.ts
{
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.

nested-arrays.ts
{
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.

ref-types.ts
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.ts
{
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:

types.ts
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.

auth.ts
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:

bearer.ts
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.json manifest 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.

rate-limit.ts
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.

middleware.ts
import { createSurf } from '@surfjs/core'
import type { SurfMiddleware } from '@surfjs/core'
 
// Logging middleware
const 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 creation
surf.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.

strict.ts
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.

sessions.ts
// Agent starts a session
POST /surf/session/start
// => { "ok": true, "sessionId": "sess_abc123" }
 
// Execute commands with session context
POST /surf/execute
{
"command": "cart.add",
"params": { "sku": "LAPTOP-01" },
"sessionId": "sess_abc123"
}
 
// The handler receives session state in ctx.state
const 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 done
POST /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.

pipeline.json
// Agent sends a pipeline request
POST /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.

paginated-commands.ts
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.

paginated-result.ts
import { paginatedResult } from '@surfjs/core'
 
// Basic usage — hasMore is derived from nextCursor
paginatedResult(items, { nextCursor: 'abc123' })
// => { items: [...], nextCursor: 'abc123', hasMore: true }
 
paginatedResult(items, { nextCursor: null })
// => { items: [...], nextCursor: null, hasMore: false }
 
// With total count
paginatedResult(items, { nextCursor: 'abc123', total: 142 })
// => { items: [...], nextCursor: 'abc123', hasMore: true, total: 142 }
 
// Explicit hasMore override
paginatedResult(items, { hasMore: false })

Response Envelope

Paginated commands return a standard envelope that agents can rely on:

paginated-response.json
// 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:

client-pagination.ts
import { SurfClient } from '@surfjs/client'
 
const client = await SurfClient.discover('https://shop.example.com')
 
// Iterate through all pages
let cursor: string | undefined
const 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.

pagination-styles.ts
// 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.

streaming-server.ts
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:

sse-protocol.txt
// Request streaming execution
POST /surf/execute
Content-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:

client-stream.ts
import { SurfClient } from '@surfjs/client'
 
const client = await SurfClient.discover('https://ai.example.com')
 
// Stream chunks as they arrive
const 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-stream.txt
// Pipeline with a streaming step
POST /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.

websocket-server.ts
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' package
surf.wsHandler(server)
 
server.listen(3000)
 
// Emit events from anywhere in your code
surf.emit('order.updated', { orderId: 'ORD-789', status: 'shipped' })

WebSocket message protocol:

ws-protocol.json
// 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.

events.ts
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: { /* ... */ },
})
ScopeDeliveryUse Case
sessionOnly the session that triggered itCart updates, user-specific notifications
globalAll subscribers (including server-side)System announcements, maintenance alerts
broadcastAll connected sessionsStock updates, live notifications

You can also subscribe and emit events programmatically on the server:

events-api.ts
// Subscribe to events
const unsubscribe = surf.events.on('cart.updated', (data) => {
console.log('Cart updated:', data)
}, sessionId) // Optional: scope to a session
 
// Emit events
surf.emit('maintenance.scheduled', {
startsAt: '2025-03-21T02:00:00Z',
message: 'Scheduled maintenance window',
})
 
// Clean up session listeners on disconnect
surf.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.

express.ts
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.ts
// app/api/surf/[...path]/route.ts
import { 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 handlers
async 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 = handleRequest
export const POST = handleRequest

Fastify

Surf provides a native Fastify plugin that registers all routes with proper serialization and SSE support.

fastify.ts
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.

hono.ts
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-app
const app = new Hono()
app.route('/', honoApp(surf))
 
export default app
 
// Or use as a Cloudflare Worker / standalone
import { 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.

discovery.ts
import { SurfClient } from '@surfjs/client'
 
// Discover a Surf-enabled site (fetches manifest automatically)
const client = await SurfClient.discover('https://shop.example.com')
 
// Or with options
const client = await SurfClient.discover('https://shop.example.com', {
auth: 'sk-my-token',
discoverTimeout: 5000,
})
 
// Inspect the manifest
console.log(client.manifest.name) // 'My Store'
console.log(client.manifest.version) // '1.0.0'
console.log(client.manifest.checksum) // 'a1b2c3...'
 
// List all commands
const cmds = client.commands()
// => { search: { description: '...', params: {...} }, 'cart.add': {...} }
 
// Get a specific command
const cmd = client.command('search')
// => { description: '...', params: {...}, hints: {...} }
 
// Create from existing manifest (skip discovery)
const client2 = SurfClient.fromManifest(manifest, { baseUrl: 'https://...' })

Execute

execute.ts
// Simple execution
const result = await client.execute('search', { query: 'laptop', limit: 5 })
// => { results: [...], total: 42 }
 
// Throws SurfClientError on failure
try {
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.

client-pipeline.ts
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 results
for (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:

typed.ts
// Define your command types
interface MyCommands {
search: {
params: { query: string; limit?: number }
result: { results: Product[]; total: number }
}
'cart.add': {
params: { sku: string; quantity?: number }
result: { added: string }
}
}
 
// Create typed client
const typed = client.typed<MyCommands>()
 
// Full autocomplete and type safety
const { results, total } = await typed.search({ query: 'laptop' })
// ^ Product[] ^ number

Client Sessions

The client SDK provides a clean session API that manages state automatically:

client-sessions.ts
// Start a session
const session = await client.startSession()
 
// Execute commands within the session
await session.execute('cart.add', { sku: 'LAPTOP-01' })
await session.execute('cart.add', { sku: 'MOUSE-05' })
 
// Session state is tracked automatically
console.log(session.id) // 'sess_abc123'
console.log(session.state) // { cart: ['LAPTOP-01', 'MOUSE-05'] }
 
// End the session
await session.end()

Client WebSocket

Connect via WebSocket for real-time command execution and event subscriptions:

client-ws.ts
// Connect to WebSocket
const ws = await client.connect()
 
// Execute commands over WebSocket
const result = await ws.execute('search', { query: 'laptop' })
 
// Listen for events
ws.on('order.updated', (data) => {
console.log('Order updated:', data)
})
 
// Disconnect when done
client.disconnect()

Retry & Cache

The client supports automatic retry with exponential backoff and an LRU response cache.

retry-cache.ts
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 caches
const result = await client.execute('search', { query: 'laptop' })
 
// Clear cache for a specific command
client.clearCache('search')
 
// Clear all caches
client.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.

terminal
# Check if a site is Surf-enabled
surf ping https://shop.example.com
# ✅ https://shop.example.com is Surf-enabled (42ms)
 
# Inspect all commands
surf 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 details
surf inspect https://shop.example.com --verbose
 
# Execute a command interactively
surf 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 output
surf test https://shop.example.com search --query "laptop" --json
 
# With authentication
surf test https://shop.example.com order.create --auth sk-token --sku LAPTOP-01

The 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.

devui.ts
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 server
const { url } = await devui.start()
console.log(`DevUI at ${url}`)
// => DevUI at http://localhost:4242/__surf
 
// Option 2: Mount as Express middleware
import express from 'express'
const app = express()
app.use(devui.middleware()) // Mounts at /__surf
app.use(surf.middleware()) // Surf routes
app.listen(3000)
 
// Stop standalone server
await 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.

surf.json
// 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

CodeHTTPMeaning
UNKNOWN_COMMAND404Command not found in manifest
INVALID_PARAMS400Missing required param or wrong type
AUTH_REQUIRED401Command requires auth but none provided
AUTH_FAILED403Token is invalid or expired
SESSION_EXPIRED410Session ID no longer valid
RATE_LIMITED429Too many requests (check Retry-After)
INTERNAL_ERROR500Unexpected server error
NOT_SUPPORTED501Feature not available

Examples

Explore real-world implementation guides: