Historical build log (phases 0–18 complete). New work is GitHub issues. Live map:
CLAUDE.md.
A Cloudflare Workers service that gives agents live, validated access to AI model schemas (request/response JSON Schemas per provider endpoint, plus model metadata), with react-query-style server-side caching: persistent, stale-while-revalidate, auto-refreshed when providers ship new models or API revisions.
Borrows directly from TanStack AI PR #622 (@tanstack/ai-schemas):
- Provider registry pattern — a
ProviderConfigper provider (fetch→load→ activity-categorised merged specs). Providers: OpenAI, Anthropic, Gemini, xAI Grok, ElevenLabs, OpenRouter, FAL. - Activity grouping —
chat | image | video | audio | embeddings | moderation; platform/admin endpoints classify tonulland are dropped. - Endpoint-id-keyed schema maps with self-contained JSON Schemas (
$refclosures bundled under$defs). - Spec-source tricks — e.g. Anthropic's spec URL resolved from the Stainless
.stats.yml; FAL'sexpand=openapi-3.0param; Gemini Discovery→OpenAPI conversion.
Key difference from the PR: no codegen, no Zod, no npm package. We extract and bundle JSON Schemas at runtime (pure JSON manipulation — portable to Workers) and serve them over HTTP. Zod codegen stays in the npm package world; this service is the live API.
This plan was executed by an autonomous loop. Each iteration:
- Read this file. Find the first unchecked
- [ ]task, top to bottom. - Implement exactly that task. Stay inside its scope; if you discover necessary side-work, add it as a new task in the right phase instead of doing it now.
- Verify against the task's acceptance criteria. Every task that adds or changes
behaviour must ship with tests covering it — write them as part of the task, not
later. Run
bun run test(plainbun run, not--bun— bun's ws shim can't start the cloudflare vitest pool),bun --bun run lint, andbun run typecheck; all must be green (lefthook enforces lint/format/typecheck on commit — never commit with--no-verify). For route/API tasks, exercise the endpoint againstbun --bun run dev(vite cloudflare plugin provides local D1/KV bindings — note the plugin is skipped under vitest; Workers-runtime tests need@cloudflare/vitest-pool-workers, see task 0.3). - Check the task off (
- [x]), append a one-line note under it if anything non-obvious happened, and commit with a conventional message (feat(api): ...,chore(db): ...). - Phase boundary: when the task just completed is the last task of its phase,
run the full test suite and lint one more time — everything green is a hard gate
before starting the next phase. Make a phase-closing commit
(
chore: complete phase N) even if the last task was already committed, and confirm CI (task 0.0) passes on that commit. Do not begin the next phase with a dirty tree, failing tests, or red CI. - If blocked (missing secret, upstream outage, ambiguous requirement), check the task
off as
- [x] ~BLOCKED:with the reason, move on to the next task, and surface the blocker in the iteration summary. - Stop the loop when every task is checked.
Rules:
- No task is done without tests: new behaviour gets new tests, and the whole suite passes before the task is checked off.
- Never edit
src/routeTree.gen.tsby hand. - All times stored as unix epoch integers; all ids lowercase kebab/slug.
- Every API response is JSON (errors included:
{ error: { code, message } }). - No secrets in code. Local:
.env.local/.dev.vars. Prod:wrangler secret put.
- D1 is the source of truth, not KV. We need relational queries (models by provider/activity/capability), versioned schema history, diffs, auth tables, and webhook subscriptions — that's SQL. KV is the hot cache in front of D1 for schema blobs (read-heavy, immutable-per-version), keyed by content hash.
- Two-tier refresh loop (Cloudflare Cron Triggers):
- Fast tier, every 15 min: poll each provider's cheap model-list endpoint
(
/v1/modelsetc.). New/removed model → update D1, record a change event, fire webhooks. This is what catches "new model just dropped". - Slow tier, daily 05:00 UTC: full OpenAPI spec sync per provider (port of the PR #622 fetch+merge+bundle pipeline, minus codegen). Content-hash each bundled endpoint schema; changed hash → new schema version row + change event.
- Fast tier, every 15 min: poll each provider's cheap model-list endpoint
(
- SWR semantics (the "server-side react query"): every cached entity carries
fetchedAt+staleTime. Reads always serve immediately; if stale, a background revalidation runs viactx.waitUntil. Cron is the backstop, SWR is the accelerator. Responses exposeETag,Last-Modified, andX-Stale-Atso agents can build their own cache layers on top. - Auth: Better Auth on D1 (drizzle adapter) with the agent-auth plugin
(
@better-auth/agent-auth) as the primary flow — the standard agent protocol: discovery at/.well-known/agent-configuration,POST /agent/register, capability grants, short-lived signed JWTs (replay-protected, audience-bound) verified in our routes viaverifyAgentRequest. Capabilities are derived from our ownopenapi.jsonwithcreateFromOpenAPIwhere possible, hand-declared otherwise. The api-key plugin stays as a low-friction alternative for simple bearer access. Anonymous read access allowed at a low rate limit; authenticated agents get higher limits + webhooks. Reads stay public — auth gates rates, subscriptions, and any future write surface, not schema access. - Validation:
@cfworker/json-schema(built for Workers; ajv's codegen trips CSP/ eval restrictions). - Worker entry:
wrangler.jsonccurrently points at@tanstack/react-start/server-entry. Replace with a thinsrc/worker.tsthat re-exports the Start fetch handler and addsscheduled(cron) — and laterqueueif we adopt Queues. Webhook delivery starts withwaitUntil+ a retry table drained by cron (no paid-plan Queues dependency).
- 0.0 CI pipeline. GitHub Actions workflow (
.github/workflows/ci.yml) running on push tomainand on pull requests:oven-sh/setup-bun,bun install --frozen-lockfile,bun --bun run check,bun --bun run lint,bun run typecheck,bun --bun run test,bun --bun run build. This is the gate the loop protocol's phase-boundary step checks against. Accepts: workflow file is valid (passesactionlintor a dry-run push) and the run is green on the commit that adds it.- Note: actionlint not installable in the sandbox; validated via YAML parse + all five commands green locally, then confirmed green on GitHub Actions (run 27326004002 on commit 320c2b0, the phase 0 closing commit). checkout bumped v4→v5 (node20 deprecation). Standing permission from Tom to push origin/main each iteration.
- 0.1 Move the database to D1. Add a
d1_databasesbinding (DB) towrangler.jsonc; switchsrc/db/index.tstodrizzle-orm/d1reading the binding from the Cloudflare env (expose agetDb(env)helper — Workers have no module-scope env); updatedrizzle.config.tsto thed1-httpdriver for remote, keep local migrations working against wrangler's local D1 (bun run db:generate+wrangler d1 migrations apply DB --local). Removebetter-sqlite3and the demotodostable. Accepts: dev server boots, a trivial query against D1 works locally.- Note: dev server only boots under
bun run dev(node runtime) —bun --bun run devhangs before vite's ready banner (pre-existing, ws-related). Vite binds IPv6-only (http://[::1]:3000).db:migratescript now applies migrations to local D1 via wrangler;db:migrate:remoteadded.@cloudflare/workers-typesadded (type-only, drizzle optional peer).
- Note: dev server only boots under
- 0.2 Custom worker entry with cron support. Create
src/worker.tsexporting{ fetch }from the TanStack Start server entry plus ascheduled(controller, env, ctx)handler that dispatches oncontroller.cron. Pointwrangler.jsoncmainat it and declare two crons:*/15 * * * *(models poll) and0 5 * * *(spec sync). Accepts:wrangler dev --test-scheduled+curl /__scheduled?cron=*/15+*+*+*+*hits the handler; normal SSR routes still render.- Note: on wrangler 4.99 the test endpoint is
/cdn-cgi/handler/scheduled?cron=…(/__scheduled404s); verified against the built worker (wrangler dev --test-scheduled -c dist/server/wrangler.json) and under vite dev — both crons dispatch, SSR home renders in both. Entry-module named exports must be handlers only (workerd rejects exported constants), so cron strings stay module-local.
- Note: on wrangler 4.99 the test endpoint is
- 0.3 KV namespace + helpers. Add
kv_namespacesbinding (SCHEMA_CACHE). Writesrc/server/kv.tswith typed get/put wrapping JSON + content-hash keys. The cloudflare vite plugin is incompatible with vitest (it is skipped underVITESTinvite.config.ts), so set up@cloudflare/vitest-pool-workers(or a miniflare test harness) here for tests that need real KV/D1 bindings. Accepts: round-trip test passes underbun run testwith a real KV binding.- Note: pool-workers 0.16 (vitest 4) replaced
defineWorkersConfigwith acloudflareTest()vite plugin — config split into vitest.config.ts + unit/workers project files (*.worker.test.tsruns in workerd). Tests MUST run via plainbun run test: under--bun, bun's ws shim can't start the pool runner (hangs after "Timeout starting cloudflare-pool runner"); ci.yml, CLAUDE.md, and the loop protocol updated accordingly. Miniflare bindings are declared inline (not via wrangler.jsonc configPath) so the pool doesn't try to load the TanStack Start worker entry.
- Note: pool-workers 0.16 (vitest 4) replaced
- 0.4 Better Auth on D1. Wire
src/lib/auth.tsto the drizzle adapter over D1, add theagent-auth(@better-auth/agent-auth),api-key, andbearerplugins, generate auth tables intosrc/db/schema.tsviabunx --bun @better-auth/cli generate(agent-auth addsagent,host,grant,approval), migrate. Keep email/password enabled (it backs delegated-mode approvals and the dashboard later). MinimalagentAuth({...})config for now — providerName/description,modes: ['autonomous', 'delegated'], empty capabilities (filled in Phase 5). Accepts: sign-up + session retrieval works through the existing/api/auth/$catch-all locally; auth tables present in local D1.- Note: better-auth 1.6 moved
apiKeyto@better-auth/api-key(added as a dep); the option isproviderDescription, notdescription. Auth is acreateAuth(db)factory insrc/lib/auth.ts(CLI-loadable viascripts/better-auth-config.ts); the runtime instance insrc/server/auth.tsbuilds fromcloudflare:workersenv and passestanstackStartCookies()viaextraPlugins— that plugin can't be in the factory (vite-only#tanstack-router-entryimports break the workers test pool). Worker tests apply drizzle migrations via the pool's TEST_MIGRATIONS/applyD1Migrations pattern. Curl-verified sign-up + get-session (needs anOriginheader, else 403 MISSING_OR_NULL_ORIGIN).
- Note: better-auth 1.6 moved
- 1.1 Core tables in
src/db/schema.ts(drizzle, sqlite dialect):providers— id, displayName, specSourceUrl, modelsEndpoint, authEnvVar, lastPolledAt, lastSyncedAt, status.models— id (slug), providerId, rawId, activity, displayName, contextWindow, maxOutput, modalities (json), pricing (json), capabilities (json), firstSeenAt, lastSeenAt, deprecatedAt nullable.endpoints— id, providerId, activity, method, path, description.schema_versions— id, endpointId, kind (input|output), contentHash, schema (json text, $defs-bundled), specRevision, createdAt, supersededAt nullable.changes— id, type (model.added|model.removed|model.updated|schema.added|schema.updated|endpoint.added|endpoint.removed), providerId, subjectId, summary, payload (json diff), createdAt. This is the public changelog feed. Accepts: migration generated and applied locally; drizzle types compile.- Note: migration drizzle/0001_sweet_zeigeist.sql;
activities/changeTypesexported as const arrays (drizzle text enums are type-level only). Worker tests cover insert + relational query across all five tables and FK enforcement.
- 1.2 Cache + delivery tables:
cache_meta— key, fetchedAt, staleTime, lastError, refreshing flag.subscriptions— id, agentId (auth user), url, secret, events (json array), providerFilter nullable, active, createdAt.webhook_deliveries— id, subscriptionId, changeId, attempt, nextAttemptAt, status (pending|ok|failed), lastResponseCode. Accepts: migration applies; FK relations defined.- Note: migration drizzle/0002_violet_lionheart.sql; deliveries indexed on (status, nextAttemptAt) for the cron drain. Worker tests cover cache_meta round-trip, subscription→user/delivery→change relations, and FK enforcement.
- 1.3 Seed script.
scripts/seed.tsinserting the 7 providers with their spec source config (port the constants/URLs from PR #622'sscripts/providers/*: Anthropic.stats.ymlresolution, FALexpand=openapi-3.0note, Gemini discovery URL, etc.). Runnable viabun run seedagainst local D1. Accepts:providerstable populated locally.- Note: seed data lives in
src/db/seed-providers.ts(typed$inferInsert, unit tested, reusable by the Phase 2 registry);scripts/seed.tsupserts viawrangler d1 execute --file(idempotent, preserves runtime columns;--remoteflag for prod). URLs/env-vars ported from PR #622 @ 276e808.
- Note: seed data lives in
- 2.1 Provider registry.
src/server/providers/types.ts+src/server/providers/{openai,anthropic,gemini,grok,elevenlabs,openrouter,fal}.ts. Port PR #622'sProviderConfigshape, adapted to Workers:fetchSpec(env)returns parsed spec objects (no filesystem),listModels(env)hits the provider's models endpoint,classify(path, op)→Activity | null. Providers needing keys are skipped with a recorded warning when the secret is absent. Accepts: unit tests forclassifyper provider using small spec fixtures.- Note: listModels also normalises to the ModelInfo shape (providers own their API
mapping; 2.4 just diffs). FAL activity rides on a fetch-time per-operation marker
(category-derived; non-taxonomy targets like 3d/json classify to null for now);
OpenRouter's video synthesis + embeddings lift ported as exported, unit-tested
helpers; Anthropic's resolved spec URL doubles as specRevision.
yamldep added for OpenAI/Anthropic YAML specs.
- Note: listModels also normalises to the ModelInfo shape (providers own their API
mapping; 2.4 just diffs). FAL activity rides on a fetch-time per-operation marker
(category-derived; non-taxonomy targets like 3d/json classify to null for now);
OpenRouter's video synthesis + embeddings lift ported as exported, unit-tested
helpers; Anthropic's resolved spec URL doubles as specRevision.
- 2.2 Schema extraction + bundling.
src/server/ingest/bundle.ts: given a merged OpenAPI spec and an endpoint, extract the input schema (request body — handleapplication/jsonandmultipart/form-data, per the PR's multipart fix) and output schema (post-200strategy, plus FAL'ssibling-get), then inline the$refclosure under$defsso every schema is self-contained. Stable stringify → SHA-256 content hash. Accepts: unit tests with fixture specs prove self-containment (no dangling$refs — port the PR's dedup-rename lesson as a test).- Note: content hashing reuses
stableStringify/contentHashfrom src/server/kv.ts (task 0.3). Improvement over the PR: refs back to the root schema rewrite to#instead of dangling in $defs;findDanglingRefsexported as the self-containment checker (used by tests, reusable as a sync-time sanity check). Inline (ref-less) body schemas are bundled too, not skipped.
- Note: content hashing reuses
- 2.3 Sync engine.
src/server/ingest/sync.ts: per provider — fetch spec → classify endpoints → bundle schemas → diff content hashes againstschema_versions→ insert new versions, mark superseded, upsertendpoints, writechangesrows, warm KV with new blobs. Idempotent; per-provider try/catch so one provider's outage doesn't sink the run; record outcome onproviders. Accepts: integration test with a fixture spec: first run inserts, second run no-ops, mutated fixture produces exactly oneschema.updatedchange.- Note: endpoint db ids are
${providerId}/${path-minus-slash}. Vanished endpoints keep their rows + version history; anendpoint.removedchange is written once (deduped against prior changes). Failed providers get status='degraded', successful syncs set lastSyncedAt + status='active'. Injectable clock for tests. D1 persists across tests in one isolate — worker tests use per-test provider ids.
- Note: endpoint db ids are
- 2.4 Model poller.
src/server/ingest/poll-models.ts: per provider, calllistModels, normalise to themodelsshape, diff against D1 →model.added/model.removed/model.updatedchanges, bumplastSeenAt. Accepts: fixture test covering add/remove/no-change.- Note: normalisation lives in each provider's listModels (task 2.1); the poller
diffs. "Removed" = deprecatedAt set (row kept), recorded once; reappearance clears
deprecation via model.updated with a before/after payload. Model db ids are
${providerId}-${slugified rawId}.
- Note: normalisation lives in each provider's listModels (task 2.1); the poller
diffs. "Removed" = deprecatedAt set (row kept), recorded once; reappearance clears
deprecation via model.updated with a before/after payload. Model db ids are
- 2.5 Wire crons.
scheduleddispatch: 15-min cron →pollAllModels(env, ctx), daily cron →syncAllSchemas(env, ctx). Stagger providers withctx.waitUntiland keep each provider's work sequential to respect subrequest limits. Accepts:--test-scheduledrun against live providers (no key required: OpenRouter) writes real rows locally.- Note: live run wrote 338 OpenRouter models + changes locally; second trigger
idempotent (0 added); keyed providers skipped with recorded warnings. Surfaced a
0.4 regression: betterAuth() can't construct at module scope in the built worker
(workerd forbids global-scope I/O/random) — src/server/auth.ts now lazy-inits via
getAuth(). Use
wrangler dev -c dist/server/wrangler.json --persist-to .wrangler/stateso the built worker shares the root local D1.
- Note: live run wrote 338 OpenRouter models + changes locally; second trigger
idempotent (0 added); keyed providers skipped with recorded warnings. Surfaced a
0.4 regression: betterAuth() can't construct at module scope in the built worker
(workerd forbids global-scope I/O/random) — src/server/auth.ts now lazy-inits via
getAuth(). Use
- 2.6 Manual trigger + status.
POST /v1/admin/sync/{provider}(admin-key gated) andGET /v1/status(public: per-provider lastPolledAt/lastSyncedAt/counts). Accepts: curl both locally.- Note: admin key via X-Admin-Key or Bearer (fails closed without ADMIN_KEY; local
value in .env.local). Logic split into testable
src/server/{admin,status}.ts. routeTree.gen.ts must be regenerated by the dev server, nottsr generate(the pinned router-cli 1.132 emits a format without theserverroute option). Live curl: status shows per-provider counts; admin sync of OpenRouter ingested 21 endpoints / 41 schema versions with zero dangling-ref warnings.
- Note: admin key via X-Admin-Key or Bearer (fails closed without ADMIN_KEY; local
value in .env.local). Logic split into testable
- 3.1
src/server/cache.ts—swr(env, ctx, key, fetcher, { staleTime, hardTtl }): KV hit + fresh → return; KV hit + stale → return ANDctx.waitUntil(revalidate)guarded by thecache_meta.refreshingflag (cheap cross-isolate dedupe; a stuck flag older than 2× staleTime is ignored); miss → fetch inline, persist to KV +cache_meta. Accepts: unit tests for all three paths and the stuck-flag case.- Note: signature is
swr(deps, key, fetcher, opts)with deps = {db, kv, waitUntil, now?} (injectable clock + collected waitUntil promises make the tests deterministic). Result carries fetchedAt/staleAt/revalidating for task 3.2's header helper. Failed background revalidation records cache_meta.lastError and keeps serving the stale value. 6 worker tests.
- Note: signature is
- 3.2 HTTP cache semantics. Response helper adding
ETag(content hash),Last-Modified,Cache-Control: public, max-age=60, stale-while-revalidate=600,X-Fetched-At,X-Stale-At; honourIf-None-Match→ 304. Apply to all schema/model read endpoints in Phase 4. Accepts: test asserts 304 on matching ETag.- Note:
cachedJson(request, value, opts)in src/server/http-cache.ts — etag computed from the body unless a stored content hash is passed; If-None-Match handles lists, weak validators, and*. X-Fetched-At/X-Stale-At are epoch seconds, matching SwrResult from 3.1.
- Note:
All under /v1, JSON-only, TanStack Start server-route handlers. Design for an LLM
consumer: every list response includes _links to drill deeper, errors carry remedies.
- 4.1 Discovery surface.
GET /v1(service description + endpoint index),GET /llms.txt(markdown usage guide for agents),GET /openapi.json(the service's own OpenAPI doc, hand-maintained insrc/server/openapi.ts). Accepts: all three render; openapi.json validates against OpenAPI 3.1 meta-schema in a test.- Note: meta-schema (2022-10-07) vendored in src/server/fixtures, validated with
@cfworker/json-schema (added now, reused by 4.4); a second test enforces unique
camelCase operationIds (the 5.1/7.4 contract). llms.txt content lives in
src/server/llms-txt.ts for reuse by /docs (7.3) and the skill (7.6). Dotted
filenames use
[.]escaping (openapi[.]json.ts); the router plugin rewrites the createFileRoute id itself on dev-server regeneration.
- Note: meta-schema (2022-10-07) vendored in src/server/fixtures, validated with
@cfworker/json-schema (added now, reused by 4.4); a second test enforces unique
camelCase operationIds (the 5.1/7.4 contract). llms.txt content lives in
src/server/llms-txt.ts for reuse by /docs (7.3) and the skill (7.6). Dotted
filenames use
- 4.2 Catalog endpoints.
GET /v1/providers— list with sync status.GET /v1/providers/{provider}/models— models for one provider.GET /v1/models?activity=&provider=&capability=&q=— cross-provider catalog, filterable; this is the headline "what can I use right now" endpoint.GET /v1/models/{provider}/{modelId}— full metadata +_links.schemas. Accepts: curl each against seeded/synced local data; filters covered by tests.- Note: query logic in src/server/catalog.ts (worker-tested; routes thin). Deprecated
models hidden unless
?deprecated=true; model detail resolves slug OR raw id; invalid?activity=→ 400 listing valid values. Curl-verified against live local data (q=claude → 20 OpenRouter models, capability=tools → 252, encoded raw-id lookup, 404s with remediation). All reads use the 3.2 cachedJson helper.
- 4.3 Schema endpoints.
GET /v1/schemas/{provider}— activities + endpoint ids.GET /v1/schemas/{provider}/{activity}— endpoint-id-keyed map (current versions), mirroring the PR'sendpoint-schema-mapshape.GET /v1/schemas/{provider}/{activity}/{endpointId}?kind=input|output&version=— single self-contained JSON Schema; defaults to current input schema. All via the SWR layer + ETag helper. Accepts: schemas served byte-identical to D1 content; 304s work; unknown ids → structured 404 with valid-id hints.- Note: endpoint ids contain slashes, so the single-schema route is a splat
(
$activity/$.ts); clients URL-encode (chat%2Fcompletions).waitUntilcomes straight fromcloudflare:workers(added to the local module declaration). Versioned (content-addressed) reads cache with staleTime 86400, current reads 300; single-schema ETag is the stored contentHash. Live-verified on synced OpenRouter data including a 304 round-trip; byte-identity asserted in worker tests.
- 4.4 Validation endpoint.
POST /v1/validatewith{ provider, endpointId, kind?, payload }→{ valid, errors: [{ path, message, keyword }] }using@cfworker/json-schema. Accepts: test with one valid + one invalid Anthropic messages payload.- Note: worker tests use a trimmed Anthropic messages fixture (valid + invalid with $defs-ref'd errors); also live-verified against the REAL synced Anthropic spec (keyless sync: 5 endpoints / 10 versions) — valid payload passes, bad payload yields structured required/enum errors. Response includes the schema contentHash so agents know which version judged them.
- 4.5 Changes feed.
GET /v1/changes?since=&provider=&type=(cursor-paginated) — the polling-friendly alternative to webhooks. Accepts: cursor pagination test.- Note: opaque (createdAt, id) keyset cursor (base64), newest first, stable under
concurrent inserts, tie-break tested on equal timestamps; limit clamped to 200;
_links.nextcarries the ready-made next-page URL. Live-verified against real sync-generated change rows.
- Note: opaque (createdAt, id) keyset cursor (base64), newest first, stable under
concurrent inserts, tie-break tested on equal timestamps; limit clamped to 200;
Built on Better Auth's agent-auth plugin (https://better-auth.com/docs/plugins/agent-auth): discovery → register → request capability grants → execute with short-lived JWTs.
- 5.1 Agent Auth provider surface. Expose the discovery document at
/.well-known/agent-configuration(route handler callingauth.api.getAgentConfiguration()). Declare capabilities inagentAuth({...}): derive the read/validate operations from our ownsrc/server/openapi.tsdoc viacreateFromOpenAPI(every Phase 4 operation has anoperationId), and hand-declaremanage_subscriptions. Autonomous mode is the default path (no human in the loop); delegated mode + approvals kept enabled but not required for any current capability. Accepts: discovery doc renders;POST /agent/register→ capability request →POST /capability/executeround-tripsget_schemalocally.- Note: full round-trip verified live by
bun scripts/agent-roundtrip.ts(Ed25519 keys via jose; agent JWT: iss=hostId, sub=agentId, aud=default_location; execute response wraps results in{data}). RequiredallowDynamicHostRegistration: true(open signup) and aresolveAutonomousUservirtual identity (autonomous agents have no human owner). Admin op (syncProvider) filtered out of capabilities; capabilities are at/capability/list, not inline in the discovery doc.
- Note: full round-trip verified live by
- 5.2 JWT verification on owned routes. For agent-authed access to our native
/v1/*routes (as opposed to the/capability/executeproxy), add arequireAgent(request)helper wrappingverifyAgentRequest(request, auth)+ grant check, used by subscriptions (Phase 6) and/v1/agents/me. Accepts: test hits a protected route with a valid agent JWT (200) and without (401).- Note: signature is
requireAgent(auth, request, {capability?})returning a principal or a ready-made 401/403 Response. verifyAgentRequest builds its session URL without the basePath, so the helper retries with/api/authappended. Agent JWT jtis are replay-protected — a fresh token per request. /v1/agents/me created minimal here (5.5 enriches it); 200/401/403 covered by worker tests + the live round-trip script.
- Note: signature is
- 5.3 API-key fallback.
POST /v1/agents/register-key{ name, email? }→ Better Auth user + api-key plugin key, returned once;Authorization: Bearer <key>accepted everywhererequireAgentis — for agents that don't speak the agent-auth protocol. Accepts: register → authed call with the key succeeds.- Note: requireAgent now returns a Principal union (agent | api-key); non-JWT bearer
values and X-Api-Key are treated as keys; api-key principals pass capability
checks (full-access fallback). The plugin's key owner field is
referenceId. Emails are pre-checked in D1 (better-auth's duplicate throw leaves a floating rejection). Live-verified: register → Bearer key on /v1/agents/me → 200.
- Note: requireAgent now returns a Principal union (agent | api-key); non-JWT bearer
values and X-Api-Key are treated as keys; api-key principals pass capability
checks (full-access fallback). The plugin's key owner field is
- 5.4 Rate limiting. Per-agent/per-key and per-IP (anonymous) limits via
Workers Rate Limiting binding (fallback: fixed-window counter in KV). Anonymous:
60 req/h; authenticated: 5k req/h. 429s include
Retry-After. Accepts: test exhausts the anonymous window and sees 429.- Note: KV fixed-window used (the Workers binding can't express hourly windows); enforced centrally in src/worker.ts fetch for /v1/* (admin-keyed requests exempt). API keys are verified for their bucket; agent JWTs bucket by *decoded* sub — full verification would consume the replay-protected jti before the route's own auth. Eventual-consistency drift accepted (abuse mitigation, not billing). Acceptance test exhausts 60 anon requests → 429 with Retry-After; keyed bucket independent.
- 5.5 Usage endpoint.
GET /v1/agents/me— agent identity, grants, limits, current usage. Accepts: curl with agent JWT and with API key.- Note: usage reads the live KV rate counter (non-incrementing readRateUsage). Curl-verified with both credential types. Also hardened 5.4 per security review: agent JWT buckets now require a signature+expiry check against the agent's stored key (jti untouched) — forged subs can no longer mint 5k/h windows (tested with rotating spoofed subs).
- 6.1 Subscriptions CRUD.
POST|GET|DELETE /v1/subscriptions(authed): url + events filter (+ optional provider filter). Secret generated server-side, returned once. Accepts: CRUD round-trip test.- Note: gated on the managesubscriptions capability; autonomous agents' virtual identities are materialised as user rows on first write (FK integrity); 10 subscriptions/owner cap; whsec-prefixed 32-byte secrets; list never echoes secrets; ownership enforced on delete. Live curl round-trip verified.
- 6.2 Webhook delivery. On new
changesrows, enqueuewebhook_deliveries; deliver viactx.waitUntilwith HMAC-SHA256 signature header (X-ModelSchemas-Signature), JSON body{ event, change, _links }. Failures → exponential backoff vianextAttemptAt, drained by the 15-min cron; 8 failures → subscription auto-paused + recorded. Accepts: integration test with a local receiver route capturing the signed payload.- Note: fan-out runs off a cache_meta checkpoint (changes.createdAt high-water mark)
so enqueue is idempotent without coupling sync/poll code; the 15-min cron runs
runWebhookTick (enqueue + drain) inside the same waitUntil as the poll. Backoff is
60s·2^attempt; signature is
sha256=<hex HMAC>over the exact body, verified by recomputation in the receiver test. Injectable fetcher = the "local receiver".
- Note: fan-out runs off a cache_meta checkpoint (changes.createdAt high-water mark)
so enqueue is idempotent without coupling sync/poll code; the 15-min cron runs
runWebhookTick (enqueue + drain) inside the same waitUntil as the poll. Backoff is
60s·2^attempt; signature is
The service's own openapi.json (task 4.1, every operation has an operationId) is
the single source for three consumer surfaces: the generated TS client, the CLI, and
the agent-auth capability list (5.1). Spec drift breaks a CI check, not a user.
- 7.1 MCP server endpoint at
/mcp(streamable HTTP, Workers-native) exposing tools:list_models,get_model,get_schema,validate_payload,recent_changes. Reuse the Phase 4 service functions directly. Accepts: MCP inspector (or a scripted client) lists tools and round-tripsget_schema.- Note: hand-rolled stateless streamable-HTTP (JSON responses, no SSE session — the MCP SDK transports are node/DO-centric); handles initialize/ping/tools/*, 202s notifications, 405s GET, JSON-RPC errors for unknown methods. Live scripted client listed 5 tools and round-tripped the real Anthropic messages schema.
- 7.2 Minimal human dashboard.
/route: provider sync status, model counts, latest changes, docs links. Tailwind + shadcn; no auth needed (read-only). Accepts: renders against local data.- Note: data via a createServerFn loader reusing getServiceStatus + listChanges (already worker-tested); plain Tailwind shadcn-style cards/badges (no new deps). Full SSR verified against local data (provider table + change feed in the HTML — note: ugrep chokes on the single-line SSR output, use python/sed to inspect).
- 7.3 Docs page
/docs: quickstart for agents (register → fetch schema → validate → subscribe), rendered from the same content asllms.txt. Accepts: renders; links resolve.- Note: renders the llmsTxt module verbatim via streamdown (already a dep) — zero drift by construction; a content-contract test pins the sections/paths the page and the future skill depend on. All 7 nav links curl-resolved 200 live.
- 7.4 Generated TS client (
@modelschemas/client). Convert the repo to bun workspaces (workspaces: ["packages/*"]); addpackages/clientgenerated by@hey-api/openapi-ts(pin the version — PR #622 pinned 0.97.2 for a reason) from our spec: addbun run openapi:emit(writesopenapi.jsonfromsrc/server/openapi.ts) andbun run generate:client. Fetch-based client, typed per operation, pluggable auth (API key header or agent JWT). CI-style check script: regenerate →git diff --exit-codeso the committed client never drifts from the spec. Accepts: client compiles; smoke test against local dev lists providers and fetches one schema, fully typed.- Note: root devDependency
@modelschemas/client: workspace:*makes bun link the package (workspaces alone don't symlink into the root). Generated code lives in packages/client/src/generated (eslint-ignored; typechecked by the root tsconfig); hand-written entry adds createModelschemasClient with bearer auth.check:clientwired into CI. Smoke: bun scripts/client-smoke.ts listed 7 providers + fetched the Anthropic schema typed.
- Note: root devDependency
- 7.5 CLI (
packages/cli, bin:modelschemas). Thin command layer over the generated client.modelschemas loginruns agent-auth registration (autonomous mode by default;--delegatedwalks the device-authorization approval flow and polls until granted;--api-keyfalls back to/v1/agents/register-key), storing credentials in~/.config/modelschemas/credentials.json(0600). Commands:models list|get,schema get <provider> <endpointId>,validate <endpointId> <file>,changes [--since],subscribe <url> [--events],whoami.--jsoneverywhere (default when not a TTY) so the CLI is itself agent-friendly. Accepts:bun packages/cli loginagainst local dev creates an agent;models listthen succeeds authenticated;validatereturns non-zero exit on invalid payload.- Note: all commands live-verified (login → active autonomous agent, 0600 creds,
MODELSCHEMAS_CONFIG_DIR override for tests; whoami; models list/get; schema get
auto-resolves activity from the provider index; validate exits 2 on invalid;
changes; subscribe). validate takes
<provider/endpointId>. Surfaced + fixed a 5.1 bug: defaultHostCapabilities derived from the raw spec included the excluded syncProvider op, 400-ing capability-less registrations — now the filtered list.
- Note: all commands live-verified (login → active autonomous agent, 0600 creds,
MODELSCHEMAS_CONFIG_DIR override for tests; whoami; models list/get; schema get
auto-resolves activity from the provider index; validate exits 2 on invalid;
changes; subscribe). validate takes
- 7.6 Distributable agent skill.
skill/modelschemas/SKILL.md(frontmatter:name,descriptionwith trigger phrases like "model schema", "what models support X", "validate this provider payload") teaching the full workflow: discovery (/.well-known/agent-configuration,llms.txt) → auth viamodelschemas login(or raw HTTP for keyless reads) → fetch/validate schemas → watch/v1/changes. Generate the endpoint reference section from the same source asllms.txtto avoid drift. Serve it:GET /skillreturns the SKILL.md, linked from/docsandllms.txt. Accepts: frontmatter valid; installing the dir into.claude/skills/and asking for an Anthropic chat schema produces a correct CLI/HTTP call sequence.- Note: SKILL.md generated from src/server/skill.ts (bun scripts/emit-skill.ts); a test pins the committed file to the module and validates frontmatter + trigger phrases; the reference section embeds llms.txt verbatim. Served at GET /skill, linked from /docs and llms.txt. The taught call sequences are the live-verified ones (schema get anthropic v1/messages; encoded HTTP path).
- 8.1 Production setup. Create real D1 + KV resources, set IDs in
wrangler.jsonc;wrangler secret putforBETTER_AUTH_SECRET, provider keys (FAL_KEY,ELEVENLABS_API_KEY, …),ADMIN_KEY. Document in README. Accepts:bun run deploysucceeds;/v1/statuslive.- Done 2026-06-11 after Tom's "deploy it": D1 557c12a0… + KV 8c8f93fc… created, IDs wired, remote migrations + seed applied, secrets set (BETTER_AUTH_SECRET, ADMIN_KEY — prod value in .env.local as PROD_ADMIN_KEY; provider keys still unset → those providers skip). Live at https://modelschemas.openstory.workers.dev with both crons. Deploy script fixed to use the vite-built config (wrangler deploy -c dist/server/wrangler.json); BETTER_AUTH_URL var set to the deployed origin.
- 8.2 First production sync. Trigger admin sync for all providers; verify
/v1/models?activity=chatreturns real multi-provider data in prod. Accepts: spot-check Anthropic + OpenRouter schemas served with ETags.- Done: admin sync of OpenRouter (21 endpoints/41 versions) + Anthropic (5/10) in prod, outcomes identical to local; /v1/models serves live multi-provider data (cron poll already fired); Anthropic messages schema served with the same contentHash as local, 304 on If-None-Match verified.
- 8.3 Observability.
wrangler.jsoncobservability.enabled, structuredconsole.logJSON lines in cron handlers (provider, duration, changes count), README runbook for "a provider sync is failing". Accepts: logs visible viawrangler tailduring a manual sync.- Note: observability.enabled set, worker renamed
modelschemas, cron handlers already log structured JSON lines (provider/outcomes), README rewritten with the runbook + production setup. The wrangler-tail acceptance can only run post-deploy (8.1) — everything tail-able is in place. Post-deploy: wrangler tail captured both manual sync invocations (request events, outcome ok).
- Note: observability.enabled set, worker renamed
-
BLOCKED8.4 Publish client + CLI. npm publish@modelschemas/clientand themodelschemasCLI (changesets or manual version bump); install instructions in README,/docs, and the skill. Accepts:bunx modelschemas@latest whoamiworks against prod.- BLOCKED 2026-06-11: npm publish is irreversible and needs Tom's npm account + scope decision (@modelschemas org availability unverified) + a deployed prod URL (8.1). Packages are publish-shaped (files/bin/exports set).
- 2026-08-21 (#48): scope kept as
@modelschemas(CLI unscoped).0.1.0could not go through Actions (npm trust/npm stageneed an existing package). Laptop bootstrap landed 2026-08-21 (org,npm publish, stage-onlynpm trust, tokens disallowed). Ongoing: OIDCnpm stage publishfrom.github/workflows/publish.yml(noNPM_TOKEN; provenance on future Actions stages).
- 8.5 Update CLAUDE.md with the new architecture (D1/KV bindings, cron
entry point, ingest pipeline layout, workspace packages, API map) replacing the
starter description. Accepts: CLAUDE.md matches reality.
- Note: full rewrite — commands (bun/--bun split, db/codegen scripts), worker entry
- ingest/API/auth/webhook architecture, workspace packages, route-tree-regen and module-scope-auth gotchas, API map. README rewritten alongside (8.1 docs + 8.3 runbook).
- Note: full rewrite — commands (bun/--bun split, db/codegen scripts), worker entry
Requested by Tom 2026-06-11: a human-facing surface alongside the agent one. The production domain is modelschemas.com.
- 9.1 Custom domain. Attach
modelschemas.comto the deployed worker (Cloudflare custom domain on this account's zone;routesinwrangler.jsoncwithcustom_domain: true), set theBETTER_AUTH_URLvar tohttps://modelschemas.com(agent JWT audiences are origin-bound), redeploy, and confirm the workers.dev URL still serves (or document the canonical-host choice). Accepts:https://modelschemas.com/v1/statuslive. If the zone is not in the OpenStory account, mark BLOCKED with what Tom needs to do.- First attempt 2026-06-11 failed: the modelschemas.com zone is not in the OpenStory Cloudflare account (PUT …/domains/records rejected). Prereq for Tom: add modelschemas.com as a zone (dashboard → Add site → point registrar nameservers at Cloudflare). The loop should retry this task first each iteration and skip forward while the zone is absent. Route was reverted so deploys stay green; service stays on modelschemas.openstory.workers.dev meanwhile. NOTE: 9.2's email sender verification ALSO depends on this zone.
- 9.2 Email OTP sign-in for humans. Add better-auth's
emailOTPplugin tocreateAuthwith a mail abstraction insrc/server/email.tsbuilt on Cloudflare Email Workers — asend_emailbinding (EMAIL) inwrangler.jsoncusing the structuredenv.EMAIL.send({to, from, subject, text})API, senderlogin@modelschemas.com. Until the zone is verified (9.1), and in local dev, every send falls back to a structured console line ({"job":"email",...}) so the OTP stays observable — never silently drop a sign-in. The sender is injectable so worker tests capture OTPs. Build a minimal/loginpage (email → code → session) with the email-otp client plugin, plus a signed-in indicator + sign-out. Keep email/password enabled (delegated-mode approvals depend on it). Regenerate worker-configuration.d.ts after adding the binding. Accepts: worker test issues + verifies an OTP via an injected sender; live dev sign-in works end to end using the logged code.- Note: live-verified — the vite plugin SIMULATES the send_email binding locally and writes messages to a temp .txt (path printed in dev output), so the dev fallback log rarely fires; the OTP was read from the simulator file and the full send→sign-in→session flow passed. better-auth's withSpan telemetry leaves a floating APIError rejection on failed sign-ins — filtered via vitest's root onUnhandledError (APIError only). Production email needs Email Routing enabled on the modelschemas.com zone (sender verification) — covered in 9.5.
- 9.3 Human API-key management.
/accountpage for signed-in users: list, create (name + optional expiry), and revoke their API keys via the better-auth api-key plugin's session-authed endpoints; the key value is shown exactly once on creation, with copy-paste usage snippet (Authorization: Bearer …). The page explains what keys unlock (5k req/h + subscriptions). Accepts: live flow — sign in, create key, key authenticates/v1/agents/me, revoke, key 401s; worker tests for the session-scoped key lifecycle.- Note: list endpoint returns {apiKeys: []}, not a bare array. Live flow verified end to end (OTP sign-in → session-cookie key create → key 200s /v1/agents/me → revoke → 401); worker tests cover the lifecycle plus cross-user isolation (Bob can't see/revoke Alice's key).
- 9.4 Frontend overhaul (use the /frontend-design:frontend-design
skill). Make the site look hot while staying agent-first: redesign
the landing page (
/) with a hero that pitches the service, a prominent "for agents" section (copy-paste curl + CLI + MCP + skill install examples, llms.txt/discovery links), the live provider status + latest-changes data, and nav to /docs, /login, /account. Restyle /docs, /login, /account to match. No regression to any agent surface (JSON endpoints untouched). Accepts: pages render with the new design against local data; all links resolve; existing tests stay green.- Note: direction = "machine registry" — dark instrument panel (phosphor green / amber on near-black), Instrument Serif display + IBM Plex Mono data + Instrument Sans body, terminal-chrome cards, pulsing status dots, scanline overlay, staggered hero reveal (reduced-motion safe). Shared chrome in src/components/site.tsx; landing hero features a real schema fragment + etag, a 4-card FOR AGENTS section (curl/validate/MCP/discovery+skill), live provider table and log-line change feed. All 9 routes/surfaces curl-verified; agent JSON untouched; 128 tests green.
- 9.5 Ship the human surface. Deploy to production and verify on
the live origin (modelschemas.com once 9.1 lands, else workers.dev):
OTP sign-in (real email via the sendemail binding when the zone is
verified; logged code via
wrangler tailotherwise), key creation, the key authenticating/v1/agents/me, and the redesigned landing page rendering. _Accepts: live production walkthrough recorded in the note.
Tom pasted the audit 2026-06-11. Fixes target https://modelschemas.com; each lands as a route/header served by the worker so they stay correct on every deploy. Re-verify on production at the end.
- 10.1 Sitemap + robots.
GET /sitemap.xml(canonical pages:/,/docs,/login,/account, plus/llms.txt,/openapi.json,/skill), generated dynamically (origin from BETTERAUTH_URL, lastmod from the latest change row) so it is always current on publish;GET /robots.txtallowing all and referencing the sitemap. _Accepts: both render locally with correct content types; sitemap validates against the sitemaps.org shape (urlset/url/loc).- Note: a scaffold public/robots.txt static asset was shadowing the route (assets win over worker routes) — removed. lastmod derives from the newest change row; worker tests cover both surfaces.
- 10.2 Link headers (RFC 8288). Add
Linkresponse headers on HTML pages (via the worker fetch wrapper):</.well-known/api-catalog>; rel="api-catalog",</openapi.json>; rel="service-desc",</docs>; rel="service-doc",</llms.txt>; rel="describedby". Accepts: curl -I of/shows the Link header; JSON endpoints unaffected. - 10.3 Discovery trio.
/.well-known/api-catalog(application/linkset+jsonper RFC 9727: anchor + service-desc/ service-doc/status),/.well-known/mcp/server-card.json(SEP-1649: serverInfo, transport endpoint/mcp, capabilities/tools), and/.well-known/agent-skills/index.json(Agent Skills Discovery RFC v0.2.0: $schema + skills[] with name/type/description/url/sha256 — the digest computed from the live SKILL.md content). Accepts: all three render with correct content types; sha256 in the index matches the served /skill body. - 10.4 Auth discovery.
/.well-known/oauth-protected-resource(RFC 9728: resource, authorizationservers, scopes_supported = public capability names),/.well-known/oauth-authorization-server(RFC 8414 fields we genuinely have: issuer, registration via agent-auth, the device-authorization endpoint, jwks where applicable) including the auth.mdagent_authblock (register_uri, identity/credential types), and/auth.md— markdown registration instructions for agents (agent-auth protocol + register-key fallback), generated from the same sources as llms.txt where possible. Honest metadata only — no fake OAuth endpoints. _Accepts: all three render; agent_auth.register_uri round-trips against the real register endpoint locally. - 10.5 Markdown negotiation. Requests with
Accept: text/markdownon HTML pages return markdown withContent-Type: text/markdown(worker wrapper):/docs→ llms.txt content,/skillalready markdown,/→ a markdown landing summary (hero pitch + agent quickstart + live links); browsers keep HTML. The audit saw a 500 — must return 200. Accepts: curl with the Accept header gets markdown 200s on/,/docs; plain requests still get HTML. - 10.6 WebMCP. On the landing page, feature-detect
navigator.modelContextandprovideContext()with tools wrapping the public API:list_models,get_schema,validate_payload(fetch wrappers with JSON Schema inputs). No-op when the API is absent. Accepts: guarded registration code ships on/without console errors in normal browsers; tool definitions match the MCP tool shapes. - 10.7 DNS-AID (+ ship + reverify). Attempt to publish
_index._agents.modelschemas.comServiceMode HTTPS/SVCB records (alpn h2, target the apex) via the Cloudflare API; DNSSEC needs zone signing + a DS record at the registrar — document for Tom if the API token lacks DNS scope. Then deploy everything and re-verify each Phase 10 surface on https://modelschemas.com. Accepts: production curls for 10.1–10.5 all pass; DNS-AID either resolves or is marked BLOCKED with exact records for Tom. Deployed + reverified 2026-06-11: production curls for 10.1–10.5 all pass (sitemap/robots, Link header HTML-only, discovery trio with sha256 match, auth trio with live register_uri, markdown negotiation). DNS-AID: BLOCKED on Tom — wrangler's OAuth token has no DNS scope (and harvesting it was denied anyway). To publish, in the Cloudflare dashboard (zone modelschemas.com → DNS) add an HTTPS record: name_index._agents, priority1, targetmodelschemas.com., paramsalpn="h2"(zone-file form:_index._agents 3600 IN HTTPS 1 modelschemas.com. alpn="h2"), then enable DNSSEC (DNS → Settings) and add the DS record at the registrar.
Agents consume APIs differently from programmers. Keep the DX surface
(/openapi.json, typed client) separate from the agent experience (AX)
surface; these tasks build the AX layer.
- 11.1 HAL self-description. Add
_links(HAL) to/v1and every/v1/*JSON read response, extended per link withmethod,contentType, and an inlineexample(HAL doesn't carry these — agents need them). Keep payload shapes backwards-compatible (additive only); document the extension in llms.txt. Accepts:GET /v1andGET /v1/modelsresponses carry_linkswhose hrefs all resolve 200 locally (worker test walks them); openapi.json regenerated without breakingcheck:client. - 11.2 Narrative API root. Make a JSON-accepting
GET /(Accept: application/json, plus theGET /v1self link) return the equivalent of MCP default instructions: narrative description, workflow, inline examples, and HAL links — the same content an MCPinitializeinstructions field would carry, generated from the llms.txt source. Accepts: JSONGET /returns the narrative document with_links; HTML browsers still get the landing page; MCP initialize instructions and the root narrative share one source. - 11.3
?wait=long-poll. Support?wait=<seconds>(cap 60s, also accepts60sform) on/v1/changesand/v1/status: hold the request until new data exists or the window expires (poll D1 every ~2s), returning whatever is current at expiry. Agents rarely have a sleep tool — this gives them one. Document on the endpoints + llms.txt; ensure Workers runtime limits are respected (use ctx.waitUntil-safe timers within the request, no cron dependency). Accepts: worker test proves?wait=2returns early when a change lands mid-wait and at ~wait expiry otherwise; rate limiter counts the call once.
Design settled with Tom (don't relitigate):
-
Generated files on disk, committed. No virtual modules. Dev-time fetch only: the vite dev server (or explicit CLI command) fetches and writes; production builds touch zero network and fail with remediation if files are missing.
.manifest.jsonin the out dir is the lockfile (endpoint → contentHash) and records the selection, soupdateneeds no separate config. -
Tree-shakeable, no barrel index. One pure
.tsmodule per endpoint+kind (schema const + generated types), zero imports, zero side effects, descriptive export names (anthropicV1MessagesRequestSchema,AnthropicV1MessagesRequest). Users import directly from the file. The schema const is annotated with an exported structuralJsonSchemaalias (exported sodeclaration: trueconsumers don't hit TS4025) — neveras const(literal-typing megabyte schemas kills tsc). -
Server-side typegen, dependency-free emitter. No json-schema-to-typescript (prettier dep + bundle weight in workerd); we own a small deterministic emitter. Generated lazily at request time via
?format=typeson the existing schema route, SWR-cached, ETag = contentHash + emitter version. tsconfig variance handled by emitting flag-agnostic declaration-only types plus one knob:optional=exact|undefined(defaultexact:foo?: T— JSON has no undefined). Alwaysunknown, neverany. Index signatures only whenadditionalPropertiesis explicitly truthy (jstt's always-on[k: string]: unknownpoisons DX). -
Formatting belongs to the user. The pull core runs the user's own prettier (dynamic
import('prettier')+resolveConfig) over generated files when present; otherwise the server's canonical formatting stands. Files carry a// @generatedbanner (source URL + contentHash + emitter version, no timestamp — output stays byte-deterministic). -
Selection grammar maps 1:1 onto the API:
provider[/endpointId][#request|response]with*globs expanded via the catalog (request/responseare the user-facing aliases for kind=input/output; filenames use them too:anthropic/v1-messages.request.ts). -
Auth: anonymous fine (a dev boot is ~1 request thanks to the manifest);
MODELSCHEMAS_API_KEYlifts to the credentialed tier. -
Packages:
@modelschemas/codegen(pull core: grammar, catalog expansion, ETag fetch, manifest, file writer, prettier pass),@modelschemas/vite(thin plugin), CLI gainspull/updateon the same core. -
12.1 TypeScript emitter.
src/server/typegen.ts: JSON Schema → self-contained.tsmodule text (banner,JsonSchemaalias, schema const, root interface/type, named$defsas exported declarations, JSDoc from descriptions). Handles type arrays, OpenAPInullable, enum/const, anyOf/oneOf unions, allOf intersections, tuples,$ref → #/$defs/*(circular-safe via named declarations), name collisions via numeric suffix. Deterministic output;TYPEGEN_VERSIONexported for ETags. Accepts: unit tests cover the subset + a real bundled fixture; generated text compiles clean understrictwithexactOptionalPropertyTypesboth on and off (in-memory tsc program in the test). -
12.2
?format=typeson the schema route. Extend/v1/schemas/{p}/{activity}/{endpointId}withformat=json|types(+optional=exact|undefined): types responses aretext/typescript; charset=utf-8, SWR-cached percontentHash+TYPEGEN_VERSION+optional, ETag/304 preserved. openapi.json + generated client regenerated (additive). Documented in llms.txt. Accepts: worker test fetches types for a synced fixture endpoint, gets compilable TS, 304 on If-None-Match replay;check:clientpasses.- Note: deviated from "SWR-cached emitted text" — the module is emitted
inline from the already-SWR-cached schema (pure CPU, cheaper than a
second KV read); the ETag (
typesEtag= hash+TYPEGEN_VERSION+optional) still fully keys the body. Verified live: real Anthropic v1/messages → 186KB module, 0 tsc diagnostics under strict+exactOptionalPropertyTypes, 304 replay, 400s on bad format/optional. SKILL.md re-emitted (it embeds llms.txt).
- Note: deviated from "SWR-cached emitted text" — the module is emitted
inline from the already-SWR-cached schema (pure CPU, cheaper than a
second KV read); the ETag (
-
12.3
@modelschemas/codegenpull core.packages/codegen: selection parsing/expansion, conditional fetch (If-None-Match from manifest), atomic file writes, manifest read/write, optional local prettier pass,pull()/checkUpdates()/verify().checkUpdatesis one/v1/changes?since=call per provider filtered to selected endpoints. Accepts: unit tests with a mocked fetch cover grammar (provider, globs, #kind), 304 short-circuit, manifest round-trip, verify failure messaging. -
12.4
@modelschemas/viteplugin.packages/vite: onserve, pull missing files then log (never auto-rewrite) upstream updates fromcheckUpdates; onbuild,verify()only — zero network — erroring with "runmodelschemas pull" remediation. Accepts: unit tests drive the plugin hooks both modes; a fixture project under the test proves generated modules are import-clean (no side effects, no barrel). -
12.5 CLI
pull/update.modelschemas pulltaking selections plus--outand--no-typesflags, andmodelschemas update --out(selection read from the manifest) on the codegen core; help text updated. Accepts: unit tests for arg parsing; roundtrip exercised in 12.6. -
12.6 E2E + docs + ship.
bun scripts/pull-roundtrip.ts: against the local dev server, pull a real selection, typecheck the generated files, mutate one upstream schema (admin sync fixture or direct D1 write), proveupdaterewrites exactly that file. README + docs page + llms.txt get the vite-plugin/CLI quickstart. Deploy; verify?format=typeson production. Accepts: roundtrip script green locally; production curl returns compilable TS with correct content-type and ETag.- Note: roundtrip green against local dev (pull → tsc-clean module → 304
no-op → staled manifest entry rewritten exactly); upstream mutation done
by staling the lockfile entry rather than a D1 write — same code path,
no SWR-staleness flake. CLI pull/update smoked end-to-end. Docs: README
quickstart; /docs + llms.txt already carry the 12.2 section.
Deployed + verified 2026-06-12 (Tom approved): production curl →
200
text/typescriptwith<hash>-types-v1-exactETag (and theoptional=undefinedvariant keyed separately); fullbun scripts/pull-roundtrip.ts https://modelschemas.comgreen — pull → tsc-clean → 304 no-op → staled-entry rewrite, all against prod.
- Note: roundtrip green against local dev (pull → tsc-clean module → 304
no-op → staled manifest entry rewritten exactly); upstream mutation done
by staling the lockfile entry rather than a D1 write — same code path,
no SWR-staleness flake. CLI pull/update smoked end-to-end. Docs: README
quickstart; /docs + llms.txt already carry the 12.2 section.
Deployed + verified 2026-06-12 (Tom approved): production curl →
200
-
BLOCKED on Tom12.7 Deploy on push via Workers Builds. Tom chose Cloudflare Workers Builds over a CI deploy job (no tokens in GitHub). Connecting the repo needs interactive GitHub auth in the Cloudflare dashboard, so the setup is Tom's: dashboard → Workers & Pages →modelschemas→ Settings → Build → Connect → GitHub repomodelschemas/modelschemas(moved fromtombeckenham/modelschemas2026-06), production branchmain, then:- Build command:
bun run build(the build image auto-runsbun installfrom bun.lock) - Deploy command:
bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/server/wrangler.json(migrations are idempotent; the emitted config under dist/server is the real worker config — NOT the repo-root wrangler.jsonc) - Root directory:
/; leave non-production branch builds enabled if PR preview versions are wanted (wrangler versions upload). Once connected, manualbun run deploybecomes the emergency path only. Accepts: a push to main produces a dashboard build that deploys; /v1/status on prod reflects the new version.
- Build command:
- 13.1 Backdate
models.firstSeenAtper provider.ModelInfogains optionalreleasedAt(epoch s); listModels populates it — OpenAI/xAI/ OpenRoutercreated, Anthropiccreated_at(ISO), FALmetadata.date(ISO), Gemini/ElevenLabs from a curated map insrc/server/providers/release-dates.ts(+ GeminiMM-YYYYpreview-id suffix fallback). The 15-min poll applies it continuously: on insertfirstSeenAt = releasedAtwhen sane-and-earlier than now; existing rows backdate in place when upstream reports an earlier date — SILENTLY (nomodel.updatedevent: the fleet-wide first pass would flood the changes feed + webhook fan-out; converges once per row so the bulk clean-path perf note above still holds). Backdates write in 30-row CASE-chunked bulk UPDATEs — each D1 query is a subrequest against the invocation's 1,000 budget, and the ~1,900-row first pass as one-per-row writes would exhaust it mid-poll (and starve the webhook drain sharing the invocation). Never forward-dates; bogus timestamps (< 2015-01-01) ignored.PollOutcome.backdatedcounts corrections for cron observability. Accepts: live listModels shows date coverage openai 129/129, anthropic 10/10, grok 10/10, openrouter 344/344, fal 1399/1399; worker test walks insert/backdate/ never-forward/update-compose cycles.
- 14.1 BytePlus as the 8th provider — fully static, keyless. BytePlus
publishes no machine-readable spec (its OpenAPI docs live behind the
console's API explorer) and the Ark data-plane
GET /modelsneeds a region-bound key AND is documented non-exhaustive, so both surfaces are embedded, ported from TanStack AI's@tanstack/ai-byteplusadapter (whose wire types are hand-written, test-pinned, and probe-verified against the ap-southeast host on 2026-07-31):src/server/providers/byteplus-spec.tssynthesizes two OpenAPI 3.1 documents — Ark (/chat/completionsOpenAI-compatible + thinking/ reasoningeffort/repetition_penalty/service_tier extensions,/images/generationsSeedream,/contents/generations/tasksSeedance async task API whose 200 is just the{id}ack, post-200 precedent per OpenRouter/ElevenLabs job acks) and Seed Speech (/tts/create,/auc/bigmodel/recognize/flash— separate host andX-Api-Keyproduct key).byteplus.tsclassifies by exact path and serves the curated 32-model catalog (18 chat / 7 video / 5 image / 2 speech) with context windows, modalities, probed structured-output capabilities, andreleasedAtfrom the-YYMMDDid suffix (byteplusIdSuffixDatein release-dates.ts). Provenance: sources point at the human docs, hashes arecontentHashof the built documents (FAL embedded-spec precedent), so content changes only land through edits here and version like any upstream change. NOTE the HMACBYTEPLUS_ACCESS_KEY/SECRET_KEYpair in the old providers-to-add table only guards the control plane — never needed. _Accepts: byteplus.test.ts walks classifyAndBundle over the embedded specs (5 endpoints, zero warnings, no dangling refs, deterministic hashes) and the catalog counts; registry/seed tests updated to 8.
-
15.1 Generate the Ark spec from BytePlus's Go SDK at sync time. Probing the Ark data plane with a real
ARK_API_KEYconfirmed there is no fetchable OpenAPI document (/openapi.json,/swagger.json,/docs,/models/{id}all answer 200 with an EMPTY body). But BytePlus's official Go SDK declares the whole Ark data plane asjson:"…"-tagged structs, and — unlike the Python SDK's Pydantic models, which need a Python runtime to introspect — Go structs are plain text. Sogo-struct-parser.ts(Workers-safe, pure string parsing) turns them into component schemas andbyteplus-ark-build.tsmaps them onto paths, lettingfetchSpecregenerate the Ark document fromraw.githubusercontent.com/byteplus-sdk/byteplus-go-sdk-v2on the daily cron. No codegen step, no committed artifact, no CI drift check — upstream SDK releases flow in and the content-hash diff versions them like any other provider's spec change. Two deliberate seams: (a) the endpoint→type map is hand-maintained, because the model package has no notion of HTTP paths and paths change far more slowly than field sets; (b) an explicit curated overlay re-applies the request fields the SDK omits (reasoning_effort,top_k,seedon chat;streamon images;output_formaton video) and the hand-written descriptions carrying probe findings that a type declaration cannot state. The overlay is an explicit list rather than a blanket merge of the old document, so a field genuinely REMOVED upstream disappears instead of being resurrected forever. Falls back to the embedded document (with a warning on the sync outcome, via the newSpecFetchResult.warnings) when GitHub is unreachable or the SDK outgrows the parser — degraded freshness, not a degraded service. Seed Speech stays hand-written: it appears in no BytePlus SDK in any language. Accepts: generated-vs-hand-port diff loses ZERO fields and gains three (function_call, image-responsetools, task-responsesafety_identifier); live end-to-end against GitHub bundles all 5 endpoints with no warnings and no dangling refs; parser unit tests cover primitives/pointers/omitempty-requiredness, const-block enums, slices/maps/interface{}/[]byte,json:"-"and embedded fields, aliases and named composites, doc comments, closure-only emission, and graceful degradation on unmapped types. -
15.2 Per-endpoint schema provenance (
derivation+verifiedAt). Surfaces how each schema's content was arrived at so agents can weigh how far to trust it — and so a hand-written corner going stale is visible rather than invisible. A four-rung trust ladder onprovenance.derivation:upstream-spec(extracted from a document the provider publishes and we re-fetch every sync — the seven original providers),generated(re-derived each sync from a provider artifact that is not a spec — BytePlus Ark from the Go SDK),probe-verified(hand-written but confirmed against live calls onverifiedAt— BytePlus TTS, 2026-08-12) anddocs-derived(hand-written, never confirmed — BytePlus ASR, blocked on a Seed ASR account grant). The first two self-heal; the last two are point-in-time claims. Providers declaredefaultDerivation, and any operation may override it with anx-modelschemas-provenancemarker (the FAL activity-marker trick) — which is how the BytePlus Ark document downgrades itself fromgeneratedtoprobe-verified/2026-07-31 when it falls back to the embedded copy. A malformed marker falls back to the provider default rather than throwing. Stored onschema_versionsbeside the existing sourceUrl/sourceHash/extractorVersion provenance (nullable, additive migration 0004), so it versions per schema version and can improve over time as endpoints get probed. Accepts: worker tests assert the columns persist and the API surfaces them (including null for rows synced before the field existed); byteplus tests pin all five endpoint grades and the fallback downgrade; openapi.json, the typed client and SKILL.md regenerated.
-
16.1 Registry is the source of truth for provider listings. The landing page and
/v1/status//v1/providersonly showed providers whose rows happened to exist in D1, and the sync/poll engines only ever UPDATE provider rows — so the adapter providers added in #18–#37 never appeared in prod without a manualbun run seed. Two fixes, both self-healing:getServiceStatusmergesproviderRegistry(code) over the DB rows, surfacing never-synced providers as a newpendingstatus with zero counts (blue dot on the site; enum added to the OpenAPI ServiceStatus schema); andensureProviderRow(sync.ts) upserts the seed row —seedForProvider: hand-written row for the original 8,seedFromAdapterotherwise, no-op for configs without aspecSourceUrl(test stubs) — at the top of every syncProvider/ pollProviderModels run, matching scripts/seed.ts semantics (config columns refresh, runtime state preserved). Accepts: status.worker.test asserts every registry provider surfaces aspendingon an unseeded DB; sync/poll worker tests unchanged. -
16.2
GET /v1/schemas— the system-wide schema index.getSystemSchemaIndexgroups every endpoint into provider → activity → endpoint-id lists (one D1 query, SWR-cached underschema-index:all, ETag like every read). Discovery wiring:_links.schemaIndexonGET /v1, an/v1/schemaspath in openapi.ts (client + SKILL.md regenerated), llms.txt quickstart step 3, and alist_schemasMCP tool (optionalproviderarg narrows to the per-provider index) mirrored into the WebMCP tool list. llms.txt no longer hard-codes the original 7-provider list. -
16.3
/schemas— the human-browsable index. New route modeled on/models: the flattened endpoint list with provider/activity/q filters (client-side; the whole index is one loader payload), capped at 300 rows with a pointer to the API, readable/json ViewToggle serving the/v1/schemasbody verbatim. Nav gains aschemaslink; the landing hero gains aGET /v1/schemasbutton; provider-table schema counts link to/schemas?provider=…. -
16.4 schema-studio browses every schema live. The example keeps its four vite-plugin-pinned, offline-verified schemas as instant entries and adds a live rail: fetch
/v1/schemason mount, pick a provider, fetch any input schema on demand and render the generated form. Same-origin on the zone; from a local dev server it hits prod — which works because the worker now serves CORS-open reads (Access-Control-Allow-Origin: *+ OPTIONS preflight) on GET/HEAD for/v1/*(admin excluded),/openapi.jsonand/llms.txt— a public keyless JSON API browsers can actually call.
- 17.1 Root-cause FAL's stuck
degradedstatus. Production D1 showed the daily 05:00 spec sync dying mid-FAL every day since Aug 1: fal + the four biggest core providers flapping degraded, and every provider after fal in the registry (byteplus + all adapters) withlast_synced_atNULL — never synced once. Cause: the sync did 2 D1findFirstround trips per endpoint (+1 per vanished endpoint, forever) and FAL is ~1,400 endpoints — past the invocation's 1,000-subrequest budget and most of its wall clock, so a full successful sync (the only thing that resets status toactive) could never complete. Fix mirrors poll-models' batching: one joined query for the provider's current versions, one for the endpoint.removed set; degraded-status write is best-effort so budget exhaustion inside the catch can't sink the rest of the run. FAL's paginated crawl also now retries 5xx/408/timeouts/network errors (not just 429), honors Retry-After, 5 attempts. - 17.2 Shard the spec-sync cron. One shared invocation meant one
provider's blowup starved everyone behind it (and uncatchable
CPU/memory kills took out the rest of the registry silently). The
daily sync now fires four crons (05:00/05:10/05:20/05:30 UTC), each
a fresh invocation with its own budgets: shard 0 is FAL alone, the
rest round-robin (
specSyncShard; keepSPEC_SYNC_SHARD_CRONSin lockstep with wrangler.jsonc — a unit test enforces it).limits.cpu_msraised to 300s (ceiling, not floor) for FAL's 1,400-spec parse+hash. Models poll unchanged: it's already batched, time-sensitive (15-min freshness), and carries the webhook drain.
- 18.1 Activity + displayName on grain=provider catalog rows. Grok
and OpenAI
/modelspayloads areid+createdonly, so every row listedactivity: nullandGET /v1/models?provider=grok&activity=imagereturned empty. Derive activity from the raw id (Grok Imagine image/video/voice vs chat; OpenAIgpt-image/sora/whisper/ttsbefore thegpt-chat default) and a display name from the id. Anthropic listed models arechat. Persisted by the 15-min poller like Gemini already was. - 18.2 Bind each model to its generation route schema. Catalog rows
gain
schemaEndpointId(and_links.schema) pointing at the shared HTTP route —v1/images/generationsfor Grok Imagine image,images/generationsforgpt-image-2,v1/messagesfor Anthropic, Gemini's:predict/:generateContent/:predictLongRunning. Model-grained FAL falls back to the raw id.GET /v1/schemas/{p}/{activity}/{rawId}aliases onto that route and pins the requestmodelfield to the one id so a Run form can be built the same way as for FAL. Validate accepts the same alias.?format=typesoverlays same-activity catalog ids (falling back to the full list when activity is still null). Follow-up on the same PR: OpenRouter activity fromarchitecture.output_modalities(video → synthesisedvideos/{id}; image-only has no classified route); BFLv1/{rawId}; BytePlus chat/image/video plus Seed Speech TTS vs ASR. - 18.3 Fill contextWindow / maxOutput / modalities / request-feature
capabilities on grain=provider rows (issue #53; pricing is a separate
PR). Native
/modelspayloads publish none of these (OpenAI, xAI) or only limits plus a capability tree (Anthropic Models API, since 2026-03) or limits +thinking/sampling defaults (Gemini). The rest comes from each provider's own published sources — never a third-party catalog (a model catalog that mirrors another caps its accuracy at theirs): OpenAI per-model markdown pages (developers.openai.com/api/docs/models/{slug}.md, fetched only for the ~65 pages the listed ids resolve to, dated snapshots via their alias), xAI's/v1/{language,image-generation,video-generation}-modelsfor modalities plusdocs.x.ai/docs/models.mdfor context windows. Adapters:openAiCompatModelFactsmaps the extension fields OpenAI-compatible hosts already put on their rows (context_window/context_length/max_context_length,max_completion_tokens,input_modalities,supports_*booleans, groq/jinasupported_features+supported_sampling_parameters, mistralcapabilities) — one mapper inlistOpenAiCompatibleModelscovers groq, fireworks, moonshot, sambanova, hyperbolic, novita (context_size, hyphenatedfeatures); mistral, jina and cohere (features) call it too. Speech/media hosts (elevenlabs, deepgram, cartesia, stability, replicate, bfl, fal, byteplus, reactor) keep their own capability objects — token limits and request params don't apply; deepseek, cerebras, dashscope, perplexity, together publish bare rows. Output uses OpenRouter's shapes:filefor documents,supported_parametersnames as feature flags (tools,tool_choice,reasoning,reasoning_effort,temperature,top_p,top_k,structured_outputs,response_format). Nothing is inferred: a value is stored only when a provider field or table cell states it. So Grok has no capabilities (xAI publishes no feature flags), Gemini has no modalities (not on the row), a lone image/vision boolean does not produce a modalities object,tool_choiceneeds its own flag, and Anthropic's tool use, sampling params and Fable-tier mandatory thinking stay unset (prose only). Those prose-only facts are the target of the AI docs-extraction follow-up, which will attach the backing quote to each value. Geminicapabilitiesno longer holdssupportedGenerationMethods; the:predictvs:generateContentdecision those methods drive is now made at list time and stored asmodels.schema_endpoint_id(migration 0005), which the read path prefers over the config binding. The config fallback still reads leftoversupportedGenerationMethodsfromcapabilitiesso un-repolled Imagen rows keep:predict— first-party data kept, no id-prefix guessing. Deploy needsdb:migrate:remotefirst (deploys do not run migrations). Docs parses are memoised in-isolate for 6h and fail closed: zero parsed rows throws and fails that provider's poll for the tick rather than nulling populated rows (which would fan out a bogusmodel.updatedper model and again on recovery). Gotcha: the first poll after deploy legitimately emits onemodel.updatedper newly filled row. - 18.4 Bound-schema catalog facts + per-field provenance (issue #53).
After listing/docs fill, the poller walks the bound generation
request schema (
schemaEndpointId→ current inputschema_versions) for OpenRoutersupported_parametersnames and input modalities. Listing/docs win on a field they stated; the schema fills remaining flags.generatedspecs are skipped (OpenAI-borrowed documents must not stamptoolsonto DeepSeek/DashScope). Host-native capability objects (FAL/Reactor/…) are left alone. Each stored value carriesmodels.fact_sources(migration 0006): per-field and per-flag{ derivation, sourceUrl, endpointId, path }.GET /v1/models/{p}/{id}always returnsfactSourcesplusdiscrepanciesagainst the already-polled OpenRouter catalog (joinauthor/rawId; no extra fetch, no unlabeled OpenRouter copy).GET /v1/models?provenance=1includesfactSourceson list rows. OpenRouter fill-nulls, pricing, anddocs-extractedremain later slices.