tarot-spread-sdk
v0.1.0
Published
Zero-dependency, type-safe Tarot SDK: deck mechanics, spread orchestration, deterministic daily guidance draws, and AI-ready reading synthesis.
Maintainers
Readme
tarot-spread-sdk
Zero-dependency, type-safe TypeScript SDK for Tarot deck mechanics, spread orchestration, deterministic daily guidance draws, and AI-ready reading synthesis.
▶ Try the live demo — draw a card of the day, ask the deck a yes/no question, or pick from a fanned deck of 78.
The live demo runs entirely in the browser with no API key and no backend: every reading you see there is composed offline by
narrateReading()andnarrateDaily(). Optional Gemini synthesis is wired up too, but it needs the local proxy — seeexamples/angular-demo.
- Zero runtime dependencies — a single lightweight bundle (ESM + CJS), 26 KB gzipped with the entire card dataset embedded (the 78-card prose corpus is 18 KB of that; the engines, including the offline narrator, make up the rest)
- Deterministic daily mechanics — the same
(date, userId)tuple returns the same card in every timezone and runtime (FNV-1a + Mulberry32) - Dual draw support — instant auto-draws and interactive manual card-picking from a face-down deck
- Complete embedded dataset — full 78-card Rider-Waite-Smith metadata, no network calls
- Reads like a reader —
narrateReading()produces full reader-voice interpretations offline, deterministically, with no LLM - AI-ready too — polarity confidence scoring, elemental balance analysis, and structured prompt contexts for LLM synthesis
Installation
pnpm add tarot-spread-sdk # or: npm install / yarn addWorks in Node ≥ 18, browsers, workers, and React Native. ESM and CJS bundles ship with full type declarations.
Dependencies
None. Installing this package adds exactly one entry to your lockfile — this one.
tarot-spread-sdk
└── (nothing)| | |
| --- | --- |
| Runtime dependencies | 0 |
| Peer dependencies | 0 — nothing to install alongside it, no framework required |
| Optional dependencies | 0 |
| Transitive packages pulled in | 0 |
| engines | Node >= 18 (for browsers: any environment with ES2022) |
That is a deliberate design constraint, not a coincidence. A divination SDK has no business widening your supply chain: every transitive package is code you did not review running in your build and, potentially, on your users' machines. Everything here is written against the standard library — the PRNG (FNV-1a + Mulberry32), the shuffle, the narrator, and the 78-card dataset are all first-party.
Practical consequences:
- No install-time surprises. No postinstall scripts, no native builds, no platform-specific binaries.
sideEffects: falseis declared, so bundlers tree-shake anything you do not import.- No polyfills needed. The only platform APIs used are
DateandMath.random.
What the package actually ships
files is limited to dist/, so the published tarball is 9 files:
| Artifact | Purpose |
| --- | --- |
| dist/index.js + .d.ts | ESM build and its types |
| dist/index.cjs + .d.cts | CommonJS build and its types |
| dist/*.map | Source maps, with the TypeScript source inlined for debugging |
| README.md, LICENSE, package.json | Included by npm automatically |
Two size numbers get confused, so to be explicit about both: 26 KB is what reaches a browser (the minified ESM bundle, gzipped) and is the number that matters for your users. 172 KB is the tarball npm downloads at install time — larger mostly because of the source maps, which never ship to production.
Development dependencies
Only relevant if you clone the repo; none of these reach consumers: typescript, tsup (bundling), vitest (tests), and @types/node.
Quickstart
import { TarotSDK } from 'tarot-spread-sdk';
const tarot = new TarotSDK();
// Deterministic card of the day
const daily = tarot.daily({ userId: 'user-42' });
console.log(daily.focus, daily.affirmation);
// Instant three-card reading, reproducible by seed
const reading = tarot.drawSpread({
type: 'timeline',
seed: 'my-question',
question: 'How is my project going?',
});
reading.cards.forEach((c) =>
console.log(`[${c.slot.name}] ${c.card.name} (${c.orientation})`),
);Core Concepts
Determinism
All randomness flows from a 32-bit seed through the Mulberry32 PRNG. String seeds are hashed with FNV-1a. A given seed always produces the same 78-card shuffle and the same per-position orientations — across Node, browsers, and timezones. Omit the seed for a random reading.
Daily draws derive their seed from fnv1a("daily::" + dateKey + "::" + userId), where dateKey is the UTC calendar date (YYYY-MM-DD). Pass a date string to pin a specific day, or a Date object to have it normalized to UTC.
Reversals
Every draw decides orientation deterministically from the same seed. The default reversal chance is 0.35; configure it globally (new TarotSDK({ reversalChance: 0.2 })) or disable reversals per reading (allowReversals: false).
API Reference
new TarotSDK(options?)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| reversalChance | number | 0.35 | Probability in [0, 1] that a drawn card lands reversed |
| defaultUserId | string | "anonymous" | Fallback user id for daily draws |
Daily guidance — tarot.daily(options?)
| Option | Type | Description |
| --- | --- | --- |
| userId | string | Part of the idempotency tuple |
| date | string \| Date | "YYYY-MM-DD" used verbatim; Date normalized to UTC. Defaults to today (UTC) |
| mode | "single" \| "triad" | One card + focus + affirmation, or a Theme / Challenge / Advice triad |
Returns DailySingleGuidance or DailyTriadGuidance: { dateKey, userId, seed, focus, affirmation } plus card (single) or cards with role (triad). Idempotent: same (date, userId) → same result, always.
Spreads — tarot.drawSpread(options)
| Option | Type | Description |
| --- | --- | --- |
| type | SpreadType | See built-in spreads below, or "custom" |
| seed | number \| string | Optional; omit for a random reading |
| question | string | Optional; carried into the reading and prompt context |
| customSlots | string[] | Required for "custom" (1–20 slot names) |
| allowReversals | boolean | Default true |
Returns a SpreadReading: { spread, mode, cards, seed, question?, drawnAt, elements, polarity? }. Each SpreadCard carries its slot, orientation, position, and deckIndex.
Built-in spreads:
| Type | Cards | Positions |
| --- | --- | --- |
| yes-no | 2 | Core Energy, Deciding Factor (auto polarity scoring) |
| yes-no-guidance | 3 | Yes/Pros, No/Cons, Actionable Guidance (auto polarity scoring) |
| timeline | 3 | Past, Present, Future |
| mind-body-spirit | 3 | Mind, Body, Spirit |
| two-paths | 5 | You, Path A, Outcome A, Path B, Outcome B |
| clarity | 8 | Biggest Fear, Where to Focus, Let Go, Draw On, Cultivate, Next Step, Inner Strength, Outside Help |
| celtic-cross | 10 | Classical ten-position spread |
| custom | 1–20 | Your own slot names via customSlots |
Interactive manual picking — tarot.beginManualSpread(options)
Same options as drawSpread. Returns a ManualPickSession state machine (awaiting-picks → complete):
const session = tarot.beginManualSpread({ type: 'timeline', seed: 'sess-1' });
session.tokens; // 78 face-down tokens to render as card backs
session.pick(4, 18); // user clicks — one or many at a time
session.pick(52);
session.status; // 'complete'
session.getReading(); // full SpreadReadingValidation is strict and all-or-nothing per batch: out-of-bounds indices throw InvalidCardIndexError, repeats throw DuplicatePickError, over-picking or reading an unfinished session throws PickSessionError. session.state is a serializable snapshot; the same seed and picks always reproduce the same cards.
Reader-voice narration — narrateReading(reading)
Turns any reading into grounded reader's prose — deterministic, offline, no LLM. Use it when you want an interpretation without an API call (and buildPromptContext when you do want the LLM).
import { narrateReading } from 'tarot-spread-sdk';
const reading = tarot.drawSpread({
type: 'yes-no-guidance',
seed: 'demo-42',
question: 'Should I take the new role?',
});
const { opening, positions, synthesis, closing, full } = narrateReading(reading);Sample output (full):
You asked: "Should I take the new role?" The Yes / No with Guidance spread answers with 3 cards.
We begin with Yes / Pros — What speaks in favor of the question — you draw The Hierophant. Time-tested wisdom and trusted teachers have something to offer; you do not have to reinvent every wheel. Here that reads as tradition, guidance, shared values.
[…]
The cards are genuinely balanced here — the outcome rests on your choice. Weighing every card and its orientation, the answer sits at 52% toward yes. The elements sit in balance — no single force is running this situation. Note no Air — the situation lacks clear thinking or plain speech.
For practical guidance, look to The Moon in Actionable Guidance: A confusion begins to clear today; let the simple explanation win. Carry this with you: "I move gently through uncertainty, trusting the dawn."
| Field | Description |
| --- | --- |
| opening | Frames the question and spread |
| positions[] | Per-slot interpretation: slot, card, orientation, keywords, text |
| synthesis | Polarity verdict, elemental balance, and reversal weighting across the spread |
| closing | Practical guidance from the advice/outcome card, plus its affirmation |
| full | All parts joined into one passage |
Also available on the instance as tarot.narrate(reading).
Daily explanation — narrateDaily(guidance)
The same treatment for a daily draw, structured as sections a reader would walk through:
const guidance = tarot.daily({ userId: 'user-42' });
const { headline, opening, sections, affirmation, roles, full } = tarot.narrateDaily(guidance);| Field | Description |
| --- | --- |
| headline | Card, orientation, and the day's overall tone (derived from polarity) |
| opening | The card's core message for today |
| sections[] | How it falls (orientation), The day's tone (element), Where it lands (arcana/suit/court), Watch for (the opposite orientation as shadow), Practice (one concrete action) |
| roles[] | Only for triad draws: theme / challenge / advice, each interpreted |
| affirmation | The card's affirmation |
Sample headline + two sections:
Seven of Wands — a genuinely mixed day, shaped mostly by how you choose to meet it.
Where it lands. This is a Minor Arcana card, so it lands in the ordinary texture of the day — specifically creativity, drive, and the projects you are pushing forward.
Watch for. Watch for the other face of this card: overwhelm, giving up ground, exhaustion. That is how today's gift curdles if you overplay it.
Every card in the deck is covered in both orientations (there's a test that sweeps all 156 combinations).
Daily LLM prompt — buildDailyPromptContext(guidance)
The daily counterpart to buildPromptContext, for when you want an LLM to write the day's reading:
const guidance = tarot.daily({ userId: 'user-42', mode: 'triad' });
const { system, prompt, context } = tarot.buildDailyPrompt(guidance);The system prompt pins the model to the cards it was given ("never invent additional cards, and never contradict the orientations provided"), asks for 2–3 short paragraphs of plain prose ending in something actionable, and carries the same reversal and safety framing. { format: 'markdown' } opts into structure.
Synthesis — polarity, elements, prompts
import { scorePolarity, elementalBalance, buildPromptContext } from 'tarot-spread-sdk';
scorePolarity(reading.cards);
// { score: -1..1, percentage: 0..100, verdict, contributions }
// verdict: 'yes' | 'conditional-yes' | 'neutral' | 'conditional-no' | 'no'
elementalBalance(reading.cards);
// { counts: {fire, water, air, earth}, dominant, missing, balanced }
const { system, prompt, context } = buildPromptContext(reading);
// system: reader persona instruction for the LLM
// prompt: rendered user message embedding the structured JSON context
// context: machine-readable payload (cards, slots, keywords, meanings…)
// Output shape: 'prose' (default) asks for plain flowing paragraphs — safe to
// render as-is. 'markdown' asks for headings and emphasis instead.
buildPromptContext(reading, { format: 'markdown' });The system prompt also instructs the model to speak directly to the querent and to avoid medical, legal, or financial predictions.
Reversed cards contribute their inverted polarity at 80% strength (REVERSAL_DAMPENING) — reversals complicate rather than simply negate.
Deck & dataset
import { TarotDeck, ALL_CARDS } from 'tarot-spread-sdk';
ALL_CARDS; // all 78 cards, canonical order
TarotDeck.getCardById('the-fool'); // lookup by slug
TarotDeck.filterByArcana('major'); // 22 cards
TarotDeck.filterBySuit('cups'); // 14 cards
const deck = new TarotDeck();
deck.shuffle(1234).draw(3); // seeded, without replacementEach card: id, name, number, arcana, suit, element, polarity (−1…1), keywords (upright/reversed), meanings (upright, reversed, dailyUpright, dailyReversed), and affirmation.
Low-level primitives
fnv1a(str), mulberry32(seed), seedFrom(...parts), normalizeSeed(seed), seededShuffle(items, rng), nextInt(rng, max) are all exported for custom mechanics.
Errors
All errors extend TarotSDKError: InvalidSpreadError, InvalidOptionsError, InvalidCardIndexError, DuplicatePickError, PickSessionError.
Framework Integration
The SDK imports nothing — no framework, no runtime dependency, and no window, document, localStorage, process or fs. The only platform APIs it touches are Date and Math.random, so it behaves the same in React, Vue, Svelte, Angular, plain Node, Deno and React Native. It ships ESM and CJS with types, so both import and require() work.
React & SSR — two things that will bite you
Everything in the SDK is deterministic except two values, and both of them produce React hydration mismatches if you render them. Neither is a bug — they're the parts that are supposed to vary — but they're worth knowing before you hit the warning.
1. reading.drawnAt is a fresh timestamp on every call. Server render and client hydration happen a second or two apart, so the two renders disagree:
server: 2026-08-29T19:05:18.585Z
client: 2026-08-29T19:05:19.692ZThe cards are identical — only the timestamp moves. Either don't render it, or pass it down from the server rather than recomputing:
// Server component
const reading = new TarotSDK().drawSpread({ type: 'timeline', seed });
return <Spread cards={reading.cards} drawnAt={reading.drawnAt} />; // passed, not recomputed2. An unseeded drawSpread() draws randomly, by design. Call it on the server and again on the client and you get different cards. Always pass a seed when the result has to survive hydration:
sdk.drawSpread({ type: 'timeline' }) // ✗ server and client disagree
sdk.drawSpread({ type: 'timeline', seed: userId }) // ✓ same cards in both rendersdaily() needs neither precaution: the same (date, userId) yields the same card in every runtime and timezone, which is what makes it safe to render on a server and hydrate on a client.
One edge case worth a thought if you cache: the day key is UTC, so a page rendered just before UTC midnight and hydrated just after will land on different days. With export const revalidate = 3600 a cached page can also serve yesterday's card for up to an hour past midnight. If that matters, revalidate on a UTC-day boundary instead of a fixed interval.
Next.js (App Router) — daily card widget
Deterministic draws make server components and client hydration agree for the whole UTC day:
// app/daily/page.tsx
import { TarotSDK, type DailySingleGuidance } from 'tarot-spread-sdk';
export const revalidate = 3600;
export default function DailyCard() {
const tarot = new TarotSDK();
const daily = tarot.daily({ userId: 'site-wide' }) as DailySingleGuidance;
return (
<section>
<h1>
{daily.card.card.name}
{daily.card.orientation === 'reversed' && ' (Reversed)'}
</h1>
<p>{daily.focus}</p>
<blockquote>{daily.affirmation}</blockquote>
</section>
);
}Interactive spread as a client component:
'use client';
import { useMemo, useState } from 'react';
import { TarotSDK, type SpreadReading } from 'tarot-spread-sdk';
export function InteractiveSpread({ seed }: { seed: string }) {
const session = useMemo(
() => new TarotSDK().beginManualSpread({ type: 'timeline', seed }),
[seed],
);
const [picked, setPicked] = useState<number[]>([]);
const [reading, setReading] = useState<SpreadReading | null>(null);
const onCardClick = (token: number) => {
if (session.status === 'complete') return;
session.pick(token);
setPicked([...session.picked]);
if (session.status === 'complete') setReading(session.getReading());
};
return reading ? (
<ul>
{reading.cards.map((c) => (
<li key={c.card.id}>
[{c.slot.name}] {c.card.name} ({c.orientation})
</li>
))}
</ul>
) : (
<div>
{session.tokens.map(({ token }) => (
<button
key={token}
disabled={picked.includes(token)}
onClick={() => onCardClick(token)}
>
🂠
</button>
))}
</div>
);
}React — plain React (Vite, CRA, anything)
No Next.js required. The SDK is synchronous and side-effect free, so a useMemo is the whole integration:
import { useMemo } from 'react';
import { TarotSDK, narrateDaily, type DailySingleGuidance } from 'tarot-spread-sdk';
const sdk = new TarotSDK();
export function useDailyCard(userId: string) {
// Recomputes only when the UTC day or the user changes — not on every render.
const dateKey = new Date().toISOString().slice(0, 10);
return useMemo(() => {
const guidance = sdk.daily({ userId }) as DailySingleGuidance;
return { guidance, narration: narrateDaily(guidance) };
}, [userId, dateKey]);
}
export function DailyCard({ userId }: { userId: string }) {
const { guidance, narration } = useDailyCard(userId);
return (
<article>
<h2>{narration.headline}</h2>
<p>{narration.opening}</p>
{narration.sections.map((section) => (
<section key={section.label}>
<h3>{section.label}</h3>
<p>{section.text}</p>
</section>
))}
<blockquote>{guidance.affirmation}</blockquote>
<small>{guidance.card.card.name} · {guidance.dateKey} (UTC)</small>
</article>
);
}React — yes/no verdict with the reader's narration
narrateReading() writes the whole interpretation offline — no API key, no network, no LLM:
import { useState } from 'react';
import { TarotSDK, narrateReading, type SpreadReading } from 'tarot-spread-sdk';
const sdk = new TarotSDK();
export function AskTheDeck() {
const [question, setQuestion] = useState('');
const [reading, setReading] = useState<SpreadReading | null>(null);
const ask = () =>
setReading(
sdk.drawSpread({
type: 'yes-no-guidance',
question: question.trim() || undefined,
// No seed: a fresh question should draw fresh cards. Pass one if the
// result has to survive a reload or a server render.
}),
);
if (!reading) {
return (
<>
<input value={question} onChange={(e) => setQuestion(e.target.value)} />
<button onClick={ask}>Ask the deck</button>
</>
);
}
const narration = narrateReading(reading);
return (
<article>
{/* Read the verdict off the reading — do NOT call verdictFor(reading).
verdictFor takes a raw number, and handing it an object silently
returns "neutral". */}
<strong>{reading.polarity.verdict}</strong>
<span>{reading.polarity.percentage}% yes</span>
<p>{narration.opening}</p>
{narration.positions.map((position) => (
<section key={position.slot}>
<h3>{position.slot} — {position.card} ({position.orientation})</h3>
<p>{position.text}</p>
</section>
))}
<p>{narration.synthesis}</p>
<em>{narration.closing}</em>
</article>
);
}Angular — daily guidance service
A complete runnable Angular app (daily card, yes/no verdict, interactive click-to-pick) lives in examples/angular-demo, and is deployed here.
// tarot.service.ts
import { Injectable } from '@angular/core';
import {
TarotSDK,
type DailySingleGuidance,
type SpreadReading,
type SpreadType,
} from 'tarot-spread-sdk';
@Injectable({ providedIn: 'root' })
export class TarotService {
private readonly sdk = new TarotSDK();
dailyFor(userId: string): DailySingleGuidance {
return this.sdk.daily({ userId }) as DailySingleGuidance;
}
drawSpread(type: SpreadType, question?: string): SpreadReading {
return this.sdk.drawSpread({ type, question });
}
}// daily-card.component.ts
import { Component, inject } from '@angular/core';
import { TarotService } from './tarot.service';
@Component({
selector: 'app-daily-card',
standalone: true,
template: `
<article>
<h2>{{ daily.card.card.name }} ({{ daily.card.orientation }})</h2>
<p>{{ daily.focus }}</p>
<em>{{ daily.affirmation }}</em>
</article>
`,
})
export class DailyCardComponent {
readonly daily = inject(TarotService).dailyFor('user-42');
}Angular — signals, with the reader's narration
The modern shape: input.required() for the user, computed() for the draw and its narration. Nothing is recomputed unless the input changes.
// daily-card.ts
import { Component, computed, inject, input } from '@angular/core';
import { narrateDaily, type DailySingleGuidance } from 'tarot-spread-sdk';
import { TarotService } from './tarot.service';
@Component({
selector: 'daily-card',
template: `
<article>
<h2>{{ narration().headline }}</h2>
<p>{{ narration().opening }}</p>
@for (section of narration().sections; track section.label) {
<section>
<h3>{{ section.label }}</h3>
<p>{{ section.text }}</p>
</section>
}
<blockquote>{{ guidance().affirmation }}</blockquote>
<small>{{ guidance().card.card.name }} · {{ guidance().dateKey }} (UTC)</small>
</article>
`,
})
export class DailyCard {
private readonly tarot = inject(TarotService);
readonly userId = input.required<string>();
readonly guidance = computed(() => this.tarot.dailyFor(this.userId()));
readonly narration = computed(() => narrateDaily(this.guidance()));
}Angular — interactive click-to-pick
beginManualSpread() hands you 78 opaque tokens. The card behind each one isn't decided until it's picked, so a reader genuinely chooses from a face-down deck:
// spread-picker.ts
import { Component, computed, inject, signal } from '@angular/core';
import { narrateReading, type SpreadReading } from 'tarot-spread-sdk';
import { TarotService } from './tarot.service';
@Component({
selector: 'spread-picker',
template: `
@if (reading(); as done) {
<p>{{ narration()!.opening }}</p>
@for (position of narration()!.positions; track position.slot) {
<section>
<h3>{{ position.slot }} — {{ position.card }} ({{ position.orientation }})</h3>
<p>{{ position.text }}</p>
</section>
}
<p>{{ narration()!.synthesis }}</p>
} @else {
<p>Pick {{ remaining() }} more card(s).</p>
@for (t of session.tokens; track t.token) {
<button [disabled]="isPicked(t.token)" (click)="pick(t.token)">🂠</button>
}
}
`,
})
export class SpreadPicker {
private readonly tarot = inject(TarotService);
readonly session = this.tarot.beginPick('timeline');
readonly picked = signal<readonly number[]>([]);
readonly reading = signal<SpreadReading | null>(null);
readonly narration = computed(() => {
const done = this.reading();
return done ? narrateReading(done) : null;
});
// The session also exposes `session.remaining` directly. It is derived here
// instead so the value tracks the `picked` signal — reading the session's
// own property would not re-run this computed, because the session is a
// plain object that mutates in place.
readonly remaining = computed(
() => this.session.spread.cardCount - this.picked().length,
);
isPicked(token: number): boolean {
return this.picked().includes(token);
}
pick(token: number): void {
if (this.session.status === 'complete') return;
this.session.pick(token);
// The session mutates in place, so copy the array — signals compare by
// reference and would not otherwise see the change.
this.picked.set([...this.session.picked]);
if (this.session.status === 'complete') {
this.reading.set(this.session.getReading());
}
}
}Add the matching method to the service:
// tarot.service.ts
beginPick(type: SpreadType, question?: string) {
return this.sdk.beginManualSpread({ type, question });
}LLM synthesis (any backend)
const reading = tarot.drawSpread({
type: 'celtic-cross',
question: 'What should I focus on this quarter?',
});
const { system, prompt } = tarot.buildPrompt(reading);
// e.g. with the Anthropic SDK:
// const msg = await client.messages.create({
// model: 'claude-sonnet-5',
// max_tokens: 1024,
// system,
// messages: [{ role: 'user', content: prompt }],
// });Examples
Runnable scripts live in examples/:
basic-usage.ts (quickstart), daily-widget.ts (deterministic daily guidance), and interactive-spread.ts (manual click-to-pick simulation). Run any of them with npx tsx examples/<file>.ts. A full Angular demo app lives in examples/angular-demo.
Development
This repo uses pnpm (see the packageManager field).
pnpm install
pnpm typecheck # strict TS, no emit
pnpm test # vitest (92 tests: determinism, bounds, idempotency, polarity, narration)
pnpm build # tsup → dist/ (ESM + CJS + d.ts)To publish, see PUBLISHING.md.
Running the Angular demo
The demo is a separate package, so run it through these root shortcuts (or cd examples/angular-demo and use pnpm start there):
pnpm demo:install # once — installs the demo's dependencies
pnpm demo # rebuilds the SDK, then serves on http://localhost:4200
pnpm demo:api # optional, second terminal — Gemini reading proxy (needs examples/angular-demo/.env)pnpm demo rebuilds dist/ first on purpose: the demo imports the built SDK, so a stale dist/ is the usual reason a new SDK feature doesn't show up in the browser.
CI runs the suite on Node 18/20/22 in two timezones (UTC and UTC+14) to guard the daily-draw idempotency contract, and checks gzipped bundle size against a 28 KB budget (currently 26 KB). The full Rider-Waite-Smith text corpus is the dominant cost at 18 KB — the original sub-15 KB target is only reachable by truncating card meanings, which we deliberately do not do.
License
MIT
