# 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](https://ankinize.com/docs/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](https://ankinize.com/docs/api) 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:

| Property | Holds |
| --- | --- |
| `status` | The HTTP status, such as 404 or 429. |
| `code` | The reason, such as `not_found` or `rate_limited`. See the [full list](https://ankinize.com/docs/api#errors). |
| `message` | A sentence for a person. |
| `response` | The 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](https://ankinize.com/docs/api#idempotency).

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.

| Resource | What it is |
| --- | --- |
| [https://ankinize.com/.well-known/agent-skills/ankinize-sdk/SKILL.md](https://ankinize.com/.well-known/agent-skills/ankinize-sdk/SKILL.md) | The 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.md](https://ankinize.com/.well-known/agent-skills/ankinize-api/SKILL.md) | The 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.json](https://ankinize.com/.well-known/agent-skills/index.json) | The discovery index of both skills, with the SHA-256 of each file. |
| [https://ankinize.com/llms.txt](https://ankinize.com/llms.txt) | The llms.txt index: what Ankinize is, and a link to every page and skill. |
| [https://ankinize.com/llms-full.txt](https://ankinize.com/llms-full.txt) | Every documentation page in one Markdown file. |
| [https://ankinize.com/api/v1/openapi.json](https://ankinize.com/api/v1/openapi.json) | The 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();
```
