AI agents shouldn't need vision models to click buttons on a webpage. That's slow, expensive, and breaks every time the UI changes.
Surf is an open protocol + JavaScript library that lets any website expose typed commands for AI agents — like robots.txt, but for what agents can do.
- 🔍 Discoverable — Agents find your commands at
/.well-known/surf.json, automatically - ⚡ Fast — Direct command execution. No screenshots, no DOM parsing. ~200ms vs ~30s
- 🔒 Typed & Safe — Full parameter validation, auth, rate limiting, sessions — built in
npm install @surfjs/coreimport { createSurf } from '@surfjs/core';
import express from 'express';
const app = express();
app.use(express.json());
const surf = await createSurf({
name: 'My Store',
commands: {
search: {
description: 'Search products',
params: { query: { type: 'string', required: true } },
run: async ({ query }) => db.products.search(query),
},
},
});
app.use(surf.middleware());
app.listen(3000);
// → Manifest served at GET /.well-known/surf.json
// → Commands executable at POST /surf/execute
// → Pipelines at POST /surf/pipeline
// → Sessions at POST /surf/session/start and /surf/session/endThat's it. Your site is now agent-navigable.
Commands don't have to go through a server. With @surfjs/web and useSurfCommands, handlers run locally in the browser — modifying UI state directly. Instant. No HTTP roundtrip.
import { useSurfCommands } from '@surfjs/react'
function MyApp() {
useSurfCommands({
'canvas.addCircle': {
mode: 'local',
run: (params) => {
addCircleToCanvas(params)
return { ok: true }
}
},
'sidebar.toggle': {
mode: 'local',
run: ({ open }) => {
setSidebarOpen(open)
return { ok: true }
}
}
})
}
// Agent runs: await window.surf.execute('canvas.addCircle', { x: 200, radius: 50 })Handlers are registered on mount, cleaned up on unmount. The window.surf dispatcher routes to the local handler first — falling back to the server if no handler is found.
| Mode | Where it runs | Use case |
|---|---|---|
'local' |
Browser only | UI state changes — no persistence needed |
'sync' |
Browser first, then server | Optimistic UI with server persistence |
| (fallback) | Server | Commands with no registered local handler |
Set an execution hint on the command definition to signal intent:
hints: { execution: 'browser' } // Always handled locally
hints: { execution: 'server' } // Always goes to server
hints: { execution: 'any' } // Runtime picks (default)Map your app's capabilities to typed, documented commands:
const surf = await createSurf({
name: 'Acme Store',
commands: {
search: {
description: 'Search products by query',
params: {
query: { type: 'string', required: true },
maxPrice: { type: 'number' },
category: { type: 'string', enum: ['electronics', 'clothing', 'books'] },
},
returns: { type: 'array', items: { $ref: '#/types/Product' } },
hints: { idempotent: true, sideEffects: false, estimatedMs: 200 },
run: async ({ query, maxPrice, category }) => {
return db.products.search(query, { maxPrice, category });
},
},
},
});A machine-readable surf.json is served at /.well-known/surf.json — agents discover it like robots.txt:
{
"surf": "0.1.0",
"name": "Acme Store",
"commands": {
"search": {
"description": "Search products by query",
"params": {
"query": { "type": "string", "required": true },
"maxPrice": { "type": "number" },
"category": { "type": "string", "enum": ["electronics", "clothing", "books"] }
},
"hints": { "idempotent": true, "sideEffects": false, "estimatedMs": 200 }
}
},
"checksum": "a1b2c3...",
"updatedAt": "2026-03-20T19:00:00.000Z"
}Any agent — using any language — can discover and call your commands:
import { SurfClient } from '@surfjs/client';
const client = await SurfClient.discover('https://acme-store.com');
const results = await client.execute('search', { query: 'blue shoes', maxPrice: 100 });| Without Surf | With Surf |
|---|---|
| Screenshot → parse → guess → click → retry | Read manifest → execute command → done |
| ~30 seconds per action | ~200ms per action |
| $0.05 in vision API calls per action | $0.00 |
| Breaks when UI changes | Stable as long as commands exist |
| Agent-specific integrations | One protocol, any agent |
| Package | Description | |
|---|---|---|
@surfjs/core |
Server-side: commands, manifest, auth, sessions, transports | |
@surfjs/web |
Browser runtime: window.surf, local command handlers |
|
@surfjs/react |
React hooks: useSurfCommands, SurfProvider, SurfBadge |
|
@surfjs/client |
Headless SDK for programmatic access — discover, execute, pipeline, sessions | |
@surfjs/cli |
Developer tool: inspect, test, and ping Surf-enabled sites | |
@surfjs/next |
Next.js App Router & Pages Router adapter | |
@surfjs/devui |
Browser DevUI overlay for inspecting Surf commands | |
@surfjs/zod |
Zod schema integration for typed command params |
import express from 'express';
import { createSurf } from '@surfjs/core';
const app = express();
app.use(express.json());
const surf = await createSurf({ name: 'My App', commands: { /* ... */ } });
app.use(surf.middleware());import Fastify from 'fastify';
import { createSurf } from '@surfjs/core';
import { fastifyPlugin } from '@surfjs/core/fastify';
const surf = await createSurf({ name: 'My App', commands: { /* ... */ } });
const app = Fastify();
app.register(fastifyPlugin(surf));import { Hono } from 'hono';
import { createSurf } from '@surfjs/core';
import { honoApp } from '@surfjs/core/hono';
const surf = await createSurf({ name: 'My App', commands: { /* ... */ } });
const app = new Hono();
const surfApp = await honoApp(surf);
app.route('/', surfApp);Hono also exports honoMiddleware(surf) which returns a fetch handler for Cloudflare Workers:
import { honoMiddleware } from '@surfjs/core/hono';
export default { fetch: await honoMiddleware(surf) };// app/api/surf/surf-instance.ts
import { createSurf } from '@surfjs/core';
export const surf = await createSurf({ name: 'My App', commands: { /* ... */ } });
// app/api/surf/route.ts — GET /.well-known/surf.json (use next.config rewrite)
import { NextResponse } from 'next/server';
import { surf } from './surf-instance';
export async function GET() {
return NextResponse.json(surf.manifest());
}
// app/api/surf/execute/route.ts — POST /api/surf/execute
import { NextRequest, NextResponse } from 'next/server';
import { surf } from '../surf-instance';
export async function POST(request: NextRequest) {
const { command, params, sessionId } = await request.json();
const response = await surf.commands.execute(command, params, { sessionId });
return NextResponse.json(response, { status: response.ok ? 200 : 500 });
}The core building block. Each command has a description, typed parameters, optional return schema, and a handler:
{
description: 'What this command does',
params: {
name: { type: 'string', required: true, description: 'User name' },
count: { type: 'number', default: 10 },
category: { type: 'string', enum: ['a', 'b', 'c'] },
tags: { type: 'array', items: { type: 'string' } },
options: { type: 'object', properties: { verbose: { type: 'boolean' } } },
},
returns: { type: 'object', properties: { id: { type: 'string' } } },
tags: ['search', 'products'],
auth: 'required', // 'none' | 'required' | 'optional' | 'hidden'
hints: {
idempotent: true, // Safe to retry
sideEffects: false, // Read-only
estimatedMs: 200, // Expected latency
},
stream: true, // Enable SSE streaming
rateLimit: { windowMs: 60000, maxRequests: 10, keyBy: 'ip' },
run: async (params, context) => {
// context.sessionId, context.auth, context.claims, context.state
// context.emit (streaming only), context.ip, context.requestId
return result;
},
}Supported parameter types: string, number, boolean, object, array
Group related commands with dot-notation — just nest objects:
const surf = await createSurf({
name: 'My App',
commands: {
cart: {
add: { description: 'Add to cart', run: async (params) => { /* ... */ } },
remove: { description: 'Remove from cart', run: async (params) => { /* ... */ } },
checkout: { description: 'Checkout', run: async (params) => { /* ... */ } },
},
user: {
profile: { description: 'Get profile', run: async () => { /* ... */ } },
},
},
});
// → Commands: cart.add, cart.remove, cart.checkout, user.profileDefine auth at the global level and per-command:
const surf = await createSurf({
name: 'My App',
auth: { type: 'bearer', description: 'JWT token' },
authVerifier: async (token, command) => {
const user = await verifyJwt(token);
return user
? { valid: true, claims: { userId: user.id, role: user.role } }
: { valid: false, reason: 'Invalid token' };
},
commands: {
publicSearch: {
description: 'Public search',
auth: 'none', // No auth required
run: async (params) => { /* ... */ },
},
getProfile: {
description: 'Get user profile',
auth: 'required', // Must authenticate
run: async (params, ctx) => {
// ctx.claims.userId available here
},
},
getRecommendations: {
description: 'Get recommendations',
auth: 'optional', // Personalized if authenticated
run: async (params, ctx) => {
if (ctx.claims) { /* personalized */ }
},
},
adminDashboard: {
description: 'Admin analytics dashboard',
auth: 'hidden', // Not in manifest unless authed
run: async (params, ctx) => { /* ... */ },
},
},
});Built-in bearerVerifier for simple token validation:
import { bearerVerifier } from '@surfjs/core';
const surf = await createSurf({
authVerifier: bearerVerifier(['token-1', 'token-2']),
// ...
});| Level | In Manifest | Requires Token | Use Case |
|---|---|---|---|
none |
✅ Always | No | Public search, browsing |
optional |
✅ Always | No (enhanced if provided) | Personalized recommendations |
required |
✅ Always | Yes | User actions, writes |
hidden |
Only with valid token | Yes | Admin tools, internal commands |
Hidden commands are completely excluded from /.well-known/surf.json when no auth token is provided. Agents without credentials don't even know they exist. When a valid Bearer token is included in the manifest request, hidden commands appear as auth: 'required'.
Global and per-command rate limits:
const surf = await createSurf({
name: 'My App',
rateLimit: { windowMs: 60_000, maxRequests: 100, keyBy: 'ip' }, // Global
commands: {
expensiveOp: {
description: 'Resource-heavy operation',
rateLimit: { windowMs: 60_000, maxRequests: 5, keyBy: 'auth' }, // Per-command override
run: async (params) => { /* ... */ },
},
},
});keyBy options: 'ip' (default), 'session', 'auth', 'global'
Stateful sessions with server-side state management:
// Server — use context.state and context.sessionId
run: async ({ sku }, ctx) => {
const cart = ctx.state?.cart ?? [];
cart.push(sku);
ctx.state = { ...ctx.state, cart };
return { cartSize: cart.length };
}
// Client — start/use/end sessions
const session = await client.startSession();
await session.execute('addToCart', { sku: 'SHOE-001' });
await session.execute('addToCart', { sku: 'HAT-002' });
const cart = await session.execute('getCart');
await session.end();Execute multiple commands in a single HTTP round-trip:
const results = await client.pipeline([
{ command: 'search', params: { query: 'shoes' }, as: 'results' },
{ command: 'getProduct', params: { id: '$results[0].id' } },
{ command: 'addToCart', params: { sku: '$results[0].sku' } },
]);
// results.results → [{ command, ok, result }, ...]Server-side pipeline options:
// POST /surf/pipeline
{
"steps": [...],
"sessionId": "optional-session",
"continueOnError": true // Continue executing steps even if one fails
}For long-running commands that produce incremental output:
Server:
const surf = await createSurf({
name: 'AI Writer',
commands: {
generate: {
description: 'Generate text with streaming',
params: { prompt: { type: 'string', required: true } },
stream: true,
run: async ({ prompt }, { emit }) => {
for (const token of generateTokens(prompt)) {
emit!({ token }); // → SSE chunk event
await sleep(50);
}
return { done: true }; // → SSE done event
},
},
},
});Client:
const response = await fetch('https://example.com/surf/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'generate', params: { prompt: 'Hello' }, stream: true }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
// SSE format: data: {"type":"chunk","data":{...}}\n\n
// Final: data: {"type":"done","result":{...}}\n\nFor real-time bidirectional communication:
Server:
import { createServer } from 'http';
const server = createServer(app);
surf.wsHandler(server); // Requires the 'ws' package
server.listen(3000);Client:
const ws = await client.connect(); // Connects to ws://host/surf/ws
ws.on('orderUpdate', (data) => console.log('Order updated:', data));
const result = await ws.execute('search', { query: 'shoes' });
await ws.startSession();
await ws.endSession();
ws.close();For browser-based agents operating within the page:
Server — inject the runtime:
const script = surf.browserScript(); // Returns <script> with window.__surf__
const bridge = surf.browserBridge(); // Returns bridge code for in-page agentsClient — use from browser:
import { WindowTransport } from '@surfjs/client';
const transport = new WindowTransport();
await transport.connect(); // Uses window.__surf__
const manifest = transport.discover();
const result = await transport.execute('search', { query: 'shoes' });
transport.on('event', (data) => console.log(data));Composable middleware pipeline for cross-cutting concerns:
import type { SurfMiddleware } from '@surfjs/core';
const logger: SurfMiddleware = async (ctx, next) => {
console.log(`→ ${ctx.command}`, ctx.params);
const start = Date.now();
await next();
console.log(`← ${ctx.command} (${Date.now() - start}ms)`);
};
const rateLimiter: SurfMiddleware = async (ctx, next) => {
if (isRateLimited(ctx.context.ip)) {
ctx.error = { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many requests' } };
return;
}
await next();
};
surf.use(logger);
surf.use(rateLimiter);Middleware has access to ctx.command, ctx.params, ctx.context (session, auth, IP), and can set ctx.result or ctx.error to short-circuit.
Define shared types referenced across commands with $ref:
const surf = await createSurf({
name: 'My App',
types: {
Product: {
type: 'object',
description: 'A product in the catalog',
properties: {
id: { type: 'string' },
name: { type: 'string' },
price: { type: 'number' },
},
},
},
commands: {
search: {
description: 'Search products',
returns: { type: 'array', items: { $ref: '#/types/Product' } },
run: async () => { /* ... */ },
},
},
});Surf events support three delivery scopes — a key security feature for multi-tenant / multi-session environments:
| Scope | Behavior |
|---|---|
session (default) |
Only delivered to the session that triggered it |
global |
Delivered to all subscribers (system announcements) |
broadcast |
Delivered to all connected clients |
const surf = await createSurf({
name: 'My App',
events: {
'order.updated': {
description: 'Order status changed',
scope: 'session', // Only the user who placed the order sees updates
data: { orderId: { type: 'string' }, status: { type: 'string' } },
},
'maintenance.scheduled': {
description: 'System maintenance announcement',
scope: 'global', // Everyone sees this
data: { message: { type: 'string' }, scheduledAt: { type: 'string' } },
},
},
commands: { /* ... */ },
});
// Server-side: emit with session context
surf.events.on('order.updated', (data) => { /* server-side listener */ });
surf.emit('order.updated', { orderId: '123', status: 'shipped' });
// Session cleanup on disconnect
surf.events.removeSession(sessionId);The @surfjs/cli package provides terminal tools for inspecting and testing Surf-enabled sites.
npm install -g @surfjs/cliFetch the manifest and pretty-print all available commands:
$ surf inspect https://acme-store.com
🏄 Acme Store (Surf v0.1.0)
E-commerce store with 50,000+ products
5 commands available:
search(query: string, maxPrice?: number, category?: string)
Search products by keyword
cart.add(sku: string, qty?: number) 🔐
Add item to cartUse --verbose to show full parameter schemas and hints.
Execute a command interactively. Missing required params are prompted:
$ surf test https://acme-store.com search --query "wireless headphones" --maxPrice 100
Executing search on https://acme-store.com...
OK
[
{ "id": "1", "name": "Wireless Headphones", "price": 79.99 }
]
⏱ 45ms execute / 312ms totalCheck if a site is Surf-enabled:
$ surf ping https://acme-store.com
✅ https://acme-store.com is Surf-enabled (23ms)| Flag | Description |
|---|---|
--json |
Machine-readable JSON output |
--auth <token> |
Bearer token for authenticated commands |
--verbose |
Show full parameter schemas and hints (inspect) |
@surfjs/devui provides an interactive browser-based inspector for exploring and testing your Surf commands during development.
import { createSurf } from '@surfjs/core';
import { createDevUI } from '@surfjs/devui';
const surf = await createSurf({ name: 'My App', commands: { /* ... */ } });
const devui = createDevUI(surf, { port: 4242 });
// Standalone server
const { url } = await devui.start();
console.log(`DevUI at ${url}`); // → http://localhost:4242/__surf
// Or as Express middleware
app.use(devui.middleware()); // Mounts at /__surfOptions:
| Option | Default | Description |
|---|---|---|
port |
4242 |
Port for standalone server |
host |
'localhost' |
Host to bind to |
path |
'/__surf' |
Mount path prefix |
title |
Manifest name | Override the UI title |
The DevUI features:
- Command sidebar with search/filter and namespace grouping
- Parameter form with type-aware inputs (text, number, checkbox, select for enums, JSON editor for objects/arrays)
- One-click execution with auth token support
- Request log with syntax-highlighted JSON and timing
- Keyboard shortcuts:
/to search,⌘Enterto execute
The main entry point. Returns a SurfInstance.
SurfConfig:
| Field | Type | Description |
|---|---|---|
name |
string |
Required. Service name (shown in manifest and DevUI) |
description |
string? |
Service description |
version |
string? |
Service version |
baseUrl |
string? |
Base URL for the service |
auth |
AuthConfig? |
Auth configuration ({ type: 'bearer' | 'apiKey' | 'oauth2' | 'none' }) |
commands |
Record<string, CommandDefinition | CommandGroup> |
Required. Command definitions (supports nesting) |
events |
Record<string, EventDefinition>? |
Event definitions with scope |
types |
Record<string, TypeDefinition>? |
Reusable type definitions (referenced via $ref) |
middleware |
SurfMiddleware[]? |
Middleware pipeline |
authVerifier |
AuthVerifier? |
Auto-installs auth enforcement middleware |
rateLimit |
RateLimitConfig? |
Global rate limit |
validateReturns |
boolean? |
Validate return values against returns schema |
strict |
boolean? |
Enable strict mode (implies validateReturns) |
| Method | Returns | Description |
|---|---|---|
manifest() |
SurfManifest |
Get the generated manifest object |
manifestHandler() |
HttpHandler |
HTTP handler for GET /.well-known/surf.json |
httpHandler() |
HttpHandler |
HTTP handler for POST /surf/execute |
middleware() |
HttpHandler |
Express/Connect middleware (manifest + execute + pipeline + sessions) |
wsHandler(server) |
void |
Attach WebSocket transport (requires ws package) |
browserScript() |
string |
Generate window.__surf__ runtime script |
browserBridge() |
string |
Generate in-page bridge for browser agents |
use(middleware) |
void |
Add middleware to the pipeline |
emit(event, data) |
void |
Emit an event to subscribers |
events |
EventBus |
Access the event bus directly |
sessions |
SessionStore |
Access the session store |
commands |
CommandRegistry |
Access the command registry |
| Code | HTTP | Meaning |
|---|---|---|
UNKNOWN_COMMAND |
404 | Command not found in manifest |
INVALID_PARAMS |
400 | Missing/wrong params |
AUTH_REQUIRED |
401 | Authentication required but not provided |
AUTH_FAILED |
403 | Token invalid or expired |
SESSION_EXPIRED |
410 | Session no longer valid |
RATE_LIMITED |
429 | Too many requests (check Retry-After header) |
INTERNAL_ERROR |
500 | Unexpected server error |
NOT_SUPPORTED |
501 | Feature/transport not available |
When adding Surf to your website, commands should only mirror actions that regular users can already perform through the public UI:
- ✅ Search products, browse content, read public data
- ✅ Add to cart, submit forms (with auth)
- ❌ Internal APIs, admin endpoints, database queries
- ❌ Backend services not already exposed to end users
Rule of thumb: If a user can't do it from the browser without special access, it shouldn't be an unauthenticated Surf command. Use auth: 'required' for any command that modifies data or performs actions on behalf of a user. For admin or internal tools, use auth: 'hidden' to keep them out of the public manifest entirely.
Agents arrive with no context about your site — no IDs, slugs, or internal references. Design commands so agents can explore from scratch:
- ✅
search("headphones")→ returns items with IDs →product.get("WH-100") - ✅
articles.list()→ returns slugs →articles.get("my-post") - ❌
article.get(slug)with no way to discover valid slugs
Good pattern: search/list → get details → take action. Never require an ID without a discovery path to find it.
Surf includes multiple layers of security by default:
- Session isolation — Session state is isolated per session ID. One user cannot access another's state.
- Event scoping — Events default to
sessionscope. A user only receives events they triggered, unless explicitly configured asglobalorbroadcast. - Per-command auth — Each command can require, optionally accept, or skip authentication independently.
- Auth verification — The
authVerifierruns before command execution, populatingcontext.claimsfor downstream use. - Rate limiting — Global and per-command rate limits by IP, session, auth identity, or globally.
- Parameter validation — All incoming parameters are validated against their declared schemas before reaching the handler.
- Return validation — In strict mode, return values are also validated against the
returnsschema. - CORS headers — All responses include
Access-Control-Allow-Origin: *for cross-origin agent access. - ETag caching — Manifest responses include checksums for efficient caching.
Agents find your Surf manifest through multiple mechanisms:
/.well-known/surf.json(recommended) — Standard discovery endpoint, fetched first- HTML
<meta name="surf">tag — Fallback for sites that can't serve well-known paths window.__surf__— In-browser runtime for browser-based agentsllms.txt— Reference in your site's/llms.txtfor LLM-based agentsrobots.txt— Agent-friendly hints (Allow: /.well-known/surf.json)
Same commands, three delivery mechanisms:
| Transport | Use Case | Latency |
|---|---|---|
| HTTP | Default. RESTful request/response. Works everywhere. | ~200ms |
| WebSocket | Real-time bidirectional. Events, live updates. | ~10ms |
| Window Runtime | Browser-based agents via window.__surf__. |
~1ms |
The full protocol specification is at SPEC.md — language-agnostic, implement it in Python, Go, Ruby, or any language.
See the examples/ directory for complete, runnable examples:
- Express — Store backend with 5 commands
- Fastify — Same store, Fastify adapter
- Hono — Same store, Hono adapter
- Next.js — App Router API integration
- Agent Client — Discover + execute + pipeline + sessions
- Streaming — SSE streaming server and client
We'd love your help! See CONTRIBUTING.md for guidelines.
# Clone and install
git clone https://github.com/hauselabs/surf.git
cd surf
pnpm install
# Build all packages
pnpm build
# Run tests
pnpm test
# Type check
pnpm typecheckMIT © agent-hause / hause.co contributors