nakdanix
v0.4.0
Published
Hebrew niqqud (vocalization) and deterministic gender rewriting (no API key needed) with batch, templates, CLI, and an MCP server. LLM fallback for cases the rules don't cover.
Maintainers
Readme
nakdanix
Hebrew niqqud (vocalization) and gender rewriting with batch, templates, CLI, and an MCP server.
- TypeScript types shipped in the box · Node 18+
- Vocalization powered by Dicta's free public Nakdan API
- Gender rewriting deterministic by default — no API key needed. Uses Dicta's
task=morphanalyzer plus a compact rule set. LLM fallback kicks in only when the rules leave gender-variable words unhandled AND an LLM is configured.
npm install nakdanixInstallation & usage
| Situation | Command |
| --- | --- |
| Use in a project | npm install nakdanix → import { nikud } from 'nakdanix' |
| Global CLI, use anywhere | npm install -g nakdanix → nakdanix "..." |
| One-off CLI without installing | npx -y nakdanix "..." |
| One-off MCP server without installing | npx -y nakdanix-mcp |
| Install straight from GitHub (pre-release testing) | npm install github:eliranchayo/nakdanix |
| Develop locally against your app | npm link in this repo, then npm link nakdanix in your app |
Requires Node 18+ for native fetch. Older Node works if you inject a fetch implementation via opts.fetch.
Attribution
nakdanix is a client for the Dicta Nakdan API. Dicta is an Israeli nonprofit that publishes state-of-the-art Hebrew NLP tools and offers the Nakdan service free of charge with no formal SLA. Everything vocalized by nakdanix passes through Dicta's servers. Please be considerate with your request volumes — the built-in rate limiting and concurrency caps exist to keep pressure light.
How this came to be
I built nakdanix while working on a narrated Hebrew trivia game. The game needs hundreds of banter lines run through TTS, and I hit six problems in a row that existing Hebrew nikud clients didn't solve:
- My banter lines have
{player_name}placeholders that need to survive the vocalization round-trip. - I have hundreds of lines to process, not one.
- Dicta's morpheme-boundary pipes (
|) were being read literally by the TTS. - I wanted to add nikud from the shell for quick spot-checks.
- I wanted Claude to add nikud directly while writing code.
- Players in the game are addressed by name and by gender — so every masc line needed a fem twin, and Dicta has no inflection endpoint to generate them.
Other people building narrated / templated / batch Hebrew content probably have the same needs. Hence the package.
Quick start
import { nikud, batch } from 'nakdanix'
// One string, plain
await nikud('שלום עולם')
// → 'שָׁלוֹם עוֹלָם'
// Templates survive untouched
await nikud('נכון, {name}. יופי לך.')
// → 'נָכוֹן, {name}. יוֹפִי לְךָ.'
// Batch with retry, concurrency, per-item error capture
const results = await batch(lines, {
concurrency: 4,
onProgress: ({ index, total }) => console.log(`${index + 1}/${total}`),
})Features
| | |
|---|---|
| Template preservation | {name}, {{var}}, ${slot} (and any regex you pass) survive the round-trip; only the Hebrew fragments between them are vocalized. |
| Pipe stripping | Dicta's morpheme-boundary pipes (|) are removed by default. Opt back in with preserveMorphemePipes: true. |
| Batch API | batch(items[]) runs strings in parallel with configurable concurrency (default 4), inter-request throttle (default 150 ms), and exponential-backoff retry on HTTP 5xx / network errors. |
| Per-item error capture | One failing string doesn't fail the batch. Each item returns {output, error}. |
| CLI | nakdanix "..." for shell one-shots. Reads from arg or stdin. |
| MCP server | nakdanix-mcp exposes nikud, nikud_batch, and variant tools over stdio for Claude and other AI agents. |
| Gender rewriting | variant(text, {gender}) rewrites masc↔fem across pronouns, verbs, and adjectives. Kinship nouns and placeholders preserved. See "Morphological variants" below. |
| Types included | TypeScript declarations ship in dist/. |
Library
import { nikud, batch, callDicta, NakdanixError } from 'nakdanix'nikud(text, opts?):
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| preferGender | 'masculine' \| 'feminine' | — | Pick gender-matching vocalization when candidates share consonants. See "Gender-aware vocalization" below. |
| preserveMorphemePipes | boolean | false | Keep Dicta's | word-segmentation markers |
| disablePlaceholders | boolean | false | Turn off template preservation |
| placeholderRe | RegExp | /\$\{[^}]+\}\|\{\{[^}]+\}\}\|\{[^}]+\}/g | Custom placeholder pattern |
| genre | 'modern' \| 'poetry' \| 'rabbinic' | 'modern' | Vocalization style |
| timeoutMs | number | 30000 | Abort after this many ms |
| endpoint | string | Dicta's default | Override the API URL |
| fetch | function | globalThis.fetch | Inject a custom fetch |
batch(inputs, opts?) accepts all of the above plus:
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| concurrency | number | 4 | Max simultaneous requests |
| minIntervalMs | number | 150 | Min ms between requests per worker |
| maxRetries | number | 3 | Retry attempts on transient failure |
| retryBaseMs | number | 500 | Initial backoff; doubles each attempt |
| onProgress | function | — | Called after each item completes |
Lower-level exports (callDicta, splitTemplate, rejoin, stripMorphemePipes, NakdanixError) are also available for consumers who want raw control.
Gender-aware vocalization
Some Hebrew words share the same consonants across masculine and feminine but differ in vocalization — mostly ל"ה verbs in present tense (עונה, רוצה, שותה, בונה) and a handful of adjectives. Pass preferGender and nakdanix picks the matching vocalization from Dicta's candidates:
const s = 'אני עונה על השאלה'
await nikud(s, { preferGender: 'masculine' })
// → 'אֲנִי עוֹנֶה עַל הַשְּׁאֵלָה' ("oneh")
await nikud(s, { preferGender: 'feminine' })
// → 'אֲנִי עוֹנָה עַל הַשְּׁאֵלָה' ("onah")Words that don't vary by gender (nouns, particles, unambiguously-gendered verbs) are unaffected — the top pick is used.
Scope: this only covers the vowel-only variation case. Cases where masculine and feminine use different consonants entirely (יודע masc vs יודעת fem — extra ת) are a morphology task, not a vocalization one. See Morphological variants (variant()) below for that.
Morphological variants (variant())
preferGender above only picks between vocalization candidates for the same consonantal skeleton. When the target gender needs different consonants — אתה יודע (masc) → את יודעת (fem, extra ת), or a verb + adjective + pronoun that all need to re-agree — reach for variant().
import { variant } from 'nakdanix'
await variant('אתה יודע לשחות', { gender: 'feminine' })
// → 'את יודעת לשחות'
await variant('אתה רואה את הבית שלך', { gender: 'feminine' })
// → 'את רואה את הבית שלך'
await variant('שלום אמא, אתה יודע?', { gender: 'feminine' })
// → 'שלום אמא, את יודעת?' (kinship אמא preserved)
await variant('אתה יודע {name}?', { gender: 'feminine' })
// → 'את יודעת {name}?' (placeholder round-trips)How it works — rules first, LLM only if needed
Dicta's public API doesn't have an inflection/generation endpoint (empirically confirmed against the full task enum: nakdan / markprefixes / abbrexp / tokenize / morph). But it DOES have task=morph, which returns per-word morphological analysis — including a bitfield we've decoded for POS + gender + tense. That's enough to build a rule-based rewriter for the common cases.
variant() is a router with three strategies:
| strategy: | Behavior |
| --- | --- |
| 'auto' (default) | Try rules first. If they cover every gender-variable word, return that. Otherwise, if an LLM is configured (via opts.llm or ANTHROPIC_API_KEY), fall back to the LLM. Otherwise, return the partial rules result. |
| 'rules-only' | Never call an LLM. Returns {path: 'rules-partial', unhandled: [...]} on partial coverage (visible via variantVerbose). Fastest, deterministic, no key needed. |
| 'llm-only' | Skip rules entirely. Original 0.3 behavior. |
What the rules cover (deterministic path, no key):
- Pronouns:
אתה↔את,אתם↔אתן,הוא↔היא,הם↔הן(plus clitic-prefixed variants likeשאתה,ואת). - Present-tense verbs, regular + guttural + ל"ה (
יודע↔יודעת,רואהstays). - Adjectives (
גדול↔גדולה,קטן↔קטנה, sofit-letter aware). - 2nd-plural clitic swap (
לכם↔לכן,שלכם↔שלכן). - Person-referring nouns (
ילד↔ילדה,סטודנט↔סטודנטית,הבן↔הבתvia prefix).
What falls to the LLM (if configured):
- Past/future-tense verb conjugations across person + gender.
- Third-person narrative rewrites needing coreference (
הוא הלך אליה→היא הלכה אליו). - Any word Dicta doesn't tag confidently.
To bring your own model instead of Anthropic:
await variant('...', {
gender: 'feminine',
llm: async (prompt) => callMyLLM(prompt), // OpenAI, Ollama, dicta-lm, whatever
})To see which path fired:
import { variantVerbose } from 'nakdanix'
const r = await variantVerbose('אתם באים היום', { gender: 'feminine' })
// { text: 'אתן באים היום', path: 'rules-partial', unhandled: ['באים'], rulesHandled: 1 }Fixed-gender words
Kinship nouns (אמא, אבא, סבא, סבתא, אחות, אח, בת, בן, דוד, דודה) don't switch when the addressee's gender switches. They ship in DEFAULT_PRESERVE_LIST and are merged into the prompt automatically. Extend with opts.preserve for proper names or domain-specific terms:
await variant('שרה שאלה אם אתה בא', {
gender: 'feminine',
preserve: ['שרה'], // merged with defaults
})
// → 'שרה שאלה אם את באה'Pass replacePreserve: true if you need to drop the default list (rare — corpus-critical rewrites where אמא really should become אבא).
Options
variant(text, opts):
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| gender | 'masculine' \| 'feminine' | — (required) | Target gender |
| strategy | 'auto' \| 'rules-only' \| 'llm-only' | 'auto' | Router strategy (see table above) |
| preserve | string[] | [] | Extra words to keep verbatim; merged with DEFAULT_PRESERVE_LIST |
| replacePreserve | boolean | false | Use only preserve[]; drop defaults |
| disablePlaceholders | boolean | false | Skip placeholder round-trip verification (LLM path only) |
| placeholderRe | RegExp | /\$\{[^}]+\}\|\{\{[^}]+\}\}\|\{[^}]+\}/g | Custom placeholder pattern |
| endpoint | string | Dicta default | Override the Dicta API base (rules path only) |
| genre | 'modern' \| 'poetry' \| 'rabbinic' | 'modern' | Dicta genre (rules path only) |
| llm | (prompt) => Promise<string> | Anthropic caller | Bring your own model |
| apiKey | string | ANTHROPIC_API_KEY env | Anthropic key (default caller only) |
| model | string | 'claude-haiku-4-5-20251001' | Model id (default caller only) |
| timeoutMs | number | 30000 | LLM/Dicta call timeout |
| anthropicBaseUrl | string | 'https://api.anthropic.com' | Override API base (LLM path only) |
| fetch | function | globalThis.fetch | Inject a custom fetch |
There's also variantRules(text, opts) — the deterministic-only path — which returns {text, coverage, handled, unhandled} directly. Useful if you want to inspect coverage without going through the router.
Path A vs Path B — which to reach for
| Situation | Reach for |
| --- | --- |
| You want to say the same thing to masc and fem, and only the vowels change (עונֶה / עונָה) | nikud(text, { preferGender: 'feminine' }) — deterministic, no LLM |
| Consonants change (יודע → יודעת, רוצה → רוצה same skeleton but combines with pronouns that DO change) | variant(text, { gender: 'feminine' }) — LLM |
| Both vowels and consonants — pipeline it | variant → nikud (or use batch with chain: 'nikud') |
Batch
batch() accepts a variant option to rewrite each string, and a chain option to layer vocalization on top:
await batch(lines, {
variant: { gender: 'feminine' },
chain: 'nikud', // rewrite each line, then vocalize the rewrite
})Retry, per-item error capture, and concurrency all work as with plain nikud batches.
CLI
$ nakdanix 'שלום עולם'
שָׁלוֹם עוֹלָם
$ echo 'הבית הגדול' | nakdanix
הַבַּיִת הַגָּדוֹל
$ nakdanix --keep-pipes 'הבית הגדול'
הַ|בַּיִת הַ|גָּדוֹל
$ nakdanix --no-templates 'נכון, {name}. יופי.'
נָכוֹן, {נָאמֶה}. יוֹפִי.
# {name} treated as literal text; braces get vocalized too
$ nakdanix --gender fem 'אני עונה על השאלה'
אֲנִי עוֹנָה עַל הַשְּׁאֵלָה
# variant subcommand — rules-based by default, no API key needed
$ nakdanix variant --gender fem 'אתה יודע לשחות'
את יודעת לשחות
$ nakdanix variant --gender fem --preserve 'שרה,דוד' 'שרה אמרה לדוד שאתה בא'
שרה אמרה לדוד שאת באה
$ echo 'אתה יודע' | nakdanix variant --gender fem
את יודעת
# --verbose reports which path fired and any unhandled words
$ nakdanix variant --gender fem --verbose 'אתם באים היום'
אתן באים היום
[variant] path=rules-partial rulesHandled=1 unhandled=["באים"]
# force the LLM path (needs ANTHROPIC_API_KEY)
$ nakdanix variant --gender fem --strategy llm-only 'הוא הלך אתמול'
היא הלכה אתמולMCP server
Wire it into an MCP-aware host (Claude Code, Claude Desktop):
{
"mcpServers": {
"nakdanix": {
"command": "npx",
"args": ["-y", "nakdanix-mcp"]
}
}
}Restart the host and the agent gains three tools:
nikud(text, opts?)— vocalize one string. Preserves templates by default.nikud_batch(inputs, opts?)— vocalize an array. Returns per-item{output, error}.variant(text, {gender, preserve?})— rewrite gender via the built-in LLM caller. RequiresANTHROPIC_API_KEYin the MCP server's env.
Example prompt to a wired-up agent:
"Add niqqud to this line:
נכון, {name}. יופי לך."
The agent will call the nikud tool and return the vocalized form with the placeholder intact.
Error handling
Every failure throws NakdanixError. It carries an optional status (populated for HTTP errors) and preserves the underlying error via .cause.
import { nikud, NakdanixError } from 'nakdanix'
try {
await nikud(text)
} catch (err) {
if (err instanceof NakdanixError && err.status === 429) {
// rate-limited — back off
} else {
throw err
}
}batch() captures per-item failures into results[i].error instead of throwing, so a single bad string never sinks the run.
Troubleshooting
- Timeouts — Dicta's endpoint occasionally slow, especially on cold start. Bump
timeoutMs(default 30 s) or reduceconcurrency. - "empty text" error — you passed
""ornull.nikud()requires non-empty input. variant()returned partial coverage instead of falling back to the LLM — check thatANTHROPIC_API_KEYis set (oropts.llmis passed) ANDstrategy !== 'rules-only'. On'auto'with no LLM, we return the partial rules result rather than throwing.variant()withstrategy: 'llm-only'throws "needs an LLM" — setANTHROPIC_API_KEY, passopts.apiKey, or bring your own viaopts.llm.variant()throws "placeholder mismatch" — the LLM dropped, duplicated, or reshaped a{name}token. Retry usually fixes it; persistent failures on the same input are worth reporting.- Rules path leaves my past-tense verb unchanged — expected. Past/future tense isn't in the rule set; use
strategy: 'auto'withANTHROPIC_API_KEYset, orstrategy: 'llm-only'. - Non-Hebrew input — passed through unchanged, no request made. This is by design.
- Output has literal
{and}— expected; placeholders are preserved. If you want them vocalized, passdisablePlaceholders: true. - HTTP 5xx — nakdanix retries automatically (default 3 attempts, exponential backoff). Persistent failures usually mean Dicta is having a bad day.
- Batch is slow — that's
minIntervalMsdoing its job. Lower it (or raiseconcurrency) if you're sure you won't strain the free service.
What it doesn't do
- Translation (English ↔ Hebrew): not this package.
- Transliteration (Hebrew ↔ Latin script): not this package.
- Morphological analysis (root, binyan, POS tags): the raw Dicta response has more of this info — use
callDicta()to get it. - Offline vocalization: everything goes through Dicta's servers. See FAQ.
FAQ
Is my text sent to Dicta? Yes — that's how vocalization happens. Don't pass anything you can't share with a third party.
Where does my text go for variant()? To whichever LLM you point it at. The default is Anthropic (api.anthropic.com); pass your own opts.llm to route elsewhere.
Does it work offline? No. Vocalization is done by Dicta's remote model, and variant() needs a remote (or self-hosted) LLM.
Can I self-host? In principle, yes — Dicta publishes their models on Hugging Face. In practice, running the model locally is nontrivial and the free API is easier.
Cloudflare Workers / edge runtimes? Yes — inject the runtime's fetch via opts.fetch.
Node < 18? Also works if you inject a fetch implementation via opts.fetch.
Why "nakdanix"? נַקְדָן (nakdan) is Hebrew for "vocalizer/pointer". The -ix suffix is unrelated to any other package.
Versioning
Currently on 0.x — the public API may change between minor versions. Once the API has been used in real projects without needing reshaping, we cut 1.0.0 and commit to strict semver from there.
Contributing
Bug reports and PRs welcome at github.com/eliranchayo/nakdanix.
Local dev:
git clone https://github.com/eliranchayo/nakdanix
cd nakdanix
npm install
npm run build
npm test # note: hits the real Dicta endpoint, requires networkLicense
MIT © Eliran Chayo
The Dicta API is a separate project by the Dicta nonprofit and retains its own terms.
