Writer’s Room · Quality Engineering

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.

Spec tests/v2/battery/battery.yamlPlaybook docs/TESTING-v2-MCP-CHAT.mdRunner scripts/run_mcp_battery.pyActor claude-sonnet-4-6Judge claude-haiku-4-5
Part One

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:

1

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.yaml
2

An 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-6
3

It 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/mcp
4

Everything 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.

5

Two graders review the tape — both must pass

A fact-checker, in codeDid the right thing actually happen? Right tool called, right data returned, no forbidden jargon shown to the user. No AI involved — it can’t be wrong about facts.
A quality reviewer, an AIWas it good? Right writer’s voice, plain language, the three-pitch ritual honored, errors explained kindly.
judge: claude-haiku-4-5
6

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.py
Diagram 1One battery run, as a story. The rest of this page unpacks steps 1 and 5: what the 28 cards test, and how the two graders divide the work.

Run modes

ModeWhenTests
SmokeOn demand — one button in CI5 tests — T2 T3 T7 T10 T24: the cheapest end-to-end proof that story creation, pitches, commit, episode writing, and subscriber auth all work
FullOn demand — same button, full depthAll 28 automated tests, in order, as one continuous playthrough
ManualBy handT0 (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.

Part Two

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

Deploy workflowpush to main → database migrations → Cloudflare deploy. Serialized — one deploy at a time, so production always ends on the newest commit.
Contracts workflowthe deploy gate: after every successful deploy (plus a daily canary), a deterministic pytest suite calls every tool directly and pins exact payloads. Red here blocks nothing retroactively but means the server changed behavior.
Battery workflowon demand (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.
The runner scriptrun_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

The actor — a Managed Agent sessionclaude-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”).
The vault decides who it isa Managed Agents vault holds the battery account’s login token. Swap the vault, and the same actor becomes the free user or the subscriber.
The judge — NOT an agentclaude-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

Worker → Containera Cloudflare Worker routes writersroom.studio/v2/mcp into the container where every tool actually executes: pitches, episodes, tier checks, the reroll confirm gate, the server-rendered TOC.
Supabase Postgresstories, episodes, personas, accounts — plus the battery’s own results tables, which back the /v2/admin/battery dashboard.
Ordinary auththe actor logs in with email + password like any user and presents a standard login token. Production cannot tell the battery from a real customer.
How they talk to each other — each arrow starts and ends under the lane it connects
The runner scripts the actor — and reads back the tape
Creates the session, posts each scripted user message, approves tool-permission prompts, and pulls the full event stream for grading.
The actor uses the product — through the public front door
Ordinary MCP tool calls, authenticated with the account’s login token. This is the actor’s only path — it holds no database or admin access.
The runner asks the judge — one call per test
A single Messages API call: transcript in, verdict out. Dashed because it’s a stateless request, not a session.
The runner reaches past the actor, straight into the database
Seeds and tears down test fixtures, and files each run’s results. The one privileged path in the whole system — and it never touches the request path being tested.
Diagram 2Three clouds, deliberately separated. The actor reaches production exactly the way a real user does — which is what makes a green battery mean something.

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.

Part Three

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
both tracks must agree ↓
PASS — or the report says exactly which check failed, and why
Diagram 3The two grading tracks. Anything checkable by code is checked by code; the judge keeps only what genuinely needs judgment.

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.

Part Four

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.

On-rampthe front door, before any story exists
T0Connector loadsfirst-run “Always allow” walkthrough — a Desktop UI flow, verified by hand
T1Help / what can I do?capabilities in user-action terms with phrases to try — never a list of tool names
↓the playthrough begins
Auditionpicking a writer — the product's core ritual
T2Start a storypremise in → THREE pitches listed by name + style, always — never one “best fit”
T3Hear Pitch 1a real pilot in the writer’s voice, saved silently, then one two-option question
T4Read another pitchpitches 2 and 3 in voices distinct from Pitch 1 and each other
T5Reroll the lineup⚠ must warn that saved drafts will be tossed, and wait for the go-ahead
T6Browse the writers“any noir voices?” — an honest no-match + adjacent picks is full credit
T7Lock in a writercommit Pitch 1; the pilot becomes Episode 1
↓
Planningthe showrunner's arc
T8Break the storycentral arc, season shape, setups/payoffs — saved as the plan
T9See the outlinespoiler-fogged past the reading position first; full plan only on explicit ask
↓
Writing & steeringepisodes + the user's control surface
T10Write the next episodesame voice, continuity intact — no renamed characters, no contradicted facts
T11Shorter episodes~700 words — graded on direction, not word-count precision
T12Remove the length cap“off” is not just another number — and it’s flagged reversible
T13Cut the season to 8an unusual number, so “that’s already the plan” can’t dodge the tool call
T14Course-correcttakes the note silently — “logging your feedback signal” is an instant fail
↓
Reading & exportgetting the work back out
T15Table of contentsclean #/title/summary table — rendered by the server, relayed verbatim
T16Re-read Episode 2verbatim text with title and hook, never a re-paraphrase
T17Export the storyevery episode + characters/threads, readable, with the web link mentioned
↓fresh sessions
Lifecycleacross chats, and letting go
T18Resume across chats“what was I working on?” — stories by premise, never raw IDs; naming one resumes it
T20Abandon the storyrecoverable via support — never described as permanent deletion
Diagram 4The continuous playthrough. T18 and T20 open fresh sessions to prove the stateless resume path; T20 runs last so every earlier test still has a live story.
Part Five

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

T19Plan checkcontracts pin tier=free with both limits null; the judge requires plain language — no invented caps, no numbers
T25New story is public, disclosure relayed🔒 pricing fliponce the flip is live: start_story creates the story public, and Claude must deliver the ownership + privacy disclosure loudly, before the pitches — not buried after them
T26Daily safety stop blocks generically🔒 enforcement flagfixture exhausts today’s counter + seeds a real continuable story → next_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 anyway

Subscriber accountunlimited private stories

T24Plan status (smoke)contracts pin tier=subscriber and both limits null; the internal safety ceiling must NEVER be mentioned to the user
T27Not blocked at scale🔒 enforcement flagfixture: many active stories + heavy same-day usage → the full pitch→commit→episode flow must run with ZERO limit, quota, or upgrade language
Diagram 5The tier matrix. T24 is the canary that subscriber auth and tier resolution work at all; T25 proves the public-by-default disclosure lands loudly; T26 proves the daily safety stop blocks in plain, plan-free language; T27 proves a paying user never hears about limits.
Part Six

Every 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.

Part Seven

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 choiceThe failure that caused it
T5 is two turns with an explicit go-aheadThe reroll tool gained a destructive-op confirm gate; a one-turn test would deadlock on the warning.
T7 locks “Pitch 1” specificallyAsking 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 episodesAsking 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 serverThree 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 judgeThe 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 transcriptsTool payloads were pure hallucination surface — twice misquoted as user-facing violations.
Deploys are serializedTwo 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.