worker

The jevstrudel Cloudflare Worker, in TypeScript. Cloudflare serves the static site's files itself; the Worker answers the paths that are not files.

FileWhat it is
src/index.tsThe routes.
src/relay.tsPOST /jev/v1/systemone: the Jev relay. TypeSafe's API refuses calls from web pages, so the page calls this (.jev(), the mood picker, the radio and "is it art?"), and it forwards them with the site's own key. It forwards only TypeSafe's question shapes (choice, score or noul) within TypeSafe's own limits, one pinned model, at most 64 KiB, at a per-visitor rate sized in relay.ts (the limit itself is in wrangler.json). Every call logs one line (see Logs below). A request byte for byte the same as one answered in the last hour is answered from the cache instead (src/answer-cache.ts).
src/votes.tsPOST /jev/votes and GET /jev/votes/summary: the "do you agree with Jev?" votes (website/src/jev/agree.mjs). A vote is { a, b, pick }, two of the deploy's own songs (its /jev/songs.json) and one of them, at a per-visitor rate of its own (VOTE_LIMIT, apart from the relay's). It is kept only as a count per (a, b, pick): no time, address or visitor. The summary returns the counts, for nix run .#agreement (tools/agreement/).
src/votes-store.tsThe votes' storage: the votes table in the site's D1 database.
src/auth.ts/jev/auth/*: accounts, signed in with passkeys (see Accounts below).
src/accounts-store.tsThe accounts' storage: users, credentials, sessions and challenges in D1.
src/session.tsThe session cookie, and the random ids, tokens and challenges accounts use.
src/budget.ts, src/budget-counter.tsJevBudget, the Durable Object counting one signed-in user's relay calls per UTC day, and its counting (apart, so tests run it on Node's SQLite).
src/radio.tsGET and POST /jev/me/radio: a signed-in listener's radio history, in D1.
src/listening.tsListening: POST /jev/reactions and GET /jev/reactions?song= (every listener's 🔥/😴, a count per song, section and reaction, anonymous), and POST /jev/performances and GET /jev/performances/<id> (a performance: one play of a song as Jev arranged it, per jev() per segment what played, under a random 22-character id, for a replay link; with who played it, from the session cookie and never the body, null when signed out, and when, to the millisecond). A song is the deploy's own or a listener's public one (listener:<id>, recorded from the page's sandbox, website/src/jev/JevPerformance.jsx). GET /jev/performances?song= lists a song's performances a page at a time, each with its path through the form, and GET /jev/performances/moves?song= says how Jev moves through it over its latest 500 (per section: plays, fallbacks, answer times, and the sections picked next with their shares); both for the song view's takes (website/src/jev/JevTakes.jsx), cached a minute. Validates shape and size (64 KB) and that the song is the deploy's or a public listener song; its own two rate limits, LISTEN_LIMIT (reactions and reads) and RECORD_LIMIT (storing), sized in the file.
src/listening-store.tsListening's storage in D1: reactions, performances, and performance_segments, a row per segment per jev, which is also Jev's decision history (nix run .#jev-history, tools/jev-history/). A performance is written in one batch of two statements.
src/events.ts, src/events-schema.tsTelemetry: event(env, name, { blobs, doubles }) writes one event to Analytics Engine; the schema says what each event's columns mean (see Storage).
src/answer-cache.tsThe relay's answer cache in KV: key, lookup, and keeping a 200 for an hour.
src/party.ts, src/party-room.tsGET /jev/party/<room>: listening parties; and GET /jev/parties[/<id>], the live ones and joining a listed one (see Listening parties). A WebSocket into the room's Durable Object (Party, one per room), which passes the host page's performance (its song, its start, every Jev decision) to its guests and counts everyone's reactions (see Listening parties). party-room.ts is the room's rules as plain code; party.ts the Durable Object that holds the sockets.
src/lobby.ts, src/lobby-room.tsGET /jev/lobby: who is listening to what. A WebSocket into the site's one LobbyRoom Durable Object, which tells every page what every visible tab plays and how far through it is, and brokers listening along (see The lobby). lobby-room.ts is its rules as plain code; lobby.ts the route, which says who the tab is, and the object that holds the sockets.
src/data.tsGET /jev/data[/*]: everything the site stores, for anyone to read (see What is public), for the jev panel's data tab (website/src/jev/DataTab.jsx); an account's page is also its profile (its public songs), and /jev/data/activity the activity tab's feed.
src/activity.tsThe activity feed: songs published and revised (with Jev's verdict and score), comments, pitches and performances, newest first, merged from five bounded queries and paged by time (see What is public).
src/party-directory.tsWhich listening parties are live: the rules of PartyDirectory, the one object every party room reports its people and song to (keyed by the room's object id, never its name), since rooms cannot be listed.
src/content.ts/jev/listeners/*: listeners' content (see Listeners' content): songs signed-in listeners publish, with revisions and a cover, comments on any song, and pitches. Public to read, signed in to write; only what Jev screened fine is shown.
src/content-store.tsIts storage in D1: listener_songs, song_revisions, covers, comments, pitches, and content_jobs, the work Jev still owes.
src/content-jobs.tsRunning that work: screening each item, scoring each fine song revision, and rescheduling what cannot run now; the cron trigger sweeps it every minute.
src/screen.tsThe screening question (a Jev choice: fine, spam or abuse) and whyNotCode, the first check on a song's code.
src/art-rubric.mjsThe art critic's rubric, shared: the Worker scores listeners' songs with it, and the site (website/src/jev/critic.mjs) and tools/critic/ score the site's own.
src/hosted-mcp.tsPOST /jev/mcp: the hosted MCP for everyone's own AI, acting for one listener with an OAuth token (see The hosted MCP).
src/hosted-tools.tsIts scopes and tool definitions, importing only shared-tools.json, so the site's /ai/ page lists exactly what it offers.
src/shared-tools.json, src/shared-mcp.tsThe tools both MCPs offer (see The tools both MCPs share) and running them: a command to the tab, or no tab for the reference and a vote.
src/mcp-settings.mjsThe settings tab's settings an MCP may read and change, and the checks; plain JavaScript, shared with the tab (website/src/jev/tabTools.mjs).
src/reference.tsThe function reference for api_reference, Strudel's and jev's (website/src/jev/jevCore.mjs's jsdoc): /jev/reference.json, which the build writes, searched (any of the query's words, a name before a mention) and described.
src/primer.tsHow to write a song with Jev, which the hosted MCP's instructions end with, and the complete song it teaches with (played in website/src/jev/reference.test.mjs).
src/oauth.tsOAuth 2.1 for it (@cloudflare/workers-oauth-provider): discovery, token, registration, the authorize page (passkey sign-in and consent), connected apps (/jev/me/apps), and a tab joining its user's hub (/jev/me/tabs).
src/tab-hub.ts, src/tab-hub-core.tsTabHub, one Durable Object per signed-in user holding that user's open tabs, and its rules as plain code (which tab, what a reply may hold, sizes).
src/catalog.tsThe site's own songs as the hosted MCP lists and reads them (/jev/catalog.json, /jev/catalog/<theme>/<song>.json, which the build writes), and siteFile, how the Worker reads any file the build writes.
src/mcp-rpc.tsMCP's JSON-RPC over HTTP, shared by both MCPs: both protocol eras (initialize, and 2026-07-28's per-request _meta with server/discover), stateless.
src/hub.tsThe dev MCP hub, a Durable Object holding every open REPL tab's WebSocket (/strudel/ws); dev only.
src/mcp.tsPOST /mcp: the dev hub's MCP; its tools become commands sent to a tab through the hub.
src/tools.jsonThe dev MCP's own tool definitions; it offers shared-tools.json's after them. The stdio proxy in tools/mcp/ reads both while the Worker is down.
migrations/The D1 database's schema, as numbered SQL migrations (see Storage).
test/Test helpers: testD1(), the D1 database in memory with every migration applied, memoryKV(), memoryR2(), memoryBudgets(), memoryTabHubs(), a passkey authenticator() in software, and cloudflare:workers stand-ins for Node.
wrangler.jsonThe config. The top level is production; env.dev adds the dev hub, and reads the song list from the dev server. Both bind the party rooms, the tab hubs and the lobby.
package.jsonThe Worker's own dependencies (@simplewebauthn/server, @cloudflare/workers-oauth-provider); worker/ is a pnpm workspace package, installed with the rest.
tsconfig.jsonFor tsc: the sources against the Workers runtime types.

Two environments

Production (nix run .#deploy, --env="") is the site plus the relay, the votes, accounts, listening, listening parties, listeners' content and the hosted MCP (/jev/mcp, for everyone's own AI, behind OAuth). It has no dev hub, so /mcp and /strudel/* answer 404 and the public page never turns the dev MCP on. The votes read the deploy's song list through the ASSETS binding, so a vote is only ever for a song that deploy has.

Development (buck2 run //:dev, wrangler dev --env dev on port 4323) adds the dev hub, so Claude Code in this repo can play into your local tab without signing in (its code is the page's own); the hosted MCP runs there too, at http://localhost:4322/jev/mcp. The dev server (localhost:4322) proxies the Worker's paths to it, and wrangler reloads the Worker on every save, tool changes included. There the votes read the song list from the dev server itself (SONGS_URL), so a new song is votable on reload; dev.mjs gives the inherited assets an empty folder, since wrangler requires one.

Storage

Each kind of data lives where its shape fits, so a new feature picks by the table below rather than by what the last one used.

WhatWhereBindingWhy there
The site's records: votes, accounts (users, passkeys, sessions, sign-in challenges), radio history, listening (reactions, performances, Jev's decision history), and listeners' content (songs, revisions, covers' records, comments, pitches, and the screening and scoring Jev owes them)D1, one database jevstrudelDBRelational, queried across rows, one copy for every Worker. SQLite, so tests run the real SQL (test/d1.ts).
Live coordination: the dev MCP hub (dev only), each signed-in user's daily Jev budget, listening-party rooms, each signed-in user's open tabs for the hosted MCP, the lobby, the directory of live partiesDurable ObjectsHUB, BUDGET, PARTY, TAB_HUB, LOBBY, PARTY_DIRECTORYOne instance per room or user, holding sockets or a strictly ordered counter. Not for records that outlive the room.
Telemetry: what happened, how often, how fast (the relay's calls, …)Workers Analytics Engine, dataset jevstrudel_eventsEVENTSWritten without waiting, sampled under load, queried with SQL by time; kept three months. Not for anything read back by the site.
Caches: Jev's answers to a request already asked, for an hourWorkers KV, namespace jevstrudel-cacheCACHERead far more than written, fine to be a minute stale across data centres, expiring by itself. Never the only copy of anything.
The hosted MCP's OAuth: registered clients, grants, tokensWorkers KV, namespace jevstrudel-oauth-kvOAUTH_KVWhere @cloudflare/workers-oauth-provider keeps them (it takes only KV): tokens, codes and client secrets as hashes, a grant's props encrypted by its token; a grant's user id, app name and redirect host in the clear, for listing and revoking a user's apps. Every record expires by itself (tokens in an hour, a grant 30 days after its last use).
Files: listener songs' cover imagesR2, bucket jevstrudel-coversCOVERSBytes, not rows: up to 2 MiB each, served whole. Each has its record in D1, which says whether it may be served.

The database's schema is migrations/ (0001_votes.sql, …). Every place that runs the Worker applies them first: //:dev and //:preview to their local databases on start (tools/dev/migrate.mjs), and the deploy to the real one before it publishes (tools/deploy/resources.mjs, which also creates the database on an account that has none, and the R2 bucket). wrangler.json names the database and the bucket but holds no id: the deploy looks them up by name each time.

To add a table, see migrations/README.md. To read the local data:

wrangler d1 execute jevstrudel --local --env dev --config worker/wrangler.json \
  --command "SELECT * FROM votes"

from the repo root (//:preview's is --env "" --persist-to worker/.wrangler/preview). The real one takes --remote in place of --local, through the deploy's wrangler, which reads the token from 1Password.

The relay's cache keys an answer by the SHA-256 of the exact body it forwards, under the pinned model, so only a request identical in every byte gets another's answer; it keeps only a 200, for an hour, and marks every answer Jev-Cache: hit or miss (src/answer-cache.ts has the reasoning). A hit is logged with outcome: "cached", and TypeSafe is not asked.

An event is declared in src/events-schema.ts (its fields, their columns, what they mean) and written with event() from src/events.ts, whose types allow exactly the declared fields. nix run .#events reads them back (tools/events/). Analytics Engine creates the dataset on the first write, so there is nothing to provision; wrangler dev keeps it local, so only the deployed Worker's events can be queried.

Production can also run locally: buck2 run //:preview serves the deploy's own static build with the production Worker on port 4324 (tools/dev/).

What is public

jevstrudel is a playground shared in the Jev community, and everything it stores is public: the jev panel's data tab (website/src/jev/DataTab.jsx) reads it through GET /jev/data[/*] (src/data.ts), which anyone may call.

StoreWhat anyone seesWhat is never shown
Accounts (D1)Every account: its id, display name and when it joined; its passkeys and sessions by date (and a passkey's transports and signature count)A passkey's credential id and public key, a session's cookie token or id_hash
Sign-in challenges (D1)How many are in flight, by purposeThe challenges
Radio history (D1)Every account's plays
Listeners' content (D1)Every song revision whole (code and spec as text), cover record, comment and pitch, public, pending or held, with Jev's verdict and confidence, the art score, and its author; every pitch vote, with who cast it and when, and the pitch each listener song answersThe image of a cover that is not public (only /jev/listeners/covers/<id> serves an image, and only a public one)
Jev's queue (D1)Every job: what it is for, whose budget pays, when it is due and why it waits
Listening, votes (D1)Every reaction count and vote count; every performance, with Jev's decision history, when it was played and by which account (signed in)
Budgets (BUDGET)Each account's Jev calls today, on its page
Tabs (TAB_HUB)How many tabs each account's AI can reach, on its pageThe tabs' ids
The lobby, parties (LOBBY, PARTY_DIRECTORY)Pages open, tabs listed and playing, and a signed-in tab's account id (its profile); each live party's song, people, whether its host is there, since when, and a listed party's object idA party's room name (its join link) and its host key's hash; a listed party's name goes only to someone joining it (Listening parties)
Answer cache (CACHE)How many answers are keptA key (a request's hash) or an answer
OAuth (OAUTH_KV)How many apps each account connectedA token, code, client secret or a grant's props; an app's name shows only to its own account, signed in
Covers (R2)How many images, and their bytesA cover that is not public

An account's page (/jev/data/accounts/<id>) is also its profile on the jev panel: it adds publicSongs, the account's songs as the listeners' list shows them, and takes, its newest 50 performances (a listener song's with its public title), which the you tab's "recently played" and the profile's "played lately" merge with its radio plays. /jev/data/activity?before=<ms>&limit= is the activity tab's feed (src/activity.ts): what happened, newest first, at most 50 an answer, with next for the page before. Songs published and revised come with Jev's verdict and, once scored, a scored item with the art; comments and pitches with their verdict and their text only when public; and every performance as a take item: who played it (null signed out), the song (a listener song's public title with it), how many sections and how it ended, and its id for the replay link. It is cached for 10 s, since everyone polls the same first page.

The lists are paged (offset, limit up to 100) and no-store; reads are DATA_LIMIT (120 a minute per visitor, namespace 1012). The KV and R2 counts are list operations, of which the free plan allows 1000 a day, so each isolate counts at most every ten minutes. Sign-up says it all plainly, on the site and on the OAuth page.

Accounts

Listeners can sign in with a passkey (WebAuthn): no password, no email, no third party. Everything works as before without an account; signed in, a listener's Jev calls are charged to a daily budget of their own, and the radio remembers what it played for them across sessions and devices.

RouteWhat it does
POST /jev/auth/register/options{ displayName }: options for navigator.credentials.create(), for a new account. Signed in and with an empty body: another passkey for your own account.
POST /jev/auth/register/verifyThe browser's registration response. A new account is created and signed in.
POST /jev/auth/login/optionsOptions for navigator.credentials.get(), for any passkey this site has (discoverable credentials: no username).
POST /jev/auth/login/verifyThe browser's authentication response; signs in.
POST /jev/auth/logoutEnds this browser's session.
GET /jev/auth/me{ user, budget }: who is signed in (or null), and today's { used, limit, resetsAt }.
GET, POST /jev/me/radioThe signed-in listener's radio history: the newest plays, or { song } just started.

The ceremonies are verified by @simplewebauthn/server, which uses only Web Crypto and runs in workerd; passkeys must verify the user (PIN, face or fingerprint), since they are the only factor. The relying party is the host the request came to: RP ID localhost in dev, the workers.dev host in production. A challenge is issued into D1 and taken back by its verify with DELETE … RETURNING, so it is used at most once, and only within five minutes. A session is an HttpOnly, Secure, SameSite=Lax cookie holding a random token; D1 keeps only its SHA-256, for 30 days. The auth routes have their own per-visitor rate (AUTH_LIMIT).

The daily budget. Each signed-in user has a JevBudget Durable Object (BUDGET.idFromName(userId)) counting the relay calls charged to them per UTC day. A call is charged when the relay is about to forward it to TypeSafe; an answer from the cache costs nothing and is not charged. The check and the charge are one SQL statement in an object that runs one call at a time, so a user's tabs and devices racing each other are counted exactly. Once the day's budget is spent, the relay answers 429 with Retry-After until 00:00 UTC and Jev-Budget: spent, so the page backs off and songs play their fallbacks; each charged answer says Jev-Budget: <used>/<limit>, which the header shows. The call that spends the last of it writes a jev.budget.exhausted event.

The budget is JEV_DAILY_PER_USER in wrangler.json (vars, in both environments), 1000 calls: the user's to set. A song asks about one call a section, a couple a minute, so 1000 is several hours of listening. It must be a positive whole number; anything else and signed-in calls are refused rather than charged against a guessed number.

Anonymous callers keep the per-address JEV_LIMIT, and are never charged. Signed-in callers are held to JEV_LIMIT too, keyed by their account rather than their address.

Listening parties

A host's page starts a party from a song's view ("listen together") and shares /songs/<theme>/<song>/?party=<room>; the room id is 128 random bits the host's page makes. Every page in the party holds one WebSocket to /jev/party/<room>, which the Worker hands to that room's Party Durable Object (idFromName(room)). The page's side is website/src/jev/party.mjs.

The protocol, JSON text frames:

FromMessageWhat it does
hostrun { song, code }A new performance: the song's id and a hash of its code. The room numbers it (run, echoed to the host) and forgets the last one's decisions, clock and reactions.
hostclock { run, startedAt, cps }When the host's scheduler started, in the room's clock (ms).
hostdecision { run, view, segment, d }One decision a jev() settled: view is the jev()'s place in the song (declaration order), d jevCore's shareDecision (values, status, a late answer's cut, where the song ends). Guests check every value before playing it.
hostend { run }The host stopped.
hostlisted { listed, room? }"Show me here" changed: the party leaves or joins the live list now (below); room is the room's own name, checked, when listing.
anyonereact { segment, kind }🔥 (fire) or 😴 (sleep) for a section. The room counts it and sends everyone reactions { segment, fire, sleep }, the totals.
anyoneping { c }Answered pong { c, s } with the room's clock, for the page's offset estimate.
roomwelcomeOn joining: role, how many are here, whether the host is, and the current run, clock, decisions and reactions, so a guest joining mid-song has it all.
roompeople { n, host }Someone joined or left.
roomrefused { status, why }Joining was refused (no party here, full, another host's key); the socket then closes with 4000 + status, which the page does not retry.

The first host connection claims the room with a key (kept in its tab's sessionStorage, so the host's reload rejoins as host); the room keeps only its SHA-256. A guest cannot join a room with nobody in it (4404: the party has ended). Limits: 50 people a room, 16 KiB a message, a burst of 30 messages per connection refilled at 5 a second, sections below 64 and jev()s below 8, and PARTY_LIMIT (20 joins a minute per visitor) at the route.

The room uses the Hibernation API, so a party between messages costs no duration. Its storage holds the current performance only, and is deleted when the last person leaves; a room found with storage and nobody in it is cleared by whoever arrives. Nothing about a party is logged or kept.

Rooms cannot be listed, so each tells the site's one PartyDirectory (src/party-directory.ts) when its people or song change, and the data tab lists the live ones: song, people, whether the host is there, since when. The directory is keyed by the room's Durable Object id, a hash that cannot be turned back into the room's name, so a party stays unlisted to join; an entry goes when its room empties, or six hours after its last report (a room lost in a crash).

A listed party can be joined from the list. The host decides, with the lobby's "show me here": a host whose tab is listed connects with listed=1, and the room then keeps its own name (the Worker's route hands the object its name in X-Jev-Party-Room, a header set by the Worker and never by the page) and reports listed to the directory. The directory gives a listed entry its object id, and GET /jev/parties lists them; GET /jev/parties/<id> asks that room (PARTY.idFromString) for its name, which it answers only while it has people and its host chose listing, and the page joins as a guest, exactly as with a link. The object id is the token rather than a new secret: it cannot be turned back into the name, the room answers for itself (so an ended or unlisted party gives nothing, with no second copy of the name to go stale), and an unlisted party's room keeps no name at all. A host's choice is read when it connects, and again the moment it changes: the host's page sends listed { listed, room? }, and the room lists or unlists itself and reports to the directory at once, guests staying in. To list itself mid-party the room needs its name, which it takes from that message only after proving it its own (PARTY.idFromName(room) equals the object's id), so the page still cannot give a room a name that is not its. Both routes are GET only, under PARTY_LIMIT.

The lobby

Every open page holds one WebSocket to /jev/lobby, the site's one LobbyRoom Durable Object (idFromName('lobby')), and says what its tab is doing; the jev panel lists everyone else's tabs, grouped by who, each with its song, whether it plays, and how far through it is, and "🎧 listen along" (website/src/jev/lobby.mjs, Lobby.jsx). The header's 👥 is how many pages are open.

Who a tab is comes from the Worker. The route reads the session (signedIn) and hands the object the account's display name, its id (for its profile; accounts are public, What is public) and a group key (the first 8 bytes of SHA-256 of lobby: and the account's id); signed out, no name or id, and the tab is its own group, which pages show as "a listener". The page never says who it is. The user's call (2026-09-27) was a more social site, so a tab is listed by default; "show me here" on the jev panel hides it (kept per browser), and a hidden tab is sent to nobody, only counted in the total.

The protocol, JSON text frames:

FromMessageWhat it does
roomwelcome { id, now, tabs, n }On joining: this tab's id, the room's clock, the visible tabs (the 200 most recently changed) and how many pages are open.
pagestatus { visible, song, edited, listener, title, playing, cycle, cps, length, max, section }What this tab is doing, when something changes (and every 30 s while playing). Each field is checked (statusOf): a site song's id, or a listener song's id and title (80 characters), the playhead and tempo only while playing, the song's end in cycles when known, and the section's name (40). The room stamps it with its clock.
roomtab { now, tab }, gone { id }, count { n }Another tab changed, left or was hidden, or the number of pages changed.
pagelisten { to }Listen along to a tab: it must be listed and playing a site song. The room asks that tab (listen { from }) and remembers who asked.
pageinvite { to, room }That tab's answer, once it is in a listening party on its song (one it started for this, or the one it is in): the party's room id, passed as join { from, room } to the asker only, and only to one that asked this tab.
pageping { c }Answered pong { c, s }, for the page's estimate of the room's clock.
roomrefused { why }The lobby is full (1000); the socket then closes with 4503, which the page does not retry.

Listening along is an ordinary listening party (above): the asker joins its room as a guest, from its own build, with the host's Jev, and party rooms stay unlisted, since only the asker learns one. Limits: 2 KiB a message, a burst of 20 per connection refilled at 1 a second, and LOBBY_LIMIT (20 joins a minute per visitor, namespace 1011) at the route. Each tab's status lives in its socket's attachment, so the room keeps no storage, and nothing about who listened to what is logged or kept.

Listeners' content

Signed-in listeners publish songs from the editor, revise them, give them a cover, comment on any song (the site's or a listener's) and pitch song ideas for Jev; anyone reads them. The user's decision (2026-09-25): Jev screens, then public. Nothing a listener writes is shown to anyone else until Jev has screened it fine.

RouteWhat it does
GET /jev/listeners/songsPublic listener songs, the most art first (unscored last), as the site's own are listed.
POST /jev/listeners/songs{ title, description, spec, code }: a new song → 201 { id, rev, status }.
GET /jev/listeners/songs/<id>One public song: its newest fine revision whole (code, spec), its fine revisions, author, art and cover.
POST /jev/listeners/songs/<id>/revisionsThe next revision, by the song's author only.
PUT /jev/listeners/songs/<id>/coverMultipart image and alt, by the song's author only; replaces the song's cover.
GET /jev/listeners/covers/<id>The image, only while the cover is fine and its song public.
GET, POST /jev/listeners/comments?song=<theme>/<song> or ?song=listener:<id>; posting { song, body }.
GET, POST /jev/listeners/pitchesPublic pitches, newest first (?sort=votes: most voted first), each with its votes, whether you voted (signed in) and the listener songs answering it; posting { body }.
PUT, DELETE /jev/listeners/pitches/<id>/voteYour vote for a public pitch, cast or taken back → { votes, voted }; one per account, at the votes' own VOTE_LIMIT per account, and never held by the pending cap (a vote adds nothing to screen).
PUT /jev/listeners/songs/<id>/pitch{ pitch: id | null }, by the song's author only: the public pitch the song answers. A site song says so in its SPEC.md (pitch:) instead.
GET /jev/listeners/mineEverything you wrote, each public, held (Jev's verdict and confidence) or pending (why, when Jev is asked again, how many attempts), and a fine revision's score still owed.

Sizes: code at most 64 KiB (the relay's cap, since the critic sends a song whole), a spec 32 KiB, a title 80 characters, a description 280, a comment or pitch 2000, a cover 2 MiB and its alt text 200. Writing is CONTENT_LIMIT (10 a minute per author) and covers COVER_LIMIT (3), reading BROWSE_LIMIT (240 a minute per visitor); an author with MAX_PENDING (20) items waiting on a screening writes nothing more until they are through, which is what bounds what one account can store unscreened while its budget is spent.

Screening. Every song revision (title, description, spec and the code whole, so its comments and strings are read with it), cover (its alt text, with the song's title), comment and pitch is one TypeSafe Choice, fine, spam or abuse, whose criteria are in src/screen.ts: harsh words about music are fine; spam is promotion, filler, and text that tries to dictate the verdict; abuse is harm to people and, for a song, code that does anything besides make music. It goes to TypeSafe the relay's way (upstream() in relay.ts, the same whyNot check first) but never through its answer cache. fine makes the item public; spam and abuse keep it off the site's lists, with the verdict (it is still on the data tab, as text: What is public). A verdict is final (a trigger refuses changing it): a held song's author publishes a new revision, which is screened again.

Scoring. A revision screened fine is scored with the site's own art rubric (src/art-rubric.mjs, the same questions as every site song), three runs averaged, as tools/critic records the site's songs: one call moves by up to 0.06 on the same code, which would reorder a list sorted by art. Until scored, the song is public and listed last.

Cost. Each screening is one call and each score three, charged to the author's daily Jev budget (JevBudget, which now charges several calls at once, or none when they do not all fit) before TypeSafe is asked, as the relay charges. An author out of budget can still publish: the item waits.

Pending and retries. The work owed is content_jobs, a row per item and task, made by the schema's triggers in the same statement that writes a pending item or screens a revision fine, and deleted by them when the verdict or score is written; so pending content always has its job, and nothing is left unscreened or unscored without one. A job runs when claimed (a lease of two minutes, taken in one UPDATE … RETURNING, so no two workers run it; a dead worker's lease expires and the job is due again). Three things claim jobs: the write itself (screened before the answer, so the author usually gets the verdict at once, and a song's score in waitUntil after it), the author's own GET /jev/listeners/mine (their due jobs, in waitUntil; the page asks again every 20 s while anything is pending), and the cron trigger every minute (triggers.crons), six jobs a run so a run stays within the free plan's 50 subrequests. A job that cannot run now is rescheduled with why: budget until the budget resets at 00:00 UTC; unreachable, upstream, unanswered and budget-unavailable after a backoff from a minute, doubling, to an hour (or TypeSafe's own Retry-After, if longer), for as long as it takes. Nothing is dropped as failed. wrangler dev fires no crons: dev runs with --test-scheduled, so curl localhost:4323/__scheduled runs a sweep.

Each attempt writes a jev.screen or jev.score event and log line: the kind, the outcome (the verdict, scored, or pending with why), TypeSafe's status and time, the attempt, and the confidence or art. Never the item, its text or its author.

Covers. The image's type is read from its bytes (PNG, JPEG, WebP or GIF; never SVG, never what the upload says), stored under a new id per upload in R2, and served with that type, nosniff, a sandboxing CSP and a day's caching. Jev reads text only, so it screens a cover's alt text, not its pixels. What guards the pixels instead: a cover is served only while its alt text is fine and its song has a public revision, so an account whose songs are all held shows no image to anyone; uploads are signed in and limited to 3 a minute; a cover is replaced, not added to, so each song has one. The gap stays: an image whose description is fine can show something that is not. Taking one down today is by hand: UPDATE covers cannot un-screen it (the verdict is final), so delete its row (DELETE FROM covers WHERE id = … through wrangler d1 execute --remote) and its object (wrangler r2 object delete jevstrudel-covers/covers/<id> --remote).

A listener's song runs in a sandbox, never in the page. Playing a listener's song evaluates someone else's code. On the site's own origin that code could do whatever the page can — for a signed-in visitor, post comments, publish, or spend their daily Jev budget with their session. So the page never evaluates it. It plays in an iframe with an opaque origin (sandbox="allow-scripts", never allow-same-origin) served with a strict CSP; the parent sends the code in by postMessage and receives back only playback state, logs and Jev decisions, each rebuilt from typed, bounded fields before anything reads it (website/src/jev/sandbox.mjs, sandboxProtocol.mjs, sandboxPolicy.mjs, sandbox/player.mjs). The boundary is the origin, so:

  • The frame has no cookies and no storage of the site's: document.cookie and localStorage throw, and it cannot reach the parent page or top. Proven headless: a published song that builds fetch/document/parent at runtime (so the name check below never sees them) reads no cookie, touches no parent, and posts no comment.
  • Its fetches carry Origin: null and no credentials. The Worker refuses every write (any method but GET/HEAD) whose Origin is not this site's, before any route runs (refuseCrossOrigin in session.ts), and signedIn returns null for such a request, so a sandbox call is never charged to or acted on as the visitor — it is an anonymous, per-address call, as signing out would be. The CSP's connect-src also limits it to the site's relay and the sample hosts site songs use.
  • The frame's own scripts and the sample pack are cross-origin to it, so they are served with Access-Control-Allow-Origin: * (public, no credentials): _headers on the deploy, the dev middleware in dev (sandboxPolicy.mjs, astro.config.mjs).

The name check and the screen are now defence in depth, not the boundary. whyNotCode still refuses, before Jev is asked, code whose identifiers reach the page's powers (network, storage, cookies, the document, evaluating strings, the ways back to the global object; every site song passes it, screen.test.ts), and the screening's abuse still covers code that does anything but make music. They give an author fast feedback and keep obvious abuse from ever going public, but the isolation no longer depends on them: code that slips past both still cannot escape the opaque origin.

Only untrusted code is sandboxed. The site's own songs are first-party, curated code and keep running in the page, where they can drive the visuals, listening parties and performance recording the sandbox deliberately lacks. The sandbox is for what the page did not write: a listener's song, and code arriving in a link (#hash/?hash). The editor tracks this provenance (sandbox.mjs): foreign code stays foreign through the visitor's edits and plays only in the sandbox; loading a site song, pattern or example makes the editor the page's own again. A listener song's link (/?listener=<id>) loads it without playing; a card plays it on the listener's click.

The residual gap is availability, not integrity: a song can still make its own sandboxed browser fetch a sample from an allowed host. It cannot read or act as the visitor.

The hosted MCP

Anyone with an account can connect their own AI (Claude Code, Claude's custom connectors, any MCP client) and make songs with it: play code in their own open tabs, read the site's and listeners' songs, publish and revise their own. The site's page for it is /ai/ (website/src/pages/ai.astro). The user's decisions (2026-09-24): OAuth 2.1 per MCP's authorization spec with the site's passkeys as the sign-in; only the signed-in user's own tabs; the code plays in the sandbox; publishing through the listener pipeline; per-user limits.

RouteWhat it does
POST /jev/mcpMCP (Streamable HTTP, JSON answers, stateless), with Authorization: Bearer. Without a token: 401 with WWW-Authenticate naming the resource's metadata.
GET /.well-known/oauth-protected-resource/jev/mcpRFC 9728: the resource, its issuer, its scopes.
GET /.well-known/oauth-authorization-serverRFC 8414: the endpoints below, PKCE (S256 only), CIMD support.
GET, POST /jev/oauth/authorizeThe authorize page: sign in with a passkey (or make an account) if needed, then the consent form; its answer redirects to the client with a code (and iss), or access_denied.
POST /jev/oauth/tokenCode and refresh-token exchange, and revocation (the library's).
POST /jev/oauth/registerDynamic client registration, for clients without Client ID Metadata Documents.
GET /jev/me/apps, DELETE /jev/me/apps/<id>The signed-in user's connected apps (grants), and disconnecting one: its tokens stop working.
GET /jev/me/tabs?session_id=A signed-in tab's WebSocket into its user's TabHub.

Who a token acts for. @cloudflare/workers-oauth-provider (1.1.0) is both the authorization server and the resource server here, one pair per origin (the issuer and resource are the request's own origin, as the passkey relying party is). The authorize page is this site's own origin, so the session cookie and passkeys are the site's: a visitor not signed in signs in or makes an account on that page, then allows or denies. The consent page shows the app's name (escaped; unverified unless it is a CIMD client, whose domain it shows), where access goes (a warning for localhost), and the scopes: play (the tab tools) and publish (writing, and reading one's own content statuses); public songs need only a valid token. The grant stores { userId, app } as its props. A tool without its scope answers the MCP scope challenge (403, insufficient_scope). Clients register by Client ID Metadata Document (preferred by the 2026 spec and by Claude; needs the global_fetch_strictly_public flag, which is set) or by dynamic registration. Access tokens last an hour; a grant lasts 30 days past its last refresh; connecting the same app again replaces its grant.

Only their own tabs. A tab joins TAB_HUB.idFromName(userId) only through /jev/me/tabs, which takes the session cookie from this site's own pages (a browser's WebSocket always sends its page's Origin, and signedIn refuses any other), and the MCP handler reaches only the hub its token's user names. There is no path from one user's token to another's hub; hosted-mcp.test.ts and tools/mcp-e2e/ hold it. A hub keeps at most 8 tabs (the oldest goes), and uses the Hibernation API, so an open tab between commands costs nothing.

The AI's code plays in the sandbox. The tab puts it in the editor as foreign code ({ ai: app }, website/src/jev/myTabs.mjs) and plays it only in the opaque-origin iframe listeners' songs play in (A listener's song runs in a sandbox, above), never in the page: code a prompt-injected agent was talked into writing cannot read the page, post, publish or reach the session. Its logs and state come back through the sandbox's protocol, rebuilt and bounded, then again by the hub (tab-hub-core.ts).

Whose budget. Jev's calls from the AI's code are charged to the listener's own daily budget: the page makes them for the frame with its own session (credentials: 'same-origin'), exactly as the listener's own Jev calls. The frame never holds the session, so nothing it could exfiltrate is worth anything; the worst it can do is spend the budget the listener gave this app to spend, at most ASKS_PER_MINUTE (30) a minute. Anonymous calls were the alternative, and wrong: they would let a connected app spend the address's shared allowance instead of its own user's, and one listener's agent would starve everyone behind that address. A listener's song, which the visitor did not ask anyone to write, stays anonymous as before. Screening and scoring what the app publishes are charged to the author as for any listener content.

Limits. MCP_LIMIT (namespace 1010): 120 requests a minute per user, every method counted; an agent iterating plays, reads logs and plays again a few times a minute. A request body is at most 384 KiB (a publish's code and spec, JSON-escaped: content.ts's own cap), code to play 64 KiB, a tab's reply 256 KiB and 500 log lines of 500 characters. Publishing also meets content.ts's CONTENT_LIMIT and pending cap, since it is the same writeSong. Registering, authorizing and token swaps (/jev/oauth/*) share the passkey ceremonies' per-visitor AUTH_LIMIT, since each writes to KV. Browser pages of other origins are refused before any route (refuseCrossOrigin), which is also what MCP's transport asks of a server against DNS rebinding; MCP clients that are not browsers send no Origin.

The tools both MCPs share. Nine tools are the dev hub's and the hosted MCP's alike (shared-tools.json, run by shared-mcp.ts), so the proxy's tool list has them in either environment. Most are a command to the tab (tab-hub-core.ts's SharedCommand), which the tab answers from what its panel shows (website/src/jev/tabTools.mjs), rebuilt from typed, bounded fields before it is read, in the dev hub as in the hosted one:

ToolScopeWhat it does
list_soundsplayThe sounds tab: each sub-tab (samples, drum-machines, synths, wavetables, user, imported), a filter, pages of at most 200, each sound's variants and how to play it. Read from the tab, since imported sounds live only in that browser.
ask_jev_soundplayJev picks a sound for a description: a Choice over the tab's sound names, asked by the page through the relay (charged as the page's own Jev calls, to the signed-in listener). Past 255 candidates it asks in banks of at most 255, several to a request, then among each bank's three likeliest, as Krug's JevLM does (website/src/jev/soundPick.mjs); the answer says its odds are among those finalists. At most 16 banks; more must be narrowed.
api_referencenoneThe reference tab's functions (/jev/reference.json, built from jsdoc's doc.json): search, or one whole. No tab.
get_settings, set_settingsplayThe settings tab's settings mcp-settings.mjs allows: theme, font, keybindings, the panel, editing options. Never the prebake script (code the page runs), sync, when or how code is evaluated, or where audio goes. Checked in the Worker and again in the tab.
play_songplayA song by id, as its card plays it: a site song in the page, looked up in the page's own build; a listener's fetched and played in the sandbox.
ask_jev_songplayThe mood picker (picker.mjs's pickByMood), one Jev call from the page; with play, its pick plays.
reactplayThe booth's 🔥/😴 for the section playing (press.mjs): Jev hears it; a site song as written counts it in its tally.
votenone"Do you agree with Jev?" through votes.ts's own rules (castVote), at VOTE_LIMIT per account (local in dev), and whether the pick agrees with the catalog's scores. No tab.

The hosted MCP adds comment (publish: content.ts's commentAs, the page's rate and pending cap, screened by Jev) and get_logs with source: "console", the page's console tab: what a site song played in the page logged, as the listener sees it (the dev hub's get_logs already is the page's console). Settings are play, not a scope of their own: only how the editor looks and types can be changed, each undone in the settings tab, and an app that may replace the editor's code and play sound in the tab is trusted with more than its font (hosted-tools.ts). A tab has 60 s for a command that asks Jev, 30 s for a play, 15 s for the rest (replyMs).

KV on the free plan allows 1,000 writes and 1,000 lists a day. An authorization writes a handful of records, a refresh two, a dynamic registration one (Claude registers again on every fresh connection, which CIMD avoids); listing a user's apps is one list. Workable at today's scale; the answer cache's writes share the same allowance.

Logs

The relay logs one structured line per call (event: "jev.relay"): outcome (refused by the relay, forwarded to TypeSafe, cached from the answer cache, or unreachable), status, upstreamMs (until TypeSafe's response headers; null when the relay refused the call itself), requestBytes, colo (the Cloudflare data centre that ran it), and the page's own timing, which ask.mjs sends as headers the relay never forwards: sectionInS, how many seconds before its section began the call went out (negative once it had begun; null before a song starts), and attempt (0, then its retries). So a late answer shows whether the call went out late (small sectionInS) or TypeSafe was slow (large upstreamMs).

Workers Logs (observability in wrangler.json) keeps them, queryable in the Cloudflare dashboard under the Worker's Observability tab; wrangler tail jevstrudel --format json (through the deploy's 1Password-fed wrangler) streams them live. Cloudflare's own per-request invocation logs are off: they record the request's metadata, which is the visitor's.

The same fields go to Analytics Engine as a jev.relay event, where they can be counted and charted over time: nix run .#events -- jev.relay. The account is never in either: a signed-in call's line and event are the same as an anonymous one's.

Type checking

wrangler bundles src/ with esbuild, which strips the types without checking them, so a type error deploys. nix flake check catches it: its worker-types check generates the Workers runtime types with wrangler types and runs tsc -p worker. To run it in the devshell, from worker/: wrangler types --include-env=false once (it writes the gitignored worker-configuration.d.ts), then tsc -p .. The check and the deploy both use strudel.worker (nix/strudel.nix): worker/ with a node_modules of exactly its dependencies, so a copy of it bundles alone.

The key comes from 1Password (JEVSTRUDEL_TYPESAFE_API_KEY): as a Worker secret on deploy, and through the process environment in dev (tools/dev/worker.mjs). It is never written to a file.