Read, lint, edit, and write SillyTavern character cards — the PNG ones with
the card hidden in a chara text chunk, and the plain JSON ones. Library plus
a small CLI. No runtime dependencies.
The problem it solves: a character card is a JSON blob base64'd into a PNG metadata chunk, and most tooling that touches one either drops the fields it doesn't recognize, re-encodes the image, or leaves a stale chunk behind that V3-aware frontends read in preference to the one you just wrote. This library does the boring parts correctly:
- Reads V1, V2, and V3 cards, from JSON or from a PNG.
- Keeps unknown and extension fields byte-for-byte through an edit. Your
lorebook, your
extensionsblock, and whatever a future spec adds all survive a load-edit-save cycle. - Writes the card back into a PNG by rebuilding the chunk list — the image data is copied, never re-encoded, so the art is bit-identical.
- Strips a stale
ccv3chunk on write, so edits actually apply everywhere. - Lints a card and tells you why something is a problem, not just that it is.
- Estimates token cost per section, split into permanent context and per-greeting. Read the honesty section below before trusting the numbers.
Everything runs locally. There is no network code in this package.
Not on npm yet — install from the repository:
npm install github:zeshutmax/tavern-card-tools
That builds on install (there is a prepare script), so you get dist/
and the tavern-card binary without any extra step.
Node 18 or newer. The library is also browser-safe: it uses only atob,
TextEncoder/TextDecoder, DataView, and typed arrays, so it runs in a page
without a bundler polyfill. (The CLI is Node-only, for obvious reasons.)
import { parseTavernCardFull, readCardFields } from "tavern-card-tools";
const result = parseTavernCardFull(jsonText);
if (!result.ok) {
// "not-json" — didn't parse; "not-a-card" — parsed, but no name anywhere
throw new Error(result.error);
}
const card = result.card;
card.shape; // "v2" for an envelope card (V2 or V3), "v1" for a bare object
card.root; // the entire parsed object, untouched
const fields = readCardFields(card); // name, description, first_mes, tags, …readCardFields reads through the shape difference for you: envelope cards
keep their fields under data, V1 cards keep them at the top level. Missing
fields come back as "" or [] rather than undefined, and a field holding
the wrong type (a number where a string belongs) degrades to empty instead of
throwing.
If you only want to know who the character is, parseTavernJson returns a
narrow five-field projection instead.
import { extractTavernPngCardJson, parseTavernCardFull } from "tavern-card-tools";
const bytes = new Uint8Array(await file.arrayBuffer());
const found = extractTavernPngCardJson(bytes.buffer);
if (!found.ok) {
// "not-png" — bad signature; "png-no-card" — a PNG, but no chara chunk
throw new Error(found.error);
}
const card = parseTavernCardFull(found.json);In Node, be careful handing a Buffer to the ArrayBuffer-taking functions:
Buffer is a view into a shared pool, so buf.buffer is usually not the bytes
you want. Copy first:
const copy = new Uint8Array(buf.byteLength);
copy.set(buf);
const found = extractTavernPngCardJson(copy.buffer);parseTavernPng is the one-step version when the narrow projection is enough.
Neither function validates chunk CRCs on read — a bit-rotted checksum should
not cost someone their character.
import { lintTavernCard } from "tavern-card-tools";
for (const finding of lintTavernCard(card, { artBytes: pngBytes.length })) {
console.log(finding.level, finding.label); // "warn", "first_mes never references {{user}}"
console.log(finding.detail); // the specifics and why it matters
}Each finding has a stable id, a level of "pass" or "warn", a one-line
label, and a detail paragraph. There is no "error" level — nothing in a
character card is fatal, and a linter that shouts is a linter people turn off.
The rules: spec envelope present and recognized; name / description /
first_mes non-empty; {{user}} referenced in the greeting; {{macros}} all
from the known SillyTavern set; <START> markers present in mes_example; and
three size checks — permanent context (~2,000 estimated tokens), card JSON
(100 KB), and art (1 MB). artBytes is optional; omit it and the art check is
skipped.
import {
writeCardFields,
embedTavernCardInPng,
extractTavernPngCardJson,
} from "tavern-card-tools";
const edited = writeCardFields(card, {
description: "Rewritten.",
tags: ["original", "slow-burn"],
});
const out = embedTavernCardInPng(artPngBytes, edited.root);
// Worth doing: confirm what you wrote reads back.
if (!extractTavernPngCardJson(out.buffer).ok) throw new Error("write failed");writeCardFields is immutable — it returns a new card and leaves the original
alone. It only touches the keys you pass. An empty value ("" or []) clears
a key that already exists but will not create one, so lean cards stay lean.
Everything you didn't mention, including fields this library has never heard
of, is preserved exactly.
embedTavernCardInPng walks the chunk list, drops any existing chara and
ccv3 text chunks, copies every other chunk verbatim, and inserts a fresh
chara tEXt chunk with a correct CRC32 immediately before IEND. It throws
not-png on a bad signature and png-missing-iend on a truncated file.
Re-embedding into an already-carded PNG replaces the chunk rather than stacking
another copy.
serializeTavernCard(card) gives you the pretty-printed (2-space) JSON for a
.json download.
The package installs a tavern-card command:
tavern-card inspect <file.png|file.json> shape, fields, token estimates
tavern-card lint <file.png|file.json> pass/warn findings
tavern-card extract <file.png> [out.json] card JSON out of a PNG
tavern-card embed <card.json> <art.png> [out.png]
One-off, without adding it to a project:
npx -p github:zeshutmax/tavern-card-tools tavern-card inspect card.png.
Omit the input path to read stdin; omit the output path to write stdout. For
embed, dropping the card path takes the card from stdin
(cat card.json | tavern-card embed art.png out.png). lint exits 1 if
anything warns, which makes it usable in a pre-commit hook or in CI over a
repository of cards.
$ tavern-card inspect wren.png
Wren Halloway
shape V2/V3 envelope (fields under data)
spec chara_card_v2 / spec_version 2.0
file 9,909 bytes (PNG — art plus the embedded card)
other fields: extensions
permanent context — sent with every message
description ~72
personality ~28
scenario ~36
mes_example ~54
total ~190
per greeting — one of these opens the chat
first_mes ~49
alt greeting 1 ~38
Token counts are estimates (blended char/word heuristic, roughly
+/-15% of a real tokenizer). Budget with them, don't bill with them.
They pipe:
tavern-card extract old.png | jq '.data.name = "New Name"' | tavern-card embed art.png new.png
V1 is a bare object with the fields at the top level: name,
description, personality, scenario, first_mes, mes_example. Still
widely readable, still common in the wild.
V2 (chara_card_v2) wraps everything in { spec, spec_version, data: {…} }
and adds creator_notes, system_prompt, post_history_instructions,
alternate_greetings, tags, character_book, and extensions.
V3 (chara_card_v3) keeps the same envelope shape, so it parses through the
identical code path and reports shape: "v2" — the field names V2 and V3 share
are read and written normally, and V3-only fields ride through untouched
because unknown fields always do. This library does not implement V3-specific
semantics (decorators, asset lists); it preserves them.
A note on ccv3. V3 cards may carry a second text chunk keyed ccv3
alongside chara, and a V3-aware frontend prefers it. Writing a card without
removing the old ccv3 chunk produces a PNG that looks correct in one frontend
and shows pre-edit content in another — a genuinely nasty bug to chase.
embedTavernCardInPng therefore strips ccv3 and writes only chara, which
every frontend reads. If your workflow depends on a V3-only field being visible
to a V3-aware reader through the ccv3 chunk specifically, this package is not
currently the right tool.
Detection rule for shape: if the object has a data sub-object with a non-empty
name, it is an envelope card; otherwise, if the top level has a non-empty
name, it is V1; otherwise it is not a card. The spec string is not used for
detection, because plenty of real exporters omit it.
The token panel splits a card's cost into two budgets, and it matters that they stay separate.
Permanent context — description, personality, scenario,
mes_example, and system_prompt — is sent with every single message. A
2,000-token permanent block on an 8k-context model means a quarter of the
window is gone before the conversation starts, every turn, forever.
Per-greeting — first_mes and each entry in alternate_greetings — costs
you one of them. The reader picks a greeting (or swipes to another); the rest
are never sent. A card with eight alternate greetings costs the same at runtime
as a card with one.
So do not add the two totals together. A card with a 400-token permanent block and six 300-token greetings has a real footprint of about 700 tokens, not 2,200. Summing the sections is the single most common way people talk themselves into deleting greetings that were costing them nothing.
The token numbers are estimates, not tokenizer output.
estimateTokens blends a character count (chars ÷ 3.6, slightly denser than the
classic ÷ 4 because roleplay prose runs punctuation-heavy) with a word count
(words × 1.35), and averages the two. That is the entire algorithm. There is no
BPE tokenizer in this package, no vocabulary file, no model-specific table.
In practice, real tokenizers land within roughly ±15% of these numbers on ordinary card prose, and every model tokenizes differently anyway — so the true number is a range even in principle.
What this is good for:
- "Is this card 800 tokens or 2,400?" — reliably answered.
- Comparing sections of the same card to find what is actually heavy.
- Watching a number go down while you cut a bloated description.
What this is not good for:
- Exact context-budget arithmetic against a hard limit.
- Anything involving cost, billing, or an API quota.
- Non-Latin scripts, heavy markup, or unusual formatting, where a character-and-word heuristic drifts further than ±15%.
If you need exact counts, run the actual tokenizer for your model. This package deliberately does not, because a dependency-free card editor that is roughly right is more useful to more people than an exact one that ships a megabyte of tables and only covers one model family.
The library always presents these as ≈. Please keep that convention if you
surface them in a UI.
mapToDraft, matchArchetype, inferGender, and distillFreeform map a card
onto your own catalog of character archetypes by keyword scoring, infer gender
from third-person pronouns, and distill a card's prose down to a length-clamped
summary. This exists for importing cards into an application that doesn't use
the Tavern format natively. If you just want to read, lint, and write cards,
ignore this part — nothing else depends on it.
This code was extracted from Card Studio, a browser tool I built for
Bae. The hosted version is at
bae.ppl.studio/tools/card-studio —
same engine, with an editor UI around it, no login, and nothing uploaded (it is
the same client-side code you are reading here). The engine was already a
single dependency-free file with no ties to the rest of that codebase, so
publishing it under MIT cost nothing and seemed more useful than leaving it
sitting in a src/lib folder. If you only want the library, you never need to
visit the site. There is also a card builder
there for writing a spec-complete V2 card from scratch, same engine underneath.
Issues and pull requests are welcome, particularly: real-world cards that parse wrongly (attach the file if you can share it), frontends whose chunk-writing conventions differ from what is handled here, and lint rules that would have saved you time.
npm install
npm run typecheck
npm test
Tests use node:test and generate their PNG fixtures in code, so there are no
binary blobs in the repository. If you change estimateTokens, a golden-value
test will fail on purpose — update it deliberately and say so in the PR.
MIT. See LICENSE.