Skip to content

Configuration

For unsurf investigate and unsurf replay, open cmux and verify its in-app browser:

Terminal window
unsurf doctor

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

Install agent-browser only when you need playable local recording or an attached Chrome/CDP session:

Terminal window
agent-browser --headed --profile ~/.unsurf-video open https://example.com
unsurf record ./demo.ts --cdp-port 9222
unsurf-local-mcp

The local MCP server defaults to CDP port 9222; tool callers can pass a different cdpPort when needed.

unsurf uses Alchemy to define hosted infrastructure as TypeScript. No YAML. No TOML.

alchemy.run.ts View source →
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();
VariableRequiredDescription
ALCHEMY_PASSWORD✅Encrypts Alchemy’s local state file. Set any strong passphrase; changing it invalidates existing state.
ALCHEMY_STATE_TOKENCloudflare 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_KEYAnthropic API key. Passed as a secret binding to the worker for LLM-guided scouting. Without it, scout falls back to heuristic-only mode.
ALCHEMY_STAGEDeployment 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.

These are configured automatically by Alchemy:

BindingTypeNamePurpose
DBD1 Databaseunsurf-dbStores sites, endpoints, paths, run history
STORAGER2 Bucketunsurf-storageStores HAR logs, screenshots, traces
BROWSERBrowser Run—Hosted browser flows, scouting, screenshots, and native rrweb session recordings via recordBrowserRunSession(...)
ANTHROPIC_API_KEYSecret—Passed through as an encrypted secret binding (only when the env var is set)
CACHEKV Namespaceunsurf-cacheGallery response caching. Optional — gallery works without it but responses are not cached.
VECTORSVectorize Indexunsurf-vectorsSemantic search index for the API directory. Required for /d/ directory and /search routes.
AIWorkers AI—Generates embeddings for Vectorize. Required alongside VECTORS for directory and search.

unsurf uses Drizzle ORM with Cloudflare D1 (SQLite). Migrations are applied automatically by Alchemy on deploy — no manual migration steps are needed.

src/db/schema.ts View source →
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.

ColumnTypeDescription
idtext PKUnique site identifier (e.g. site_lx1a_k9m3)
urltextThe URL that was passed to scout
domaintextExtracted hostname — used for grouping and deduplication
first_scouted_attext (ISO 8601)Timestamp of the first scout
last_scouted_attext (ISO 8601)Timestamp of the most recent scout

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

ColumnTypeDescription
idtext PKUnique endpoint identifier
site_idtext FK → sites.idThe site this endpoint belongs to
methodtextHTTP method (GET, POST, etc.)
path_patterntextURL path with parameter placeholders (e.g. /api/v1/users/:id)
request_schematext (JSON)JSON Schema of the request body, inferred from observed traffic
response_schematext (JSON)JSON Schema of the response body
request_headerstext (JSON)Notable request headers (auth tokens, content types)
response_headerstext (JSON)Notable response headers
sample_countintegerHow many times this endpoint was observed (higher = more confident schema)
first_seen_attext (ISO 8601)When this endpoint was first captured
last_seen_attext (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.

ColumnTypeDescription
idtext PKUnique path identifier (returned as pathId)
site_idtext FK → sites.idThe site this path belongs to
tasktextThe natural-language task passed to scout
stepstext (JSON array)Ordered list of HTTP calls to replay
endpoint_idstext (JSON array)IDs of the endpoints this path touches
statustextactive, broken, or healing
created_attext (ISO 8601)When the path was created
last_used_attext (ISO 8601)When worker last executed this path
fail_countintegerConsecutive failures (reset to 0 on success)
heal_countintegerTotal number of successful heals

Audit log of every tool invocation — scout, worker, and heal. Used for debugging, monitoring, and billing.

ColumnTypeDescription
idtext PKUnique run identifier
path_idtext FK → paths.idThe path this run relates to (null for scout runs on new sites)
tooltextWhich tool ran: scout, worker, or heal
statustextsuccess or failure
inputtext (JSON)The full input passed to the tool
outputtext (JSON)The tool’s result (null on failure)
errortextError message (null on success)
duration_msintegerWall-clock time of the run
har_keytextR2 object key for the HAR recording (scout runs only)
created_attext (ISO 8601)When the run started
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.

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"
}

Explore a website and capture its API endpoints.

Request body

{
"url": "https://example.com",
"task": "find the user search endpoint"
}
FieldTypeRequiredDescription
urlstring✅Target URL to scout
taskstring✅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

StatusBodyWhen
400{"error": "Missing 'url' and 'task' in request body"}Required fields missing
500{"error": "..."}Browser, network, or storage failure

Replay a scouted path — executes the captured API calls directly, no browser needed.

Request body

{
"pathId": "path_lx1a_q7z2",
"data": { "query": "alice" }
}
FieldTypeRequiredDescription
pathIdstring✅Path ID from a previous scout
dataobjectData to interpolate into the request

Response 200

{
"success": true,
"response": { "users": [{ "id": 1, "name": "Alice" }] }
}

Errors

StatusBodyWhen
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

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"
}
FieldTypeRequiredDescription
pathIdstring✅ID of the broken path
errorstringDescription of what went wrong — helps the re-scout

Response 200

{
"healed": true,
"newPathId": "path_mx2b_r8a1"
}

Errors

StatusBodyWhen
400{"error": "Missing 'pathId' in request body"}Required field missing
500{"error": "..."}Browser, network, or storage failure

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"
}

List all domains in the directory. Requires VECTORS + AI bindings.

Query parameters

ParamTypeDefaultDescription
offsetinteger0Pagination offset
limitinteger20Max results to return

Response 200

{
"fingerprints": [{ "..." : "fingerprint objects" }],
"count": 5
}

Get the fingerprint (summary) for a domain.

Response 200 — Fingerprint object for the domain.

Errors

StatusBodyWhen
404{"error": "NotFoundError: ..."}Domain not in directory
503{"error": "Directory not configured ..."}VECTORS / AI bindings missing

Get the full OpenAPI 3.1 spec for a domain.

Response 200 — OpenAPI 3.1 JSON document.

Get the endpoints for a specific capability (e.g. search, auth, crud).

Response 200 — Capability slice with matching endpoints.

Get a single endpoint by method and path.

Response 200 — Endpoint detail object.

Publish a scouted site to the directory. Requires VECTORS + AI bindings.

Request body

{
"siteId": "site_lx1a_k9m3",
"contributor": "alice"
}
FieldTypeRequiredDescription
siteIdstring✅ID of a previously scouted site
contributorstringOptional contributor name

Response 200 — The published fingerprint object.

Errors

StatusBodyWhen
400{"error": "Missing 'siteId' in body"}Required field missing
503{"error": "Directory not configured ..."}VECTORS / AI bindings missing

Validate a domain’s endpoints before publishing.

Request body

{
"domain": "example.com",
"endpoints": [
{ "method": "GET", "path": "/api/users" }
]
}
FieldTypeRequiredDescription
domainstring✅Domain to validate
endpointsarray✅Endpoints to validate (each with method and path)

Response 200 — Validation result.

Errors

StatusBodyWhen
400{"error": "Missing 'domain' or 'endpoints' in body"}Required fields missing

Remove a domain from the directory.

Response 200

{ "deleted": "example.com" }

Semantic search across all directory entries. Requires VECTORS + AI bindings.

Query parameters

ParamTypeRequiredDescription
qstring✅Natural-language search query
limitintegerMax results (default 10)

Response 200

{
"results": [{ "..." : "matching fingerprints" }],
"total": 3
}

Errors

StatusBodyWhen
400{"error": "Missing 'q' query parameter"}Query missing
503{"error": "Search not configured ..."}VECTORS / AI bindings missing

Search the gallery (legacy). Accepts q, domain, and limit query parameters.

Response 200

{
"results": [{ "..." : "gallery entries" }],
"total": 2
}

Get the OpenAPI spec for a gallery entry.

Response 200 — OpenAPI JSON document.

Publish a scouted site to the gallery (legacy).

Request body

{
"siteId": "site_lx1a_k9m3",
"contributor": "alice"
}
FieldTypeRequiredDescription
siteIdstring✅ID of a previously scouted site
contributorstringOptional contributor name

Response 200 — The published gallery entry.

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.

Any other method/path combination returns 404:

{ "error": "Not found" }
CommandDescription
bun run devLocal development via Alchemy
bun run deployDeploy to Cloudflare
bun run generateGenerate Drizzle migrations (internal dev workflow — not needed for deployment)
bun run testRun test suite
bun run checkLint and format (Biome)
bun run typecheckTypeScript type checking