Skip to content
disnetPublic

About

A collaborative kanban board on the AT Protocol

Resources

Stars

29 stars

Watchers

1 watching

Forks

Repository files navigation

Skyboard

A collaborative kanban board built on AT Protocol (Bluesky's decentralized data layer). Each user's data lives in their own AT Protocol repository. There is no Skyboard server: the browser assembles board state itself from public atproto infrastructure, and writes go directly to each user's PDS.

How it works

Users sign in with their Bluesky account and create boards. Each board gets an AT URI (e.g. at://did:plc:abc.../dev.skyboard.board/3k...) that can be shared with collaborators. To open a board the browser asks Constellation which records point at it, reads each of those records from its author's PDS, and subscribes to Jetstream for live updates.

All data is stored locally in IndexedDB (via Dexie). Writes (creating tasks, editing, commenting) go to Dexie first, then background sync pushes them to the user's PDS. Every participant's browser sees that commit on Jetstream and applies it directly.

Architecture

Three public services divide the work. Constellation is a global atproto backlink index: every dev.skyboard.* record carries a boardUri, so asking it for links to one board URI lists every record on the board, by author and record key. Each PDS remains the source of truth for record content. Jetstream delivers live commits, which carry the full record body, so a change is applied without any refetch.

If Constellation is unreachable, boards already in Dexie keep working and writes keep syncing; loads degrade to listing the repos of the board owner plus participants already known locally.

 ┌─ Browser (per user) ────────────────────────────────────────────┐
 │                                                                 │
 │   ┌──────────────────────────────────────────────────────────┐  │
 │   │                     Svelte 5 UI                          │  │
 │   │              Board  →  Columns  →  Cards                 │  │
 │   └───────┬──────────────────────────────────▲───────────────┘  │
 │           │ user action                      │ render           │
 │           ▼                                  │                  │
 │   ┌───────────────────┐          ┌───────────┴──────────────┐   │
 │   │  Dexie (IndexedDB)│─────────▶│   materializeTasks()     │   │
 │   │                   │ liveQuery│                          │   │
 │   │  boards           │          │  group ops by task       │   │
 │   │  tasks            │          │  filter by trust +       │   │
 │   │  ops              │          │    permissions           │   │
 │   │  trust            │          │  per-field LWW merge     │   │
 │   │                   │          │  → MaterializedTask[]    │   │
 │   │  (syncStatus:     │          └──────────────────────────┘   │
 │   │   pending/synced) │                                         │
 │   └──┬──────────▲─────┘                                         │
 │      │          │ hydrate + apply live commits                  │
 └──────┼──────────┼───────────────────────────────────────────────┘
        │          │                   ┌───────────────────────┐
        │          └───── REST ───────▶│    Constellation      │
        │                              │  (backlink index)     │
        │                              │                       │
        │                              │  /links/all           │
        │                              │  /links               │
        │                              └───────────────────────┘
        │                                  which records are
        │                                  on the board
        │
        │ background sync              ┌───────────────────────┐
        │ putRecord / deleteRecord     │  Participants' PDSes  │
        │                              │                       │
        ▼                        ◀──── │  getRecord            │
 ┌──────────────────────────────┐ REST │  listRecords (backup) │
 │        User's PDS            │      └───────────────────────┘
 │   (Personal Data Server)     │          record content
 │                              │
 │   at://did:plc:xxx/          │
 │     dev.skyboard.board/*     │
 │     dev.skyboard.task/*      │
 │     dev.skyboard.op/*        │
 │     dev.skyboard.trust/*     │
 └──────────────┬───────────────┘
                │ commit events
                ▼
 ┌──────────────────────────────┐
 │          Jetstream           │──── WebSocket ────▶ every browser
 │    (AT Protocol firehose)    │      (full record bodies)
 └──────────────────────────────┘

Multi-user coordination

When multiple users collaborate on a board, each writes only to their own PDS. Every browser aggregates records from all participants — the board owner's PDS has board configuration and trust grants, while tasks and ops can live in any participant's PDS. Ops reference tasks in any repo via AT URI.

 ┌─ Alice's PDS ─────────────┐  ┌─ Bob's PDS ────────────┐  ┌─ Carol's PDS ──────────┐
 │  (board owner)            │  │  (trusted collaborator)│  │  (untrusted)           │
 │                           │  │                        │  │                        │
 │  ┌───────────────────┐    │  │                        │  │                        │
 │  │ Board             │    │  │                        │  │                        │
 │  │ columns, perms    │    │  │                        │  │                        │
 │  └───────────────────┘    │  │                        │  │                        │
 │                           │  │                        │  │                        │
 │  ┌────────┐ ┌────────┐    │  │  ┌────────┐ ┌────────┐ │  │  ┌────────┐ ┌────────┐ │
 │  │ Tasks  │ │  Ops   │    │  │  │ Tasks  │ │  Ops   │ │  │  │ Tasks  │ │  Ops   │ │
 │  └───┬────┘ └────────┘    │  │  └────────┘ └───┬────┘ │  │  └────────┘ └───┬────┘ │
 │      │                    │  │                 │      │  │                 │      │
 │  ┌───┴────────────────┐   │  │            ┌────┘      │  │            ┌────┘      │
 │  │ Trust              │   │  │            │           │  │            │           │
 │  │ └ Bob ✓            │   │  │            │           │  │            │           │
 │  └────────────────────┘   │  │            │           │  │            │           │
 └───────────────────────────┘  └────────────┼───────────┘  └────────────┼───────────┘
          │                                  │                           │
          │     ┌────────────────────────────┘                           │
          │     │  Ops target tasks in ANY repo via AT URI               │
          │     │  at://did:plc:alice/dev.skyboard.task/rkey             │
          │     │                       ┌────────────────────────────────┘
          │     │                       │
          └─────┴───────────────────────┘
                        │
                  commit events
                        │
                        ▼
              ┌───────────────────┐           ┌───────────────────────┐
              │     Jetstream     │           │    Constellation      │
              │  (AT Proto relay) │           │  (indexes .boardUri   │
              └─────────┬─────────┘           │   backlinks)          │
                        │                     └───────────┬───────────┘
                        │  live commits          who participates
                        └───────────┬────────────────────┘
                                    ▼
 ┌─────────────────────────────────────────────────────────────────────────────┐
 │                           Each User's Browser                               │
 │                                                                             │
 │  Discover         Hydrate from     Filter by      Merge via      Render     │
 │  participants ──▶ their PDSes ──▶ trust +  ────▶ per-field ──▶  board       │
 │                                    permissions    LWW            view       │
 │                                                                             │
 │  Bob's ops → applied ✓         Carol's ops → pending proposals              │
 └─────────────────────────────────────────────────────────────────────────────┘

Data model

There are four record types, each stored as AT Protocol records in the user's repo:

  • Board (dev.skyboard.board) — name, description, column configuration, and permission rules. Owned by whoever created it.
  • Task (dev.skyboard.task) — a card on the board with title, description, column, and fractional index position. Owned by whoever created it. Write-once: the record captures the initial state at creation and is never updated directly.
  • Op (dev.skyboard.op) — an edit to any task, including your own. Contains partial field updates (title, description, columnId, position) and targets a task by AT URI.
  • Trust (dev.skyboard.trust) — a per-board grant allowing another user's ops to take effect on your view of the board.

Here's how two users coordinate on a single board. Each user can only write records to their own PDS — cross-user edits work by writing Ops that reference another user's Task by AT URI.

  Alice's PDS (did:plc:alice)              Bob's PDS (did:plc:bob)
  ───────────────────────────              ───────────────────────────

  Board                                    (Alice owns the board)
  ├ name: "Sprint Board"
  ├ columns: [Todo, Doing, Done]
  └ permissions: [{move_task: trusted}]

  Task                                     Task
  ├ title: "Fix login bug"                 ├ title: "Add dark mode"
  ├ columnId: todo                         ├ columnId: todo
  └ position: "a0"                         └ position: "a1"
       ▲                                        ▲
       │ target                                 │ target
       │                                        │
  Op   │  (Alice moves her task)           Op   │  (Bob edits his own task)
  ├ target: at://alice/task/...            ├ target: at://bob/task/...
  ├ columnId: doing                        ├ description: "Support light/dark"
  └ updatedAt: T1                          └ updatedAt: T3

                                           Op      (Bob edits Alice's task)
                                           ├ target: at://alice/task/...  ◄─ cross-repo
                                           ├ title: "Fix auth bug"
                                           └ updatedAt: T2

  Trust  (Alice grants Bob access)
  ├ board: at://alice/board/...
  └ subject: did:plc:bob


  Materialization (runs in each browser)
  ═══════════════════════════════════════════════════════════════

  For Alice's task ("Fix login bug"):

    base     title: "Fix login bug"   columnId: todo   position: "a0"
               │                        │
    + op T1    │                        └──▶ doing     (Alice, owner)
    + op T2    └──▶ "Fix auth bug"                     (Bob, trusted)
               ─────────────────────    ────────────   ─────────────
    result     title: "Fix auth bug"   col: doing      position: "a0"

               Per-field LWW: each field resolved independently by timestamp.
               Bob's op applied because Alice published a Trust record for him.
               Without trust, it would appear in the Proposals panel instead.

Why ops?

You can only write to your own AT Protocol repo. If you want to move or edit someone else's card, you publish an Op record to your repo proposing the change. The board owner (and other participants who trust you) will see your edit applied; those who don't trust you will see it as a pending proposal.

All task edits go through ops — even edits to your own tasks. This ensures per-field LWW timestamps are always correct. A single updatedAt on the task record can't distinguish which fields actually changed, so a direct write to one field (e.g. title) would poison the timestamp for all fields, silently reverting concurrent ops on other fields (e.g. a collaborator's column move). By routing all edits through ops, each field change carries its own timestamp and LWW resolves correctly.

Conflict resolution

Edits are resolved using per-field last-writer-wins (LWW) with ISO 8601 timestamps. Each mutable field — title, description, columnId, position — is independently resolved. The value with the latest timestamp wins.

This is formally an LWW-Register Map, a well-known CRDT. The merge function is commutative, associative, and idempotent, so all clients converge to the same state regardless of the order they receive operations.

Fractional indexing

Task ordering within columns uses fractional indexing — each task has a position string that sorts lexicographically. Moving a task generates a new position string between its neighbors, so only the moved task is ever updated. This is critical for the distributed model: since you can only write to your own repo, integer-based ordering (which requires renumbering multiple tasks) would fail when moving other users' cards. With fractional indexing, a single op with the new position is sufficient.

A trust layer sits on top: only ops from the task owner, the current user, or explicitly trusted users are merged into the effective board state. Untrusted ops are stored but shown separately in a Proposals panel. Once trust converges (all participants agree on who to trust), the underlying LWW properties guarantee full convergence.

Trust system

  • The board owner's edits are always trusted on their own board.
  • Your own ops are always applied in your local view.
  • Other users require an explicit trust grant (per-board) before their ops affect your view.
  • Joining a board auto-trusts the board owner.
  • Untrusted ops and tasks from unknown users appear in the Proposals panel, where you can review them and grant trust.

Board permissions

On top of the trust system, board authors can configure fine-grained permission rules that control what operations are allowed and by whom. Permissions are stored on the board record and enforced during operation materialization.

Operations

Five operation types can be independently controlled:

  • create_task — creating new cards on the board
  • edit_title — changing a card's title
  • edit_description — changing a card's description
  • move_task — moving a card to a different column
  • reorder — reordering cards within a column

Scopes

Each operation is assigned one of three scopes:

  • author_only — only the board owner can perform the operation
  • trusted — the board owner and explicitly trusted users (default for all operations)
  • anyone — any user can perform the operation

Per-column rules

Permission rules can optionally be scoped to specific columns, allowing patterns like "anyone can create tasks in the Inbox column, but only trusted users can move tasks to Done."

Pending operations

When a user performs an action they don't have full permission for (e.g. scope is trusted but the user hasn't been trusted yet), the operation is stored but shown greyed out as pending, awaiting board author approval.

Sync architecture

Reading board data (src/lib/constellation.ts)
  → Browser reads the board record from the owner's PDS (getRecord)
  → GET /links/all?target=<boardUri> → which collections are non-empty
  → GET /links per non-empty collection → each record's (did, rkey)
  → getRecord for each one, kept if value.boardUri still matches
  → Upserted into Dexie in one transaction (local pending records take
    priority); local rows the PDS reports as RecordNotFound are pruned

Real-time (src/lib/jetstream.ts)
  → One WebSocket to Jetstream, wantedCollections=dev.skyboard.*
  → Commit events carry the full record → applied straight to Dexie
  → Reconnect replays from the last time_us cursor; if the gap is too
    large, subscribed boards are reloaded instead

Writing (task creation, edits, comments, etc.)
  → db.tasks.add() / db.ops.add() in IndexedDB (syncStatus: 'pending')
  → materializeTasks merges base task + ops via per-field LWW
  → UI updates immediately from local state
  → Background sync pushes record to user's PDS via putRecord
  → PDS commit picked up by Jetstream → every subscribed browser applies it

Cross-PDS aggregation happens in the browser. A cold board load costs a few Constellation calls plus one getRecord per record on the board, run in parallel; a reload skips records that can't have changed (ops, trusts, approvals, reactions already held); DID→PDS lookups are cached in localStorage for a day. Boards already in Dexie render instantly while that refresh runs.

Appview (legacy)

The appview (appview/) is the caching aggregation server the web app used before Constellation. It runs on Bun with SQLite and deploys to Fly.io. Neither the web app nor the CLI reads from it now; keep it only through the rollout soak, then it can be decommissioned.

See appview/README.md for development and deployment details.

Tech stack

Web app:

Appview:

  • Bun runtime with built-in SQLite and WebSocket support
  • Hono for HTTP routing
  • SQLite (WAL mode) for data caching
  • Deploys to Fly.io with persistent volume

CLI

Skyboard includes a command-line interface (sb) for managing boards and tasks from the terminal. It lives in the cli/ directory as a standalone package.

Install

cd cli
npm install
npm run build
npm link        # makes `sb` available globally

Auth

The CLI uses AT Protocol OAuth with a loopback redirect — sb login opens your browser, you authorize with your Bluesky account, and the session is stored locally in ~/.config/skyboard/.

sb login alice.bsky.social    # opens browser for OAuth
sb whoami                     # show current user
sb logout

Board navigation

sb boards                     # list your boards + joined boards
sb use "Sprint Board"         # set default board (by name, rkey, AT URI, or URL)
sb add at://did:plc:xxx/dev.skyboard.board/rkey   # join a board
sb cols                       # show columns with task counts

Cards

sb cards                      # list all cards grouped by column
sb cards -c "In Progress"     # filter by column (name, prefix, or number)
sb cards -s "login"           # search titles and descriptions

sb new "Fix login bug"                    # create card in first column
sb new "Update docs" -c done -d "..."     # create in specific column with description

sb show 3labc12               # show card details (rkey prefix, min 4 chars)
sb mv 3lab done               # move card to column
sb edit 3lab -t "New title"   # edit title
sb edit 3lab -d "Details"     # edit description
sb comment 3lab "Looks good"  # add comment
sb rm 3lab                    # delete card (owner only, with confirmation)

Cards are referenced by TID rkey prefix (like git short hashes). sb cards shows 7-character truncated rkeys. Columns can be matched by name, prefix (in matches In Progress), or 1-based index.

JSON output

All commands support --json for machine-readable output, useful for scripting:

sb cards --json | jq '.[].cards[].title'
sb whoami --json

How it works

The CLI authenticates via OAuth and has no local database. It asks Constellation which records are on a board, reads each one directly from its author's PDS, runs the same materializeTasks() merge logic as the web app, and displays the result. Write commands (new, mv, edit, comment) create AT Protocol records (tasks, ops, comments) in your PDS; other clients receive them through Jetstream or their next Constellation-backed refresh.

Development

npm install
npm run dev      # Start dev server at http://127.0.0.1:3001
npm run build    # Production build
npm run check    # Type checking

By default the dev server reads from https://constellation.microcosm.blue and wss://jetstream2.us-east.bsky.network/subscribe. Both are configurable — copy the example env file and edit it to point at your own instances:

cp .env.example .env

Remove or empty VITE_CONSTELLATION_URL / VITE_JETSTREAM_URL to fall back to the defaults.

About

A collaborative kanban board on the AT Protocol

Resources

Stars

29 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages