https://github.com/hauselabs/surf
Give AI agents a typed CLI to your website, app, or API.
https://github.com/hauselabs/surf
ai ai-agents automation developer-tools javascript llm nodejs open-source protocol sdk typescript web-api web-scraping
Last synced: 3 months ago
JSON representation
Give AI agents a typed CLI to your website, app, or API.
- Host: GitHub
- URL: https://github.com/hauselabs/surf
- Owner: hauselabs
- License: mit
- Created: 2026-03-20T23:43:10.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-03-29T15:27:37.000Z (3 months ago)
- Last Synced: 2026-03-29T18:06:09.880Z (3 months ago)
- Topics: ai, ai-agents, automation, developer-tools, javascript, llm, nodejs, open-source, protocol, sdk, typescript, web-api, web-scraping
- Language: TypeScript
- Homepage: https://surf.codes
- Size: 687 KB
- Stars: 1
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
README
# π Surf.js
**Give AI agents a CLI to your website.**
[](https://github.com/hauselabs/surf/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@surfjs/core)
[](https://opensource.org/licenses/MIT)
[](https://www.typescriptlang.org/)
[](https://surf.codes/docs)
[Website](https://surf.codes) Β· [Docs](https://surf.codes/docs) Β· [Protocol Spec](./SPEC.md) Β· [Examples](./examples) Β· [Contributing](./CONTRIBUTING.md)
[](https://surf.codes/badge)
---
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
## Quick Start
```bash
npm install @surfjs/core
```
```js
import { 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/end
```
That's it. Your site is now agent-navigable.
## Browser-Side Execution
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.
```tsx
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.
## Execution Modes
| 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:
```typescript
hints: { execution: 'browser' } // Always handled locally
hints: { execution: 'server' } // Always goes to server
hints: { execution: 'any' } // Runtime picks (default)
```
## How It Works
### 1. Define commands
Map your app's capabilities to typed, documented commands:
```ts
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 });
},
},
},
});
```
### 2. Surf generates a manifest
A machine-readable `surf.json` is served at `/.well-known/surf.json` β agents discover it like `robots.txt`:
```json
{
"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"
}
```
### 3. Agents execute commands
Any agent β using any language β can discover and call your commands:
```ts
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 });
```
## Why Surf?
| 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 |
## Packages
| Package | Description | |
|---|---|---|
| [`@surfjs/core`](./packages/core) | Server-side: commands, manifest, auth, sessions, transports | [](https://www.npmjs.com/package/@surfjs/core) |
| [`@surfjs/web`](./packages/web) | Browser runtime: `window.surf`, local command handlers | [](https://www.npmjs.com/package/@surfjs/web) |
| [`@surfjs/react`](./packages/react) | React hooks: `useSurfCommands`, `SurfProvider`, `SurfBadge` | [](https://www.npmjs.com/package/@surfjs/react) |
| [`@surfjs/client`](./packages/client) | Headless SDK for programmatic access β discover, execute, pipeline, sessions | [](https://www.npmjs.com/package/@surfjs/client) |
| [`@surfjs/cli`](./packages/cli) | Developer tool: inspect, test, and ping Surf-enabled sites | [](https://www.npmjs.com/package/@surfjs/cli) |
| [`@surfjs/next`](./packages/next) | Next.js App Router & Pages Router adapter | [](https://www.npmjs.com/package/@surfjs/next) |
| [`@surfjs/devui`](./packages/devui) | Browser DevUI overlay for inspecting Surf commands | [](https://www.npmjs.com/package/@surfjs/devui) |
| [`@surfjs/zod`](./packages/zod) | Zod schema integration for typed command params | [](https://www.npmjs.com/package/@surfjs/zod) |
---
## Framework Adapters
### Express / Connect
```ts
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());
```
### Fastify
```ts
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));
```
### Hono
```ts
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:
```ts
import { honoMiddleware } from '@surfjs/core/hono';
export default { fetch: honoMiddleware(surf) };
```
### Next.js (App Router)
```ts
// 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 });
}
```
---
## Features
### Commands
The core building block. Each command has a description, typed parameters, optional return schema, and a handler:
```ts
{
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`
### Namespacing
Group related commands with dot-notation β just nest objects:
```ts
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.profile
```
### Authentication
Define auth at the global level and per-command:
```ts
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:
```ts
import { bearerVerifier } from '@surfjs/core';
const surf = await createSurf({
authVerifier: bearerVerifier(['token-1', 'token-2']),
// ...
});
```
#### Auth Levels
| 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'`.
### Rate Limiting
Global and per-command rate limits:
```ts
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'`
### Sessions
Stateful sessions with server-side state management:
```ts
// 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();
```
### Pipelines
Execute multiple commands in a single HTTP round-trip:
```ts
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:
```ts
// POST /surf/pipeline
{
"steps": [...],
"sessionId": "optional-session",
"continueOnError": true // Continue executing steps even if one fails
}
```
### SSE Streaming
For long-running commands that produce incremental output:
**Server:**
```ts
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:**
```ts
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\n
```
### WebSocket Transport
For real-time bidirectional communication:
**Server:**
```ts
import { createServer } from 'http';
const server = createServer(app);
surf.wsHandler(server); // Requires the 'ws' package
server.listen(3000);
```
**Client:**
```ts
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();
```
### Window Runtime (In-Browser)
For browser-based agents operating within the page:
**Server β inject the runtime:**
```ts
const script = surf.browserScript(); // Returns with window.__surf__
const bridge = surf.browserBridge(); // Returns bridge code for in-page agents
```
**Client β use from browser:**
```ts
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));
```
### Middleware
Composable middleware pipeline for cross-cutting concerns:
```ts
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.
### Reusable Types
Define shared types referenced across commands with `$ref`:
```ts
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 () => { /* ... */ },
},
},
});
```
---
## Event Scoping
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 |
```ts
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);
```
---
## CLI
The `@surfjs/cli` package provides terminal tools for inspecting and testing Surf-enabled sites.
```bash
npm install -g @surfjs/cli
```
### `surf inspect <url>`
Fetch the manifest and pretty-print all available commands:
```bash
$ 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 cart
```
Use `--verbose` to show full parameter schemas and hints.
### `surf test <url> <command>`
Execute a command interactively. Missing required params are prompted:
```bash
$ 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 total
```
### `surf ping <url>`
Check if a site is Surf-enabled:
```bash
$ surf ping https://acme-store.com
β
https://acme-store.com is Surf-enabled (23ms)
```
### CLI Flags
| Flag | Description |
|---|---|
| `--json` | Machine-readable JSON output |
| `--auth <token>` | Bearer token for authenticated commands |
| `--verbose` | Show full parameter schemas and hints (inspect) |
---
## DevUI
`@surfjs/devui` provides an interactive browser-based inspector for exploring and testing your Surf commands during development.
```ts
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 /__surf
```
**Options:**
| 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, `βEnter` to execute
---
## API Reference
### `createSurf(config): Promise<SurfInstance>`
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`) |
### `SurfInstance`
| 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 |
### Error Codes
| 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 |
---
## Security
### β οΈ Only expose what's already public
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.
### Design for zero prior knowledge
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.
### Built-in protections
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 `session` scope. A user only receives events they triggered, unless explicitly configured as `global` or `broadcast`.
- **Per-command auth** β Each command can require, optionally accept, or skip authentication independently.
- **Auth verification** β The `authVerifier` runs before command execution, populating `context.claims` for 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 `returns` schema.
- **CORS headers** β All responses include `Access-Control-Allow-Origin: *` for cross-origin agent access.
- **ETag caching** β Manifest responses include checksums for efficient caching.
---
## Discovery
Agents find your Surf manifest through multiple mechanisms:
1. **`/.well-known/surf.json`** (recommended) β Standard discovery endpoint, fetched first
2. **HTML `<meta name="surf">` tag** β Fallback for sites that can't serve well-known paths
3. **`window.__surf__`** β In-browser runtime for browser-based agents
4. **`llms.txt`** β Reference in your site's `/llms.txt` for LLM-based agents
5. **`robots.txt`** β Agent-friendly hints (`Allow: /.well-known/surf.json`)
---
## Transports
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 |
---
## Protocol
The full protocol specification is at **[SPEC.md](./SPEC.md)** β language-agnostic, implement it in Python, Go, Ruby, or any language.
## Examples
See the [`examples/`](./examples) directory for complete, runnable examples:
- **[Express](./examples/express)** β Store backend with 5 commands
- **[Fastify](./examples/fastify)** β Same store, Fastify adapter
- **[Hono](./examples/hono)** β Same store, Hono adapter
- **[Next.js](./examples/nextjs)** β App Router API integration
- **[Agent Client](./examples/agent-client)** β Discover + execute + pipeline + sessions
- **[Streaming](./examples/streaming)** β SSE streaming server and client
## Contributing
We'd love your help! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
```bash
# 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 typecheck
```
## License
[MIT](./LICENSE) Β© agent-hause / hause.co contributors