NIP for Kanban Boards Nostr Implementation Proposal Copyright (c) 2025 Vivek Ganesan: https://raw.githubusercontent.com/vivganes/kanbanstr/refs/heads/main/NIP-100.md
Kanban Boards
The current implementation handles private and collaborative boards via board-level p-tags (maintainers) and the application's store/permission model. Wrapping board events in kind:1059 gift-wraps is not used by the runtime.
Key points:
- Use
p-tags on the canonical30301board event to list maintainers who may edit the board. - Use snapshot events (
30303) for complete-state snapshots and for restore/rollback workflows. - For encrypted/private payloads prefer application-level encryption of snapshots or external encrypted storage rather than protocol-level gift-wraps.
See docs/STORES.md and docs/COLLABORATION/BOARD-SHARING.md for permission and private-board details.
["d", ""],
["title", "Card Title"],
["description","Card Description"], //can contain markdown too
["pub","private"], //publication state: 'private' or 'published'
["alt","A card representing a task"], //Human-readable plaintext summary to be shown in non-supporting clients - as per NIP-31
["s", "col2-id"], // PRIMARY: Unique column reference
["col_label", "In Progress"], // SECONDARY: Human-readable for display
["rank","10"], // order of the card in the column - cards may be displayed in the ascending order of rank by default
// card url attachments with 'u' tags similar to NIP-98
["u","https://attachment1"],
["u","https://attachment2"],
// add assignees using 'p' tag (METADATA ONLY - NOT EDIT PERMISSION)
[ "p", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"],
[ "p", "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],
[ "p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
// The board this card will be a part of.
["a", "30301:<board-creator-pubkey>:<board-d-identifier>", "<optional-relay-url>"],
],
// other fields...
}
**⚠️ CRITICAL:** Card `p` tags are for **assignees only** (metadata), NOT edit permission!
- Only Board p-tags (from the 30301 event) grant write permission
- To publish a Card, signer MUST be: Card author OR Board creator OR Board maintainer
- Card assignees have NO special permissions (it's just metadata for UI)
When editing a card, the maintainers can copy the card event with the `d` tag intact and publish a new event.
When a client gets multiple card events with the same `d` tag, it takes the latest one by any maintainer or the creator of the board event as the source of truth.
### Tracker Card Event
In case one wants to just track another nostr event (like a tracker card, without modifying the original event), one can designate a tracker card using a `k` tag to denote kind and `e` tag to denote the nostr event to be tracked.
```javascript
{
"created_at": 34324234234, //<Unix timestamp in seconds>
"kind": 30302,
"tags": [
["d", "<new-tracker-card-d-identifier>"],
["k", "1"], //this one tracks a text note
["e", "<event-id>", "<relay-url>"] // as per NIP-10
// other fields as per card event above...
],
}
In case of tracking a replaceable event, one can use a tag instead of the e tag above.
In case of tracking another Kanban card (30302) event, one cannot use 'a' tag as that is already used for board association. Hence, we use tags similar to git issues (NIP-34)
{
"created_at": 34324234234, //<Unix timestamp in seconds>
"kind": 30302,
"tags": [
["d", "<new-tracker-card-d-identifier>"],
["k", "30302"], //this one tracks a text note
["refs/board", "30301:<target-board-creator-pubkey>:<target-board-d-identifier>"], // very much like git issues
["refs/card", "<tracked-card-d-identifier>"
],
}The clients MAY display this tracker card like they display the tracked event, or using the 'alt' tag of the original event if not supported.
Any 30302 event with a k tag will be treated as a tracker card.
In case of tracked card, its status is deemed to be the s tag value of the event it tracks.
This allows the automatic movement of a card (like a Git issue) across different columns as the card's status changes in the source system, without any manual updates in the board.
If the tracked event does not have an s tag, then tracker card event's s tag is shown as the status of the card.
To establish relationships like parent-child, blocked by, etc, we need the possibility link a card to another card using a relationship.
In order to link any card to an existing card, we add a refs/link tag to it with the following format
['i','kanban:<target-board-creator-pubkey>:<target-board-d-identifier>:<linked-card-d-identifier>','link label','link label for the reverse direction']For example, to designate a card as a child of another card, you could add the tag
['i','kanban:<board-of-parent-card-creator-pubkey>:<board-of-parent-card-d-identifier>:<parent-card-d-identifier>','is a child of','is a parent of']In order to say that the current card is blocked by another card, you could add the tag
['i','kanban:<board-of-blocker-card-creator-pubkey>:<board-of-blocker-card-d-identifier>:<blocker-card-d-identifier>','is blocked by','blocks']Clients MAY show easy drop-downs to designate the link labels so that the users don't have to type it.
- 30301: Kanban Board Definition
- 30302: Kanban Card
- 8571: Column Order Patch (Project Extension)
d: Unique identifier for the boardtitle: Board name
d: Unique identifier for the cardtitle: Card titlea: This points to the board that this card belongs to
This project uses a dedicated patch event to synchronize board-level column changes (order + column metadata) without requiring maintainers to republish the Board event (kind 30301).
Rationale: Board events are parameterized replaceable (30301:<publisherPubkey>:<d>). If a maintainer/editor republishes kind 30301, it creates a fork board under their pubkey. A separate regular-kind patch avoids this.
Required tags:
d: The board id (same value as the board'sdtag). Allows reliable subscriptions via#d.a: The canonical board address:30301:<board-creator-pubkey>:<board-d-identifier>updated_at_ms: Millisecond timestamp for Last-Write-Wins (LWW) ordering
Optional tags:
order: The column IDs in their desired order (one tag with multiple values). This tag MAY be omitted for “metadata-only” patches.col: Repeatable column metadata update tags.
col tag format (repeatable):
['col', '<columnId>', '<nameOrEmpty>', '<colorOrEmpty>']
Semantics:
- Empty strings in
colmean “no update” (they do not clear existing values). - A patch event MUST include at least one meaningful change (
orderand/or at least onecoltag with a non-empty name or color). Clients SHOULD ignore empty/no-op patches. - LWW ordering MUST use
updated_at_ms(millisecond precision). If multiple patches exist, clients MUST apply only the most recent one.
-
Only the board creator can:
- Modify board
-
Only the board maintainers can:
- Add a card to the board
- Publish edits to the existing cards (including the status)
- Publish column patches (kind
8571) referencing the canonical board address
-
Any user can:
- View the board and cards
- React, comment, zap on board and cards
Clients MAY:
- Display boards in a visual column layout
- Allow drag-and-drop card movement for authorized users
- Implement proper authorization checks before allowing modifications
- Implement additional features like card labels, due dates, or assignments
- Support board templates
- Provide filtering and search capabilities
- Prioritize to show the maintainer comments on cards
- Clients MUST verify event signatures and delegation tokens before allowing modifications
- Relays MAY implement additional spam prevention measures
- Relays MAY choose to retain only a few recent versions of board and card events.
To maintain a consistent board state:
-
Clients should handle concurrent updates by:
- Using the event timestamp to resolve conflicts
- Maintaining card order within columns
-
For performance, clients can:
- Cache board and card data locally
- Use efficient subscription filters when requesting updates
// Subscribe to the cards of a board
{
"kinds": [30302],
"#a": ["30301:<board-creator-pubkey>:<board-d-identifier>"]
}
// Subscribe to column-order patches (recommended: filter by canonical board ref and by board id)
[
{
"kinds": [8571],
"#a": ["30301:<board-creator-pubkey>:<board-d-identifier>"]
},
{
"kinds": [8571],
"#d": ["<board-d-identifier>"]
}
]To support private boards accessible only to a specific group of users, this NIP proposes the use of NIP-59 Gift Wrap. All events associated with a private board MUST be wrapped within a kind: 1059 (GiftWrap) event. This allows for efficient filtering using a context tag on the outer wrapper.
Principle: A unique, non-human-readable context tag is created by hashing the board's unique address (<kind>:<pubkey>:<d-tag>). This hash is added as a t-tag to the outer kind: 1059 wrapper, allowing clients to fetch only events related to a specific board.
Let's assume a board with two members: <creator-pubkey> and <member1-pubkey>.
1. Define Board Address and Context Hash:
- Board Address:
30301:<creator-pubkey>:my-private-project - Context Hash (
t-tag):sha256("30301:<creator-pubkey>:my-private-project")->8c8a...
2. Create the Inner Board Event (kind: 30301):
This is a standard board event, but it must include p-tags for all members.
// Inner Event (The "Gift")
{
"kind": 30301,
"pubkey": "<creator-pubkey>",
"created_at": ...,
"tags": [
["d", "my-private-project"],
["title", "My Secret Project"],
["p", "<creator-pubkey>"],
["p", "<member1-pubkey>"]
// ... other board tags like "col"
],
"content": "A private board for planning world domination."
}3. Wrap it in a kind: 1059 Event:
This is the event that gets published to relays.
// Outer Wrapper (Published Event)
{
"kind": 1059,
"pubkey": "<creator-pubkey>",
"created_at": ...,
"tags": [
["p", "<creator-pubkey>"],
["p", "<member1-pubkey>"],
["t", "8c8a..."] // The context hash for efficient filtering
],
"content": " "content": "<encrypted JSON of the inner kind: 1 event>"
}Status: Non-Replaceable
Purpose: Manual snapshots of complete board state for version control & rollback
{
"kind": 30303, // Non-replaceable
"created_at": 1730000000,
"tags": [
["a", "30301:<board-creator-pubkey>:<board-d-identifier>"],
// Snapshot Metadata
["v", "Sprint-3 Planning"], // User label (not version number!)
["r", "manual"], // Reason: 'manual' (user-clicked button)
["t", "1730000000"] // Timestamp
],
"content": "{...complete board JSON with all cards...}"
}Content Format:
{
"id": "project-alpha",
"name": "Project Alpha",
"description": "Board Description",
"columns": [
{
"id": "col1",
"name": "Backlog",
"cards": [
{ "id": "card1", "heading": "Task 1", "content": "...", ... }
]
}
],
"publishState": "private",
"author": "<board-creator-pubkey>"
}Key Points:
- ✅ Non-replaceable: Every snapshot creates a new event (no overwrites)
- ✅ Complete state: Full board JSON in content (no need to fetch old 30302s)
- ✅ User-labeled:
vtag contains user description (e.g. "Before Refactor") - ✅ Rollback: Restore to any snapshot by loading JSON + reconstructing board
Relay Query (load all snapshots for a board):
{
"kinds": [30303],
"#a": ["30301:<board-creator-pubkey>:<board-d-identifier>"]
}Status: Ephemeral (not persisted)
Purpose: Soft lock - indicate that a user is currently editing a card
{
"kind": 20001, // Ephemeral (TTL ~5 min)
"created_at": 1730000000,
"tags": [
["a", "30302:<card-creator-pubkey>:<card-d-identifier>"],
["d", "editing-<card-id>"],
["expires", "1730000300"] // 5 min TTL
],
"content": "{ \"user\": \"Alice\", \"clientId\": \"xyz...\", \"startedAt\": \"2025-10-26T10:00:00Z\" }"
}Key Points:
- ✅ Ephemeral: Not persisted by relays (expires after TTL)
- ✅ Warning signal: Other clients see this event and show warning (Anna is editing)
- ✅ Soft lock: Not a hard lock - Paul can still edit, but sees warning
- ✅ Auto-refresh: Client republishes every 4 min (before expiration)
Use Case (Conflict Prevention):
09:00 Anna opens card → publishes Kind 20001 event
09:02 Paul opens same card → sees ephemeral event, shows warning
09:05 Anna saves → Conflict detection runs (3-way merge)
09:10 Both changes merged & published
| Tag | Kind | Purpose | Example |
|---|---|---|---|
d |
30301, 30302, 30303 | Event ID | "project-alpha" |
a |
30303, 20001 | Reference to other event | "30301:pubkey:board-id" |
v |
30303 | Version/Snapshot label | "Sprint-3 Planning" |
r |
30303 | Reason (manual/auto) | "manual" |
t |
30303 | Timestamp | "1730000000" |
title |
30301 | Board name | "Project Alpha" |
description |
30301 | Board description | "..." |
pub |
30301, 30302 | Publish state | "private" or "published" |
col |
30301 | Column definition | ["col", "id", "name", "order"] |
s |
30302 | Column reference | "col-id" |
p |
30301, 30302, 30303 | Pubkey (maintainer/author) | "82341f..." |
u |
30302 | URL attachment | "https://..." |
expires |
20001 | Expiration timestamp | "1730000300" |
}
```
---
#### Example 2: Adding a Card to the Private Board
**1. Create the Inner Card Event (`kind: 30302`):**
The card references the board via the `a`-tag and also includes `p`-tags for the group.
```javascript
// Inner Event (The "Gift")
{
"kind": 30302,
"pubkey": "<creator-pubkey>",
"created_at": ...,
"tags": [
["d", "new-card-123"],
["title", "Design the Lair"],
["a", "30301:<creator-pubkey>:my-private-project"],
["p", "<creator-pubkey>"],
["p", "<member1-pubkey>"]
],
"content": "The lair needs a volcano."
}
```
**2. Wrap it in a `kind: 1059` Event:**
The wrapper uses the **same context hash (`t`-tag)** as the board.
```javascript
// Outer Wrapper (Published Event)
{
"kind": 1059,
"pubkey": "<creator-pubkey>",
"created_at": ...,
"tags": [
["p", "<creator-pubkey>"],
["p", "<member1-pubkey>"],
["t", "8c8a..."] // The SAME context hash
],
"content": "<encrypted JSON of the inner kind: 30302 event>"
}
```
---
#### Example 3: Commenting on a Private Card
**1. Create the Inner Comment Event (`kind: 1`):**
The comment references the card via the `a`-tag.
```javascript
// Inner Event (The "Gift")
{
"kind": 1,
"pubkey": "<member1-pubkey>",
"created_at": ...,
"tags": [
["a", "30302:<creator-pubkey>:new-card-123"],
["p", "<creator-pubkey>"],
["p", "<member1-pubkey>"]
],
"content": "A shark tank would also be a great addition."
}
```
**2. Wrap it in a `kind: 1059` Event:**
Again, the wrapper uses the same board context hash.
```javascript
// Outer Wrapper (Published Event)
{
"kind": 1059,
"pubkey": "<member1-pubkey>",
"created_at": ...,
"tags": [
["p", "<creator-pubkey>"],
["p", "<member1-pubkey>"],
["t", "8c8a..."] // The SAME context hash
],
"content": "<encrypted JSON of the inner kind: 1 event>"
}
```