← BACK

turnstile

TypeScript ⭐ 0

Turnstile

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]            │              │
└────────────┴──────────────────────────────────┴──────────────┘

What people use it for

  • ✍️ Hand-writing prompts — system prompts and multi-turn tests, without JSON escaping
  • 🔄 Switching providers — paste an OpenAI transcript, export it for Anthropic
  • 📦 Building small datasets — eval sets and fine-tune files as JSONL
  • 🌱 Comparing variants — branch at turn 3 and try a different phrasing

Start in 60 seconds

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 of
src/components/WelcomeScreen.tsx if you want to change what it says.)

Your first conversation, in five steps:

  1. A conversation is already open with a system and a user message. Click into the system message and describe the assistant's job.
  2. Click the user message and type your question.
  3. Press Ctrl+Enter — a new message appears below, with the role already flipped to assistant.
  4. Watch the Tokens tab on the right count as you type.
  5. Open the Format tab, pick a target, and hit Export or Copy to clipboard.

Nothing needs saving. Every change is written to your browser's storage
automatically; the header tells you when (saved / saving… / unsaved changes).


The three panes

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.

A message card

Each message is a card with a colored stripe telling you its role at a glance —
🟡 system, 🟣 developer, 🔵 user, 🟢 assistant, ⚪ tool.

  • Chevron collapses it to one line
  • Role dropdown changes what it is
  • Token count for that message alone
  • × deletes it (undo with Ctrl+Z)
  • opens everything else: duplicate, insert above/below, convert to a tool call, disable, add a note, branch, markdown preview, delete

How do I…

…write a whole conversation without touching the mouse?

Turnstile has two modes, like a text editor with a command mode.

  • You're typing in a message → insert mode
  • Press Escblock mode, where single keys act on whole messages

In block mode: j and k move between messages, 15
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.

…convert a transcript from one provider to another?
  1. Open the Format tab and choose the format you're importing from.
  2. Click Import into this conversation and pick your file.
  3. Switch the target format to the one you want.
  4. Click Export — or Copy to clipboard if it's going straight into code.

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.

…reuse the same prompt with different inputs?

Write {{like_this}} anywhere in a message. The Vars tab picks it up
automatically and gives you a box to fill in.

  • Values are substituted when you export or copy — the editor always shows the
    template, so you never lose it.
  • Tick preview substituted content in the Vars tab to see the filled-in version.
  • Need literal braces? {{{{not_a_variable}}}} exports as {{not_a_variable}}.
  • Forgot to fill one in? It stays as-is in the output and you get a warning.
…try a different wording without losing the original?

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.

…take a message out without deleting it?

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.

…build a JSONL dataset?

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.

…edit the raw text directly?

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:

  • Your text stays as you typed it. The editor doesn't reformat under you.
  • Broken text can't break your conversation. If it doesn't parse, the error
    appears above the editor and the cards stay as they were until you fix it.
  • Notes and collapsed state survive for messages you didn't change.
  • Text that isn't inside a <|im_start|><|im_end|> pair becomes its own
    untagged block — a neutral, dashed card. Nothing gets silently merged into
    the message above it. Give it a role from the dropdown to make it a normal
    message.
…insert blocks and my own tags faster in raw mode?

While 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:

  • token — the bare <|im_sep|>, dropped inline wherever your cursor is
  • block — a whole <|im_start|>im_sep<|im_end|> pair, like the built-ins

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

…move my work to another machine, or back it up?

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.

…fix an export that's blocked?

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.

…send the conversation to a model and see what it says?

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.


Keyboard cheat sheet

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
15 (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.


Good to know

  • Undo is generous. 100 steps, and typing collapses into sensible chunks — one undo removes a phrase, not a letter. Deleting a message or a whole conversation is undoable too.
  • Anthropic token counts are estimates. There's no local Anthropic tokenizer, so those numbers are marked with . Counts for OpenAI models are exact.
  • Markdown is export-only. It's for reading and sharing, not round-tripping.
  • Messy input is welcome. A transcript that was cut off mid-stream, has junk between blocks, or uses a role nobody recognises will still import — you'll get warnings, not a wall of errors.
  • Your data is yours. Conversations live in this browser's storage on this machine. Clearing site data deletes them, so export a vault first.

Troubleshooting

The page is blank

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.

My conversations disappeared

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.

Export is greyed out

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.


Putting it online

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.


For developers — stack, layout and conventions

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

  • TypeScript strict; no any, no non-null assertions.
  • Adapters never import the store or React, and never see disabled messages or variables — the export pipeline handles both before serializing.
  • Role colors are Tailwind theme tokens (bg-role-user, …) in src/index.css, never inline hex.
  • No dependencies beyond the ones the PRD approves.

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.
  • ChatML has no tool-call syntax, so tool blocks ride in fenced JSON (```tool_use) — plain content that any ChatML consumer accepts.
  • Message bodies are textareas, not editors: a CodeMirror instance per message gets slow past ~30 messages and steals keybindings.
  • Alt shortcuts in the raw view are registered above CodeMirror's own Alt-u/Alt-l/Alt-A case commands, which would otherwise swallow them.
  • Token counting allows ChatML special tokens explicitly — gpt-tokenizer throws on them by default, which is fatal in an editor whose content is full of them.
  • The bundle is ~4 MB (1.8 MB gzipped), nearly all tokenizer data, in exchange for exact counts from the first paint.
  • Vitest runs on the threads pool; the default forks pool can't start workers on this Windows setup.