Ahmad Tawil

Interfaces · Shipped

Tone Studio

Paste any text and see it rewritten in five registers at once, with every changed word highlighted against the original.

Input
Fan-out
Claude API
Compare view
TypeScriptNext.jsClaude APIServer-Sent Events

Paste any text and see it rewritten in five registers at once, with every changed word highlighted against the original.

Live demo · Video · Day 01 / 30


1. What it does #

You drop in a paragraph — an email, a bio, a product description — and get five rewrites side by side: formal, warm, direct, technical, and persuasive. Each version streams in as it's written, and changed words are underlined so you can see exactly what was altered rather than re-reading five near-identical blocks of text.

The point is comparison. Most rewriting tools give you one answer and make you guess whether a different instruction would have been better.


2. Architecture #

Client

Server · /api/rewrite

SSE tokens

baseline

Input text

Fan-out

Promise.all

Formal

Warm

Direct

Technical

Persuasive

Claude API

streaming

5 independent panes

Word-level diff

Compare view

The highlighted node is where the interesting work happens. The five calls fire simultaneously and each streams into its own pane independently — a slow or failed register doesn't block the other four. The panes fill at different speeds, which is why the interface feels fast even though total token usage is higher than a single combined call.

Diffing runs on the client against the settled text of each pane, not on every streamed token.


3. Project structure #

tone-studio/
├── app/
│   ├── api/
│   │   └── rewrite/
│   │       └── route.ts          # fan-out + SSE stream
│   ├── layout.tsx
│   └── page.tsx

├── components/
│   ├── ComparePane.tsx           # one register column
│   ├── DiffText.tsx              # word-level highlighting
│   └── InputPanel.tsx

├── lib/
│   ├── registers.ts              # the 5 prompt configs — edit here, not in the route
│   ├── claude.ts                 # API client, streaming, retry
│   └── diff.ts                   # diff-match-patch wrapper

├── shell/                        # reusable across all 30 projects
│   ├── useStream.ts              # SSE hook
│   ├── Toast.tsx
│   ├── FileDrop.tsx
│   └── theme.css                 # shared tokens

├── .env.example
├── package.json
└── README.md

Two things are deliberate. lib/registers.ts holds the prompts as data, so adding a register is a config change rather than a code change. And shell/ stays separate from components/ because nothing in it knows anything about this project — it's the generic scaffolding, copied forward into the next build.


4. Stack #

LayerChoiceWhy this one
ModelClaude (claude-sonnet-4-6)Strong instruction-following on register control; streaming supported natively
PromptingTemplates in lib/registers.tsRegisters are data, not code — new ones need no redeploy of logic
BackendNext.js route handlersSame project as the frontend, no separate service to run for a single endpoint
StreamingServer-Sent EventsOne-directional token flow; simpler than WebSockets and needs no connection state
ConcurrencyPromise.all fan-outFive registers run in parallel and fail independently
Diffingdiff-match-patchBattle-tested word-level diff; runs client-side so streaming stays cheap
UIReact 19 + TailwindFast iteration, and the shared shell/ is built on it for reuse across later projects
GuardsToken cap + request timeoutPublic demo can't run up an unbounded bill

5. Key decision #

Five parallel calls instead of one call returning five variants.

A single call is cheaper — you pay for the input text once instead of five times — and it's the obvious first implementation. I rejected it for two reasons:

  1. Nothing renders until everything finishes. With one call you can't stream into five panes, because the model produces the registers sequentially inside one response. The user stares at an empty screen for the full generation, then gets everything at once. Perceived latency is roughly 4× worse.
  2. No fault isolation. If the model drifts on the third register, it often degrades the fourth and fifth too, and a malformed response loses all five. Separate calls fail separately.

The cost difference is real but small at this input size. If the input grew to full documents, I'd switch to prompt caching on the shared input block rather than back to a single call.

Secondary decision: diffing happens client-side. Server-side diffing would mean recomputing the comparison on every streamed token, which is wasted work for a result nobody reads until the pane settles.


6. Run it #

git clone https://github.com/AhmadTawil1/tone-studio && cd tone-studio
cp .env.example .env.local        # add ANTHROPIC_API_KEY
npm install
npm run dev                        # http://localhost:3000

Environment variables:

VariableRequiredPurpose
ANTHROPIC_API_KEYyesModel access
MAX_INPUT_CHARSnoInput ceiling, defaults to 4000
DAILY_TOKEN_CAPnoSpend guard for the public demo

7. What I'd do next #

  • The diff is naive. Word-level comparison flags a reordered sentence as a full rewrite. A semantic diff — or at least sentence-alignment before word diffing — would be far more readable on longer text.
  • No way to save a register. The five presets are hardcoded. Letting users define and store their own register would turn this from a toy into something reusable, and it's the natural bridge to Day 06 (Voice Print), which extracts a register from writing samples instead of hardcoding it.
  • Streaming has no backpressure handling. Five concurrent SSE connections is fine for one user and untested for fifty. Real deployment needs a queue and a per-IP concurrency limit.

8. Debug dashboard (dev-only) #

/debug is a local-only page (it 404s in production) that visualizes the most recent /api/rewrite request: a timeline chart showing all 5 registers' bars running concurrently, per-register stats (time to first word, total duration, tokens), and a parallel-vs-sequential time comparison. It exists to make the "Key decision" above verifiable instead of just asserted.

Debug dashboard timeline