Configuration
Local browser runtime
Section titled “Local browser runtime”Canonical investigation provider: cmux
Section titled “Canonical investigation provider: cmux”For unsurf investigate and unsurf replay, open cmux and verify its in-app browser:
unsurf doctorNo agent-browser installation or CDP port is required. cmux provides authenticated interaction, snapshots, screenshots, and human takeover. Its Default WebKit profile is shared across surfaces, and WKWebView currently does not provide screencast, CDP trace, or network capture.
Optional video/CDP adapter
Section titled “Optional video/CDP adapter”Install agent-browser only when you need playable local recording or an attached Chrome/CDP session:
agent-browser --headed --profile ~/.unsurf-video open https://example.comunsurf record ./demo.ts --cdp-port 9222unsurf-local-mcpThe local MCP server defaults to CDP port 9222; tool callers can pass a different cdpPort when needed.
alchemy.run.ts
Section titled “alchemy.run.ts”unsurf uses Alchemy to define hosted infrastructure as TypeScript. No YAML. No TOML.
import alchemy, { type StateStoreType } from "alchemy";
import type { Binding } from "alchemy/cloudflare";
import {
Ai,
BrowserRendering,
CustomDomain,
D1Database,
KVNamespace,
R2Bucket,
RateLimit,
VectorizeIndex,
Worker,
} from "alchemy/cloudflare";
import { CloudflareStateStore } from "alchemy/state";
const stateStore: StateStoreType | undefined = process.env.ALCHEMY_STATE_TOKEN
? (scope) => new CloudflareStateStore(scope)
: undefined;
const app = await alchemy("unsurf", {
password: process.env.ALCHEMY_PASSWORD || "dev-password",
stage: process.env.ALCHEMY_STAGE || "production",
...(stateStore ? { stateStore } : {}),
});
const DB = await D1Database("unsurf-db", {
migrationsDir: "./migrations",
adopt: true,
});
// Bundles are write-once, read-for-a-while. 90-day TTL keeps costs bounded
// and matches the retention we promise in docs. `trace/` prefix isolates
// the skill's objects from anything else the main worker writes.
const STORAGE = await R2Bucket("unsurf-storage", {
adopt: true,
lifecycle: [
{
id: "trace-bundles-90d",
conditions: { prefix: "trace/" },
enabled: true,
deleteObjectsTransition: {
condition: { type: "Age", maxAge: 90 * 24 * 60 * 60 },
},
storageClassTransitions: [
{
condition: { type: "Age", maxAge: 30 * 24 * 60 * 60 },
storageClass: "InfrequentAccess",
},
],
},
],
});
const BROWSER = BrowserRendering();
const CACHE = await KVNamespace("unsurf-gallery-cache", {
adopt: true,
});
// Vectorize index for semantic search + capability classification
const VECTORS = await VectorizeIndex("unsurf-vectors", {
dimensions: 768,
metric: "cosine",
adopt: true,
});
const AI = Ai();
const bindings: Record<string, Binding> = {
DB,
STORAGE,
BROWSER,
CACHE,
VECTORS,
AI,
};
// Optional: pass Anthropic API key for LLM-guided scout
if (process.env.ANTHROPIC_API_KEY) {
bindings.ANTHROPIC_API_KEY = alchemy.secret(process.env.ANTHROPIC_API_KEY);
}
// Bindings used by the unsurf MCP server (src/mcp.ts) for the
// `unsurf_search` and `unsurf_execute` tool pair:
// TRACE_INGEST_TOKEN — auth against trace.coey.dev for /search
// CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN — Workers AI for
// kimi-k2.6 planner used by unsurf_execute loopPlan
// All three are optional; the tools self-report misconfiguration.
if (process.env.TRACE_INGEST_TOKEN) {
bindings.TRACE_INGEST_TOKEN = alchemy.secret(process.env.TRACE_INGEST_TOKEN);
}
if (process.env.CLOUDFLARE_ACCOUNT_ID) {
bindings.CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID;
}
if (process.env.CLOUDFLARE_API_TOKEN) {
bindings.CLOUDFLARE_API_TOKEN = alchemy.secret(process.env.CLOUDFLARE_API_TOKEN);
}
export const WORKER = await Worker("unsurf", {
name: "unsurf",
entrypoint: "./src/cf-worker.ts",
bindings,
compatibility: "node",
url: true,
adopt: true,
});
// Custom domain for the API worker
await CustomDomain("unsurf-api-domain", {
name: "unsurf-api.coey.dev",
workerName: WORKER.name,
adopt: true,
});
// ==================== trace viewer + ingest ====================
//
// Serves trace.coey.dev/r/:id (viewer) and /upload (ingest) for the `record`
// skill. v0.0.1 uses one Worker for both; split when there's >1 upload client.
// Required env vars at deploy time:
// TRACE_SIGNING_KEY 32-byte hex (generate: openssl rand -hex 32)
// TRACE_INGEST_TOKEN random bearer string (generate: openssl rand -hex 32)
const traceSigningKey = process.env.TRACE_SIGNING_KEY;
const traceIngestToken = process.env.TRACE_INGEST_TOKEN;
if (!traceSigningKey || !traceIngestToken) {
console.warn(
"[unsurf-trace] TRACE_SIGNING_KEY and/or TRACE_INGEST_TOKEN missing; " +
"using dev defaults. Generate real values with `openssl rand -hex 32` " +
"before production deploys.",
);
}
// KV namespace keyed by SHA-256(token) for per-user ingest credentials.
// Value shape: { owner, scope, createdAt, revokedAt?, quotaPerDay? }.
// Legacy TRACE_INGEST_TOKEN still authenticates as a fallback to preserve
// existing callers during migration.
const TRACE_TOKENS = await KVNamespace("unsurf-trace-tokens", {
adopt: true,
});
// 120 uploads/minute per token hash is 2/s sustained — generous for real
// use, low enough to cap damage if a token leaks. Namespace id 1001 is
// arbitrary; must be stable across deploys.
const TRACE_INGEST_RATE_LIMIT = RateLimit({
namespace_id: 1001,
simple: { limit: 120, period: 60 },
});
export const TRACE_WORKER = await Worker("unsurf-trace", {
name: "unsurf-trace",
entrypoint: "./src/trace-worker.ts",
bindings: {
STORAGE,
TRACE_TOKENS,
TRACE_INGEST_RATE_LIMIT,
TRACE_SIGNING_KEY: alchemy.secret(
traceSigningKey || "dev-signing-key-replace-before-real-use-00000000",
),
TRACE_INGEST_TOKEN: alchemy.secret(
traceIngestToken || "dev-ingest-token-replace-before-real-use",
),
},
compatibility: "node",
url: true,
adopt: true,
});
await CustomDomain("unsurf-trace-domain", {
name: "trace.coey.dev",
workerName: TRACE_WORKER.name,
adopt: true,
});
await app.finalize(); Environment variables
Section titled “Environment variables”| Variable | Required | Description |
|---|---|---|
ALCHEMY_PASSWORD | ✅ | Encrypts Alchemy’s local state file. Set any strong passphrase; changing it invalidates existing state. |
ALCHEMY_STATE_TOKEN | Cloudflare API token scoped to Alchemy’s remote state store (CloudflareStateStore). When set, state is persisted remotely instead of on disk — required for CI deploys. | |
CLOUDFLARE_API_TOKEN | ✅ | Cloudflare API token with Workers, D1, R2, and Browser Run permissions. Used by Alchemy to provision resources. |
CLOUDFLARE_ACCOUNT_ID | ✅ | Your Cloudflare account ID. Found at the top of the Cloudflare dashboard. |
ANTHROPIC_API_KEY | Anthropic API key. Passed as a secret binding to the worker for LLM-guided scouting. Without it, scout falls back to heuristic-only mode. | |
ALCHEMY_STAGE | Deployment stage (production by default). Set to a different value to deploy an isolated copy (e.g. staging, dev). |
Set these in a .env file at the project root or export them in your shell before running bun run deploy.
Cloudflare bindings
Section titled “Cloudflare bindings”These are configured automatically by Alchemy:
| Binding | Type | Name | Purpose |
|---|---|---|---|
DB | D1 Database | unsurf-db | Stores sites, endpoints, paths, run history |
STORAGE | R2 Bucket | unsurf-storage | Stores HAR logs, screenshots, traces |
BROWSER | Browser Run | — | Hosted browser flows, scouting, screenshots, and native rrweb session recordings via recordBrowserRunSession(...) |
ANTHROPIC_API_KEY | Secret | — | Passed through as an encrypted secret binding (only when the env var is set) |
CACHE | KV Namespace | unsurf-cache | Gallery response caching. Optional — gallery works without it but responses are not cached. |
VECTORS | Vectorize Index | unsurf-vectors | Semantic search index for the API directory. Required for /d/ directory and /search routes. |
AI | Workers AI | — | Generates embeddings for Vectorize. Required alongside VECTORS for directory and search. |
Database schema
Section titled “Database schema”unsurf uses Drizzle ORM with Cloudflare D1 (SQLite). Migrations are applied automatically by Alchemy on deploy — no manual migration steps are needed.
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const sites = sqliteTable("sites", {
id: text("id").primaryKey(),
url: text("url").notNull(),
domain: text("domain").notNull(),
firstScoutedAt: text("first_scouted_at").notNull(),
lastScoutedAt: text("last_scouted_at").notNull(),
});
export const endpoints = sqliteTable(
"endpoints",
{
id: text("id").primaryKey(),
siteId: text("site_id")
.notNull()
.references(() => sites.id),
method: text("method").notNull(),
pathPattern: text("path_pattern").notNull(),
requestSchema: text("request_schema"),
responseSchema: text("response_schema"),
requestHeaders: text("request_headers"),
responseHeaders: text("response_headers"),
sampleCount: integer("sample_count").notNull().default(1),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
},
(table) => [
uniqueIndex("endpoints_site_method_path").on(table.siteId, table.method, table.pathPattern),
],
);
export const paths = sqliteTable("paths", {
id: text("id").primaryKey(),
siteId: text("site_id")
.notNull()
.references(() => sites.id),
task: text("task").notNull(),
steps: text("steps").notNull().default("[]"),
endpointIds: text("endpoint_ids").notNull().default("[]"),
status: text("status").notNull().default("active"),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
failCount: integer("fail_count").notNull().default(0),
healCount: integer("heal_count").notNull().default(0),
});
export const runs = sqliteTable("runs", {
id: text("id").primaryKey(),
pathId: text("path_id").references(() => paths.id),
tool: text("tool").notNull(),
status: text("status").notNull(),
input: text("input").notNull(),
output: text("output"),
error: text("error"),
durationMs: integer("duration_ms"),
harKey: text("har_key"),
createdAt: text("created_at").notNull(),
});
export const gallery = sqliteTable(
"gallery",
{
id: text("id").primaryKey(),
domain: text("domain").notNull(),
url: text("url").notNull(),
task: text("task").notNull(),
endpointCount: integer("endpoint_count").notNull(),
endpointsSummary: text("endpoints_summary").notNull(),
specKey: text("spec_key").notNull(),
contributor: text("contributor").default("anonymous"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
version: integer("version").notNull().default(1),
},
(table) => [index("idx_gallery_domain").on(table.domain)],
);
// Directory tables for fingerprint-first architecture
export const fingerprints = sqliteTable(
"fingerprints",
{
id: text("id").primaryKey(),
domain: text("domain").notNull().unique(),
url: text("url").notNull(),
endpointCount: integer("endpoint_count").notNull(),
capabilities: text("capabilities").notNull(), // JSON array
methods: text("methods").notNull(), // JSON object {"GET": 5, "POST": 3}
auth: text("auth").notNull(), // none|bearer|cookie|api-key|oauth|basic|unknown
confidence: integer("confidence").notNull(), // 0-100 stored as int
specKey: text("spec_key").notNull(), // R2 key for full OpenAPI
contributor: text("contributor").default("anonymous"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
version: integer("version").notNull().default(1),
},
(table) => [index("idx_fingerprints_domain").on(table.domain)],
);
export const directoryEndpoints = sqliteTable(
"directory_endpoints",
{
id: text("id").primaryKey(),
fingerprintId: text("fingerprint_id")
.notNull()
.references(() => fingerprints.id),
method: text("method").notNull(),
path: text("path").notNull(),
summary: text("summary").notNull(),
capability: text("capability").notNull(), // auth|payments|content|crud|...
requestSchema: text("request_schema"), // JSON Schema as string
responseSchema: text("response_schema"), // JSON Schema as string
auth: integer("auth").notNull(), // 0 or 1
exampleRequest: text("example_request"), // JSON
exampleResponse: text("example_response"), // JSON
vectorId: text("vector_id"), // ID in Vectorize for semantic search
},
(table) => [
index("idx_directory_endpoints_fingerprint").on(table.fingerprintId),
index("idx_directory_endpoints_capability").on(table.capability),
uniqueIndex("idx_directory_endpoints_unique").on(table.fingerprintId, table.method, table.path),
],
);
// FTS5 virtual table for full-text search on gallery
export const galleryFts = sql`
CREATE VIRTUAL TABLE IF NOT EXISTS gallery_fts USING fts5(
domain,
url,
task,
endpoints_summary,
content='gallery',
content_rowid='rowid'
);
`;
// Triggers to keep gallery_fts in sync with gallery
export const galleryFtsSyncTriggers = [
sql`
CREATE TRIGGER IF NOT EXISTS gallery_ai AFTER INSERT ON gallery BEGIN
INSERT INTO gallery_fts(rowid, domain, url, task, endpoints_summary)
VALUES (NEW.rowid, NEW.domain, NEW.url, NEW.task, NEW.endpoints_summary);
END;
`,
sql`
CREATE TRIGGER IF NOT EXISTS gallery_ad AFTER DELETE ON gallery BEGIN
INSERT INTO gallery_fts(gallery_fts, rowid, domain, url, task, endpoints_summary)
VALUES ('delete', OLD.rowid, OLD.domain, OLD.url, OLD.task, OLD.endpoints_summary);
END;
`,
sql`
CREATE TRIGGER IF NOT EXISTS gallery_au AFTER UPDATE ON gallery BEGIN
INSERT INTO gallery_fts(gallery_fts, rowid, domain, url, task, endpoints_summary)
VALUES ('delete', OLD.rowid, OLD.domain, OLD.url, OLD.task, OLD.endpoints_summary);
INSERT INTO gallery_fts(rowid, domain, url, task, endpoints_summary)
VALUES (NEW.rowid, NEW.domain, NEW.url, NEW.task, NEW.endpoints_summary);
END;
`,
];
export type Site = typeof sites.$inferSelect;
export type NewSite = typeof sites.$inferInsert;
export type Endpoint = typeof endpoints.$inferSelect;
export type NewEndpoint = typeof endpoints.$inferInsert;
export type Path = typeof paths.$inferSelect;
export type NewPath = typeof paths.$inferInsert;
export type Run = typeof runs.$inferSelect;
export type NewRun = typeof runs.$inferInsert;
export type Gallery = typeof gallery.$inferSelect;
export type NewGallery = typeof gallery.$inferInsert;
export type FingerprintRow = typeof fingerprints.$inferSelect;
export type NewFingerprintRow = typeof fingerprints.$inferInsert;
export type DirectoryEndpointRow = typeof directoryEndpoints.$inferSelect;
export type NewDirectoryEndpointRow = typeof directoryEndpoints.$inferInsert; One row per distinct website that has been scouted.
| Column | Type | Description |
|---|---|---|
id | text PK | Unique site identifier (e.g. site_lx1a_k9m3) |
url | text | The URL that was passed to scout |
domain | text | Extracted hostname — used for grouping and deduplication |
first_scouted_at | text (ISO 8601) | Timestamp of the first scout |
last_scouted_at | text (ISO 8601) | Timestamp of the most recent scout |
endpoints
Section titled “endpoints”Every unique API call observed during scouting. A site has many endpoints. Endpoints are deduplicated by the composite unique index (site_id, method, path_pattern).
| Column | Type | Description |
|---|---|---|
id | text PK | Unique endpoint identifier |
site_id | text FK → sites.id | The site this endpoint belongs to |
method | text | HTTP method (GET, POST, etc.) |
path_pattern | text | URL path with parameter placeholders (e.g. /api/v1/users/:id) |
request_schema | text (JSON) | JSON Schema of the request body, inferred from observed traffic |
response_schema | text (JSON) | JSON Schema of the response body |
request_headers | text (JSON) | Notable request headers (auth tokens, content types) |
response_headers | text (JSON) | Notable response headers |
sample_count | integer | How many times this endpoint was observed (higher = more confident schema) |
first_seen_at | text (ISO 8601) | When this endpoint was first captured |
last_seen_at | text (ISO 8601) | When this endpoint was last captured |
A replayable sequence of steps that accomplishes a task on a site. Created by scout, executed by worker, repaired by heal. A site can have many paths; each path references one or more endpoints.
| Column | Type | Description |
|---|---|---|
id | text PK | Unique path identifier (returned as pathId) |
site_id | text FK → sites.id | The site this path belongs to |
task | text | The natural-language task passed to scout |
steps | text (JSON array) | Ordered list of HTTP calls to replay |
endpoint_ids | text (JSON array) | IDs of the endpoints this path touches |
status | text | active, broken, or healing |
created_at | text (ISO 8601) | When the path was created |
last_used_at | text (ISO 8601) | When worker last executed this path |
fail_count | integer | Consecutive failures (reset to 0 on success) |
heal_count | integer | Total number of successful heals |
Audit log of every tool invocation — scout, worker, and heal. Used for debugging, monitoring, and billing.
| Column | Type | Description |
|---|---|---|
id | text PK | Unique run identifier |
path_id | text FK → paths.id | The path this run relates to (null for scout runs on new sites) |
tool | text | Which tool ran: scout, worker, or heal |
status | text | success or failure |
input | text (JSON) | The full input passed to the tool |
output | text (JSON) | The tool’s result (null on failure) |
error | text | Error message (null on success) |
duration_ms | integer | Wall-clock time of the run |
har_key | text | R2 object key for the HAR recording (scout runs only) |
created_at | text (ISO 8601) | When the run started |
Relationships
Section titled “Relationships”sites 1─┬─∞ endpoints └─∞ paths 1──∞ runs- A site has many endpoints (all API calls observed) and many paths (replayable task sequences).
- A path belongs to one site and references specific endpoints via
endpoint_ids. - A run belongs to one path and records a single tool invocation.
REST API
Section titled “REST API”The worker exposes a JSON REST API alongside the MCP endpoint.
Health check and service metadata.
Response 200
{ "name": "unsurf", "version": "0.2.0", "description": "Authenticated browser runtime, proof artifacts, and reusable website seams", "tools": ["scout", "worker", "heal"], "mcp": "/mcp", "docs": "https://unsurf.coey.dev"}POST /tools/scout
Section titled “POST /tools/scout”Explore a website and capture its API endpoints.
Request body
{ "url": "https://example.com", "task": "find the user search endpoint"}| Field | Type | Required | Description |
|---|---|---|---|
url | string | ✅ | Target URL to scout |
task | string | ✅ | Natural-language description of what to find |
Response 200
{ "siteId": "site_lx1a_k9m3", "endpointCount": 4, "pathId": "path_lx1a_q7z2", "openApiSpec": { "..." : "OpenAPI 3.1 document" }}Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "Missing 'url' and 'task' in request body"} | Required fields missing |
500 | {"error": "..."} | Browser, network, or storage failure |
POST /tools/worker
Section titled “POST /tools/worker”Replay a scouted path — executes the captured API calls directly, no browser needed.
Request body
{ "pathId": "path_lx1a_q7z2", "data": { "query": "alice" }}| Field | Type | Required | Description |
|---|---|---|---|
pathId | string | ✅ | Path ID from a previous scout |
data | object | Data to interpolate into the request |
Response 200
{ "success": true, "response": { "users": [{ "id": 1, "name": "Alice" }] }}Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "Missing 'pathId' in request body"} | Required field missing |
500 | {"error": "PathBrokenError: POST /api/v1/users returned 404"} | Path is broken — call heal |
500 | {"error": "..."} | Network or storage failure |
POST /tools/heal
Section titled “POST /tools/heal”Repair a broken path by retrying, then re-scouting if retries fail.
Request body
{ "pathId": "path_lx1a_q7z2", "error": "POST /api/v1/users returned 404"}| Field | Type | Required | Description |
|---|---|---|---|
pathId | string | ✅ | ID of the broken path |
error | string | Description of what went wrong — helps the re-scout |
Response 200
{ "healed": true, "newPathId": "path_mx2b_r8a1"}Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "Missing 'pathId' in request body"} | Required field missing |
500 | {"error": "..."} | Browser, network, or storage failure |
GET /api
Section titled “GET /api”Service metadata and directory route map.
Response 200
{ "name": "unsurf", "version": "0.3.0", "description": "The typed internet — a machine-readable directory of every API", "directory": { "fingerprint": "/d/:domain", "capability": "/d/:domain/:capability", "endpoint": "/d/:domain/:method/:path", "spec": "/d/:domain/spec", "search": "/search?q=:query", "publish": "POST /d/publish" }, "tools": ["scout", "worker", "heal"], "mcp": "/mcp", "docs": "https://unsurf.coey.dev"}GET /d/
Section titled “GET /d/”List all domains in the directory. Requires VECTORS + AI bindings.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
offset | integer | 0 | Pagination offset |
limit | integer | 20 | Max results to return |
Response 200
{ "fingerprints": [{ "..." : "fingerprint objects" }], "count": 5}GET /d/:domain
Section titled “GET /d/:domain”Get the fingerprint (summary) for a domain.
Response 200 — Fingerprint object for the domain.
Errors
| Status | Body | When |
|---|---|---|
404 | {"error": "NotFoundError: ..."} | Domain not in directory |
503 | {"error": "Directory not configured ..."} | VECTORS / AI bindings missing |
GET /d/:domain/spec
Section titled “GET /d/:domain/spec”Get the full OpenAPI 3.1 spec for a domain.
Response 200 — OpenAPI 3.1 JSON document.
GET /d/:domain/:capability
Section titled “GET /d/:domain/:capability”Get the endpoints for a specific capability (e.g. search, auth, crud).
Response 200 — Capability slice with matching endpoints.
GET /d/:domain/:method/:path
Section titled “GET /d/:domain/:method/:path”Get a single endpoint by method and path.
Response 200 — Endpoint detail object.
POST /d/publish
Section titled “POST /d/publish”Publish a scouted site to the directory. Requires VECTORS + AI bindings.
Request body
{ "siteId": "site_lx1a_k9m3", "contributor": "alice"}| Field | Type | Required | Description |
|---|---|---|---|
siteId | string | ✅ | ID of a previously scouted site |
contributor | string | Optional contributor name |
Response 200 — The published fingerprint object.
Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "Missing 'siteId' in body"} | Required field missing |
503 | {"error": "Directory not configured ..."} | VECTORS / AI bindings missing |
POST /d/validate
Section titled “POST /d/validate”Validate a domain’s endpoints before publishing.
Request body
{ "domain": "example.com", "endpoints": [ { "method": "GET", "path": "/api/users" } ]}| Field | Type | Required | Description |
|---|---|---|---|
domain | string | ✅ | Domain to validate |
endpoints | array | ✅ | Endpoints to validate (each with method and path) |
Response 200 — Validation result.
Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "Missing 'domain' or 'endpoints' in body"} | Required fields missing |
DELETE /d/:domain
Section titled “DELETE /d/:domain”Remove a domain from the directory.
Response 200
{ "deleted": "example.com" }GET /search
Section titled “GET /search”Semantic search across all directory entries. Requires VECTORS + AI bindings.
Query parameters
| Param | Type | Required | Description |
|---|---|---|---|
q | string | ✅ | Natural-language search query |
limit | integer | Max results (default 10) |
Response 200
{ "results": [{ "..." : "matching fingerprints" }], "total": 3}Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "Missing 'q' query parameter"} | Query missing |
503 | {"error": "Search not configured ..."} | VECTORS / AI bindings missing |
GET /gallery
Section titled “GET /gallery”Search the gallery (legacy). Accepts q, domain, and limit query parameters.
Response 200
{ "results": [{ "..." : "gallery entries" }], "total": 2}GET /gallery/:id/spec
Section titled “GET /gallery/:id/spec”Get the OpenAPI spec for a gallery entry.
Response 200 — OpenAPI JSON document.
POST /gallery/publish
Section titled “POST /gallery/publish”Publish a scouted site to the gallery (legacy).
Request body
{ "siteId": "site_lx1a_k9m3", "contributor": "alice"}| Field | Type | Required | Description |
|---|---|---|---|
siteId | string | ✅ | ID of a previously scouted site |
contributor | string | Optional contributor name |
Response 200 — The published gallery entry.
POST /mcp
Section titled “POST /mcp”MCP (Streamable HTTP) endpoint. See MCP Server guide and MCP Tools reference.
All endpoints return permissive CORS headers (Access-Control-Allow-Origin: *). OPTIONS preflight requests are handled automatically.
Unknown routes
Section titled “Unknown routes”Any other method/path combination returns 404:
{ "error": "Not found" }Scripts
Section titled “Scripts”| Command | Description |
|---|---|
bun run dev | Local development via Alchemy |
bun run deploy | Deploy to Cloudflare |
bun run generate | Generate Drizzle migrations (internal dev workflow — not needed for deployment) |
bun run test | Run test suite |
bun run check | Lint and format (Biome) |
bun run typecheck | TypeScript type checking |