Incantory
Sign in

TypeScript SDK

@incantory/sdk: resolve owner/slug@label refs to hash-verified versions, render with typed variables, pin in incantory.lock and report evals.

@incantory/sdk is built on fetch, so it runs on Node 20.19 or later, in browsers, on edge runtimes and in Deno. It ships ESM and CommonJS, and its only runtime dependency is @incantory/shared (the same render and hashing code the website uses).

bash
npm install @incantory/sdk
ts
import { Incantory } from '@incantory/sdk';

const inc = new Incantory({ token: process.env.INCANTORY_TOKEN }); // token optional for public prompts
const prompt = await inc.prompts.get('alice/code-review@production');
const { text } = prompt.render({ language: 'Go', code }) as { text: string };

Client options

ts
new Incantory({
  token,      // inc_... API token; default $INCANTORY_TOKEN (Node). '' forces anonymous
  baseUrl,    // default $INCANTORY_URL or https://incantory.ai
  cacheDir,   // disk cache (Node); false = memory only; default $XDG_CACHE_HOME/incantory or ~/.cache/incantory
  cache,      // bring your own CacheStore
  fetch,      // custom fetch (tests, proxies)
  lockfile,   // path to incantory.lock: refs in it resolve to their pinned version
  maxRetries, // default 3
  timeoutMs,  // default 30000
  userAgent,  // appended to X-Incantory-Client
});

The constructor does no I/O; the cache and lockfile are opened on first use.

Resolving prompts

inc.prompts.get(ref, { offline?, ignoreLock? }) returns a Prompt.

RefWhat happens
owner/slug@v3Served from the local cache with no request once cached; otherwise GET /versions/3 (immutable)
owner/slug@productionGET /labels with If-None-Match (usually a 304), then the version as above
owner/slugGET /prompts/{owner}/{slug} with ETag revalidation; the latest version is embedded

Every downloaded version is re-hashed with incantory-tree-v1; a mismatch with the server's contentHash throws integrity_error.

If the API cannot be reached, label and latest lookups fall back to the last cached answer and set prompt.stale = true. Pass { offline: true } to skip the network entirely (offline_cache_miss when nothing is cached).

The Prompt value

Field or methodContents
ref / requestedRefThe pinned ref (owner/slug@v3) and the ref you asked for
version, contentHash, kindVersion number, hash and prompt kind
files, frontmatter, variables, targetModelsPackage contents and metadata
yanked, deprecationWhether the version was yanked, and the Deprecation header
fromCache, staleWhere the answer came from
render(vars, { onMissing }){ kind: 'text', text } or { kind: 'chat', messages }
text(vars)The rendered output as plain text
toFiles()Files as they would be written to disk
verify()Recompute and check the hash

Chat prompts render to messages, split on ## system / ## user / ## assistant before variables are substituted.

Registry calls

MethodEndpoint
prompts.detail(ref)GET /prompts/{o}/{s} with ?version or ?label
prompts.list(q), prompts.search(text, q), prompts.iterate(q)GET /prompts (keyset cursors)
prompts.versions(ref, { cursor, limit })GET /versions
prompts.version(ref, n)GET /versions/{n}
prompts.diff(ref, a, b)GET /diff/{a}..{b}
prompts.labels.list / set / delete / history/labels (set can fail with version_yanked or eval_required)
prompts.fork(ref, { version, slug, licenseSpdx })POST /forks
prompts.create(input)POST /prompts
prompts.commit(ref, { files, message, baseVersion })POST /versions (409 stale_base / no_changes)
me()GET /api/me

Pinning with incantory.lock

ts
await inc.lock.pin(['alice/haiku@production', 'bob/review']); // resolve now and write the pins
const results = await inc.lock.verify(); // [{ ref, version, status: ok|mismatch|missing|error, outdated, latestVersion }]
const pinned = new Incantory({ lockfile: 'incantory.lock' }); // get() now serves pinned versions
json
{
  "lockfileVersion": 1,
  "prompts": {
    "alice/haiku@production": { "version": 3, "contentHash": "...", "resolvedAt": "2026-09-28T12:00:00.000Z" }
  }
}

The file is byte-identical whether the TypeScript SDK, the Python SDK or the CLI writes it. A client configured with a lockfile throws lock_mismatch if the registry ever serves different bytes for a pinned version.

Typed variables

bash
npx incantory codegen alice/haiku@production -o src/prompts.d.ts

The generated file augments PromptVariablesRegistry, so prompts.get('alice/haiku@production') returns a prompt whose render() arguments are typed: required variables are enforced, enums become literal unions, and a mistake is a compile error. generateTypes({ owner, slug, package }) does the same programmatically.

Reporting evals

ts
import { parseJsonl } from '@incantory/sdk';

const results = parseJsonl(await readFile('results.jsonl', 'utf8'));
await inc.evals.report(results, { create: { ref: 'alice/haiku@v3', model: 'claude-opus-5-5' } }); // or { runId }

Results are sent in chunks of 500. See Evals and the CI gate.

Device login

ts
const flow = await inc.device.start({ deviceName: 'my tool' });
console.log(flow.userCode, flow.verificationUrl);
if ((await flow.wait()) === 'approved') inc.setToken(flow.token);

The SDK mints the token itself; the server only ever sees its SHA-256 and a short prefix.

Verifying webhooks

verifyWebhookSignature(secret, header, body, toleranceSec = 300) checks the Incantory-Signature header of a webhook delivery. Pass the raw request body exactly as received. It returns a Promise<boolean> (it uses Web Crypto, so it also runs in browsers and edge runtimes) and never throws on malformed input; timestamps older than toleranceSec are rejected to stop replays.

ts
import { verifyWebhookSignature } from '@incantory/sdk';

export async function POST(req: Request) {
  const body = await req.text();
  const ok = await verifyWebhookSignature(process.env.INCANTORY_WEBHOOK_SECRET!, req.headers.get('incantory-signature') ?? '', body);
  if (!ok) return new Response('bad signature', { status: 401 });
  const event = JSON.parse(body);
  // ...
  return new Response(null, { status: 204 });
}

Errors and retries

Every failure is an IncantoryError with status (0 when there was no response), code (the API's stable code, or an SDK code such as network_error, timeout, integrity_error, lock_mismatch, label_not_found, offline_cache_miss, invalid_ref), message, requestId, details and retryAfter. Retry behaviour is described in Rate limits and errors.

Cache

The disk cache lives under <cacheDir>/v1/ and is shared with the Python SDK: immutable versions by content hash, @vN refs, and ETag-revalidated label and latest lookups (keyed per token fingerprint, never the token itself).