Skip to main content

Subscribe to events over MCP

An AI agent that is connected to Kanera over MCP can ask to be told when work changes, instead of polling. Kanera implements the webhook delivery of the draft MCP events extension: the agent's host registers an HTTPS callback, Kanera verifies it, and from then on every matching change is posted to it as a signed JSON occurrence.

This is the mechanism behind "watch this board and tell me when a comment lands" in a client such as ChatGPT. The host owns the callback; you do not run a server or paste a URL anywhere in Kanera.

Who this is for

If you use ChatGPT, read Watch work for changes in ChatGPT and stop there. The rest of this page is the wire-level contract for people building their own MCP host or a custom integration, and for anyone who wants to know exactly what an agent can and cannot be told.

What an agent can subscribe to​

EventFires whenExtra fields in data
card.createdA card is created in the monitored scope.title, listId, url
card.updatedA card changes: title, description, completion, archive state, and other card fields.title, listId, url
card.movedA card moves within or between lists or boards.listId (the destination), fromListId (the source), prevPosition, url
comment.createdA comment is posted on a card.commentId, text (up to 8,000 characters), url
priorities.changedYour own Up next queue may have changed.None. See Your Up next queue.

Every card and comment occurrence carries workspaceId, boardId, cardId, and an actor (see Telling your own writes apart). Payloads are bounded summaries: a card's description, attachments, and full comment thread are never included. The agent reads the current record with cards.get or comments.list when it needs more.

For the four card and comment events, subscription arguments narrow what is delivered:

ArgumentRequiredEffect
workspaceIdYesOnly events in this workspace.
boardIdNoOnly events on this board. Required for board guests.
listIdNoOnly events for cards in this workspace list. For card.moved, a card entering or leaving the list matches and reorders within it do not. Omit boardId to watch the shared list on every accessible board.
cardIdNoOnly events for this card. Requires boardId.

Access follows the connected identity on every delivery, not only at subscribe time. A workspace member can watch the whole workspace; a cross-organisation guest must name a board they have been invited to; a guest limited to assigned items must name a card they are currently assigned to. If the identity later loses access to the scope, delivery stops and the subscription expires. Losing access to one card inside a board-wide subscription skips that occurrence only.

Your Up next queue​

priorities.changed tells an agent that the connected user's own Up next queue may have changed, so it can re-read the queue instead of polling. It fires on every change that reorders or resizes the queue:

  • an entry is added, moved, or removed, whether from the web app, the API, or another agent;
  • a queued card is completed, archived, restored, or reassigned, which takes it out of the live queue or puts it back.

It takes no arguments (pass {}) and is never scoped to a workspace, because the queue spans every workspace you work in. The occurrence carries only who changed it:

"data": {
"targetUserId": "…",
"actor": { "kind": "user", "userId": "…", "self": false }
}

targetUserId is always the connected user. Nothing about the queue's cards is included. Call priorities.list for the current ranking; it applies your normal card visibility.

You can only watch your own queue. There is no argument for another person's, and that includes workspace admins who can read a teammate's queue with priorities.list. Even a notification without content would reveal when, and by whom, a card the watcher cannot see was completed or reassigned. A guest or a read-only API key can watch its own queue too.

Watch work for changes in ChatGPT​

ChatGPT connected through the ChatGPT setup guide discovers the events capability automatically. If you connected before this feature existed, open the Kanera app in Settings -> Apps and scan its tools again.

In a conversation where the Kanera app is attached, ask for what you want to happen:

Watch the Product board in Kanera. When a new comment appears on any card, summarise it for me and suggest a reply.
Tell me whenever a card moves into the Done list on the Launch board.
Whenever my Up next queue changes, tell me what is now at the top.

ChatGPT creates the subscription on its own hosted callback, refreshes it while the task is active, and ends it when you stop the task. Kanera never sends your work anywhere ChatGPT has not proven it controls (see Callback verification).

More worked examples, including answering questions on cards, triaging new work, and guarding a single card, are in Examples of how to use.

Two things to know when writing the task:

  • Avoid loops. Every occurrence names its actor and whether the subscribing connection caused it. A task that reacts to comments by commenting should check actor.self before writing again, or it will answer itself.
  • Read before you write. The occurrence is a summary. Ask the task to read the card first, and to use the tools' idempotency keys for repeatable changes.

Delivery is best-effort with a bounded retry window (see Delivery and retries). If ChatGPT's callback is unreachable for longer than that, the occurrence is missed; the task recovers by reading current state.

The protocol​

Events are available only on the hosted HTTP endpoint, https://mcp.kanera.app/mcp or your own deployment's /mcp, and only to clients on the current MCP protocol, which is what adds event subscriptions. kanera mcp over stdio does not offer them: a local stdio host has no hosted callback to deliver to.

server/discover advertises the capability under both capabilities.events (what ChatGPT reads today) and capabilities.extensions["io.modelcontextprotocol/events"] (where the extension proposal places it). Three request methods follow, authenticated exactly like tool calls, with an OAuth access token or an API key as the bearer. The examples below show only the method parameters; the client library adds the protocol's per-request headers and metadata.

events/list​

Returns the catalog above with a JSON Schema for each event's arguments and payload. The catalog is a single page; cursor must be absent or empty.

{
"jsonrpc": "2.0",
"id": 1,
"method": "events/list",
"params": {}
}

events/subscribe​

{
"jsonrpc": "2.0",
"id": 2,
"method": "events/subscribe",
"params": {
"name": "comment.created",
"arguments": { "workspaceId": "…", "boardId": "…" },
"delivery": {
"mode": "webhook",
"url": "https://receiver.example.com/kanera/events",
"secret": "whsec_…"
},
"ttlMs": 86400000
}
}
FieldRules
delivery.modeOnly webhook is supported. Any other mode returns -32014 Unsupported with data.feature = "deliveryMode".
delivery.urlHTTPS, no credentials in the URL, no fragment, and it must resolve to a public address. Loopback, private, link-local, cloud-metadata, and IPv4-mapped or transition IPv6 addresses are refused.
delivery.secretwhsec_ followed by 24 to 64 bytes of base64. Kanera stores it encrypted and signs every delivery with it.
ttlMsRequested lifetime. The default and the maximum are 24 hours; a shorter positive value is honoured, and null (no expiry) is capped at 24 hours.
cursor, maxAgeMsAccepted for compatibility. Kanera offers no replay: cursor is always returned as null, and a non-null request cursor is reported as truncated: true.

The response:

{
"id": "sub_…",
"refreshBefore": "2026-10-07T13:00:00.000Z",
"cursor": null,
"truncated": false,
"deliveryStatus": {
"active": true,
"lastDeliveryAt": "2026-10-06T12:58:10.000Z",
"lastError": null
}
}

The subscription id is deterministic for the connection, callback URL, event name, and arguments (for priorities.changed, the connected user), so calling events/subscribe again with the same identity is a refresh: it extends refreshBefore, accepts a new secret (both the old and the new secret sign deliveries for five minutes), and returns deliveryStatus for a subscription that was still active. deliveryStatus is omitted on a first subscribe and on a renewal after expiry. Refresh before refreshBefore; an expired subscription renewed later starts fresh from the renewal time, with no replay of what it missed.

active is always true: Kanera never suspends a subscription for delivery failures. It bounds retries per occurrence instead and reports the endpoint's health through lastError (one of connection_refused, timeout, tls_error, http_4xx, http_5xx, challenge_failed) and failedSince, present while the endpoint keeps failing.

A connection may hold up to 100 active subscriptions. The 101st returns -32013 ResourceExhausted.

events/unsubscribe​

Takes the same name, arguments, and delivery.url, with no secret. Only the connection that created a subscription can end it; another credential's request is a no-op. Unsubscribing is idempotent.

Errors​

Kanera uses the error codes the draft documents for events. Subscription failures carry structured data so a host can act on them without parsing messages.

CodeNameWhen
-32011NotFoundname is not in the catalog (data.kind = "event").
-32012ForbiddenThe identity cannot see the requested scope, or the credential is not an API key or OAuth connection.
-32013ResourceExhaustedThe subscription limit (data.max) or a rate limit.
-32014UnsupportedA delivery mode other than webhook (data.feature, data.value).
-32015CallbackEndpointErrorVerification failed. data.reason is one of connection_refused, timeout, tls_error, http_4xx, http_5xx, or challenge_failed. A refused destination is reported as challenge_failed, so error responses cannot be used to map a private network.
-32602InvalidParamsA malformed request, an invalid secret or URL, a workspace, board, or card in arguments that does not exist, or any argument passed to priorities.changed.

Callback verification​

Before a subscription is stored, Kanera proves the callback is yours. It posts a verification request to delivery.url, signed with the secret you supplied:

{ "type": "verification", "challenge": "…" }

The endpoint must answer 2xx with a JSON body that echoes the challenge exactly:

{ "challenge": "…" }

Anything else fails the subscribe with -32015 and the reason above. Redirects are never followed. A successful verification is cached for five minutes per connection and callback URL, so refreshing or varying arguments does not repeat the challenge.

Verification runs with the same strict destination policy as delivery: public HTTPS addresses only, resolved once and pinned for the connection so a DNS change between the check and the request cannot redirect it.

Delivery and retries​

Each occurrence is one POST to the callback with Content-Type: application/json:

{
"eventId": "evt_…",
"name": "comment.created",
"timestamp": "2026-10-06T12:58:09.412Z",
"data": {
"workspaceId": "…",
"boardId": "…",
"cardId": "…",
"commentId": "…",
"text": "Can we ship this on Thursday?",
"url": "https://board.kanera.app/o/3F2A9C1D7B4E6A08/c/PROJ-123",
"actor": { "kind": "user", "userId": "…", "self": false }
},
"cursor": null
}
HeaderContent
webhook-idThe eventId. Stable across retries; use it to make the handler idempotent.
webhook-timestampUnix seconds when this attempt was signed. Fresh on every retry.
webhook-signatureOne or more space-separated v1,<base64> signatures (two during a secret rotation).
X-MCP-Subscription-IdThe subscription id.

The body bytes are identical on every attempt; only the timestamp and signature change.

Respond 2xx to acknowledge. Kanera treats responses as follows:

ResponseBehaviour
2xxDelivered. The body is ignored.
410, 413, and other 4xxThis occurrence is dropped. The subscription continues; future occurrences are still delivered.
408, 425, 429, 5xx, timeout, TLS or connection failureRetried with the same eventId: five attempts in total at roughly 0 s, 30 s, 1.5 min, 3.5 min, and 7.5 min. After that the occurrence is dropped.

A response body over 4 KiB is discarded without affecting the outcome. An occurrence over 256 KiB is never sent. Kanera keeps delivery records for 14 days.

Deliveries for an active subscription survive Kanera restarts. Authorization is rechecked before every attempt, so a disconnected agent, a revoked key, a removed member, or a guest who lost an assignment stops receiving data at the next attempt, not at the next refresh.

priorities.changed occurrences are queued the moment the queue change is saved, not from the durable change log that card events use, because the queue is not tied to a single workspace. In the rare case that Kanera restarts in that instant, one notification can be lost; the next priorities.list read is always current.

Verify the signature​

The scheme is the Standard Webhooks layout. Decode the secret after the whsec_ prefix from base64, HMAC-SHA256 the string {webhook-id}.{webhook-timestamp}.{body}, and compare against each signature in the header in constant time:

import crypto from "node:crypto";
import express from "express";

const app = express();
const key = Buffer.from(process.env.KANERA_MCP_EVENT_SECRET!.slice("whsec_".length), "base64");

app.post("/kanera/events", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
const payload = JSON.parse(body) as { type?: string; challenge?: string };
const id = req.header("webhook-id") ?? "";
const timestamp = req.header("webhook-timestamp") ?? "";
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
const digest = Buffer.from(expected);
const valid = (req.header("webhook-signature") ?? "").split(" ").some((entry) => {
const received = Buffer.from(entry.replace(/^v1,/, ""));
return received.length === digest.length && crypto.timingSafeEqual(received, digest);
});
if (!valid) return res.status(401).send("invalid signature");
// Verification challenge: echo it back.
if (payload.type === "verification") return res.json({ challenge: payload.challenge });
// Occurrence: handle it idempotently by webhook-id.
res.sendStatus(204);
});

Always verify against the raw body. Reject deliveries whose webhook-timestamp is more than a few minutes from now to bound replay.

Telling your own writes apart​

An agent that reacts to events by writing back will see its own writes come around as events. userId alone cannot separate them: a personal API key or an OAuth agent acts as its user, so a comment the agent posted and a comment the person posted carry the same userId.

Every occurrence therefore includes:

"actor": { "kind": "apiKey", "userId": "…", "self": true }
FieldMeaning
kinduser (the web app), apiKey, agent (an interactive OAuth agent), support, automation, or system.
userIdThe user the change was made as. null for automation and system changes.
selftrue only when the subscribing connection itself made the change: the same OAuth agent grant, the same API key used directly, or the same unattended service connection.

Automation effects are reported as automation even when the agent's own write triggered them, so a rule that adds a label after the agent comments does not read as the agent's write.

The title and text fields are user content. Treat them as data, never as instructions to the agent.

Calling the same API directly​

The MCP methods relay to three public API routes, which accept the same bearer credentials and the same request and response bodies. Use them from an integration that holds an API key and wants event subscriptions without speaking MCP:

RouteMCP method
GET /api/v1/mcp-eventsevents/list
POST /api/v1/mcp-events/subscribeevents/subscribe
POST /api/v1/mcp-events/unsubscribeevents/unsubscribe

Failures are returned as public API problem documents with the codes MCP_EVENT_NOT_FOUND, MCP_EVENT_UNSUPPORTED, MCP_SUBSCRIPTION_LIMIT, and MCP_CALLBACK_ENDPOINT, which the MCP layer maps onto the error table above.

For a team integration that should outlive any one connection and cover the full event catalog, use Webhooks instead. MCP event subscriptions belong to the credential that created them, expire within 24 hours unless refreshed, and cover only the four card and comment events plus your own Up next queue. Webhooks do not deliver Up next changes, because the queue is personal rather than workspace data.

Troubleshooting​

ProblemWhat to check
The client does not offer events.It must connect to the hosted /mcp endpoint on the current MCP protocol. Rescan the server in the client; stdio via kanera mcp never offers events.
events/subscribe returns -32015 with challenge_failed.The callback did not echo the challenge, answered a non-2xx status, redirected, or is on a private, loopback, or metadata address. Kanera deliberately reports all of these the same way.
-32012 Forbidden on subscribe.The identity cannot see the workspace, board, or card. Board guests must pass boardId; assigned-items-only guests must pass an assigned cardId. Session cookies cannot subscribe; use an OAuth connection or an API key.
Deliveries stopped but the subscription still refreshes.Check deliveryStatus.lastError and failedSince on the refresh response. If the credential was revoked or the user lost access, the subscription has expired and refresh returns no deliveryStatus.
The agent keeps reacting to its own comments.Check data.actor.self before writing.
-32602 when subscribing to priorities.changed.Pass empty arguments. A targetUserId or any other argument is rejected: only your own queue can be watched.
Missed changes after an outage.Retries stop after about 7.5 minutes and there is no replay. Read current state with cards.list, cards.get, comments.list, or priorities.list.