Honeytongue Release 0.1

Honeytongue

A persuasion mechanic for text games
Release 0.1 / Serial number 260925 / MIT License

Characters your players can actually argue with. Players type anything, and each character judges it by their own values.

npm install honeytongue

East Gate

Rain drips from the arch of the east gate. Harry Goatleaf, the gatekeeper, values honesty and despises flattery. He leans on his spear under the lantern.

You're the finest guard in the kingdom. Surely you can make an exception?

0.36 / 4unconvinced

Harry doesn't even look up. "Gate's shut till dawn."

Please, I have a letter that has to reach the city tonight.

1.84 / 4unconvinced

"Everyone's got a reason," Harry says. "Mine's keeping this job."

I won't lie to you. This letter is a fever remedy for the apothecary. Let me through and I'll send her to your daughter tonight.

3.52 / 4convinced

Harry lets out a long breath and lifts the bar.

Three attempts to persuade a gatekeeper. Scores are illustrative until live results are in; the tall mark on each meter is Harry's threshold.

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.

  1. 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
  2. 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() },
    );
  3. 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.

VerdictWhenPatience cost
convincedThe score reached the thresholdNone
unconvincedNot persuasive enough to this character1
offendedThreatening or insulting, including repeating an insult2
repeatedToo 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.

FieldDefaultWhat it does
name, persona, goalRequiredWho they are, and what the player wants from them
levels5-level rubricYour own rubric: 2 to 10 descriptions, weakest first
threshold80% of the top levelScore needed to convince them
patienceUnlimitedHow much failure they'll put up with
reactionsA generic lineReplies for unconvinced attempts, picked by score: [{ min, text }]
repeatReactionA generic lineWhat they say when the player repeats themselves
secretsNoneFacts that only help once the player learns them
failCost, offendedCost1, 2Patience lost per failed or offensive attempt
hostileAt, repeatSimilarity, memory, maxInputLength0.7, 0.8, 4, 500Fine 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.

  1. 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) };
  2. Store your key as a secret and deploy.

    npx wrangler secret put TYPESAFE_API_KEY
    npx wrangler deploy
  3. 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

ExportUse it for
PersuadableA 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.