Lessons from Implementing NIP-34 in Flotilla-Budabit

At the Flotilla-Budabit project we’re building a Nostr-native Git-centric collaboration client based on Flotilla. NIP-34 is the backbone: it defines how repositories are announced, how repository state is broadcast, how patches and issues are represented, and how status is tracked - using plain Nostr events. NIP-22 supplies the comment threading model, and NIP-32 defines a general labeling system we can apply across those objects. After multiple iterations, we've done some reflecting on what worked, what didn’t, and the exact event shapes and cross-NIP interactions we implemented (and wrestled with) and we're sharing it here with you.

Biz

15. September 2025September 15, 2025 · 10 Min. Lesezeit10 min read

Repository Announcements (kind 30617)#

Purpose: Announce a Git repo and its collaboration policy.

{
  "kind": 30617,
  "content": "",
  "tags": [
    ["d", "<repo-id>"],                               // required (kebab-case handle)
    ["name", "<human-readable name>"],
    ["description", "<brief description>"],
    ["web", "<browsing-url>", "..."],                 // multi
    ["clone", "<git-clone-url>", "..."],              // multi
    ["relays", "<relay-url>", "..."],                 // multi; where patches/issues should go
    ["r", "<earliest-unique-commit-id>", "euc"],     // fork/mirror fingerprint
    ["maintainers", "<npub>", "..."],                 // multi
    ["t", "<topic-tag>"]                              // optional, lightweight tags
  ]
}

What worked#

Frictions#

Budabit practice#


Repository State (kind 30618)#

Purpose: Optional truth source for refs/HEAD from a publisher.

{
  "kind": 30618,
  "content": "",
  "tags": [
    ["d", "<repo-id>"],                                   // ties to the 30617
    ["refs/heads/<branch>", "<commit-id>"],               // 0..n
    ["refs/tags/<tag>", "<commit-id>"],                   // 0..n
    ["HEAD", "ref: refs/heads/<branch-name>"]
  ]
}

Optional ancestry hint (light “ahead” info):

["refs/heads/<branch>", "<commit-id>", "<parent-short>", "<grandparent-short>", "..."]

What worked#

Frictions#

Budabit practice#


Patches (kind 1617)#

Purpose: Carry git format-patch content (or a cover letter for the first in a series). Patchsets and revisions are modeled with NIP-10 e reply chains.

{
  "kind": 1617,
  "content": "<git format-patch>",              // or cover letter on first in series
  "tags": [
    ["a", "30617:<repo-owner-pubkey>:<repo-id>"],
    ["r", "<repo-euc>"],
    ["p", "<repository-owner>"],
    ["p", "<reviewer-npub>"],

    // series structure
    ["t", "root"],                               // on FIRST patch of a series only
    ["t", "root-revision"],                      // on FIRST patch of a revision only

    // optional “stable commit id” envelope for reproducibility
    ["commit", "<commit-id>"],
    ["r", "<commit-id>"],                        // discover patches targeting this commit
    ["parent-commit", "<parent-commit-id>"],
    ["commit-pgp-sig", "-----BEGIN PGP SIGNATURE-----..."], // "" if unsigned
    ["committer", "<name>", "<email>", "<timestamp>", "<tz offset minutes>"]
  ]
}

Chaining rules

What worked#

Frictions#

Budabit practice#


Issues (kind 1621) + Comments (NIP-22)#

Purpose (1621): Markdown issue threads; may include a subject and lightweight t tags.

{
  "kind": 1621,
  "content": "<markdown body>",
  "tags": [
    ["a", "30617:<repo-owner-pubkey>:<repo-id>"],
    ["p", "<repository-owner>"],
    ["subject", "<title>"],
    ["t", "<issue-label>"],          // lightweight tags (see NIP-32 section below)
    ["t", "<another-label>"]
  ]
}

Comments: Use NIP-22 (kind:1111), not kind-1 notes. NIP-22 introduces the upper/lower scoped tags and K/k kind markers so replies can target events, addresses, or external identities precisely.

What worked#

Frictions#

Budabit practice#


Status (kinds 1630–1633)#

Lifecycle kinds:

{
  "kind": 1631,
  "content": "<markdown note>",
  "tags": [
    ["e", "<root-issue-or-root-patch-id>", "", "root"],
    ["e", "<accepted-revision-root-id>", "", "reply"],     // if applicable
    ["p", "<repository-owner>"],
    ["p", "<root-author>"],
    ["p", "<revision-author>"],

    // routing & filters
    ["a", "30617:<repo-owner-pubkey>:<repo-id>", "<relay-url>"],
    ["r", "<repo-euc>"],

    // details (1631)
    ["q", "<applied-or-merged-patch-event-id>", "<relay-url>", "<pubkey>"], // repeat per patch
    ["merge-commit", "<merge-commit-id>"],
    ["r", "<merge-commit-id>"],
    ["applied-as-commits", "<commit-id-in-master>", "..."],
    ["r", "<applied-commit-id>"] // repeat
  ]
}

Validation: The most recent status (created_at) from either the root author or a maintainer is valid.
Revision rule: If root is Applied/Merged (1631) and a revision isn’t named in that 1631, that revision is Closed (1632).

Budabit practice#


Labels via NIP-32 (the “clunky but powerful” bit)#

NIP-34 happily coexists with NIP-32; in fact, NIP-34 uses simple t tags for lightweight labels, while NIP-32 provides a generalized, queryable labeling system across any target (events, people, relays, topics). Understanding NIP-32 is key to building rich, interoperable filters.

The tags and event#

Targets: You must include at least one of e, p, a, r, or t to say what you’re labeling (events, pubkeys, addresses, relays, topics). Include relay hints for e/p (NIP-01 style).

Self-labeling: You can also add l/L directly to non-1985 events to self-label that event (e.g., language tags on a note).

Why it feels clunky (but is flexible)#

How NIP-34 and NIP-32 meet#

Budabit practice (label resolution)#

We compute effective labels for any object by merging, in this order:

  1. Self-labels on the object (l/L present on the 1621/1617/30617 event).
  2. External label events (kind:1985) that target the object via e/a (and, if in scope, by r:euc group tagging).
  3. Legacy lightweight tags (t) for quick filters when namespaces aren’t necessary.

We expose both raw namespaces and a normalized view (“status/open”, “type/bug”) so power users can query by L while casual users click simple chips.

Example: label an issue (by address) with a namespaced status

{
  "kind": 1985,
  "tags": [
    ["L", "com.budabit.status"],                // namespace
    ["l", "open", "com.budabit.status"],        // value (marked with the namespace)
    ["a", "30617:<repo-owner-pubkey>:<repo-id>"],
    ["e", "<issue-root-event-id>", "<relay-url>"]
  ]
}

Example: label a patch with a standard nostr t (via #t)

{
  "kind": 1985,
  "tags": [
    ["L", "#t"],                                // says “we’re attaching a `t` value”
    ["l", "needs-review", "#t"],                // a t-like label
    ["e", "<patch-root-id>", "<relay-url>"]
  ]
}

Example: self-label language on an issue

{
  "kind": 1621,
  "content": "Hola mundo",
  "tags": [
    ["a", "30617:<repo-owner-pubkey>:<repo-id>"],
    ["subject", "Mensaje"],
    ["L", "ISO-639-1"],
    ["l", "es", "ISO-639-1"]
  ]
}

Querying labels (what we actually subscribe to)#


Cross-cutting lessons#

  1. Build a tiny graph engine. Patch series/revisions (NIP-10), comments (NIP-22), status (163x), and labels (1985) are all separate streams. Rebuild a DAG per root; render from that.
  2. Colocate by r:euc. It’s the best cross-host fingerprint we have (fork/mirror grouping, patch targeting).
  3. Redundant subscriptions. Different relays index differently. We fan out by repo address a, root e, and r:euc, then dedupe.
  4. Labels: embrace namespaces. Use NIP-32 for anything that needs to be queried reliably. Keep t for quick UX filters.
  5. Authority is policy. NIP-34 leaves auth open. We combine 30617 maintainers + local policy to decide whose 30618 and 163x we treat as authoritative; we still display provenance.

Low-impact improvements we’d love (without changing NIP-34 semantics)#


Nostr-Git#

To keep development fast while still maintaining fork compatibility, we rolled most of the Git-specific functionality into it's own package. In addition to the NIP-32 support, this package contains several useful components such as a Svelte library for displaying NIP-32 event kinds in various ways, an Isomorphic-Git wrapper for in browser Git support and functions for manipulating various event kinds. We aim to make Nostr-Git available separately from Flotilla-Budabit through your favorite package manager.


Final Take#

NIP-34 + NIP-22 + NIP-32 are a clean, composable substrate. The flip side of that minimalism is client-side assembly and policy - but once we leaned in (group by r:euc, rebuild graphs from e markers, fold status, merge labels by namespace), Budabit’s UX became more predictable.

If you’re implementing this: start with grouping and the graph utilities; add a label resolver that understands L/l and t; make your subscriptions redundant and your joins idempotent. The rest will click.

#NIP-34#flotilla#git#t

Written by

Comments

No comments yet. Start the conversation.

Contents

11 sections

  1. Repository Announcements (kind 30617)
  2. Repository State (kind 30618)
  3. Patches (kind 1617)
  4. Issues (kind 1621) + Comments (NIP-22)
  5. Status (kinds 1630–1633)
  6. Labels via NIP-32 (the “clunky but powerful” bit)
  7. Cross-cutting lessons
  8. Low-impact improvements we’d love (without changing NIP-34 semantics)
  9. Nostr-Git
  10. Final Take
  11. Links