Published arrangements never fit a real school ensemble: six clarinets and no trombone, a first-year trumpet who tops out at written D5, a key the class can actually read. So directors rewrite by hand, part by part, checking every range and every transposition.

Stand hands that work to an agent — and puts the checking in the page, at the moment the agent writes.

agent  →  page    put a D6 in the trumpet, bar 1
page   →  agent   No. Written E6 (sounding D6) is above the elementary
                  trumpet range F3–C5. Drop it an octave.
agent  →  page    then G4
page   →  agent   Written.

Nobody typed a note in between. The page refused, the agent read the reason, and it corrected itself.

What it does

Load a public-domain melody, then say who is playing: "two flutes, clarinet, alto sax, trumpet, trombone, snare — fifth grade." Your agent writes each part straight into the score through this page's tools. The page renders real notation (conductor score and transposed part views), plays it, and checks every note it is asked to write:

  • Range — a note that player cannot reach at that level is refused, with the bar, the note in both spellings, the allowed range and the fix.
  • Transposition — the agent never does the arithmetic. Tools take and return sounding pitch; the page derives every written part, so a clarinet reads D while the score is in C.
  • Bar arithmetic — a bar that does not add up never lands.
  • Taste — the one thing a model cannot judge, because it cannot hear. ask_human puts two candidate endings on screen with a ▶ on each and waits for the person to listen and choose.

You drag notes by hand, lock the bars you like (write_part skips them and says so), mute parts, and export MusicXML, one file per player, or MIDI. Nothing leaves the browser unless you export it: the Worker only serves static files.

Why this needs WebMCP

The page holds what the agent cannot fake. Notation, sound, per-instrument ranges at four teaching levels, transposition. That turns the tool boundary into a music guardrail — the page can say no, precisely, and the model self-corrects from the error. An agent driving a UI through screenshots gets none of that, and a backend MCP server cannot show a person the score it is changing.

The agent cannot hear; the person can. ask_human is not a confirmation dialog bolted onto a chatbot. It is the one judgement a language model genuinely cannot make, handed back by design.

One score, two hands. Locks, hand edits and tool calls share the same live document and the same timeline. Nobody copy-pastes MusicXML through a chat window.

How I built it

  • WebMCP: document.modelContext.registerTool with one AbortController per surface. Three phase surfaces — empty (4 tools), arranging (18), exported (3). readOnlyHint on reads, descriptions under 500 characters, bars 1-based at the API boundary and 0-based in the store, every failure written so a model can act on it, play reporting needs_gesture when the browser has not started audio yet. No tool can export, print, lock a bar, or answer for the person. Those are buttons.
  • Music core (pure TypeScript, unit-tested): pitch and key arithmetic; a 23-instrument table with clef, transposition and a sounding range per level; the checker (bar length, unknown pitch, range, level-appropriate rhythm and key, leaps, voice crossing, parallel fifths); a rule-based harmonizer; MusicXML with correct per-part <transpose>; MIDI; part extraction.
  • Page: React 19 + Vite. VexFlow for the conductor score and transposed part views, with click-to-edit, lock badges and a playback cursor. Tone.js for per-section timbres and A/B variant preview. One store funnels every mutation — agent, hand, or demo — through the same validation, locks, undo and log.
  • Tests: 104 unit tests across the music core; 8 Playwright end-to-end tests driving every tool through a faithful document.modelContext stand-in, including the refused write, the locked bar, the human's A/B choice and export-is-human-only. All pass against the deployed site.
  • Judge mode: ?judge=1 runs the whole loop with captions and no agent.

Challenges

Making refusal useful instead of annoying: the error has to name the bar, both spellings of the note, the allowed range and a fix the model can act on. Transposition correctness got its own verification pass — written = sounding + transposition, but MusicXML's <transpose> is the negative of that, and six instrument ranges turned out to be entered in written pitch where sounding was required. And keeping a score readable while an agent rewrites it live.

What I learned

The most valuable thing a page can give an agent is not more actions. It is the ability to say no, precisely. And the most valuable thing an agent can give a musician is a first draft it knows it cannot verify alone.

Try it (no login, nothing to install)

  1. Open the live URL in ChatGPT's built-in browser (model Sol or Terra).
  2. "Load Ode to Joy, set up a beginning band — two flutes, clarinet, alto sax, trumpet, trombone, snare — at elementary level, and arrange it."
  3. Watch for a refused write; the agent rewrites it an octave lower. Then "play bars 1 to 4" (the page asks for one click first — browsers only start audio on a gesture).
  4. "Give me two options for the last two bars and let me hear them." The card waits for your ear.
  5. Lock a bar with 🔒, then "rewrite the trumpet part" — the locked bar comes back untouched.
  6. "Show me the trumpet part alone", then export Parts, MusicXML or MIDI with the buttons.

No agent? Press ▶ Watch a 60-second demo on the start screen, or add ?judge=1 to the URL.

What's next

MusicXML import so you can bring your own tune, real instrument samples, per-player practice tracks, and a shared link so a section leader's agent can suggest changes to one part.

Built With

Share this project:

Updates

Submission history