Plugins & integrations
Integrations are out-of-process, SDK-driven, bidirectional services. A plugin is an independent process that authenticates as its own agent actor, receives the board's signed event stream, mutates the board through the REST API, and runs its own inbound receiver for the external system it bridges (Slack, GitHub, GitLab, …).
You can build a plugin without reading the core source. Start with the @kanban/plugin-sdk package — it implements the protocol below; any language can implement the same wire contract.
npm create kanban-plugin my-plugin # scaffolds a runnable skeleton
cd my-plugin && pnpm install && pnpm testThe out-of-process model
A plugin is not loaded into the core. It talks to the core only over the public wire: the signed webhook stream (core → plugin) and the REST API (plugin → core). This keeps the core small and lets the ecosystem grow without anyone running untrusted code in the API process.
┌─────────┐ signed webhooks ┌──────────────┐ REST + EntityLink API ┌─────────┐
│ Core │ ─────────────────▶ │ Plugin │ ──────────────────────▶ │ Core │
│ (API) │ │ (your svc) │ ◀────────────────────── │ (API) │
└─────────┘ └──────┬───────┘ responses └─────────┘
│ inbound (Slack/GitHub/GitLab webhooks)
▼
external systemA plugin plays four roles: it acts as an agent (a dedicated actor provisioned at install), receives outbound webhooks (the board's signed Activity stream), mutates via REST (a board-leashed token), and runs its own inbound receiver for the external system's webhooks.
Install handshake
POST /api/boards/:id/integrations (board admin + integrations:write) runs one transaction that:
- creates a dedicated agent actor (the plugin's principal),
- adds it as a board member (role
member), - inserts the
integrationsrow, - mints a board-leashed scoped token for the actor (least privilege),
- registers a webhook subscription at the plugin's
inboundUrl, - emits
integration.installed.
The response is the only place the token and signingSecret are returned — store them immediately. Removal (DELETE …/integrations/:id) soft-deletes the integration, deactivates its webhook subscription, and revokes the token.
Authentication & scopes
The minted token is leashed to one board and holds the minimum for bidirectional sync: boards:read, boards:write, cards:write, comments:write, integrations:write. It deliberately does not hold members:write, tokens:write, or webhooks:write — a plugin syncs cards and links but cannot manage members, mint tokens, or alter webhook subscriptions. See Reference → Scopes.
Secrets (a Slack bot token, a GitHub PAT, …) are stored as IntegrationSecret rows: set write-only via PUT …/integrations/:id/secrets (admin), AES-256-GCM encrypted at rest (INTEGRATION_SECRET_KEY), never echoed by any read API. The plugin process holds its own decrypted copies (injected at startup) and reads them through ctx.secrets.get(...) — it never reads them back from the core.
Verifying the outbound stream
Every webhook the core delivers is signed with the v2 replay-protected scheme (X-Signature: v2=<hex>, X-Kanban-Delivery-Id, X-Kanban-Timestamp) — the same scheme documented for webhooks. The SDK's verifyKanbanDelivery (and the EventRouter, which keeps a bounded replay cache) verify the signature, enforce the clock window, and dedupe delivery ids automatically.
Outbound flow (board → external)
React to board events and push them to the external system, recording an EntityLink so later updates find their counterpart:
import { createPlugin } from "@kanban/plugin-sdk";
const plugin = createPlugin({ manifest, apiUrl, token, signingSecret, selfActorId, secrets });
plugin.onEvent("card.created", async (event, ctx) => {
if (ctx.isEcho(event)) return; // never re-emit our own writes
const issue = await createGithubIssue(/* … */);
await ctx.links.upsert(event.cardId!, {
system: "github", externalId: issue.node_id, url: issue.html_url,
});
});Route the raw webhook bytes into plugin.dispatchEvent(rawBody, headers) — it verifies the signature, drops echoes, parses, and dispatches to onEvent handlers. The ctx.client action client wraps the REST mutation surface with your token and a generated clientMutationId (ctx.client.cards.create/move/update/archive, .comments.add, .fields.set, …).
Inbound flow (external → board)
Receive the external system's webhooks, verify the provider's signature before any mutation, resolve the linked card, and mutate the board:
plugin.onInbound({
method: "POST",
path: "/github/webhook",
verifyExternalSignature: (req) => { /* provider HMAC over req.rawBody */ return ok; },
handler: async (req) => {
const link = await plugin.ctx.links.findByExternal(boardId, "github", externalId);
if (link) await plugin.ctx.client.comments.add(link.cardId, { body: "…" });
return { status: 200, body: "ok" };
},
});| Provider | Verify |
|---|---|
| Slack | v0= HMAC over v0:{timestamp}:{rawBody} with the signing secret; reject stale timestamps |
| GitHub | X-Hub-Signature-256 = sha256= HMAC-SHA256 over the raw body |
| GitLab | constant-time compare X-Gitlab-Token to a shared token (not HMAC) |
Always verify over the raw request bytes, never the re-serialized JSON.
EntityLinks & the echo-loop guard
An EntityLink maps a board card to an external object owned by your integration — the glue for bidirectional sync. Links are unique on (integration, externalSystem, externalId), so upsert is idempotent.
Without a guard, syncing loops forever (external → board → external → …). The fix is actor attribution: your inbound mutations are authored by your integration's own actor, so drop events you caused with ctx.isEcho(event) (which checks event.actorId === selfActorId, with event.origin as defense-in-depth).
Per-install config
Every integration row carries a non-secret config blob (channel mappings, list ids, repo/project). A board admin sets it at install or via PATCH …/integrations/:id { config } — no redeploy. The plugin reads it through ctx.config (getters at event time), and the SDK auto-refreshes when it receives integration.updated, so admin remaps apply live.
Local testing
The scaffold ships a replay harness that signs a sample payload and drives it through your event router — proving onEvent handlers fire without a running core (plugin.dispatchEvent(body, headers)). For real webhooks, expose your port with a tunnel (ngrok, cloudflared) and use the public HTTPS URL as the integration's inboundUrl. Point the plugin at a local core by setting KANBAN_API_URL, KANBAN_TOKEN, KANBAN_SIGNING_SECRET, and KANBAN_SELF_ACTOR_ID from the one-time install response.
Deployment
A plugin is a plain container — the scaffold generates a multi-stage Dockerfile and an .env.example. Give it the install credentials plus external secrets, and expose its inbound port behind HTTPS. The reference integrations (Slack, GitHub, GitLab) run behind an opt-in Compose profile so a plain docker compose up is unchanged:
docker compose --profile integrations up # starts slack/github/gitlab alongside apiDeliveries are at-least-once — keep inbound and outbound handlers idempotent (EntityLink upsert + ctx.isEcho make this natural). A 401 from the action client means the integration was uninstalled (its token was revoked).
Publishing
A central plugin registry/marketplace is intentionally out of scope for v1. For now, distribute a plugin as any service: publish the package and/or a container image, and document the install command + required IntegrationSecret keys. The wire contract described here (signed webhooks + REST + the EntityLink API + the manifest schema) is the stable surface a future registry will build on.