Kanban Boards
Agent-first kanban boards where cards are units of work and lanes bind to agents, teams, or webhooks. Drop a board into any app with an API key and org ID — agents pick up cards as they enter lanes, work them live, and advance them automatically.
Inside the Cadra app, boards live at /work, and a board can be linked to a Jira or Notion board. The first section below covers linking a board; the rest of this guide covers embedding a board in your own app.
Link a Notion or Jira board
Linking takes three moves: connect the app to Cadra, give Cadra access to the board from the app's side, then link the board in Cadra. Your cards stay where they are; Cadra works them in place.
- Connect the app in Cadra. For Notion, choose Connect and sign in to Notion, or paste a Notion integration key instead. For Jira Data Center, enter your Jira address and a personal access token.
- Give Cadra access to the board. In Notion, open the database, open the ••• menu, choose Connections, and add the Cadra integration. If you connected with Notion sign-in, you can also select the pages during sign-in. In Jira, the person who owns the token must be able to see the project.
- Open Link a board. In Cadra, go to Work → Link a board and pick Notion or Jira.
- Choose the board. For Notion, search for the database and select it. For Jira, enter the project key and pick the board.
- Pass the ready check. Cadra confirms it can read and update the cards and that the board has the columns it needs. If something is missing, Cadra can fix it for you.
- Set the lanes. Each column on your board becomes a lane. Give each lane an agent or a person.
- Link, then Start. Name the board and choose Link. On the board's page, choose Start when you are ready for agents to begin.
Installation / Import
npm install @cadraos/sdkimport { KanbanBoard, createDefaultKanbanAdapter } from "@cadraos/sdk/kanban";Quick start
Build an adapter once with createDefaultKanbanAdapter, then mount <KanbanBoard />. The board auto-provisions server-side on the first getBoard() call — no manual setup required.
import { KanbanBoard, createDefaultKanbanAdapter } from "@cadraos/sdk/kanban";
const adapter = createDefaultKanbanAdapter({
baseUrl: "https://your-cadra-instance.example.com/v1",
apiKey: "sk-cad-xxxxxxxx",
orgId: 123,
});
export default function BoardPage() {
return (
<KanbanBoard
adapter={adapter}
onError={(message) => console.error("Board error:", message)}
/>
);
}Creating cards
Provide onCreateCard to control what gets submitted when a user opens the new-card dialog. Return a CreateCardInput to proceed, or null to cancel. The callback receives the target lane so you can pre-fill lane-specific fields.
<KanbanBoard
adapter={adapter}
onCreateCard={(lane) => {
// Return null to cancel (e.g. open your own modal instead)
if (lane.id === "blocked") return null;
return {
title: "New task",
description: "",
laneId: lane.id,
// any additional fields your board schema accepts
};
}}
onCardCreated={(card) => {
console.log("Card created:", card.id);
}}
/>Controlling features
Toggle create, edit, and advance affordances independently. Use pollIntervalMs for a background silent refetch — there is no board-level SSE push yet, so polling is how the board stays current while a run is in progress.
<KanbanBoard
adapter={adapter}
enableCreate={true} // default true — show "Add card" button
enableEdit={true} // default true — allow card edits
enableAdvance={true} // default true — show manual advance controls
pollIntervalMs={5000} // silent background refetch every 5 s
onRetryCard={(card) => console.log("Retrying:", card.id)}
className="h-full"
/>Custom adapter
Implement KanbanAdapter directly when you need to route calls through your own backend, add request middleware, or talk to a non-Cadra store. All methods must match the interface below exactly — the board calls them internally.
import type { KanbanAdapter, KanbanBoard as BoardState, KanbanCard, CreateCardInput, TransitionCardInput } from "@cadraos/sdk/kanban";
class MyAdapter implements KanbanAdapter {
async getBoard(): Promise<BoardState> {
// Return board with lanes array. Auto-provisioned on first call
// if the board doesn't exist yet.
return fetch("/api/kanban/board").then((r) => r.json());
}
async listCards(input?: { laneId?: string }): Promise<KanbanCard[]> {
const params = input?.laneId ? `?laneId=${input.laneId}` : "";
return fetch(`/api/kanban/cards${params}`).then((r) => r.json());
}
async createCard(input: CreateCardInput): Promise<KanbanCard> {
return fetch("/api/kanban/cards", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.json());
}
async getCard(cardId: string): Promise<KanbanCard> {
// cardId is the integer primary-key string from listCards/createCard,
// NOT the UUID. See "How the live run works" below.
return fetch(`/api/kanban/cards/${cardId}`).then((r) => r.json());
}
async updateCard(cardId: string, patch: Partial<KanbanCard>): Promise<KanbanCard> {
return fetch(`/api/kanban/cards/${cardId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}).then((r) => r.json());
}
async transitionCard(input: TransitionCardInput): Promise<KanbanCard> {
return fetch("/api/kanban/transition", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.json());
}
// Optional — provide to show a live run stream on the card.
// executionId is card.executionId (a UUID), distinct from cardId.
async createRunStream(executionId: string) {
return myStreamController.open(executionId);
}
}AgentBoard — config-driven boards
For multi-tenant deployments where lane/handler configuration is managed centrally, use <AgentBoard />. It splits responsibility: a BoardConfigClient resolves a published board config (the control plane), while a host-owned cardSource supplies the data plane. All KanbanBoardProps except adapter pass through.
import { AgentBoard, createDefaultKanbanAdapter } from "@cadraos/sdk/kanban";
import { BoardConfigClient } from "@cadraos/sdk/kanban";
const configClient = new BoardConfigClient({
baseUrl: "https://your-cadra-instance.example.com/v1",
apiKey: "sk-cad-xxxxxxxx",
});
// configClient API:
// configClient.getActive(uuid) → latest published config
// configClient.getPinned(uuid, version) → specific version
// configClient.clearCache() → bust local cache
const cardSource = createDefaultKanbanAdapter({
baseUrl: "https://your-cadra-instance.example.com/v1",
apiKey: "sk-cad-xxxxxxxx",
orgId: 123,
});
export default function TenantBoardPage() {
return (
<AgentBoard
configClient={configClient}
boardConfigUuid="board-config-uuid"
version={2} // optional — omit to always use latest published
cardSource={cardSource}
pollIntervalMs={5000}
onError={(message) => console.error(message)}
/>
);
}How the live run works
When an agent picks up a card it starts an execution. The board surfaces that run by reusing the existing ExecutionStreamController — there is no bespoke kanban stream. The stream is keyed on card.executionId (a UUID), which is separate from the cardId you pass to getCard and transitionCard.
Two IDs, two purposes:
| Field | Type | Used for |
|---|---|---|
| card.id | string (integer PK) | getCard, updateCard, transitionCard |
| card.executionId | string (UUID) | createRunStream, execution stream key |
The board listens for a run-terminal event on the stream and triggers a refetch of that card, which advances the UI without a full board reload. If pollIntervalMs is set, a background refetch also runs on the interval as a fallback. Auto-provisioning fires once on the first getBoard() call — subsequent calls return the cached server record.