Skip to main content

TypeScript SDK

@kanera/sdk is a typed client for the Kanera public API. It has no runtime dependencies and is built on global fetch, so it runs unchanged on Node 18+, Bun, Deno, Cloudflare Workers, and browsers.

npm install @kanera/sdk
import { Kanera } from "@kanera/sdk";

const kanera = new Kanera({ apiKey: process.env.KANERA_API_KEY! });

const session = await kanera.session();
if (session.scope === "read") throw new Error("this credential cannot write");

for await (const card of kanera.work.iterateCards()) {
console.log(card.key, card.title);
}

await kanera.cards.setCompletion("MKT-42", true);
await kanera.comments.create("MKT-42", { body: "Shipped in 1.4.0." });

The SDK is published under the MIT license, deliberately more permissive than the Kanera server (Elastic License 2.0), so embedding it in your own application raises no license-policy questions.

Authenticate​

Create an API key in Profile settings -> API keys or Workspace settings -> API, and read it from the environment rather than committing it. See choose an API key type for which kind fits your integration.

const kanera = new Kanera({ apiKey: process.env.KANERA_API_KEY! });

Self-hosting? Point the client at your own deployment:

const kanera = new Kanera({ apiKey, baseUrl: "https://api.your-kanera.example" });

Check the scope first​

const { scope } = await kanera.session();

read means every mutation will be refused with a FORBIDDEN error. Checking once at start-up is cheaper than discovering it halfway through a migration.

What is on the client​

NamespaceCovers
kanera.workspacesWorkspaces, their boards, members, and automations
kanera.boardsAccessible boards, opening a board, board cards and activity; watch and unwatch
kanera.listsList-level card operations: archive, move, set completion
kanera.separatorsCreate, style, position, move, and delete board-list separators
kanera.cardsCards, plus .attachments, .checklists, and .bulk; watching and reminders (watch, unwatch, setReminder, clearReminder, listReminders, followState); pollNotifications
kanera.commentsCard comments and reactions
kanera.runsAgent runs: announce, heartbeat, and finish in-flight work on a card
kanera.notesNotes, backlinks, and note attachments
kanera.searchSearch across accessible content
kanera.workCross-board work, history, portfolio summaries, and Up next queues
kanera.automationsWorkspace automation rules and typed execution history
kanera.webhookEndpointsRegister and manage outgoing webhooks, inspect deliveries, and retry failures
kanera.httpThe raw request client, for anything not yet wrapped

Card references​

Anywhere a card is named, pass a UUID, a human key such as MKT-42, or a canonical card URL. Keys are resolved once per client and cached, so repeating one across a bulk operation costs a single lookup.

A key prefix is unique only inside an organisation, and a personal credential can see several. A key visible in more than one is rejected, not guessed at — pass the canonical URL or the UUID to disambiguate.

Card relationships​

Read, add, and remove dependencies with the typed card methods. References can be keys, UUIDs, or canonical card URLs.

// MKT-42 blocks WEB-18. Both cards must belong to the same workspace.
const relation = await kanera.cards.addRelation("MKT-42", "WEB-18", "blocks");
await kanera.cards.addRelation("WEB-18", "MKT-43", "blocked_by");
await kanera.cards.addRelation("WEB-18", "MKT-44", "relates_to");

const { relations, hiddenBlockerCount } = await kanera.cards.listRelations("WEB-18");
const detail = await kanera.cards.get("WEB-18"); // also includes relations
const waiting = await kanera.work.cards({ filters: { blockedOnly: true } });
await kanera.cards.removeRelation("MKT-42", relation.id);

listRelations returns only cards the credential can open. Hidden active blockers contribute to hiddenBlockerCount. Adds are idempotent; cycles and completed cards as new blockers are rejected. The work query filters also support blockedOnly: true to find blocked open cards across accessible boards, and atRiskOnly: true for open cards whose visible blocker is overdue or due after them. Completing a card through the SDK does not display the web app's blocked-work warning.

Dependency graphs​

cards.dependencyGraph reads the same bounded graph as the web view. Choose one board, workspace, or set of card references:

const aroundCard = await kanera.cards.dependencyGraph({ cards: ["MKT-42"] });
const boardGraph = await kanera.cards.dependencyGraph({ boardId });
const workspaceGraph = await kanera.cards.dependencyGraph({ workspaceId });

for (const edge of aroundCard.edges) {
console.log(edge.sourceCardId, edge.kind, edge.targetCardId);
}

The result includes cards, edges, lookups, card-face custom fields, board canEdit flags, and truncated. cards accepts keys, UUIDs, or canonical URLs and resolves them to the API's cardIds. A board graph can include connected cards on other readable boards; inScope distinguishes them. Hidden active blockers remain counted through blockedByCount, and the graph stops at 400 cards. See the graph API for traversal details.

Board separators​

Separators are board-owned items that share a list's ordering lane with cards. Their typed anchors can target either kind of item, so an integration can create an exact section and then place cards around it without calculating numeric positions.

const separator = await kanera.separators.create(boardId, listId, {
title: "This week",
color: "blue",
anchor: { side: "before", item: { type: "card", id: cardId } },
});

await kanera.cards.move("MKT-42", {
listId,
anchor: { side: "after", item: { type: "separator", id: separator.id } },
});

await kanera.separators.update(separator.id, { title: "Next up", color: "green" });

Use kanera.separators.move to reposition a separator relative to a card, another separator, or a list edge. Deleting a separator leaves every card in place.

Reminders and watching​

Watching and reminders are the connected person's own, so they need a personal key or an OAuth connection; workspace keys receive 403. A reminder is private, at most one per card, and up to eight weeks ahead.

await kanera.cards.watch("MKT-42");
const state = await kanera.cards.setReminder("MKT-42", new Date("2026-10-16T15:00:00+01:00"));
state.reminder?.remindAt; // "2026-10-16T14:00:00.000Z"

const pending = await kanera.cards.listReminders(); // soonest first, with each card's key, title and url
await kanera.cards.clearReminder("MKT-42");

setReminder replaces any existing reminder on the card, including one that already fired. See Reminders.

To hear about replies and mentions without receiving webhooks, poll your notification inbox. Keep the returned cursor and pass it back as since:

let cursor: string | undefined;
const page = await kanera.cards.pollNotifications({ since: cursor });
for (const item of page.items) console.log(item.summary, item.card?.url);
cursor = page.cursor; // repeat while page.hasMore

Create several cards​

kanera.cards.bulk.create creates up to 50 cards on one board in one request, each with its own list, labels, assignees, and due date. Every card is validated before any is created, so pass an idempotencyKey and the whole batch is safe to retry.

const { cards } = await kanera.cards.bulk.create(boardId, [
{ listId, title: "Draft the brief", labelIds: [labelId] },
{ listId, title: "Review with legal", assigneeIds: [userId], dueDateLocalDate: "2026-11-02" },
], { idempotencyKey: crypto.randomUUID() });

kanera.cards.create takes the same labelIds, assigneeIds, dueDateLocalDate, and dueDateSlot for a single card.

Checklists​

Create a checklist with its whole contents in one request: items with descriptions, assignees, and due dates, and one level of sub-checklists under top-level items. The server generates and connects every ID atomically and returns them, so there is nothing to look up afterwards.

const plan = await kanera.cards.checklists.create("MKT-42", {
title: "Launch",
items: [
{
text: "Prepare release",
description: "Coordinate the release.",
assigneeId: userId,
dueDateLocalDate: "2026-12-01",
subChecklists: [{ title: "Verification", items: [{ text: "Run smoke tests" }, { text: "Confirm rollback" }] }],
},
{ text: "Announce launch" },
],
});

// Add items in order at a position (omit the anchor to append).
await kanera.cards.checklists.addItems("MKT-42", plan.id, [{ text: "Kickoff" }], { afterItemId: null });

// Different changes for chosen items, applied atomically.
const [verification] = plan.subChecklists;
await kanera.cards.checklists.updateItems("MKT-42", verification.items.map((item) => ({ itemId: item.id, changes: { completed: true } })));

// The same assignee or due date on every item of one checklist.
await kanera.cards.checklists.updateAllItems("MKT-42", plan.id, { assigneeId: userId });

// Read the card's checklists without the rest of card detail.
const { checklists } = await kanera.cards.checklists.list("MKT-42");

Sub-checklist items take only text and completed, and items move only within their own group: between top-level checklists, or between the sub-checklists of one parent item. A batch is validated in full before anything is written; a rejected request's error.options.body.issues names the exact field, such as items[0].subChecklists[0].items[1].text, and unknown fields are rejected rather than ignored.

Agent runs​

When your integration is an agent doing work on a card, tell Kanera it started. The card tile then shows a live chip naming the agent and the job, card detail shows the in-flight run, and the terminal outcome moves into the activity feed. Nobody has to guess whether a job is still going.

const run = await kanera.runs.start("MKT-42", {
title: "Drafting launch copy",
summary: "Reading the brief and the last two campaigns.",
externalUrl: "https://github.com/acme/site/pull/318", // optional; where a person can watch it
});

// Every update is a heartbeat. Send one at least every 10 minutes on a long job.
await kanera.runs.heartbeat(run.id);

// Waiting on a person? Say so, and the card's chip turns amber.
await kanera.runs.update(run.id, { status: "blocked", summary: "Need sign-off on the pricing line." });

await kanera.runs.finish(run.id, "succeeded", { summary: "Draft posted as a comment." });

finish takes succeeded, failed, or cancelled. A terminal status ends the run and makes it immutable, so a later update on it fails with a CONFLICT error — check error.status === 409 if a retry could land after your own finish.

A live run with no heartbeat for fifteen minutes is marked stalled by Kanera, so a crashed agent cannot leave a card showing "working" forever. A stalled run is not ended: the agent can still come back and close it, or set status: "running" to resume.

Use kanera.runs.list(card) before starting so two agents do not run the same card, { includeEnded: true } for the full history, and kanera.runs.get(runId) to re-read one run.

Register webhooks​

An admin manages every endpoint in a workspace. A non-admin write-capable credential or interactive OAuth agent may also register endpoints, but only sees the ones its own connection created. This is useful when an agent needs event callbacks without workspace-admin authority.

const endpoint = await kanera.webhookEndpoints.create(workspaceId, {
name: "Release agent",
url: "https://agent.example.com/kanera",
eventTypes: ["card:updated", "comment:created"], // [] means every event
});

// The secret is returned only now (and after rotateSecret).
await saveSecret(endpoint.secret);

const deliveries = await kanera.webhookEndpoints.deliveries(workspaceId, endpoint.id);

Connection-scoped endpoints return scope: "connection"; admin-created endpoints return scope: "workspace". The resource also provides list, update, rotateSecret, retryDelivery, and delete. See Webhooks for payload verification and delivery semantics.

Pagination​

Every list endpoint is bounded; there is no "give me the whole board" call. The common integration bug is reading page one and treating it as the whole set, so iteration is the default shape:

for await (const board of kanera.boards.iterate()) { /* … */ }

const firstFifty = await kanera.comments.iterate("MKT-42").all(50);

// Or drive the pages yourself.
let cursor: string | undefined;
do {
const page = await kanera.comments.list("MKT-42", { cursor });
cursor = page.nextCursor ?? undefined;
} while (cursor);

Errors​

import { KaneraApiError } from "@kanera/sdk";

try {
await kanera.cards.update("MKT-42", { title: "New" });
} catch (error) {
if (error instanceof KaneraApiError && error.isForbidden) {
// A read-only credential, or no Editor access to this board. Retrying will not help.
}
}

KaneraApiError carries the HTTP status, Kanera's stable error code, and guards for the cases worth branching on: isUnauthenticated, isForbidden, isNotFound, isRateLimited, isRetryable. Transport failures raise KaneraConnectionError.

Retries and idempotency​

Reads and DELETE/PUT are retried automatically on rate limits and transient upstream failures, with exponential backoff, full jitter, and Retry-After honoured when Kanera sends it.

Mutations are not retried unless you supply an idempotency key, because a retry after an ambiguous failure would create a second card or post a second comment. Supply one and the API replays the first outcome instead of repeating the write:

await kanera.comments.create("MKT-42", { body: "Deployed." }, {
idempotencyKey: crypto.randomUUID(),
});

Verify webhooks​

parseWebhook verifies the signature and returns the parsed event, or throws. It throws rather than returning a boolean so a handler cannot forget to check before using the payload:

import { parseWebhook } from "@kanera/sdk";

app.post("/kanera", async (req, res) => {
const event = await parseWebhook({
secret: process.env.KANERA_WEBHOOK_SECRET!,
payload: req.rawBody, // the exact bytes; re-serialised JSON will not verify
headers: req.headers,
});
// event.type, event.workspaceId, event.data
res.sendStatus(204);
});

The signature covers {timestamp}.{body}, and the timestamp is checked against a five-minute window — configurable — to bound replay. It uses Web Crypto, so it works in workers and edge runtimes as well as Node. See Webhooks for endpoint setup and the event catalog.

Options​

new Kanera({
apiKey,
baseUrl: "https://api.kanera.app", // your own origin when self-hosting
timeoutMs: 30_000,
maxRetries: 2,
organisationId, // pin an identity-wide personal credential to one organisation
userAgent: "acme-sync/2.1", // appended to the SDK's own User-Agent
onRetry: ({ attempt, delayMs }) => log.warn({ attempt, delayMs }),
fetch: myFetch, // supply your own agent or proxy
});

Escape hatch​

The API moves faster than the typed surface, and nothing is gated behind a wrapper:

const result = await kanera.http.post<MyType>("/api/v1/some/new/endpoint", body);

A note on browsers​

The package runs in a browser, but using it there means shipping an API key to the client. Only do that in a trusted first-party context. For anything user-facing, call Kanera from your server.