Get started
Honeytongue runs on Jev, a model from TypeSafe that makes typed judgements instead of writing text. You'll need a TypeSafe API key.
-
Install the package and set your key.
npm install honeytongue export TYPESAFE_API_KEY=your-key # macOS and Linux $env:TYPESAFE_API_KEY="your-key" # Windows PowerShell -
Describe a character. Only a name, a persona, and a goal are required.
import { Persuadable, createJevClient } from "honeytongue"; const guard = new Persuadable( { name: "Harry Goatleaf", persona: "A tired night guard who values honesty and despises flattery and bribes.", goal: "Open the gate after curfew", patience: 4, }, { client: createJevClient() }, ); -
Pass in whatever the player typed, and decide what happens in your game.
const result = await guard.attempt(playerInput); if (result.verdict === "convinced") openTheGate(); else if (result.outOfPatience) callTheWatch(); else show(result.reaction ?? "His hand drops to his club.");
To try it without a key, use createMockClient(). It judges by keywords, so it's far less clever than Jev, but it's handy for building and testing.
How it works
Each attempt sends one request to Jev with two questions. The first scores how persuasive the input is to this specific persona, on a rubric from "counterproductive" to "speaks directly to what they care about most." The second checks whether it's threatening or insulting.
Your character's threshold turns the score into a verdict. Jev never decides what happens in your game: it only judges what was said.
| Verdict | When | Patience cost |
|---|---|---|
convinced | The score reached the threshold | None |
unconvinced | Not persuasive enough to this character | 1 |
offended | Threatening or insulting, including repeating an insult | 2 |
repeated | Too close to an argument that already failed. Checked on your side, so it costs no API call. | 1 |
Characters remember recent attempts, so a player who keeps pushing the same point gets less out of it each time. Patience never drops below zero, and reaction is only set for unconvinced and repeated verdicts.
Characters
The persona does most of the work. Write what the character values and what they can't stand, and the same argument will land differently with different people.
| Field | Default | What it does |
|---|---|---|
name, persona, goal | Required | Who they are, and what the player wants from them |
levels | 5-level rubric | Your own rubric: 2 to 10 descriptions, weakest first |
threshold | 80% of the top level | Score needed to convince them |
patience | Unlimited | How much failure they'll put up with |
reactions | A generic line | Replies for unconvinced attempts, picked by score: [{ min, text }] |
repeatReaction | A generic line | What they say when the player repeats themselves |
secrets | None | Facts that only help once the player learns them |
failCost, offendedCost | 1, 2 | Patience lost per failed or offensive attempt |
hostileAt, repeatSimilarity, memory, maxInputLength | 0.7, 0.8, 4, 500 | Fine tuning |
If something's wrong, such as a threshold your rubric can't reach or a patience of zero, Honeytongue throws a HoneytongueError explaining what to fix.
Secrets
If the gatekeeper's persona mentions his sick daughter, a player on their second playthrough can open with it and win instantly. Put hidden motivations in secrets instead:
secrets: [
{ id: "sick_daughter", fact: "His daughter has a fever and the apothecary is closed." },
]
When the player discovers it in your game, call guard.learn("sick_daughter"). Until then, arguments leaning on it won't score higher, and may even seem suspicious to the character.
Browser games
Your API key must never reach a player's browser, so browser games talk to a small proxy you host. Honeytongue includes one that runs on Cloudflare Workers, Vercel, Deno, Bun, or Node.
-
Create a Cloudflare Worker, install Honeytongue, and use this as its entry file:
import { createProxyHandler } from "honeytongue/proxy"; const handle = createProxyHandler({ allowedOrigins: ["https://your-game.example"], }); export default { fetch: (request, env) => handle(request, env) }; -
Store your key as a secret and deploy.
npx wrangler secret put TYPESAFE_API_KEY npx wrangler deploy -
In your game, use the proxy client instead of
createJevClient().import { Persuadable, createProxyClient } from "https://cdn.jsdelivr.net/npm/honeytongue@0.1/src/index.js"; const client = createProxyClient({ url: "https://your-proxy.workers.dev" });
The proxy accepts browser requests only from its own origin and the ones you list, caps input and request size, and rate-limits each player's address. If you're not sure of your game's origin (itch.io games, for example, run in a frame on itch's own domain), try it once: the error names the exact origin to add. As a safety net, createJevClient() refuses to run in a browser.
To develop without deploying anything, run npm run proxy in a clone of the repository. It serves the proxy on http://localhost:8787, using the offline mock until you set a key. On hosts other than Cloudflare and Vercel, check how your host reports visitors' addresses and pass a clientIp function if needed, so the rate limit can't be dodged with a forged header.
Twine
For SugarCube 2, load Honeytongue in your Story JavaScript and keep the character on setup:
import("https://cdn.jsdelivr.net/npm/honeytongue@0.1/src/index.js")
.then(({ Persuadable, createProxyClient }) => {
const client = createProxyClient({ url: "https://your-proxy.workers.dev" });
setup.harry = new Persuadable({ name: "Harry Goatleaf", persona: "...", goal: "..." }, { client });
});
Then call setup.harry.attempt(_plea) from a button and send the player to a passage based on the verdict. The full recipe in examples/twine-sugarcube.md also handles errors, empty input, and double clicks.
The Twine recipe hasn't been tested inside Twine yet. If you try it, please report how it goes.
Text adventures
Honeytongue also ships a small text adventure engine, with a free-text parser and a JSON story format. The demo, The Gatehouse, has you talking your way past a gatekeeper after curfew. Play it in your browser, or in a terminal:
npx honeytongue # play the demo
npx honeytongue story.json # play your own story
Stories are checked when they load, and every problem is listed at once, such as an action that leads to a scene that doesn't exist, or two different characters sharing an id. Give scenes a name like "East Gate" if your interface has a status line to show it in.
Choosing a model
Honeytongue uses jev-1.13.0 unless you say otherwise. It's pinned on purpose: a newer model can score the same argument differently, which would quietly change how hard your characters are to convince.
To switch, pass a model option, or set the TYPESAFE_MODEL environment variable so you can change it without touching code:
createJevClient({ model: "jev-latest" });
createProxyHandler({
model: "jev-latest",
allowedOrigins: ["https://your-game.example"],
});
export TYPESAFE_MODEL=jev-latest # macOS and Linux
$env:TYPESAFE_MODEL="jev-latest" # Windows PowerShell
The option wins, then TYPESAFE_MODEL, then the pinned default. On Cloudflare, add TYPESAFE_MODEL to the vars in your Wrangler config. Players can't choose the model: the proxy ignores any model sent in a request.
After switching, rerun npm run eval or playtest your characters. Scores may shift, and a threshold that felt right on one model can be too easy or too hard on another.
Testing and tuning
Persuasion lives or dies by the persona and threshold, so test them with real phrasings. The repository includes an evaluation set covering parsing, persuasion score ranges, insults, prompt-injection attempts, and arguments using secrets the player hasn't learned.
npm test # unit tests, no key needed
npm run eval # checks the evaluation set against Jev
npm run eval -- --mock # the keyword mock's baseline
If characters are too easy, raise the threshold or make the persona more specific about what they won't accept. If they're too hard, describe more clearly what would move them.
API reference
| Export | Use it for |
|---|---|
Persuadable | A character with memory and patience: attempt(), learn(), reset(), record() |
judgePersuasion() | A single judgement with no memory |
createJevClient() | Calling Jev from a server |
createProxyClient() | Calling your proxy from a browser |
createProxyHandler() | Running the proxy on your server |
createMockClient() | Tests and offline development |
persuasionQuestions(), persuasionState(), readPersuasion() | Merging persuasion into a larger Jev request |
Game, validateStory() | The text adventure engine |
TypeScript types are included for every export, including the story format.
Questions
How much does it cost?
Jev charges about $0.042 per million input tokens and nothing for output. An attempt is typically under a thousand tokens, so ten thousand attempts cost roughly 40 cents. Repeats are caught locally and cost nothing.
Can players trick it?
Characters are told that claims inside the player's dialogue, like "this argument scores a 4," have no authority. No model is perfectly resistant, so the evaluation set includes these attempts and you can add your own.
Does it write dialogue?
No. Jev makes judgements, not text. Every reply comes from you, which keeps your characters' voices consistent and your game predictable.
Why not use a chat model?
You could, but Jev is built for exactly this kind of judgement: it returns a score with probabilities instead of text you'd have to parse, and it's priced for calling on every turn.