{"id":47612486,"url":"https://github.com/hauselabs/surf","last_synced_at":"2026-04-01T20:40:17.597Z","repository":{"id":345842943,"uuid":"1187612696","full_name":"hauselabs/surf","owner":"hauselabs","description":"Give AI agents a typed CLI to your website, app, or API.","archived":false,"fork":false,"pushed_at":"2026-03-29T15:27:37.000Z","size":703,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-03-29T18:06:09.880Z","etag":null,"topics":["ai","ai-agents","automation","developer-tools","javascript","llm","nodejs","open-source","protocol","sdk","typescript","web-api","web-scraping"],"latest_commit_sha":null,"homepage":"https://surf.codes","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hauselabs.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-20T23:43:10.000Z","updated_at":"2026-03-29T15:34:37.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/hauselabs/surf","commit_stats":null,"previous_names":["hauselabs/surf"],"tags_count":14,"template":false,"template_full_name":null,"purl":"pkg:github/hauselabs/surf","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hauselabs%2Fsurf","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hauselabs%2Fsurf/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hauselabs%2Fsurf/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hauselabs%2Fsurf/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hauselabs","download_url":"https://codeload.github.com/hauselabs/surf/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hauselabs%2Fsurf/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31291752,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-01T13:12:26.723Z","status":"ssl_error","status_checked_at":"2026-04-01T13:12:25.102Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai","ai-agents","automation","developer-tools","javascript","llm","nodejs","open-source","protocol","sdk","typescript","web-api","web-scraping"],"created_at":"2026-04-01T20:40:16.373Z","updated_at":"2026-04-01T20:40:17.579Z","avatar_url":"https://github.com/hauselabs.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n# 🏄 Surf.js\n\n**Give AI agents a CLI to your website.**\n\n[![CI](https://github.com/hauselabs/surf/actions/workflows/ci.yml/badge.svg)](https://github.com/hauselabs/surf/actions/workflows/ci.yml)\n[![npm version](https://img.shields.io/npm/v/@surfjs/core.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/core)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)\n[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue?style=flat-square\u0026logo=typescript\u0026logoColor=white)](https://www.typescriptlang.org/)\n[![Docs](https://img.shields.io/badge/docs-surf.codes-0057FF?style=flat-square\u0026logo=googledocs\u0026logoColor=white)](https://surf.codes/docs)\n\n[Website](https://surf.codes) · [Docs](https://surf.codes/docs) · [Protocol Spec](./SPEC.md) · [Examples](./examples) · [Contributing](./CONTRIBUTING.md)\n\n\u003cbr/\u003e\n\n[![Surf-enabled](https://surf.codes/badge.svg)](https://surf.codes/badge)\n\n\u003c/div\u003e\n\n---\n\nAI agents shouldn't need vision models to click buttons on a webpage. That's slow, expensive, and breaks every time the UI changes.\n\n**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*.\n\n- 🔍 **Discoverable** — Agents find your commands at `/.well-known/surf.json`, automatically\n- ⚡ **Fast** — Direct command execution. No screenshots, no DOM parsing. ~200ms vs ~30s\n- 🔒 **Typed \u0026 Safe** — Full parameter validation, auth, rate limiting, sessions — built in\n\n## Quick Start\n\n```bash\nnpm install @surfjs/core\n```\n\n```js\nimport { createSurf } from '@surfjs/core';\nimport express from 'express';\n\nconst app = express();\napp.use(express.json());\n\nconst surf = await createSurf({\n  name: 'My Store',\n  commands: {\n    search: {\n      description: 'Search products',\n      params: { query: { type: 'string', required: true } },\n      run: async ({ query }) =\u003e db.products.search(query),\n    },\n  },\n});\n\napp.use(surf.middleware());\napp.listen(3000);\n// → Manifest served at GET /.well-known/surf.json\n// → Commands executable at POST /surf/execute\n// → Pipelines at POST /surf/pipeline\n// → Sessions at POST /surf/session/start and /surf/session/end\n```\n\nThat's it. Your site is now agent-navigable.\n\n## Browser-Side Execution\n\nCommands 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.\n\n```tsx\nimport { useSurfCommands } from '@surfjs/react'\n\nfunction MyApp() {\n  useSurfCommands({\n    'canvas.addCircle': {\n      mode: 'local',\n      run: (params) =\u003e {\n        addCircleToCanvas(params)\n        return { ok: true }\n      }\n    },\n    'sidebar.toggle': {\n      mode: 'local',\n      run: ({ open }) =\u003e {\n        setSidebarOpen(open)\n        return { ok: true }\n      }\n    }\n  })\n}\n\n// Agent runs: await window.surf.execute('canvas.addCircle', { x: 200, radius: 50 })\n```\n\nHandlers 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.\n\n## Execution Modes\n\n| Mode | Where it runs | Use case |\n|------|--------------|----------|\n| `'local'` | Browser only | UI state changes — no persistence needed |\n| `'sync'` | Browser first, then server | Optimistic UI with server persistence |\n| *(fallback)* | Server | Commands with no registered local handler |\n\nSet an execution hint on the command definition to signal intent:\n\n```typescript\nhints: { execution: 'browser' }  // Always handled locally\nhints: { execution: 'server' }   // Always goes to server\nhints: { execution: 'any' }      // Runtime picks (default)\n```\n\n## How It Works\n\n### 1. Define commands\n\nMap your app's capabilities to typed, documented commands:\n\n```ts\nconst surf = await createSurf({\n  name: 'Acme Store',\n  commands: {\n    search: {\n      description: 'Search products by query',\n      params: {\n        query: { type: 'string', required: true },\n        maxPrice: { type: 'number' },\n        category: { type: 'string', enum: ['electronics', 'clothing', 'books'] },\n      },\n      returns: { type: 'array', items: { $ref: '#/types/Product' } },\n      hints: { idempotent: true, sideEffects: false, estimatedMs: 200 },\n      run: async ({ query, maxPrice, category }) =\u003e {\n        return db.products.search(query, { maxPrice, category });\n      },\n    },\n  },\n});\n```\n\n### 2. Surf generates a manifest\n\nA machine-readable `surf.json` is served at `/.well-known/surf.json` — agents discover it like `robots.txt`:\n\n```json\n{\n  \"surf\": \"0.1.0\",\n  \"name\": \"Acme Store\",\n  \"commands\": {\n    \"search\": {\n      \"description\": \"Search products by query\",\n      \"params\": {\n        \"query\": { \"type\": \"string\", \"required\": true },\n        \"maxPrice\": { \"type\": \"number\" },\n        \"category\": { \"type\": \"string\", \"enum\": [\"electronics\", \"clothing\", \"books\"] }\n      },\n      \"hints\": { \"idempotent\": true, \"sideEffects\": false, \"estimatedMs\": 200 }\n    }\n  },\n  \"checksum\": \"a1b2c3...\",\n  \"updatedAt\": \"2026-03-20T19:00:00.000Z\"\n}\n```\n\n### 3. Agents execute commands\n\nAny agent — using any language — can discover and call your commands:\n\n```ts\nimport { SurfClient } from '@surfjs/client';\n\nconst client = await SurfClient.discover('https://acme-store.com');\nconst results = await client.execute('search', { query: 'blue shoes', maxPrice: 100 });\n```\n\n## Why Surf?\n\n| Without Surf | With Surf |\n|---|---|\n| Screenshot → parse → guess → click → retry | Read manifest → execute command → done |\n| ~30 seconds per action | ~200ms per action |\n| $0.05 in vision API calls per action | $0.00 |\n| Breaks when UI changes | Stable as long as commands exist |\n| Agent-specific integrations | One protocol, any agent |\n\n## Packages\n\n| Package | Description | |\n|---|---|---|\n| [`@surfjs/core`](./packages/core) | Server-side: commands, manifest, auth, sessions, transports | [![npm](https://img.shields.io/npm/v/@surfjs/core.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/core) |\n| [`@surfjs/web`](./packages/web) | Browser runtime: `window.surf`, local command handlers | [![npm](https://img.shields.io/npm/v/@surfjs/web.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/web) |\n| [`@surfjs/react`](./packages/react) | React hooks: `useSurfCommands`, `SurfProvider`, `SurfBadge` | [![npm](https://img.shields.io/npm/v/@surfjs/react.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/react) |\n| [`@surfjs/client`](./packages/client) | Headless SDK for programmatic access — discover, execute, pipeline, sessions | [![npm](https://img.shields.io/npm/v/@surfjs/client.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/client) |\n| [`@surfjs/cli`](./packages/cli) | Developer tool: inspect, test, and ping Surf-enabled sites | [![npm](https://img.shields.io/npm/v/@surfjs/cli.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/cli) |\n| [`@surfjs/next`](./packages/next) | Next.js App Router \u0026 Pages Router adapter | [![npm](https://img.shields.io/npm/v/@surfjs/next.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/next) |\n| [`@surfjs/devui`](./packages/devui) | Browser DevUI overlay for inspecting Surf commands | [![npm](https://img.shields.io/npm/v/@surfjs/devui.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/devui) |\n| [`@surfjs/zod`](./packages/zod) | Zod schema integration for typed command params | [![npm](https://img.shields.io/npm/v/@surfjs/zod.svg?style=flat-square)](https://www.npmjs.com/package/@surfjs/zod) |\n\n---\n\n## Framework Adapters\n\n### Express / Connect\n\n```ts\nimport express from 'express';\nimport { createSurf } from '@surfjs/core';\n\nconst app = express();\napp.use(express.json());\nconst surf = await createSurf({ name: 'My App', commands: { /* ... */ } });\napp.use(surf.middleware());\n```\n\n### Fastify\n\n```ts\nimport Fastify from 'fastify';\nimport { createSurf } from '@surfjs/core';\nimport { fastifyPlugin } from '@surfjs/core/fastify';\n\nconst surf = await createSurf({ name: 'My App', commands: { /* ... */ } });\nconst app = Fastify();\napp.register(fastifyPlugin(surf));\n```\n\n### Hono\n\n```ts\nimport { Hono } from 'hono';\nimport { createSurf } from '@surfjs/core';\nimport { honoApp } from '@surfjs/core/hono';\n\nconst surf = await createSurf({ name: 'My App', commands: { /* ... */ } });\nconst app = new Hono();\nconst surfApp = await honoApp(surf);\napp.route('/', surfApp);\n```\n\nHono also exports `honoMiddleware(surf)` which returns a fetch handler for Cloudflare Workers:\n\n```ts\nimport { honoMiddleware } from '@surfjs/core/hono';\nexport default { fetch: honoMiddleware(surf) };\n```\n\n### Next.js (App Router)\n\n```ts\n// app/api/surf/surf-instance.ts\nimport { createSurf } from '@surfjs/core';\nexport const surf = await createSurf({ name: 'My App', commands: { /* ... */ } });\n\n// app/api/surf/route.ts — GET /.well-known/surf.json (use next.config rewrite)\nimport { NextResponse } from 'next/server';\nimport { surf } from './surf-instance';\nexport async function GET() {\n  return NextResponse.json(surf.manifest());\n}\n\n// app/api/surf/execute/route.ts — POST /api/surf/execute\nimport { NextRequest, NextResponse } from 'next/server';\nimport { surf } from '../surf-instance';\nexport async function POST(request: NextRequest) {\n  const { command, params, sessionId } = await request.json();\n  const response = await surf.commands.execute(command, params, { sessionId });\n  return NextResponse.json(response, { status: response.ok ? 200 : 500 });\n}\n```\n\n---\n\n## Features\n\n### Commands\n\nThe core building block. Each command has a description, typed parameters, optional return schema, and a handler:\n\n```ts\n{\n  description: 'What this command does',\n  params: {\n    name: { type: 'string', required: true, description: 'User name' },\n    count: { type: 'number', default: 10 },\n    category: { type: 'string', enum: ['a', 'b', 'c'] },\n    tags: { type: 'array', items: { type: 'string' } },\n    options: { type: 'object', properties: { verbose: { type: 'boolean' } } },\n  },\n  returns: { type: 'object', properties: { id: { type: 'string' } } },\n  tags: ['search', 'products'],\n  auth: 'required',        // 'none' | 'required' | 'optional' | 'hidden'\n  hints: {\n    idempotent: true,       // Safe to retry\n    sideEffects: false,     // Read-only\n    estimatedMs: 200,       // Expected latency\n  },\n  stream: true,             // Enable SSE streaming\n  rateLimit: { windowMs: 60000, maxRequests: 10, keyBy: 'ip' },\n  run: async (params, context) =\u003e {\n    // context.sessionId, context.auth, context.claims, context.state\n    // context.emit (streaming only), context.ip, context.requestId\n    return result;\n  },\n}\n```\n\n**Supported parameter types:** `string`, `number`, `boolean`, `object`, `array`\n\n### Namespacing\n\nGroup related commands with dot-notation — just nest objects:\n\n```ts\nconst surf = await createSurf({\n  name: 'My App',\n  commands: {\n    cart: {\n      add: { description: 'Add to cart', run: async (params) =\u003e { /* ... */ } },\n      remove: { description: 'Remove from cart', run: async (params) =\u003e { /* ... */ } },\n      checkout: { description: 'Checkout', run: async (params) =\u003e { /* ... */ } },\n    },\n    user: {\n      profile: { description: 'Get profile', run: async () =\u003e { /* ... */ } },\n    },\n  },\n});\n// → Commands: cart.add, cart.remove, cart.checkout, user.profile\n```\n\n### Authentication\n\nDefine auth at the global level and per-command:\n\n```ts\nconst surf = await createSurf({\n  name: 'My App',\n  auth: { type: 'bearer', description: 'JWT token' },\n  authVerifier: async (token, command) =\u003e {\n    const user = await verifyJwt(token);\n    return user\n      ? { valid: true, claims: { userId: user.id, role: user.role } }\n      : { valid: false, reason: 'Invalid token' };\n  },\n  commands: {\n    publicSearch: {\n      description: 'Public search',\n      auth: 'none',     // No auth required\n      run: async (params) =\u003e { /* ... */ },\n    },\n    getProfile: {\n      description: 'Get user profile',\n      auth: 'required', // Must authenticate\n      run: async (params, ctx) =\u003e {\n        // ctx.claims.userId available here\n      },\n    },\n    getRecommendations: {\n      description: 'Get recommendations',\n      auth: 'optional', // Personalized if authenticated\n      run: async (params, ctx) =\u003e {\n        if (ctx.claims) { /* personalized */ }\n      },\n    },\n    adminDashboard: {\n      description: 'Admin analytics dashboard',\n      auth: 'hidden',   // Not in manifest unless authed\n      run: async (params, ctx) =\u003e { /* ... */ },\n    },\n  },\n});\n```\n\nBuilt-in `bearerVerifier` for simple token validation:\n\n```ts\nimport { bearerVerifier } from '@surfjs/core';\nconst surf = await createSurf({\n  authVerifier: bearerVerifier(['token-1', 'token-2']),\n  // ...\n});\n```\n\n#### Auth Levels\n\n| Level | In Manifest | Requires Token | Use Case |\n|-------|-------------|----------------|----------|\n| `none` | ✅ Always | No | Public search, browsing |\n| `optional` | ✅ Always | No (enhanced if provided) | Personalized recommendations |\n| `required` | ✅ Always | Yes | User actions, writes |\n| `hidden` | Only with valid token | Yes | Admin tools, internal commands |\n\n**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'`.\n\n### Rate Limiting\n\nGlobal and per-command rate limits:\n\n```ts\nconst surf = await createSurf({\n  name: 'My App',\n  rateLimit: { windowMs: 60_000, maxRequests: 100, keyBy: 'ip' }, // Global\n  commands: {\n    expensiveOp: {\n      description: 'Resource-heavy operation',\n      rateLimit: { windowMs: 60_000, maxRequests: 5, keyBy: 'auth' }, // Per-command override\n      run: async (params) =\u003e { /* ... */ },\n    },\n  },\n});\n```\n\n**`keyBy` options:** `'ip'` (default), `'session'`, `'auth'`, `'global'`\n\n### Sessions\n\nStateful sessions with server-side state management:\n\n```ts\n// Server — use context.state and context.sessionId\nrun: async ({ sku }, ctx) =\u003e {\n  const cart = ctx.state?.cart ?? [];\n  cart.push(sku);\n  ctx.state = { ...ctx.state, cart };\n  return { cartSize: cart.length };\n}\n\n// Client — start/use/end sessions\nconst session = await client.startSession();\nawait session.execute('addToCart', { sku: 'SHOE-001' });\nawait session.execute('addToCart', { sku: 'HAT-002' });\nconst cart = await session.execute('getCart');\nawait session.end();\n```\n\n### Pipelines\n\nExecute multiple commands in a single HTTP round-trip:\n\n```ts\nconst results = await client.pipeline([\n  { command: 'search', params: { query: 'shoes' }, as: 'results' },\n  { command: 'getProduct', params: { id: '$results[0].id' } },\n  { command: 'addToCart', params: { sku: '$results[0].sku' } },\n]);\n// results.results → [{ command, ok, result }, ...]\n```\n\nServer-side pipeline options:\n\n```ts\n// POST /surf/pipeline\n{\n  \"steps\": [...],\n  \"sessionId\": \"optional-session\",\n  \"continueOnError\": true  // Continue executing steps even if one fails\n}\n```\n\n### SSE Streaming\n\nFor long-running commands that produce incremental output:\n\n**Server:**\n```ts\nconst surf = await createSurf({\n  name: 'AI Writer',\n  commands: {\n    generate: {\n      description: 'Generate text with streaming',\n      params: { prompt: { type: 'string', required: true } },\n      stream: true,\n      run: async ({ prompt }, { emit }) =\u003e {\n        for (const token of generateTokens(prompt)) {\n          emit!({ token });    // → SSE chunk event\n          await sleep(50);\n        }\n        return { done: true }; // → SSE done event\n      },\n    },\n  },\n});\n```\n\n**Client:**\n```ts\nconst response = await fetch('https://example.com/surf/execute', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ command: 'generate', params: { prompt: 'Hello' }, stream: true }),\n});\n\nconst reader = response.body.getReader();\nconst decoder = new TextDecoder();\n// SSE format: data: {\"type\":\"chunk\",\"data\":{...}}\\n\\n\n// Final:      data: {\"type\":\"done\",\"result\":{...}}\\n\\n\n```\n\n### WebSocket Transport\n\nFor real-time bidirectional communication:\n\n**Server:**\n```ts\nimport { createServer } from 'http';\nconst server = createServer(app);\nsurf.wsHandler(server); // Requires the 'ws' package\nserver.listen(3000);\n```\n\n**Client:**\n```ts\nconst ws = await client.connect(); // Connects to ws://host/surf/ws\nws.on('orderUpdate', (data) =\u003e console.log('Order updated:', data));\nconst result = await ws.execute('search', { query: 'shoes' });\nawait ws.startSession();\nawait ws.endSession();\nws.close();\n```\n\n### Window Runtime (In-Browser)\n\nFor browser-based agents operating within the page:\n\n**Server — inject the runtime:**\n```ts\nconst script = surf.browserScript(); // Returns \u003cscript\u003e with window.__surf__\nconst bridge = surf.browserBridge(); // Returns bridge code for in-page agents\n```\n\n**Client — use from browser:**\n```ts\nimport { WindowTransport } from '@surfjs/client';\n\nconst transport = new WindowTransport();\nawait transport.connect(); // Uses window.__surf__\nconst manifest = transport.discover();\nconst result = await transport.execute('search', { query: 'shoes' });\ntransport.on('event', (data) =\u003e console.log(data));\n```\n\n### Middleware\n\nComposable middleware pipeline for cross-cutting concerns:\n\n```ts\nimport type { SurfMiddleware } from '@surfjs/core';\n\nconst logger: SurfMiddleware = async (ctx, next) =\u003e {\n  console.log(`→ ${ctx.command}`, ctx.params);\n  const start = Date.now();\n  await next();\n  console.log(`← ${ctx.command} (${Date.now() - start}ms)`);\n};\n\nconst rateLimiter: SurfMiddleware = async (ctx, next) =\u003e {\n  if (isRateLimited(ctx.context.ip)) {\n    ctx.error = { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many requests' } };\n    return;\n  }\n  await next();\n};\n\nsurf.use(logger);\nsurf.use(rateLimiter);\n```\n\nMiddleware has access to `ctx.command`, `ctx.params`, `ctx.context` (session, auth, IP), and can set `ctx.result` or `ctx.error` to short-circuit.\n\n### Reusable Types\n\nDefine shared types referenced across commands with `$ref`:\n\n```ts\nconst surf = await createSurf({\n  name: 'My App',\n  types: {\n    Product: {\n      type: 'object',\n      description: 'A product in the catalog',\n      properties: {\n        id: { type: 'string' },\n        name: { type: 'string' },\n        price: { type: 'number' },\n      },\n    },\n  },\n  commands: {\n    search: {\n      description: 'Search products',\n      returns: { type: 'array', items: { $ref: '#/types/Product' } },\n      run: async () =\u003e { /* ... */ },\n    },\n  },\n});\n```\n\n---\n\n## Event Scoping\n\nSurf events support **three delivery scopes** — a key security feature for multi-tenant / multi-session environments:\n\n| Scope | Behavior |\n|---|---|\n| `session` (default) | Only delivered to the session that triggered it |\n| `global` | Delivered to all subscribers (system announcements) |\n| `broadcast` | Delivered to all connected clients |\n\n```ts\nconst surf = await createSurf({\n  name: 'My App',\n  events: {\n    'order.updated': {\n      description: 'Order status changed',\n      scope: 'session',  // Only the user who placed the order sees updates\n      data: { orderId: { type: 'string' }, status: { type: 'string' } },\n    },\n    'maintenance.scheduled': {\n      description: 'System maintenance announcement',\n      scope: 'global',   // Everyone sees this\n      data: { message: { type: 'string' }, scheduledAt: { type: 'string' } },\n    },\n  },\n  commands: { /* ... */ },\n});\n\n// Server-side: emit with session context\nsurf.events.on('order.updated', (data) =\u003e { /* server-side listener */ });\nsurf.emit('order.updated', { orderId: '123', status: 'shipped' });\n\n// Session cleanup on disconnect\nsurf.events.removeSession(sessionId);\n```\n\n---\n\n## CLI\n\nThe `@surfjs/cli` package provides terminal tools for inspecting and testing Surf-enabled sites.\n\n```bash\nnpm install -g @surfjs/cli\n```\n\n### `surf inspect \u003curl\u003e`\n\nFetch the manifest and pretty-print all available commands:\n\n```bash\n$ surf inspect https://acme-store.com\n\n🏄 Acme Store (Surf v0.1.0)\n   E-commerce store with 50,000+ products\n\n   5 commands available:\n\n   search(query: string, maxPrice?: number, category?: string)\n   Search products by keyword\n\n   cart.add(sku: string, qty?: number) 🔐\n   Add item to cart\n```\n\nUse `--verbose` to show full parameter schemas and hints.\n\n### `surf test \u003curl\u003e \u003ccommand\u003e`\n\nExecute a command interactively. Missing required params are prompted:\n\n```bash\n$ surf test https://acme-store.com search --query \"wireless headphones\" --maxPrice 100\n\n   Executing search on https://acme-store.com...\n\n   OK\n\n   [\n     { \"id\": \"1\", \"name\": \"Wireless Headphones\", \"price\": 79.99 }\n   ]\n\n   ⏱  45ms execute / 312ms total\n```\n\n### `surf ping \u003curl\u003e`\n\nCheck if a site is Surf-enabled:\n\n```bash\n$ surf ping https://acme-store.com\n✅ https://acme-store.com is Surf-enabled (23ms)\n```\n\n### CLI Flags\n\n| Flag | Description |\n|---|---|\n| `--json` | Machine-readable JSON output |\n| `--auth \u003ctoken\u003e` | Bearer token for authenticated commands |\n| `--verbose` | Show full parameter schemas and hints (inspect) |\n\n---\n\n## DevUI\n\n`@surfjs/devui` provides an interactive browser-based inspector for exploring and testing your Surf commands during development.\n\n```ts\nimport { createSurf } from '@surfjs/core';\nimport { createDevUI } from '@surfjs/devui';\n\nconst surf = await createSurf({ name: 'My App', commands: { /* ... */ } });\nconst devui = createDevUI(surf, { port: 4242 });\n\n// Standalone server\nconst { url } = await devui.start();\nconsole.log(`DevUI at ${url}`);  // → http://localhost:4242/__surf\n\n// Or as Express middleware\napp.use(devui.middleware());  // Mounts at /__surf\n```\n\n**Options:**\n\n| Option | Default | Description |\n|---|---|---|\n| `port` | `4242` | Port for standalone server |\n| `host` | `'localhost'` | Host to bind to |\n| `path` | `'/__surf'` | Mount path prefix |\n| `title` | Manifest name | Override the UI title |\n\nThe DevUI features:\n- Command sidebar with search/filter and namespace grouping\n- Parameter form with type-aware inputs (text, number, checkbox, select for enums, JSON editor for objects/arrays)\n- One-click execution with auth token support\n- Request log with syntax-highlighted JSON and timing\n- Keyboard shortcuts: `/` to search, `⌘Enter` to execute\n\n---\n\n## API Reference\n\n### `createSurf(config): Promise\u003cSurfInstance\u003e`\n\nThe main entry point. Returns a `SurfInstance`.\n\n**`SurfConfig`:**\n\n| Field | Type | Description |\n|---|---|---|\n| `name` | `string` | **Required.** Service name (shown in manifest and DevUI) |\n| `description` | `string?` | Service description |\n| `version` | `string?` | Service version |\n| `baseUrl` | `string?` | Base URL for the service |\n| `auth` | `AuthConfig?` | Auth configuration (`{ type: 'bearer' \\| 'apiKey' \\| 'oauth2' \\| 'none' }`) |\n| `commands` | `Record\u003cstring, CommandDefinition \\| CommandGroup\u003e` | **Required.** Command definitions (supports nesting) |\n| `events` | `Record\u003cstring, EventDefinition\u003e?` | Event definitions with scope |\n| `types` | `Record\u003cstring, TypeDefinition\u003e?` | Reusable type definitions (referenced via `$ref`) |\n| `middleware` | `SurfMiddleware[]?` | Middleware pipeline |\n| `authVerifier` | `AuthVerifier?` | Auto-installs auth enforcement middleware |\n| `rateLimit` | `RateLimitConfig?` | Global rate limit |\n| `validateReturns` | `boolean?` | Validate return values against `returns` schema |\n| `strict` | `boolean?` | Enable strict mode (implies `validateReturns`) |\n\n### `SurfInstance`\n\n| Method | Returns | Description |\n|---|---|---|\n| `manifest()` | `SurfManifest` | Get the generated manifest object |\n| `manifestHandler()` | `HttpHandler` | HTTP handler for `GET /.well-known/surf.json` |\n| `httpHandler()` | `HttpHandler` | HTTP handler for `POST /surf/execute` |\n| `middleware()` | `HttpHandler` | Express/Connect middleware (manifest + execute + pipeline + sessions) |\n| `wsHandler(server)` | `void` | Attach WebSocket transport (requires `ws` package) |\n| `browserScript()` | `string` | Generate `window.__surf__` runtime script |\n| `browserBridge()` | `string` | Generate in-page bridge for browser agents |\n| `use(middleware)` | `void` | Add middleware to the pipeline |\n| `emit(event, data)` | `void` | Emit an event to subscribers |\n| `events` | `EventBus` | Access the event bus directly |\n| `sessions` | `SessionStore` | Access the session store |\n| `commands` | `CommandRegistry` | Access the command registry |\n\n### Error Codes\n\n| Code | HTTP | Meaning |\n|---|---|---|\n| `UNKNOWN_COMMAND` | 404 | Command not found in manifest |\n| `INVALID_PARAMS` | 400 | Missing/wrong params |\n| `AUTH_REQUIRED` | 401 | Authentication required but not provided |\n| `AUTH_FAILED` | 403 | Token invalid or expired |\n| `SESSION_EXPIRED` | 410 | Session no longer valid |\n| `RATE_LIMITED` | 429 | Too many requests (check `Retry-After` header) |\n| `INTERNAL_ERROR` | 500 | Unexpected server error |\n| `NOT_SUPPORTED` | 501 | Feature/transport not available |\n\n---\n\n## Security\n\n### ⚠️ Only expose what's already public\n\nWhen adding Surf to your website, commands should **only mirror actions that regular users can already perform** through the public UI:\n\n- ✅ Search products, browse content, read public data\n- ✅ Add to cart, submit forms (with auth)\n- ❌ Internal APIs, admin endpoints, database queries\n- ❌ Backend services not already exposed to end users\n\n**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.\n\n### Design for zero prior knowledge\n\nAgents arrive with **no context** about your site — no IDs, slugs, or internal references. Design commands so agents can explore from scratch:\n\n- ✅ `search(\"headphones\")` → returns items with IDs → `product.get(\"WH-100\")`\n- ✅ `articles.list()` → returns slugs → `articles.get(\"my-post\")`\n- ❌ `article.get(slug)` with no way to discover valid slugs\n\n**Good pattern:** search/list → get details → take action. Never require an ID without a discovery path to find it.\n\n### Built-in protections\n\nSurf includes multiple layers of security by default:\n\n- **Session isolation** — Session state is isolated per session ID. One user cannot access another's state.\n- **Event scoping** — Events default to `session` scope. A user only receives events they triggered, unless explicitly configured as `global` or `broadcast`.\n- **Per-command auth** — Each command can require, optionally accept, or skip authentication independently.\n- **Auth verification** — The `authVerifier` runs before command execution, populating `context.claims` for downstream use.\n- **Rate limiting** — Global and per-command rate limits by IP, session, auth identity, or globally.\n- **Parameter validation** — All incoming parameters are validated against their declared schemas before reaching the handler.\n- **Return validation** — In strict mode, return values are also validated against the `returns` schema.\n- **CORS headers** — All responses include `Access-Control-Allow-Origin: *` for cross-origin agent access.\n- **ETag caching** — Manifest responses include checksums for efficient caching.\n\n---\n\n## Discovery\n\nAgents find your Surf manifest through multiple mechanisms:\n\n1. **`/.well-known/surf.json`** (recommended) — Standard discovery endpoint, fetched first\n2. **HTML `\u003cmeta name=\"surf\"\u003e` tag** — Fallback for sites that can't serve well-known paths\n3. **`window.__surf__`** — In-browser runtime for browser-based agents\n4. **`llms.txt`** — Reference in your site's `/llms.txt` for LLM-based agents\n5. **`robots.txt`** — Agent-friendly hints (`Allow: /.well-known/surf.json`)\n\n---\n\n## Transports\n\nSame commands, three delivery mechanisms:\n\n| Transport | Use Case | Latency |\n|---|---|---|\n| **HTTP** | Default. RESTful request/response. Works everywhere. | ~200ms |\n| **WebSocket** | Real-time bidirectional. Events, live updates. | ~10ms |\n| **Window Runtime** | Browser-based agents via `window.__surf__`. | ~1ms |\n\n---\n\n## Protocol\n\nThe full protocol specification is at **[SPEC.md](./SPEC.md)** — language-agnostic, implement it in Python, Go, Ruby, or any language.\n\n## Examples\n\nSee the [`examples/`](./examples) directory for complete, runnable examples:\n\n- **[Express](./examples/express)** — Store backend with 5 commands\n- **[Fastify](./examples/fastify)** — Same store, Fastify adapter\n- **[Hono](./examples/hono)** — Same store, Hono adapter\n- **[Next.js](./examples/nextjs)** — App Router API integration\n- **[Agent Client](./examples/agent-client)** — Discover + execute + pipeline + sessions\n- **[Streaming](./examples/streaming)** — SSE streaming server and client\n\n## Contributing\n\nWe'd love your help! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.\n\n```bash\n# Clone and install\ngit clone https://github.com/hauselabs/surf.git\ncd surf\npnpm install\n\n# Build all packages\npnpm build\n\n# Run tests\npnpm test\n\n# Type check\npnpm typecheck\n```\n\n## License\n\n[MIT](./LICENSE) © agent-hause / hause.co contributors\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhauselabs%2Fsurf","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhauselabs%2Fsurf","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhauselabs%2Fsurf/lists"}