Write conversations for language models the way you'd write a document — not by hand-editing JSON.
Turnstile is a conversation editor. You write messages as blocks, and it handles
the formats: ChatML, OpenAI, Anthropic, JSONL, Markdown. Import in one, export
in another, and the tool keeps the structure honest along the way.
It runs entirely in your browser. No account, no server, nothing uploaded — your
conversations are stored on your own machine.
┌────────────┬──────────────────────────────────┬──────────────┐
│ Library │ Your conversation │ Inspector │
│ │ │ │
│ search │ ▾ system 142 tok × ⋮ │ Tokens │
│ tags │ You are a helpful assistant. │ Vars │
│ backups │ ────────────────────────────── │ Issues │
│ │ ▾ user 38 tok × ⋮ │ Format │
│ │ Summarise {{doc}} in 50 words. │ │
│ │ [+ message] │ │
└────────────┴──────────────────────────────────┴──────────────┘
npm install
npm run dev # open the URL it prints, usually http://localhost:5173
That's it — no configuration, no API keys, no sign-in.
A welcome screen types itself out while your conversations load — click or press
any key to go straight in. (Its lines live at the top ofsrc/components/WelcomeScreen.tsx if you want to change what it says.)
Your first conversation, in five steps:
system and a user message. Click into the system message and describe the assistant's job.user message and type your question.assistant.Nothing needs saving. Every change is written to your browser's storage
automatically; the header tells you when (saved / saving… / unsaved changes).
| Pane | What it's for |
|---|---|
| Library (left) | All your conversations. Search by title or by anything said inside them, filter by tag, back everything up. |
| Conversation (middle) | The messages themselves. Drag a card by its header to reorder. |
| Inspector (right) | Token counts, variables, problems to fix, and export. |
Both side panes collapse with the buttons in the top corners, giving the
conversation the full width.
Each message is a card with a colored stripe telling you its role at a glance —
🟡 system, 🟣 developer, 🔵 user, 🟢 assistant, ⚪ tool.
Turnstile has two modes, like a text editor with a command mode.
In block mode: j and k move between messages, 1–5
set the role, x disables one, and Enter drops you back into
the text.
The chords work everywhere: Ctrl+Enter for a new message,
Alt+↑/↓ to move one, Ctrl+D
to duplicate. Forgot one? Ctrl+K opens a searchable list of
every command.
The preview at the bottom of the tab always shows exactly what will be written,
so you can check before you commit.
Turnstile handles the awkward differences for you: Anthropic keeps the system
prompt in a separate top-level field, OpenAI puts tool calls in tool_calls
with JSON-encoded arguments, and Anthropic tucks tool results inside a user
turn. You just switch the dropdown.
Write {{like_this}} anywhere in a message. The Vars tab picks it up
automatically and gives you a box to fill in.
{{{{not_a_variable}}}} exports as {{not_a_variable}}.Open the ⋮ menu on the last message you want to keep and choose
Branch from here.
You get a new conversation containing everything up to that point, and the
library shows ⑂ from … under it so you can see where it came from. The
original is untouched — rewrite the ending in the branch and compare.
Toggle disabled in the ⋮ menu, or x in block mode.
The message stays in the document, dimmed and struck through, but it's left out
of every export and every token count. It's the right tool for "does this
instruction actually matter?"
For a note to yourself that should never leave the editor, use Add note —
notes are attached to a message and are never exported in any format.
Set the target format to JSONL and export. Each export writes your
conversation as one line, in the OpenAI shape.
Importing a JSONL file opens the first conversation and tells you how many
others were in the file. To bring in a whole set, use Import vault in the
library instead.
Press Ctrl+R. The message cards are replaced by the whole
conversation as text, in whatever format you're targeting. Blocks are
color-coded by role so you can tell them apart while editing.
Your edits are read back into the message cards about a second after you stop
typing. A few things worth knowing:
<|im_start|> … <|im_end|> pair becomes its ownWhile editing raw ChatML, hold Alt:
| Press | Get |
|---|---|
| Alt+S | an empty system block |
| Alt+U | an empty user block |
| Alt+A | an empty assistant block |
| Alt+T | an empty tool block |
The cursor lands inside, ready to type.
Your own tags: click block shortcuts in the raw view header. Give a tag a
name and a letter — say im_sep on Alt+C — and pick what
it inserts:
<|im_sep|>, dropped inline wherever your cursor is<|im_start|>im_sep … <|im_end|> pair, like the built-insAlready added it and want the other behaviour? Click the little token /
block label on the tag to flip it. Type the name with or without the <||>; both work. Your tags are remembered across reloads.
Export vault at the bottom of the library writes every conversation into a
single JSON file. Import vault reads it back on the other machine.
Since there's no server, this is the backup — worth doing before you clear
browser data. A damaged file still imports whatever conversations are readable
and tells you what it skipped.
Open the Issues tab — it lists what's wrong for the format you're targeting,
and clicking a row jumps to the message and flashes it.
Errors block the one-click export because the output would be wrong or would
fail at the API. Common ones:
| It says | It means | Fix |
|---|---|---|
system-inline |
Anthropic keeps the system prompt separate, so it must be your first message | Move it to the top |
must-start-user |
Anthropic wants a user turn first | Add or move one |
untagged-block |
A block never got a role | Pick one from its dropdown |
orphan-tool-result |
A tool result points at a tool call that isn't there | Fix the id, or remove it |
imstart-in-content |
A message literally contains <|im_end|>, which would break ChatML |
Use a different format, or remove it |
Warnings never block anything — they're advice.
And if you know better than the checker, Export anyway is always there.
This is off by default, and there's no network code in the app at all until you
turn it on.
cp .env.example .env.local # set VITE_ENABLE_INFERENCE=true
npm run dev
A Run panel appears. Choose OpenAI or Anthropic, paste your own API key, and
the reply is appended as a new assistant message.
Your key is stored on your machine and the request goes straight from your
browser to the provider — Turnstile has no server to send it through. What gets
sent is exactly what the Format tab preview shows.
| Key | Does |
|---|---|
| Ctrl+Enter | New message below |
| Ctrl+Shift+Enter | New message above |
| Ctrl+D | Duplicate this message |
| Ctrl+Backspace | Delete this message |
| Alt+↑ / ↓ | Move it up / down |
| Esc | Leave the text → block mode |
| j / k | (block mode) next / previous message |
| 1…5 | (block mode) system, developer, user, assistant, tool |
| x | (block mode) disable / enable |
| Enter | (block mode) back into the text |
| Ctrl+R | Raw text view |
| Ctrl+K | Command palette — everything, searchable |
| Ctrl+Z / Shift+Z | Undo / redo |
| Ctrl+S | Save now (it saves anyway) |
On a Mac, use ⌘ instead of Ctrl.
≈. Counts for OpenAI models are exact.Turnstile shows an error panel rather than a blank page if something goes wrong
while rendering, so a truly blank page usually means the dev server isn't
running or the page didn't load. Check the terminal running npm run dev, then
reload.
If you do get the error panel, the message on it is the real cause — reloading
clears most of them, and your conversations are safe either way.
They're stored per-browser and per-site. They won't appear in a different
browser, a different profile, or private-browsing windows, and clearing site
data removes them.
If the library is genuinely empty, click New conversation to start again, and
use Export vault from then on so there's always a copy outside the browser.
There's an error for the format you picked. Open the Issues tab to see which
message, click it to jump there, or use Export anyway if you meant it.
It's a static site — build it and host the folder anywhere.
npm run build # writes dist/
npm run preview # check the built version locally
GitHub Pages: push to main and the included workflow builds and publishes
it for you.
git init -b main
git add -A
git commit -m "Turnstile"
git remote add origin https://github.com/arcofchanu/turnstile.git
git push -u origin main
Then enable it once under Settings → Pages → Source: GitHub Actions. The site
lands at https://arcofchanu.github.io/turnstile/ — it works from a project
subpath without any configuration.
Built from chatml-editor-prd.md; all six phases of
its build plan are implemented. React 18 + TypeScript (strict) + Vite, Zustand
for state, Dexie for storage, CodeMirror for the raw view only.
| Command | Does |
|---|---|
npm run dev |
Dev server |
npm run build |
Typecheck, then build to dist/ |
npm test |
~320 tests, single run |
npm run test:watch |
Tests in watch mode |
npm run typecheck |
Types only |
Where things live
| Folder | Holds |
|---|---|
src/adapters/ |
The five format adapters and the validation rules. Pure functions — no React, no store. Round-trip tested against a 31-conversation corpus. |
src/store/ |
Zustand store: one slice for the document, one for UI, plus the undo history. |
src/db/ |
IndexedDB schema, record migrations, debounced autosave. |
src/lib/ |
Shared logic: token counting, variables, the export pipeline, the keymap, vault import/export. |
src/components/ |
The UI, grouped by pane. |
src/inference/ |
The optional Run feature. Not in the bundle unless enabled. |
Conventions
any, no non-null assertions.disabled messages or variables — the export pipeline handles both before serializing.bg-role-user, …) in src/index.css, never inline hex.Decisions worth knowing
Message.rawRole and Message.untagged extend the PRD's model so unknown roles and text outside blocks survive a round-trip instead of being coerced or dropped.```tool_use) — plain content that any ChatML consumer accepts.Alt-u/Alt-l/Alt-A case commands, which would otherwise swallow them.gpt-tokenizer throws on them by default, which is fatal in an editor whose content is full of them.threads pool; the default forks pool can't start workers on this Windows setup.