Skip to content
AnkinizeOpenAPILog in
OverviewSync with AnkiAuthenticationAPI referenceTypeScript SDKAnkiConnect alternativeAI agentsMCP serverSelf-host

Tutorials

Browser extension

TypeScript SDK

The official SDK is the REST API as plain, typed functions: one per endpoint, named after the operation. It sends standard fetch requests, has no dependencies, and leaves out the functions you do not import.

Install#

bun add @ankinize/sdk

It runs wherever fetch does: Node 20 and later, Bun, Deno, Cloudflare Workers and browsers. It is an ES module with its own type declarations.

Set it up#

Create an API key under API Keys & Devices in the dashboard (see Authentication), then tell the SDK which key to use. Do this once, before the first call:

Once, at start-up
import { client } from "@ankinize/sdk";

client.setConfig({
  // Read the key from the environment, never from source code.
  auth: process.env.ANKINIZE_API_KEY,
});

The SDK sends requests to https://ankinize.com unless you say otherwise, so baseUrl is optional. Set it only if you run your own server, for example baseUrl: "https://anki.example.com".

Keep the key on your server

An API key can read and change your whole collection. Use the SDK from code you run yourself, not from a web page other people open.

Make calls#

Each function takes one object: the path values, query values and body fields side by side, with no wrapping. It returns the parsed answer, typed from the API's schemas, and throws if the call fails.

Read and write
import { createNote, getNote, listDecks, listNotes, q } from "@ankinize/sdk";

// Nothing to pass.
const { decks } = await listDecks();

// A query value.
const { notes } = await listNotes({ q: q.did(decks[0].id), limit: 20 });

// A path value.
const note = await getNote({ id: notes[0].id });

// Body fields, as they appear in the API reference.
const created = await createNote({
  notetypeName: "Basic",
  deckId: decks[0].id,
  fields: { front: "der Hund", back: "the dog" },
  tags: ["german"],
});

The function for an endpoint has the endpoint's operation id as its name, and the TypeScript tab under every endpoint in the API reference shows the call. Request and response types are exported too:

ts
import type { Deck, Note } from "@ankinize/sdk";

Searching#

Every list is narrowed by one search, q: listNotes, listCards, listDecks, listTags and listNoteTypes take it, and nothing else narrows them. It is Anki's search text (the syntax of the Anki browser) or a search value built with q, which writes the text and its escaping for you and is checked against the list it is sent to. The server does the searching, so there is no need to page through a collection to find a note.

Vocabulary starting with dog, not suspended
import { listNotes, q } from "@ankinize/sdk";

const { notes } = await listNotes({
  q: q.and(
    q.tag("vocab"),
    q.field("Front", q.glob("dog*")), // * is any run, _ one character
    q.not(q.is("suspended")),
  ),
  limit: 50,
});

// The same search as text.
await listNotes({ q: "tag:vocab front:dog* -is:suspended" });

A plain string is literal: it matches those characters in any case, and * and _ are only characters. For anything else pass a pattern: q.glob("dog*"), q.regex("^d.g"), q.accents("cafe") (ignoring accents) or q.fuzzy("japnese"). Combine searches with q.and, q.or and q.not; the terms are the ones of Anki's search (q.deck, q.notetype, q.is, q.flag, q.prop, q.added, q.rated, q.nid, ...), and q.raw takes search text the builders do not cover. A search value knows which lists it can be sent to: listDecks({ q: q.is("due") }) does not compile, because is:due says nothing about a deck.

  • fz: (q.fuzzy) is Ankinize's, not Anki's. Every word of the search must be close to a word of the note, in any order, ignoring case, accents and markup: a word of up to 4 characters must match exactly, one of 5 to 8 may have one typo, a longer one two. It answers yes or no, in id order, and never ranks.
  • A card matches when it satisfies the whole search. A note matches when one of its cards does, so q.and(q.deck("German"), q.is("due")) finds notes with a due card in that deck. A note without cards is found only by a search that asks nothing of cards, such as tags and fields.
  • On listDecks, listTags and listNoteTypes the search filters names: plain text matches anywhere in the name, and q.regex, q.accents, q.fuzzy and the list's own term (q.deck, q.tag, q.notetype) work too. These lists are not paged.
  • The old deckId, notetypeId, noteId and tag parameters are gone; say it in q (q.did(id), q.mid(id), q.nid(id), q.tag(name)).

Paging through a list#

listNotes and listCards return a page and a nextCursor. To read every match, use streamNotes or streamCards: an async iterable that fetches the next page only when the last one is used up, until nextCursor is null. A search can come back with a short or empty page and a cursor, so it never stops at an empty page; stopping early reads no more pages.

Every note with a tag
import { q, streamNotes } from "@ankinize/sdk";

for await (const note of streamNotes({ q: q.tag("vocab") })) {
  console.log(note.fields.front);
}

By hand, pass nextCursor back as cursor until it is null.

Passing results through steps#

Everything is a plain function, so results can be passed through steps. pipe(value, f, g) runs a value through functions and flow(f, g) builds the function. iter.map, iter.filter, iter.take and iter.toArray are data-last steps for async iterables (arrays have their own methods), and indexBy, groupBy and nest take any iterable. q.andAlso, q.orElse and q.butNot continue a search inside a pipe.

ts
import { iter, listDecks, nest, pipe, q, streamNotes } from "@ankinize/sdk";

// The first 20 fronts of due vocabulary.
const fronts = await pipe(
  streamNotes({ q: pipe(q.tag("vocab"), q.andAlso(q.is("due"))) }),
  iter.map((note) => note.fields.front ?? ""),
  iter.take(20),
  iter.toArray,
);

// The decks as a tree, from the :: in their names.
const tree = pipe((await listDecks()).decks, nest((deck) => deck.name));

Media files#

A file goes up as a Blob and comes down as one, so there is nothing to encode.

ts
import { openAsBlob } from "node:fs";
import { getMedia, putMedia } from "@ankinize/sdk";

await putMedia({ name: "cat.png", body: await openAsBlob("cat.png") });

const file = await getMedia({ name: "cat.png" }); // a Blob
console.log(file.type, file.size);

Many at once#

createNotes, updateNotes, deleteNotes, updateCards, deleteCards, createTags, updateTags (a rename) and deleteTags take one item or an array. They are the only way the SDK reaches the batch endpoints. They cut the array into calls the API accepts, send them one after the other, wait and retry when the server is busy or the rate limit asks to, and answer once with the entries in the order you gave them.

Import a list
import { createNotes } from "@ankinize/sdk";

const { created, failed, results } = await createNotes(notes);
for (const r of results) if (!r.ok) console.log(r.index, r.error);

An entry the collection refuses is reported in results and the rest are written. { atomic: true } writes all or nothing, and needs everything to fit one call (200 notes, edits or tag names, 1000 ids or tag names). If a later call fails for good, the helper throws a PartialBatchError whose completed holds the answers of the calls that went through.

Type-safe names#

A note names its note type and deck, and its fields are an object keyed by each field's name in camelCase (Back Extra is backExtra). Out of the box those names are plain strings. To have the compiler check them, generate a declaration of your own collection and keep it in your project; run it again after you add a note type, a field or a deck:

ts
import { client, generateTypes } from "@ankinize/sdk";

client.setConfig({ auth: process.env.ANKINIZE_API_KEY });
await Bun.write("ankinize.d.ts", await generateTypes());

After that createNote and createNotes reject a misspelt notetypeName or deckName, and a key in fields that the chosen note type does not have, and the terms q.deck, q.notetype and q.field (by the field's exact name, such as Back Extra) reject a name that is not there. Pass a pattern, for example q.glob("Lang*"), for a wildcard. Tags are left out unless you ask, with generateTypes({ tags: true }): a union of thousands of tags slows the compiler. Notes made by notetypeId are not checked.

Errors#

Any answer that is not a success throws an AnkinizeError, which is a normal Error with what the API said:

PropertyHolds
statusThe HTTP status, such as 404 or 429.
codeThe reason, such as not_found or rate_limited. See the full list.
messageA sentence for a person.
responseThe raw Response, for headers such as Retry-After. Its body is already read.
Wait when Anki is busy
import { AnkinizeError, createDeck } from "@ankinize/sdk";

try {
  await createDeck({ name: "Languages::German" });
} catch (error) {
  if (!(error instanceof AnkinizeError)) throw error; // a network failure, say

  if (error.status === 423 || error.status === 429) {
    // Anki is syncing, or the plan's rate limit is used up. Nothing changed.
    const seconds = Number(error.response.headers.get("Retry-After") ?? 1);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    // ...send the same request again
  } else {
    console.error(error.code, error.message);
  }
}

Retrying safely#

A write can take an idempotencyKey like any other field (the SDK sends it as the Idempotency-Key header). A retry with the same key returns the first answer instead of doing the work twice; see Retrying safely.

ts
const key = crypto.randomUUID(); // make it once, reuse it on every retry

await createNote({
  notetypeName: "Basic",
  deckName: "Languages::German",
  fields: { front: "der Hund", back: "the dog" },
  idempotencyKey: key,
});

More than one key or server#

client.setConfig sets the defaults. A single call can override them with an extra, last object (the only one, when the call takes nothing else). The shared settings are left alone, so calls made for different people can run at the same time:

ts
await listDecks({}, { auth: aliceKey });

await getNote({ id: 1736899300456 }, { auth: bobKey, baseUrl: "https://anki.example.com" });

Your own fetch#

The SDK calls the global fetch when a request is sent, so a mock installed in a test or a polyfill installed late is used. To route requests somewhere else, pass your own:

ts
client.setConfig({ fetch: myFetch });

A custom fetch turns errors into plain objects

The SDK builds AnkinizeError inside the fetch it installs. If you replace that, a failed call throws the API's JSON body, { error: { code, message } }, and you check the status yourself.

For AI agents#

An agent that writes code against Ankinize should load the SDK skill first: it is this page in the form an agent follows best, with the full list of functions generated from the API itself. A coding agent can also be pointed at this page's Markdown.

ResourceWhat it is
https://ankinize.com/.well-known/agent-skills/ankinize-sdk/SKILL.mdThe agent skill for the TypeScript SDK: set-up, every function, batches, errors, retries and type-safe names. Load this when the task is TypeScript or JavaScript.
https://ankinize.com/.well-known/agent-skills/ankinize-api/SKILL.mdThe agent skill for the REST API: authentication, conventions and every endpoint. Use it from any other language or with plain HTTP.
https://ankinize.com/.well-known/agent-skills/index.jsonThe discovery index of both skills, with the SHA-256 of each file.
https://ankinize.com/llms.txtThe llms.txt index: what Ankinize is, and a link to every page and skill.
https://ankinize.com/llms-full.txtEvery documentation page in one Markdown file.
https://ankinize.com/api/v1/openapi.jsonThe OpenAPI document: exact parameters and responses. SDK functions are named after its operation ids.

Any page of these docs is also available as Markdown: add .md to its address (https://ankinize.com/docs/sdk.md), or ask for it with Accept: text/markdown.

Without the SDK#

The SDK adds nothing the API does not already do. If you would rather not add a package, this is the same call by hand:

ts
const response = await fetch("https://ankinize.com/api/v1/decks", {
  headers: { Authorization: `Bearer ${process.env.ANKINIZE_API_KEY}` },
});
if (!response.ok) throw new Error((await response.json()).error.message);
const { decks }: { decks: { id: number; name: string }[] } = await response.json();

On this page

  • Install
  • Set it up
  • Make calls
  • Searching
  • Paging through a list
  • Passing results through steps
  • Media files
  • Many at once
  • Type-safe names
  • Errors
  • Retrying safely
  • More than one key or server
  • Your own fetch
  • For AI agents
  • Without the SDK