The v2 MCP Chat Battery, Explained
Thirty test cards — twenty-eight automated, two by hand — that play through the entire product against live production on demand, then grade the conversation twice.
How a run works
The battery is an on-demand mystery shopper. An AI pretends to be a customer, uses the real product the way a real person would — typing messages into a chat — and then two graders review the tape. Nothing is mocked: it hits the same live server, database, and billing code an actual reader hits.
It is one of two tiers of testing. A separate, fully deterministic contract suite checks the server itself on every deploy — exact payloads, exact error shapes, no AI in the loop — and that suite is what blocks a bad release. The battery watches the softer thing the contracts can’t: how the whole conversation feels when an AI drives it. Its results are monitored, never a deploy gate.
Here is one run, start to finish:
A script says what a user would try
Each of the 28 test cards holds the exact messages a user would type (“I want to write a story about…”, “Read me Pitch 1”) and what a good experience looks like in response.
tests/v2/battery/battery.yamlAn AI plays the customer
A Claude model opens a chat session with the Writer’s Room connector loaded — exactly what a real user’s Claude looks like — and types the scripted messages, one conversation per test.
actor: claude-sonnet-4-6It uses the real product
The chat drives the live production server under two real accounts — one free, one paid subscriber — so stories get written, limits get hit, and billing rules fire for real. Where a test needs a specific starting point (say, an account that has already hit its daily safety stop), the runner plants that state in the database first and removes it after.
writersroom.studio/v2/mcpEverything goes on tape
Every message the “user” sent, every reply Claude gave, and every behind-the-scenes action the server took is recorded as one transcript per test.
Two graders review the tape — both must pass
A report card comes back
Pass or fail per test, with the reason for every failure, printed straight into the CI job log — and the test data is cleaned out of the database.
scripts/run_mcp_battery.pyRun modes
| Mode | When | Tests |
|---|---|---|
| Smoke | On demand — one button in CI | 5 tests — T2 T3 T7 T10 T24: the cheapest end-to-end proof that story creation, pitches, commit, episode writing, and subscriber auth all work |
| Full | On demand — same button, full depth | All 28 automated tests, in order, as one continuous playthrough |
| Manual | By hand | T0 (Desktop connector UI) and T29 (admin audiobook path) — not reachable headlessly |
Three tests (T25–T27) additionally require live tier enforcement on the server — and T25 the full pricing flip. Until then they are skipped loudly — their own row in the report, never a silent pass — and they activate automatically the moment the switches flip.
The architecture — who runs where
The battery spans three separate clouds, and the separation is the point. GitHub Actions is the conductor and the only place holding privileged credentials. The simulated user lives in Anthropic’s cloud as a Claude Managed Agent — not on the GitHub runner — with nothing but an ordinary account login. And everything user-visible actually happens on the production server: the battery observes the product, it never simulates it.
GitHub Actionsthe conductor — ubuntu runner, holds all secrets
main → database migrations → Cloudflare deploy. Serialized — one deploy at a time, so production always ends on the newest commit.workflow_dispatch) — monitoring, not a gate. Never checks out untrusted code — it only drives the already-deployed URL. Persistent failures escalate to a rolling “Battery watch” issue.run_mcp_battery.py — seeds fixtures, creates the actor sessions, feeds them the script, runs the code checks, calls the judge, prints the report.Anthropic's cloudthe simulated user — Claude Managed Agents
claude-sonnet-4-6 running on Anthropic’s infrastructure with the Writer’s Room connector attached — the same setup a real user’s Claude has. The runner only posts messages and reads the event stream (the “tape”).claude-haiku-4-5 via one plain, temperature-0 API call per test. No tools, no session — it can only read the tape and grade.Productionthe product under test — Cloudflare + Supabase
writersroom.studio/v2/mcp into the container where every tool actually executes: pitches, episodes, tier checks, the reroll confirm gate, the server-rendered TOC./v2/admin/battery dashboard.Two consequences of this shape are worth naming. First, realism: because the actor is a genuine Claude session in Anthropic’s cloud using the public connector, a battery pass certifies the actual user experience — connector loading, tool permissions, auth, and all — not a lab replica of it. Second, containment: the actor holds nothing privileged. If a test needs unusual account state, the runner plants it in the database from the GitHub side; the actor itself can only do what any customer could do.
How a test is graded
Every test is graded two ways, and both must pass. The division of labor is deliberate — and it is the product of several rounds of failure triage.
Deterministic checks — pure code
- tool_calledthe right tool actually fired (or, for tool_not_called, didn't)
- tool_result_no_errorthe call succeeded at least once
- tool_result_errorthe call failed with exactly the expected code (tier_blocked, admin_only)
- tool_result_field / _lenexact payload pins (candidate counts, masked limits) — retired to the contract suite, which owns payload shape now
- agent_text_no_matchregex leak scan — no internal field or tool names in anything shown to the user
- orderchoreography: this call happened before that one
LLM judge — haiku, rubric per test
- rubricthe PASS condition: voice, craft, choreography, plain-language quality
- red_flagsconcrete fail signals the judge must quote from a CLAUDE line if it claims they happened
- user_facing_onlythe judge sees ONLY user/Claude lines — tool plumbing is hidden so it can never be misquoted as a “leak”
- scope: fullsome rubrics grade the whole conversation, not just this test's slice
Why the split matters. The internal-identifier leak scan used to be judge work — and the judge twice failed the run by “quoting” leaks that appeared only in machine context, or nowhere at all. It is now a deterministic regex over Claude’s user-facing lines: underscore identifiers cannot occur in natural prose, so the scan has zero hallucination surface. With user_facing_only, the judge never even sees a tool payload it could misgrade.
The story journey — T0 to T20
Before any story exists, two cards check the front door. T0 verifies the very first thing a new user ever sees — the Desktop connector loading and its one-time “Always allow” permissions walkthrough. That’s a click-through UI with no headless equivalent, so it’s the one entry here checked by hand. T1 then asks the blank-slate question every new user asks — “What can I do here?” — and requires an answer in user terms (“try saying: ‘I want to write…’”), never a list of internal machinery.
From there, the heart of the battery is one continuous playthrough. Tests run in order and share state: the lighthouse story started in T2 is the one exported in T17 and abandoned in T20. The premise is fixed — a lighthouse keeper on a remote island starts receiving letters addressed to a keeper who died there a century ago — deliberately genre-flavored so the three writers’ voices are distinguishable.
Membership tiers — T19, T24–T27
The membership model has one paid axis: privacy. Free accounts write unlimited stories that are public once the pricing flip is live; the paid plan keeps every story private. Neither plan sells a volume cap — a quiet daily safety stop (deliberately never advertised, and never numbered here either) applies to every account. The deterministic contract suite pins the exact payloads; these battery cards grade how the actor talks about all of it. They run last on purpose: their fixtures mutate account state, and T21’s whole-run judge shouldn’t see them.
Free accountunlimited stories · public once the flip is live
tier=free with both limits null; the judge requires plain language — no invented caps, no numbersstart_story creates the story public, and Claude must deliver the ownership + privacy disclosure loudly, before the pitches — not buried after themnext_episode blocks, and the relay must be plan-free: “today’s writing limit is reached, back tomorrow” — no tier names, no numbers, no upsell, and no episode written anywaySubscriber accountunlimited private stories
tier=subscriber and both limits null; the internal safety ceiling must NEVER be mentioned to the userEvery card, in brief
Onboarding & help
T0Connector loads + first-run onboardingmanual
The Desktop connector’s “Always allow” permissions walkthrough — a UI flow with no headless equivalent. Automated runs resolve it with an ungraded session preamble; this card is verified by hand.
T1Help / what can I do
"What can I do here?"The answer must be in user-action terms with example phrases to try (“try saying: ‘I want to write…’”) covering starting, pitches, continuing, and length. A bare list of internal tool names, or a flow diagram, fails.
The audition
T2Start a storysmoke
"I want to write a story about [the lighthouse premise]"Code asserts start_story returned exactly 3 candidates. The judge asserts all three writers were listed by name and style before any pilot was written, and that the actor went straight into the audition instead of brainstorming natively — the product’s “always show the lineup, never filter for the user” directive.
T3Hear Pitch 1smoke
"Read me Pitch 1."A real pilot episode in the first writer’s voice — prose, not a synopsis — saved silently, followed by exactly one two-option question: lock this writer, or hear another? A brief “your draft is saved” is fine; asking permission to save, or narrating save mechanics, is not. Choreography checks run over the whole transcript because a good actor often pre-fetches Pitch 1 during the start turn.
T4Read another pitch
"Read another pitch." · "And the third one."Pitches 2 and 3 must read in voices clearly distinct from Pitch 1 and from each other — the entire thesis of the product is that the writers aren’t interchangeable. Claiming an earlier draft was lost also fails.
T5Reroll the lineup
"None of these are working…" · "Yes — toss the saved drafts."A two-turn destructive-op test. The first message must produce a warning that rerolling clears the saved drafts (the tool returns needs_confirmation when drafts exist); only the explicit go-ahead triggers the reroll. The fresh trio must not overlap the original three, presented as a lineup of names + styles — reading only the first new pitch in full is the expected choreography.
T6Browse the writers
"Show me the other writers… any noir voices?"A roster relay with a filter the roster can’t satisfy. Full credit for an honest no-match plus nearest-adjacent voices (dark, mystery, crime-adjacent); the failure modes are ignoring the ask entirely or showing internal persona IDs.
T7Lock in a writersmoke
"Lock in Pitch 1 — let’s run with it."Code asserts commit_persona returned status: active; the judge asserts plain-language confirmation that the pilot is now Episode 1. The commit is idempotent per slot — if a network retry re-sends it, the tool replays success instead of erroring, which once broke a smoke run.
Planning
T8Break the story
"Where is this story going? Lock in the plan."The arc must be laid out — central arc, season shape, setups/payoffs or character arcs — and saved as the plan. Internal tool names or YAML field dumps fail.
T9See the outline
"…without spoiling what’s ahead." · "Now show me the full plan."The first reply must fog everything past the user’s reading position; the second, on explicit request, reveals the full plan. A future beat leaking into the no-spoilers view is the fail.
Writing & steering
T10Write the next episodesmoke
"Great. Write the next episode."Episode 2 must continue in the committed writer’s voice with continuity from Episode 1 — consistent names, no contradicted facts, no drift into generic narration — and be saved without error.
T11Adjust episode length
"Make the episodes shorter — about 700 words." · "Now write the next one."Graded on direction, not precision: an episode that undershoots (~500 words) still passes. The failure mode being tested is the next episode staying as long as before after the change was acknowledged.
T12Remove the length cap
"Drop the word-count limit — let the writer write what each scene needs."“Off” must be understood as a distinct state, not just another number — confirmed in any plain-language form, with some signal that it’s reversible.
T13Adjust the season length
"Cut the season down — wrap the whole arc in 8 episodes total."Deliberately phrased as a change to an unusual number: the actor’s own story map routinely plans ~10 episodes, and asking for the value already on the plan let it answer “that’s already the plan” without calling the tool. Claiming past episodes get rewritten also fails.
T14React / course-correct
"Tighter please — and lose any side-characters that aren’t pulling weight."The note must be captured for the next episode without announcing capture mechanics. “Logging your feedback signal” is an instant fail.
Reading & export
T15List episodes (table of contents)
"What episodes do I have so far?"A clean, ordered number/title/one-line-summary table. The server renders the table itself and hands it over as display_text for verbatim relay — adopted after three straight runs proved prompt nudges couldn’t make an actor build a table reliably.
T16Re-read an episode
"Read me Episode 2 again."The full text, verbatim, with title and hook — never a re-paraphrase or summary.
T17Export the whole story
"Put the whole story together so I can save it."A readable bundle of every episode plus characters and threads, with a mention that it can also be read on the web. Partial exports or raw JSON fail.
Sessions, failure & account hygiene
T18Resume across chatsfresh session
"What was I working on?" · "Continue the lighthouse one."Runs in a brand-new session to prove statelessness. Stories listed by premise (never raw IDs); naming one resumes it with context. Volunteering other stories unprompted violates the “one story at a time” directive.
T19Check my plan / limits
"What plan am I on, and how many episodes do I have left today?"The judge requires the plan explained in plain language — free tier, unlimited writing, how to upgrade. The exact payload (tier, limits masked to null) is pinned by the contract suite; any numeric cap presented as the plan’s limit is a red flag here, because no tier advertises one.
T20Abandon a storyfresh session
"I’m done with this lighthouse story — abandon it."Ordered last so every earlier test still has a live story. Confirmation must correctly describe the semantics: recoverable via support, not permanently deleted.
T22Graceful failure modesfresh session
"Skip all the pitches — just write Episode 5 right now."Deliberately provokes a wrong-status error on a brand-new story. The decline must be plain-language (“you need to pick a writer first”); a raw wrong_status / ep_num_skip code or stack trace shown to the user is the fail.
T23Check my costs
"What has my writing here cost so far?"Costs are explained as informational — what the platform spent serving the account, plus a ballpark token estimate — never framed as a bill, an invoice, or an amount owed, and never conflated with plan pricing.
The whole-run guardrail
T21Behavior & craft guardrails
(sends nothing — grades the entire accumulated conversation)Three fronts. (1) No internal leakage — a deterministic regex scan over everything Claude showed the user: no tool names, field names, or rejected=false. Pure code; the judge is not consulted. (2) No IP contamination — no living authors, named published works, franchises, or copyrighted characters in any pitch or episode prose (the platform’s own writer-roster blurbs are carved out as platform copy). (3) No tell-don’t-show crutches — the constructions that mark AI slop: “moved with the practiced precision of someone who…”, “it was the kind of X that…”, “smiled the smile of someone who…”, “the particular quality of X that Y”, “the instinct of X operating…”. This craft half is the only genuinely content-variable grading left in the battery.
Membership tiers
T24Subscriber plan statussmoke
"What plan am I on…?" (as the subscriber account)The smoke-mode canary that subscriber auth and tier resolution work. The judge requires the plan presented as paid with unlimited writing; the exact payload (tier, limits masked to null) is pinned by the contract suite. The internal daily safety stop is masked by the server and must never be surfaced to the user.
T25Free tier — new story is public, disclosure relayedflip-gatedfresh session
Once the pricing flip is fully live, a free-tier story is created public and the server’s response opens with a loud disclosure. Claude must relay it prominently — before or alongside the three pitches: readable by anyone in Rooms, may help improve the writers, private writing is the paid plan. Skipping it, burying it after the pilot prose, or vague-ing it into “some sharing settings apply” fails; so does refusing to start the story instead of disclosing.
T26Daily safety stop blocks genericallyenforcement-gatedfresh session
"Continue my night courier story — write the next episode now."Fixture exhausts the account’s daily safety stop and seeds a real continuable story (committed writer + Episode 1), named in the message — both scars from runs where a bare synthetic story or an unnamed “my story” dead-ended the actor into clarifying questions. next_episode must block with tier_blocked, no episode gets written — and the relay must stay generic. The stop is a silent abuse valve on every plan, so tier framing, numbers, or an upgrade pitch in the relay is the actor inventing policy, and each is a scanned failure.
T27Subscriber — not blocked at scaleenforcement-gated
Fixture: four active stories and heavy same-day episode usage on the subscriber account. The full pitch→commit→episode flow must proceed with zero limit, quota, or upgrade language anywhere — volume is not a sales trigger.
Audiobooks
T28Admin-preview gate refuses gracefullyfresh session
"Turn my night courier story into an audiobook…"The tool correctly refuses (admin_only); Claude must relay “audiobooks are in a limited admin preview” gracefully — without dumping the error, apologizing in loops, retrying the same call hoping for a different outcome, or improvising a workaround like offering to read the story aloud itself.
T29Admin happy pathmanual
Voice audition → generate → listen, run by hand with an admin account and a real ElevenLabs key. Battery accounts are deliberately non-admin.
Why the battery looks the way it does
Nearly every quirk in the spec is a scar from a real failure. The odd-looking choices are the load-bearing ones:
| Design choice | The failure that caused it |
|---|---|
| T5 is two turns with an explicit go-ahead | The reroll tool gained a destructive-op confirm gate; a one-turn test would deadlock on the warning. |
| T7 locks “Pitch 1” specifically | Asking to commit an unread pitch made the actor (reasonably) audition it first, pushing the commit out of the graded slice. |
| T13 asks to cut to 8 episodes | Asking for the value already on the plan let the actor answer “that’s already the plan” without calling the tool. |
| T15’s table comes from the server | Three runs proved rubric nudges can’t reliably make an actor build a table; now it’s rendered server-side and relayed verbatim. |
| T21’s leak scan is code, not judge | The judge twice “quoted” leaks that existed only in machine context — or nowhere at all. |
| T26 names “my night courier story” | An unnamed “my story” against leftover fixtures earned a fair “which one?” clarification instead of the tool call. |
Judges get user_facing_only transcripts | Tool payloads were pure hallucination surface — twice misquoted as user-facing violations. |
| Deploys are serialized | Two merges 34 seconds apart raced wrangler deploy; the older image won, and production sat one merge behind for two days. |
The end state: everything except T21’s prose-craft judgment is deterministic or server-controlled. The battery’s job is to page a human only when something real broke.