For agents, on top of README.md, which they read first.

Notes for agents

songs.mjs runs at build time only. Its import.meta.glob paths are relative to this file and reach the repo-root songs/; the browser never imports it. Anything the welcome tab needs must be in the JSON loadSongs() returns.

Untrusted code plays only in the sandbox; never setCode it into the page. A listener's song and code from a link are someone else's, and evaluating them in the page would run them on the site's origin with the visitor's session. Load them with showForeign/loadListenerSong (which mark the editor foreign), never editor.setCode — a listener song carries its code as foreignCode, not code, exactly so no path that plays a site song can pick it up. guardEditor (sandbox.mjs) makes editor.evaluate/toggle/block-eval on foreign code go to the sandbox and the page's own repl.evaluate refuse it. Trusted loaders (a site song, a pattern, an example, the dev hub's MCP play, which is the repo's own Claude on localhost) call setOwnCode, which returns the editor to the page's own. The hosted MCP's play (myTabs.mjs) is not trusted: it is showForeign(…, { ai }), and never setOwnCode. The MCPs' play-song (tabTools.mjs) reaches the page only with a site song found by id in readSongs(), this page's own build, exactly as its card; a listener's song it plays through loadListenerSong, foreign. Keep tabTools.mjs taking ids and data, never code. The boundary is the opaque origin, not the name denylist (worker/README.md, "A listener's song runs in a sandbox").

Whose budget a frame's Jev calls spend is fixed when the frame is made (charge, from its provenance: only { ai } is charged, to the listener who connected that app). Provenance changing destroys the frame, so a listener's song can never inherit a charged frame. Keep it that way; never decide per call.

Everything from the sandbox is untrusted data. The frame runs a listener's code, so every postMessage it sends is rebuilt by sandboxProtocol.mjs (fromFrame) from typed, bounded fields before the page reads it, and its log text is escaped before the console (which renders HTML). Never pass a frame message's field to dangerouslySetInnerHTML, eval, or on as-is; add a new field to the protocol's allowlist, with a bound, and a test in sandboxProtocol.test.mjs.

The sandbox page needs <base href="/"> and its CSP allows it. Songs load samples from a root-relative URL (samples('jev-samples/strudel.json')); the page is at /jev/sandbox/, so without the base the URL would resolve under that path. base-uri 'self' in the CSP (sandboxPolicy.mjs) is what lets the <base> tag take effect — 'none' silently blocks it, and samples 404 (2026-09-26). AudioWorklets need worker-src 'self' blob: data: (the meter is a blob, superdough's dough worklet a data: URL in dev).

init's deferred load must not clobber a chosen song. useReplContext's initCode().then(...) runs after an await, by which point the visitor may have played a song (a card rewrites the URL to ?listener=). It returns early if editor.code !== initialCode, or it would tear down a listener song already playing in its sandbox (2026-09-26).

Playing a song stops first. WelcomeTab awaits editor.stop() before setCode/evaluate because songs count cycles from 0, and only the scheduler's stop() resets the count (packages/core/cyclist.mjs); pause() does not.

Evaluations run one at a time, and the newest wins (packages/core/repl.mjs, serialized). One waiting in beforeEval (the welcome song waits there for the first click) holds the next until it settles, and a superseded one stops at its next await without setting its pattern or reaching afterEval, so the booth's one draft (boothStore.beginEval/commitEval) only ever holds one evaluation's views. A stop() or newer start() during beforeStart cancels that start (cyclist.mjs). The corollary: nothing awaited in beforeEval/beforeStart may wait forever, or every later evaluation waits with it; a guest's wait for the host's clock ends on leave and on stop (releaseClockWaiters, party.mjs). Overlapping, a first click on a card played the welcome song instead, with an empty booth and no autoplay (2026-09-27).

Every query of the pattern moves a jev()'s playhead, the Drawer's frames included, so nothing may query a song at a cycle it is not at. Strudel's Drawer kept lastFrame from the song before and queried the next song from that song's end: after autoplay every section counted as begun and played its fallbacks ("answered after the section began … asked at cycle 67.5"). packages/draw/draw.mjs now resets it on start and never queries backwards (2026-09-27).

Jev's text goes in single quotes. The transpiler turns double-quoted AND backtick strings in REPL code into mini-notation, so choice("Pick a move", …) arrives as a Pattern; jevCore.mjs throws at evaluation saying so. Backticks were assumed safe and were not (2026-09-24).

Never block inside a query. Queries run on the scheduler's timer; a modal or a synchronous wait there stalls audio. Jev calls start from a query but are never awaited in it.

A Jev answer is untrusted until checked. jevCore.mjs accepts only an own key of the choice's criteria (Object.hasOwn), so neither a stray label nor "toString" can select anything; everything else plays the fallback and logs why.

The booth UI shows the last successful evaluation. booth() only drafts its view into boothStore; useReplContext's afterEval commits it. Committing from booth() itself would show a booth whose code failed to evaluate while the previous song keeps playing.

A new deploy waits for the visitor. The service worker is in prompt mode (astro.config.mjs), and src/pwa.ts switches to a waiting deploy by messaging the worker itself. Don't go back to registerSW's updateSW(true): it reloads only on a Workbox 'controlling' event flagged as an update, which a deploy found by the periodic check is not, so the button did nothing (2026-09-24). release-notes.json stays out of the precache so the modal reads it live.

form keeps the written order playable. Every section's next must include the section after it as written: that is the fallback when Jev can't answer, and validate refuses a form without it or without an ending.

jev() is TypeSafe's request and nothing else. It takes state and questions only, built with the SDK's choice/score/noul (same signatures as @typesafe-ai/sdk). Anything Jev does not know (when to ask, fallbacks, a form) is Strudel's: .every(cycles, { fallback }) and walk(...). Don't add options to jev(); the user rejected the earlier config-object API (.jev({ every, about, form, intensity, moves })) for hiding Jev behind an API of our own (2026-09-24).

Accounts are optional, always. Anonymous use must stay exactly as it is: no feature may require signing in except the account's own (its budget, its radio history). account.mjs treats a missing or failing /jev/auth/me as signed out, so a static host without the Worker still works.

ask.mjs must not import account.mjs. The budget reaches the header through onBudget listeners, and account.mjs resets the back-off (resetBackoff) when who is signed in changes, since a back-off earned against one account's budget or the address's rate limit does not apply to another. ask.mjs is also loaded by the Node tests, which should not pull in the browser's WebAuthn library.

A party guest's start is set in partyStarted, last in beforeStart. The cyclist's first tick begins at scheduler.lastEnd (packages/core/cyclist.mjs; stop() zeroes it), so a guest sets it to the host's cycle right before the first tick. Anything awaited after partyStarted in beforeStart delays the start without moving that cycle, and the guest falls behind by exactly that long. Keep it last.

Follow mode's wire is jevCore's, not the party's. shareDecision, watchDecisions and followDecisions know nothing of rooms or sockets. A view's wire name is its declaration order, the same key replay uses (view.index), and a shared decision's status, values and from are a recorded segment's fields, so a party's decisions are a performance as setReplay reads one. The difference is timing: replay has every segment before the song starts and settles inside request(); follow mode's arrive while it plays, so they go through receive() and can be late. Only the same code keeps the declaration order, which is why a guest plays the song from its own build by id and never code the host sends.

The lobby shows other people's tabs as data, and plays nothing of theirs. Everything in lobbyStore.tabs is another page's report (checked by worker/src/lobby-room.ts, then cut to known fields by tabOf): render it as text only. "Listen along" only ever joins a listening party (joinParty), which plays a site song from this page's own build by id; "play it" plays a site song found in readSongs(); a listener's song is a /?listener= link, which loads it foreign, into the sandbox. Never let a lobby message carry code, and never let one start playback in this page by any other path. A tab asked to be listened to starts a party only on its own site song (invite), and sends its room once it is in it, because the room turns away a guest who arrives first.

A host's re-evaluation mid-song is not shared. Follow mode is set on the views of the evaluation that starts (partyBeforeStart); a host that re-evaluates while playing gets new views that are not watched, so guests keep playing their fallbacks until the host's next start. Same for a host who edits the song: guests hear it as published (the page says so).

jev.mjs exports are the REPL's globals. repl/util.mjs evaluates every export into song scope, so export only what songs call; test hooks live in jevCore.mjs.

A replay asks Jev nothing, ever. In replay (setReplay) request() still computes the form's allowed sections and allow's options, then settles from the recording instead of calling decide; it never falls through to the relay, even for a segment the recording lacks (that plays the fallbacks). jevCore.test.mjs and performance.test.mjs count calls to prove it. Recorded values are the ones that played (after sampling), so a replay never samples again.

A listener song's take is recorded and replayed through the sandbox only. The recorder reads the frame's booth (sandboxed, its jevs rebuilt by sandboxProtocol.mjs; a booth whose jevs were refused is not recorded) and the song's code from sandbox.foreign().song, never from the frame. Its replay reaches the frame as the play message's replay (cleanReplay), armed by loadListenerSong for that song id only: never setReplay it in the page, and never let a replay armed for one song reach another's play (replayFor).

Jevs in a performance are matched by declaration order (view.index, from boothStore.adopt). Keep adopt returning the draft index; a song's jev()s must share one cadence to be recorded (performanceOf returns null otherwise).

listenersSoFar names only the song's own sections. The tally is anyone's writing (the Worker checks only a name's shape), so jevCore keeps names the form has (or section numbers, without a form) and bounded counts, at most LISTENERS_MAX.