/ ideation.org
ideation.org
1 #+title: TUI-AtMessenger — Ideation 2 #+author: Xavier Brinon 3 #+date: 2026-04-23 4 #+startup: overview 5 #+options: toc:3 num:nil 6 7 * Vision 8 9 Build a series of terminal UI applications on the AT Protocol, learning 10 the protocol from the inside out by shipping one real app per tier of 11 its architecture. 12 13 References that seeded this: 14 - Dan Abramov, /Open Social/ — <https://overreacted.io/open-social/> 15 - AT Protocol documentation — <https://atproto.com/> 16 17 Why TUI specifically: 18 - The terminal rewards keyboard-driven power-users, the same demographic 19 that cares about data portability. 20 - TUIs force you to be honest about what the core interaction is — 21 there is no room for decorative UI to hide a weak data model. 22 - ATProto's JSON-over-HTTP shape is ideal for terminal clients: no 23 heavy asset pipeline, no image CDNs required for the skeleton. 24 25 * Core ATProto Concepts 26 27 The key move ATProto makes is splitting things that traditional 28 platforms fuse together. Internalizing this split is the prerequisite 29 for every project below. 30 31 | Layer | Primitive | Analogy | 32 |--------------+----------------------+----------------------------| 33 | Identity | DID + handle | Your domain name | 34 | Storage | PDS (repo of records) | Your git repo | 35 | Schema | Lexicon | An npm types package | 36 | Distribution | Relay / Jetstream | Git host's commit stream | 37 | Presentation | AppView | Read-side projection (CQRS)| 38 39 ** Identity — DIDs and handles 40 41 - A =did:plc:...= is a decentralized identifier, opaque and permanent. 42 - A handle like =alice.bsky.social= is a DNS record pointing at the DID. 43 - Handles are /rebindable/ — change your DNS, keep your DID, keep your 44 data and followers. 45 - This is the vendor-lock-in escape hatch at the identity layer. 46 47 ** Storage — the PDS 48 49 - Every user has a Personal Data Server storing signed JSON records. 50 - Records live at =at://did/collection/rkey= (e.g. 51 =at://did:plc:abc/app.bsky.feed.post/3kx2abc=). 52 - Collections are namespaced by lexicon ID (reverse DNS). 53 - You can self-host or use a hosted PDS, and migrate between them 54 without permission from any app. 55 56 ** Schema — lexicons 57 58 - A lexicon is a JSON-Schema-ish contract defining a record type. 59 - Lexicon IDs are reverse-DNS: =app.bsky.feed.post=, =sh.xavier.note=. 60 - Whoever controls the domain controls the schema. 61 - This is the /real/ open-social move: anyone can publish a lexicon, 62 any client can adopt it, no gatekeeper. 63 64 ** Distribution — the firehose and Jetstream 65 66 - Every PDS streams changes to Relays. 67 - A Relay emits a firehose of CBOR-encoded CAR files with MST diffs — 68 powerful but brutal. 69 - *Jetstream* is Bluesky's JSON-simplified firehose 70 (=wss://jetstream2.us-east.bsky.network/subscribe=) — same events, 71 plain JSON, no auth. This is the beginner on-ramp. 72 73 ** Presentation — AppViews 74 75 - An AppView indexes the firehose and serves a shaped view: timelines, 76 thread trees, notifications, follow graphs. 77 - Multiple AppViews can present the same underlying repo differently. 78 - Your TUI apps are /clients of/ an AppView (for reads) and /writers to/ 79 the PDS (for creates/updates). 80 81 ** Key insights 82 83 - *The lexicon is not owned by Bluesky.* =app.bsky.feed.post= is 84 their schema for microblog posts. You can publish 85 =com.xavier.messenger.dm= and any client that knows that lexicon 86 can read/write it against any PDS. 87 - *AT URIs are dereferenceable.* Give one to a resolver and it walks 88 DID → PDS → repo → record. This is why ATProto feels web-like rather 89 than federation-like. 90 - *Pull via firehose, not push via federation.* Unlike ActivityPub, 91 every relay sees /everything/, and AppViews decide what to index. 92 Your TUI can tap the firehose with zero auth. 93 94 * Stack 95 96 Chosen because TypeScript is the language I already know well, and 97 because the official ATProto SDK is written in TS (so docs and types 98 align 1:1). 99 100 | Concern | Library | Notes | 101 |------------------+--------------------------+-------------------------------------------| 102 | ATProto client | =@atproto/api= | Official, complete, tracks new lexicons | 103 | Lightweight alt | =atcute= (mary-ext) | Tree-shakeable; consider if bundle size | 104 | Firehose | =@atproto/sync= + =@atproto/repo= | CBOR + CAR parsing | 105 | Jetstream | Bun =WebSocket= + JSON | The starter on-ramp | 106 | TUI framework | =ink= | React-renders-to-terminal | 107 | Ink extras | =ink-text-input=, =ink-select-input=, =ink-use-stdout-dimensions= | | 108 | Runtime | *Bun* | Native WS + TS, no build step | 109 | Schema types | =@atproto/lexicon-cli= | Codegen from lexicon JSON to TS types | 110 | Credentials | =keytar= | OS keychain — don't put passwords in dotfiles | 111 | HTTP (Tier 4) | =hono= or =elysia= | Lightweight, Bun-friendly | 112 | DB (Tier 4) | =bun:sqlite= | Built into Bun, fast enough | 113 114 * Curriculum — One Fully-Fleshed App Per Tier 115 116 ** Tier 1 — =attop= · Live network dashboard 117 118 *Pitch:* htop-for-the-ATmosphere. Connect to Jetstream with zero auth, 119 show what is happening across every PDS in the network, right now. 120 121 *** TUI sketch 122 #+begin_example 123 ┌─ attop · 2026-04-23 15:42:01 · 847 evt/s ──────────────────────┐ 124 │ Posts ████████████████░░░░ 412/s Lang: en 58 ja 14 pt 8 │ 125 │ Likes ███████████░░░░░░░░░ 287/s │ 126 │ Follows ███░░░░░░░░░░░░░░░░░ 93/s │ 127 │ Reposts █░░░░░░░░░░░░░░░░░░░ 24/s │ 128 ├─ Top handles (last 60s) ───────────────────────────────────────┤ 129 │ jay.bsky.team 42 ▓▓▓▓▓▓▓▓ │ 130 │ pfrazee.com 31 ▓▓▓▓▓▓ │ 131 │ why.bsky.team 18 ▓▓▓▓ │ 132 ├─ Last post ────────────────────────────────────────────────────┤ 133 │ @alice.bsky.social · 15:42:00 │ 134 │ just finished my first atproto consumer │ 135 └────────────────────────────────────────────────────────────────┘ 136 [q] quit [/] filter [l] lang [p] pause 137 #+end_example 138 139 *** What it teaches 140 - Jetstream WebSocket consumption, zero auth required. 141 - Record shape: =did=, =commit.collection=, =commit.operation=, =commit.record=. 142 - Streaming aggregation in React without re-rendering on every event. 143 - Ink layout primitives: =Box=, =flexDirection=, =flexGrow=, borders. 144 145 *** Key code — batched aggregation 146 #+begin_src javascript 147 // Never setState per event — at 2000 evt/s React melts. 148 // Batch in a ring, flush at 250ms. 149 export function createAggregator(flushMs = 250) { 150 let buffer: JetstreamEvent[] = []; 151 const subs: ((s: Snapshot) => void)[] = []; 152 setInterval(() => { 153 if (!buffer.length) return; 154 const snap = reduce(buffer); 155 buffer = []; 156 subs.forEach(fn => fn(snap)); 157 }, flushMs); 158 return { 159 push: (e: JetstreamEvent) => buffer.push(e), 160 subscribe: (fn: (s: Snapshot) => void) => subs.push(fn), 161 }; 162 } 163 #+end_src 164 165 *** Insights 166 - =wantedCollections= query-param filters server-side — never download 167 the whole network to filter client-side. 168 - The 250 ms batching pattern is reused across /all four apps/: 169 atread timeline updates, atnote save debounce, atfeedgen metrics. 170 171 *** Done when 172 Can leave it running for an hour, stays responsive, =[/]= lets you 173 filter by keyword (e.g. =/rust= shows only posts mentioning rust). 174 Effort: ~1 weekend. 175 176 ** Tier 2 — =atread= · Timeline reader with posting 177 178 *Pitch:* mutt-for-Bluesky. Full timeline experience in a terminal, 179 keyboard-driven, compose buffer that opens =$EDITOR=. 180 181 *** TUI sketch 182 #+begin_example 183 ┌─ atread · @xavier.you.social ───────────────────────────────────┐ 184 │ > @alice.bsky.social · 2h │ 185 │ thinking about lexicons today. anyone using custom ones? │ 186 │ ♥ 12 ↻ 3 💬 5 │ 187 │ │ 188 │ @bob.bsky.social · 3h │ 189 │ new blog post on atproto identity resolution: ... │ 190 │ ♥ 47 ↻ 11 💬 8 │ 191 ├─────────────────────────────────────────────────────────────────┤ 192 │ [j/k] nav [enter] thread [r] reply [l] like [t] repost │ 193 │ [c] compose [f] feeds [n] notifications [q] quit │ 194 └─────────────────────────────────────────────────────────────────┘ 195 #+end_example 196 197 *** What it teaches 198 - App passwords vs OAuth — start with app passwords (simpler). 199 - =BskyAgent.login()=, =getTimeline()=, =post()=, =like()=, =repost()=. 200 - Cursor-based pagination (every list endpoint returns =cursor=). 201 - =getPostThread()= returns a tree — render nested Ink components. 202 - Optimistic updates: bump the like count locally, then call the API. 203 204 *** Key code — auth + timeline 205 #+begin_src javascript 206 // packages/atlib/src/auth.ts — reused by atread, atnote, atfeedgen 207 import { BskyAgent } from '@atproto/api'; 208 import keytar from 'keytar'; 209 210 export async function loadAgent(service = 'https://bsky.social') { 211 const agent = new BskyAgent({ service }); 212 const creds = await keytar.findCredentials('atproto-tui'); 213 if (creds[0]) { 214 await agent.login({ 215 identifier: creds[0].account, 216 password: creds[0].password, 217 }); 218 } 219 return agent; 220 } 221 #+end_src 222 223 #+begin_src javascript 224 function useTimeline(agent: BskyAgent) { 225 const [feed, setFeed] = useState<FeedViewPost[]>([]); 226 const [cursor, setCursor] = useState<string | undefined>(); 227 const loadMore = async () => { 228 const res = await agent.getTimeline({ cursor, limit: 30 }); 229 setFeed(prev => [...prev, ...res.data.feed]); 230 setCursor(res.data.cursor); 231 }; 232 useEffect(() => { loadMore(); }, []); 233 return { feed, loadMore }; 234 } 235 #+end_src 236 237 *** Insights 238 - *App passwords are scoped.* Cannot change email or delete the 239 account. Right credential for a TUI — blast radius is limited. 240 - =getTimeline= returns =FeedViewPost=, not =Post=. Each entry has 241 =post=, optional =reply= and =reason=. =reason= means "this is a 242 repost"; =reply= means "this is a reply". The post itself has a 243 =record= (raw) plus /view/ fields (like count, viewer state). 244 The /view ≠ record/ distinction is how AppViews add indexed data 245 to raw records — this pattern is everywhere in ATProto. 246 247 *** Done when 248 Can read a day's worth of timeline, reply to a post, like/repost/ 249 thread-view, and =[c]= opens =$EDITOR= for a fresh post. Effort: 250 ~2 weekends. 251 252 ** Tier 3 — =atnote= · Notes with a custom lexicon 253 254 *Pitch:* a note-taking TUI whose notes live in your PDS under a lexicon 255 /you/ author. Switch clients tomorrow, notes come with you. This is the 256 "aha" moment where ATProto stops being a social protocol and becomes a 257 personal data substrate. 258 259 *** TUI sketch 260 #+begin_example 261 ┌─ atnote · sh.xavier.note ─────────┬─ # lexicon deep-dive ──────┐ 262 │ ▸ lexicons │ created: 2026-04-20 │ 263 │ # lexicon deep-dive 4d │ tags: #atproto #learning │ 264 │ # DID resolution notes 6d │─────────────────────────────│ 265 │ # ratatui vs ink 2w │ a Lexicon is basically a │ 266 │ ▸ atproto-learning │ JSON Schema with extra │ 267 │ # jetstream gotchas 1d │ metadata about how the │ 268 │ # feed gen plan 3d │ record is addressed... │ 269 ├───────────────────────────────────┴─────────────────────────────┤ 270 │ [n] new [/] search [d] delete [e] edit [s] sync [q] quit │ 271 └─────────────────────────────────────────────────────────────────┘ 272 #+end_example 273 274 *** What it teaches 275 276 - Lexicon authoring :: write =lexicons/sh/xavier/note.json= defining a 277 =record= type with fields like =title=, =body=, =tags=, =folder=, 278 =createdAt=. 279 - Codegen :: =@atproto/lexicon-cli= generates TypeScript types. 280 - Record CRUD :: =agent.com.atproto.repo.createRecord=, =listRecords=, 281 =putRecord=, =deleteRecord=. 282 - Repo internals :: MST / rev / CID — repo is a Merkle tree; every 283 write has a revision; every record has a content ID. 284 285 *** Lexicon document 286 #+begin_src json 287 { 288 "lexicon": 1, 289 "id": "sh.xavier.note", 290 "defs": { 291 "main": { 292 "type": "record", 293 "key": "tid", 294 "record": { 295 "type": "object", 296 "required": ["title", "body", "createdAt"], 297 "properties": { 298 "title": { "type": "string", "maxLength": 200 }, 299 "body": { "type": "string", "maxGraphemes": 100000 }, 300 "folder": { "type": "string", "maxLength": 100 }, 301 "tags": { "type": "array", "items": { "type": "string" } }, 302 "createdAt": { "type": "string", "format": "datetime" }, 303 "updatedAt": { "type": "string", "format": "datetime" } 304 } 305 } 306 } 307 } 308 } 309 #+end_src 310 311 *** Key code — CRUD on your own collection 312 #+begin_src javascript 313 export async function createNote(agent: BskyAgent, note: Note) { 314 return agent.com.atproto.repo.createRecord({ 315 repo: agent.session!.did, 316 collection: 'sh.xavier.note', 317 record: { 318 ...note, 319 createdAt: new Date().toISOString(), 320 $type: 'sh.xavier.note', 321 }, 322 }); 323 } 324 325 export async function listNotes(agent: BskyAgent, cursor?: string) { 326 const res = await agent.com.atproto.repo.listRecords({ 327 repo: agent.session!.did, 328 collection: 'sh.xavier.note', 329 cursor, 330 limit: 50, 331 }); 332 return { notes: res.data.records, cursor: res.data.cursor }; 333 } 334 #+end_src 335 336 *** Insights 337 - Lexicon IDs use reverse-DNS like Java packages. =sh.xavier.note= means 338 "I control =xavier.sh=, and I've published a lexicon named =note=." 339 Controlling the domain = controlling the schema. This is why ATProto 340 identities /are/ domains. 341 - =key: "tid"= means record keys are timestamp IDs — sortable, globally 342 unique, ~13 chars. Use =tid= for collections you add to, 343 =literal:self= for singletons like the profile record. 344 - *Your notes are public by default.* Anyone with your DID can 345 =listRecords= on =sh.xavier.note=. For private notes, encrypt 346 client-side before storing — ATProto has no private-record primitive 347 yet. A real protocol limitation worth knowing. 348 349 *** Done when 350 Can create / list / edit / delete notes from the TUI, notes survive 351 across reinstalls (they live in your PDS), a =curl= against the public 352 PDS endpoint with your DID returns them as JSON. Effort: ~2–3 weekends. 353 354 ** Tier 4 — =atfeedgen= · Custom feed generator + operator TUI 355 356 *Pitch:* a program that serves a custom Bluesky feed algorithm /and/ 357 gives you a TUI to watch it operate in real time. By the end, 358 Bluesky's official app will have a feed in it that your laptop is 359 serving. 360 361 *** TUI sketch 362 #+begin_example 363 ┌─ atfeedgen · feed://my-rust-feed · up 2h 14m ──────────────────┐ 364 │ Subscribers 127 (+3 last hour) │ 365 │ Requests/min 42 ▓▓▓▓▓▓▓▓▓▓ │ 366 │ Index size 12,847 posts │ 367 │ Jetstream lag 0.2s ✓ healthy │ 368 ├─ Top posts right now ──────────────────────────────────────────┤ 369 │ @steve.bsky.social · score 0.94 │ 370 │ async/await finally clicked. here's the mental model... │ 371 ├─ Recent requests ──────────────────────────────────────────────┤ 372 │ 15:42:03 did:plc:abc GET getFeedSkeleton 30 items 42ms │ 373 └─────────────────────────────────────────────────────────────────┘ 374 [p] posts [s] subs [r] reindex [l] logs [q] quit 375 #+end_example 376 377 *** What it teaches 378 - The =app.bsky.feed.generator= record, published once to register the 379 feed with Bluesky. 380 - Serving =app.bsky.feed.getFeedSkeleton= over XRPC — contract is 381 "given a cursor, return an ordered list of post URIs". 382 - DID document publication via =/.well-known/did.json= so Bluesky can 383 verify you own the feed. 384 - JWT verification (requests come signed with the viewer's DID). 385 - Running an indexer (Jetstream → SQLite) alongside an HTTP server 386 alongside the TUI — all in one Bun process. 387 - /Deploying publicly/ — Bluesky's servers must reach your HTTPS 388 endpoint. 389 390 *** Process shape 391 #+begin_example 392 ┌────────────────────── Bun process ──────────────────────┐ 393 │ │ 394 │ Jetstream ──▶ SQLite ──▶ Scorer ──▶ In-memory ranks │ 395 │ │ │ 396 │ ▼ │ 397 │ HTTP (Hono) ──▶ getFeedSkeleton ──▶ AT-URIs │ 398 │ │ 399 │ Ink TUI ──▶ subscribes to same stats emitter │ 400 │ │ 401 └─────────────────────────────────────────────────────────┘ 402 #+end_example 403 404 *** Key code — the feed endpoint (the whole contract) 405 #+begin_src javascript 406 app.get('/xrpc/app.bsky.feed.getFeedSkeleton', async (c) => { 407 const cursor = c.req.query('cursor'); 408 const limit = Number(c.req.query('limit') ?? 30); 409 410 const posts = db.query(` 411 SELECT uri, indexed_at FROM indexed_posts 412 WHERE score > 0.5 ${cursor ? 'AND indexed_at < ?' : ''} 413 ORDER BY indexed_at DESC LIMIT ? 414 `).all(cursor ? [cursor, limit] : [limit]); 415 416 return c.json({ 417 cursor: posts.at(-1)?.indexed_at, 418 feed: posts.map(p => ({ post: p.uri })), 419 }); 420 }); 421 #+end_src 422 423 *** Key code — register the feed (run once) 424 #+begin_src javascript 425 await agent.com.atproto.repo.putRecord({ 426 repo: agent.session!.did, 427 collection: 'app.bsky.feed.generator', 428 rkey: 'my-rust-feed', 429 record: { 430 $type: 'app.bsky.feed.generator', 431 did: 'did:web:feed.xavier.sh', // your server 432 displayName: 'Rust Vibes', 433 description: 'Curated posts about Rust, async, and systems.', 434 createdAt: new Date().toISOString(), 435 }, 436 }); 437 #+end_src 438 439 *** Insights 440 - *A feed generator is a DID, not a URL.* =did:web:feed.xavier.sh= 441 resolves to =https://feed.xavier.sh/.well-known/did.json=, which 442 declares the service endpoint. Feeds have /identities/ — change 443 hosting providers without breaking subscriptions. 444 - You return URIs, not post content. The feed generator only orders 445 =at://= URIs. The Bluesky AppView hydrates them before rendering. 446 That is why feed gens are cheap. 447 - *The TUI is the operator UX.* The /product/ is the feed (consumed 448 on millions of phones); the TUI is admin console. This flips the 449 usual TUI-as-product assumption and mirrors how real infra is built. 450 451 *** Done when 452 Someone else can subscribe to your feed in the Bluesky mobile app and 453 see posts. Effort: ~3–4 weekends, mostly deployment. 454 455 * Monorepo Structure 456 457 Likely shape once Tier 2 is underway (premature for Tier 1): 458 459 #+begin_example 460 TUI-AtMessenger/ 461 ├── package.json # workspace root (Bun workspaces) 462 ├── packages/ 463 │ └── atlib/ # shared: auth, config, theme, keymaps, hooks 464 ├── apps/ 465 │ ├── attop/ # Tier 1 466 │ ├── atread/ # Tier 2 467 │ ├── atnote/ # Tier 3 468 │ └── atfeedgen/ # Tier 4 469 └── lexicons/ 470 └── sh/xavier/note.json # Tier 3 output 471 #+end_example 472 473 Rationale: 474 - A monorepo matters pedagogically: by Tier 3 you want =useAgent()= 475 from =atlib= rather than reimplementing auth. Mirrors real ATProto 476 clients (Bluesky, Graysky, Smokesignal, Tangled). 477 - Lexicons live /outside/ =apps/= on purpose — they are a protocol 478 artifact, not app code. By Tier 3 you will publish 479 =sh.xavier.note.json= under your own DID, at which point other 480 clients (not just yours) can consume it. 481 482 * Learning Through-Line 483 484 | Primitive | Tier that teaches it | 485 |----------------------------------------+----------------------| 486 | Firehose / Jetstream | 1 (also reused in 4) | 487 | App passwords + =BskyAgent= | 2 (reused in 3, 4) | 488 | Records, collections, AT URIs | 2 | 489 | Lexicons — authoring + codegen | 3 | 490 | Repo structure — rkey, CID, rev, MST | 3 | 491 | XRPC serving | 4 | 492 | DID documents, =did:web= | 4 | 493 | Network participant, not just client | 4 | 494 495 After Tier 4 there is nothing fundamental about ATProto left untouched. 496 497 * Open Questions and Decisions to Resolve 498 499 - [ ] Bun vs Node runtime — leaning Bun for native WS + TS, but does 500 =keytar= build cleanly on Bun? Test early. 501 - [ ] App passwords vs OAuth for Tier 2 — app passwords ship faster; 502 OAuth is the forward path. Probably: app passwords first, OAuth 503 upgrade inside Tier 2. 504 - [ ] Domain for lexicon namespace — =sh.xavier= placeholder; need a 505 real controlled domain before publishing the note lexicon publicly. 506 - [ ] Tier 2 → Messenger pivot — folder name =AT-Messenger= implies 507 a DMs app. Option: after Tier 2 (atread) works, fork into 508 =atmessenger= using =chat.bsky.convo.*= lexicons as a Tier 2.5 509 exercise before Tier 3. 510 - [ ] Feed-generator hosting for Tier 4 — Fly.io? Railway? A tiny VPS? 511 Needs public HTTPS with a stable domain. 512 513 * Next Action 514 515 Lean toward *shipping Tier 1 end-to-end before any monorepo work* — 516 one running Ink app is worth more than four empty workspaces. Promote 517 to a monorepo at the start of Tier 2 when reuse pressure becomes real. 518 519 * Idea Catalogue 520 521 ATProto is a /data substrate/, not a social network. Once you see 522 that, "what can be built" becomes "what can a user own as portable 523 signed JSON." The catalogue below is organized by which protocol 524 primitive each category exploits — so the axis is /what you learn/, 525 not /what the UI looks like/. 526 527 ** Alternative AppViews over existing lexicons 528 529 Read-mostly clients that present =app.bsky.*= records differently. 530 You are rebuilding the frontend over the same data. 531 532 - Photo-first client — only render posts with image embeds, grid 533 layout, Instagram vibe. 534 - Long-form reader — filter out <280-char noise, show only posts 535 linking to long articles or quote-threads. 536 - Link aggregator (HN-style) — rank external links shared in posts, 537 strip commentary, show the links. 538 - Terminal client variants — mutt / weechat-style (=atread= is this). 539 - Accessibility-first client — screen-reader optimized, high 540 contrast, alt-text required. 541 - Ambient / slow client — surface 10 posts/day max, force 542 intentionality. 543 - Single-person reader — lock the app to one profile; daily digest. 544 545 ** Apps with new lexicons (PDS as backend) 546 547 The real open-social move. You publish =com.you.thing=, your PDS 548 stores it, multiple clients can read/write the same records. 549 550 *** Personal data and quantified-self 551 552 - Notes — =atnote= / Tier 3. 553 - Bookmarks manager — Linkat exists and is an excellent reference. 554 - Recipe book — ingredients, steps, photos, all portable. 555 - Habit tracker — daily check-ins as records; streaks calculated 556 client-side. 557 - Workout log — sets, reps, PRs, with optional publishing. 558 - Reading list / "currently reading" — BookHive exists. 559 - Movie watchlist / reviews — Skylights exists (Letterboxd clone). 560 - Location check-ins — Foursquare / Swarm-like; =com.you.checkin= 561 with geo. 562 - Sleep / mood journal — private-by-encryption, public if desired. 563 - Travel log — trips as records, destinations as embeds. 564 565 *** Creator and developer tooling 566 567 - Git forge on ATProto — Tangled exists; repos, issues, PRs as records. 568 - Paste bin — Pastesphere exists. 569 - Code gist sharing — like GitHub gists but portable. 570 - Comments-as-a-service — embed a widget on any blog; each comment is 571 a record in the /commenter's/ PDS. Spam handled via labelers, not 572 local moderation. 573 - Portfolio generator — static-site plugin that pulls =com.you.project= 574 records and builds from them. 575 - Résumé / CV — professional identity as a signed record under your 576 domain. 577 578 *** Social and community 579 580 - Forum — Frontpage is the canonical example. 581 - Events and RSVPs — Smokesignal exists; events-as-records with RSVP 582 records pointing back. 583 - Polls — =com.you.poll= with =com.you.vote= records referencing it. 584 - Classifieds / marketplace — listings with images, messages via the 585 existing chat lexicon. 586 - Book clubs — shared reading lists plus discussion threads. 587 - Study groups / flashcards — shared decks, spaced-rep records per user. 588 589 *** Communication 590 591 - Direct messages — =chat.bsky.convo.*= is already a lexicon; a TUI 592 messenger over it is a real project. 593 - Group chat — piggyback on =chat.bsky.convo= group threads or define 594 your own. 595 - Voice notes — audio blobs in PDS, minimal metadata record. 596 - Email-like threaded messages — subjects, CC, longer form. 597 598 ** Firehose consumers (read-only, often no auth) 599 600 Tap Jetstream, aggregate, present. Cheap to build, valuable to users. 601 602 - Trending topics dashboard — =attop= / Tier 1. 603 - Full-network search — index everything, grep-like UI. 604 - Archive.org for Bluesky — historical snapshots, "what did this DID 605 post in 2024?" 606 - Moderation observatory — surface posts with labels applied; 607 transparency tool. 608 - Network graph explorer — visualize follow clusters, identify 609 communities. 610 - Handle-change tracker — watch =com.atproto.identity.*= events; 611 show rebrands. 612 - Link-decay monitor — crawl external links posted, flag dead ones. 613 - "Who's on ATProto now" — onboarding dashboard, newest signups. 614 - Cross-platform comparison — scrape Mastodon/Nostr, diff against 615 ATProto. 616 - Realtime news dashboard — keyword-filter, rank by velocity. 617 618 ** Custom feed generators 619 620 You serve =getFeedSkeleton=, Bluesky's app shows your feed, all you do 621 is order URIs. 622 623 - Topic-curated feeds — "Rust Vibes", "Book Talk", "Cooking Tonight". 624 - Chronological / anti-algorithm — strict time order from follows. 625 - Slow-social — max one post per author per day. 626 - "What I missed" — high-engagement posts from follows in the last week. 627 - Language-filtered — better than Bluesky's default lang-detection. 628 - Re-discovery feed — posts from people you used to follow. 629 - Cross-user mix — mash two users' timelines (you + your partner). 630 - "Quality starter pack" — showcase feeds for onboarding. 631 - Negative feeds — "everything /except/ politics"; filter-by-exclusion. 632 - Conversational feeds — only surface replies-to-replies (real 633 discussions). 634 635 ** Bots and agents 636 637 Run a daemon, react to events, post records. Classic Mastodon-bot 638 genre but with ATProto superpowers. 639 640 - RSS → Bluesky bridge. 641 - GitHub → Bluesky bridge (stars, releases, commits). 642 - Cross-poster — mirror Mastodon / Nostr / Twitter. 643 - Translation bot — reply in requested language. 644 - Summarizer bot — TL;DR of long threads. 645 - Thread unroller — detect thread, reply with rendered single-page link. 646 - Weather / stocks / sports — classic informational bots. 647 - Label-on-demand — "label this post as sarcasm" community moderation. 648 - Embedding service — post gets a reply with its semantic neighbors. 649 - Archival bot — when you post, auto-archive linked pages. 650 651 ** Infrastructure and meta 652 653 You are not building a client; you are building a piece of the protocol. 654 655 - PDS-in-a-box — one-click self-hosted PDS (Docker compose, sensible 656 defaults). 657 - Repo exporter / backup tool — CAR-file dumps, scheduled S3 uploads. 658 - DID rotation / migration tool — move from PDS A to PDS B safely. 659 - Mini relay — subscribe to upstream relays, re-emit filtered streams. 660 - Labeler service — publish labels (spam, NSFW, bot); users subscribe. 661 - Domain-handle broker — help non-technical users set up custom-domain 662 handles. 663 - Verification service — check that =alice.nytimes.com= really is the 664 NYT's Alice. 665 - OAuth-for-ATProto helper library — the OAuth spec is newer and 666 under-served. 667 668 ** Bridges to other protocols 669 670 - ATProto ↔ ActivityPub — an existing bridge exists; room for better UX. 671 - ATProto ↔ Nostr — overlapping audiences, different mental models. 672 - ATProto ↔ RSS — expose posts as RSS under your domain. 673 - ATProto ↔ Webmentions — quote-post becomes a Webmention on the 674 quoted site. 675 - ATProto ↔ IndieWeb microformats — already close conceptually. 676 677 ** Quirky and experimental 678 679 High-learning, low-stakes. Good for understanding edge cases. 680 681 - =atshell= — REPL where =at://= URIs act like filesystem paths. 682 - Collaborative live canvas — CRDT-backed whiteboard; records are edits. 683 - Shared game state — play chess / go by appending moves to a shared 684 lexicon. 685 - "Profile as a site" generator — one static site per DID, 686 auto-rebuilt from PDS. 687 - Lexicon marketplace — a site indexing every published lexicon with 688 usage stats. 689 - Federation inspector — pick a DID, visualize every record type it uses. 690 - Repo diff tool — =git diff= for ATProto repos at two timestamps. 691 - Spaces / audio rooms — ephemeral rooms referenced by records, audio 692 out-of-band. 693 694 ** Primitive-to-idea mapping 695 696 If you want to continue the /learning/ frame, here is the correspondence. 697 698 | Idea category | Primary primitive taught | 699 |-------------------------+--------------------------------------------------------| 700 | Alternative AppViews | XRPC consumption, pagination, view vs. record distinction | 701 | New-lexicon apps | Lexicon authoring, custom record CRUD, PDS as database | 702 | Firehose consumers | Jetstream / real firehose, streaming aggregation | 703 | Custom feeds | DID-as-service, =getFeedSkeleton=, XRPC serving | 704 | Bots | Auth, write-heavy workflows, rate limiting, event loops | 705 | Infrastructure | Deep internals — MST, CAR, DID docs, repo format | 706 | Bridges | Lexicon translation, identity mapping across systems | 707 708 ** Open-territory assessment 709 710 Most Alternative-AppView and Bot categories have competent existing 711 implementations on Bluesky — not a great wedge unless you hold a 712 strong UX opinion. The wide-open spaces /right now/: 713 714 - Personal-data apps that aren't social :: recipes, habits, reading 715 lists. Small communities, high devotion, minimal competition. 716 =atnote= lands here. 717 - Specialized feed generators :: feed-gen infra is mature, the feeds 718 themselves are under-imagined. 719 - Labeler services :: only a handful exist; community moderation is 720 underbuilt. 721 - Infrastructure one-liners :: PDS-in-a-box, backup tools, migration 722 utilities. Users want these and there is little competition. 723 724 ** Insights 725 726 - ATProto's gravity /is/ its lexicon registry. The more builders 727 converge on shared lexicons (=social.recipe=, =events.rsvp=), the 728 more the network compounds. The protocol rewards /coordination/, 729 not just /shipping/. 730 - Private-data apps are a frontier, not a settled space. No native 731 private-record primitive exists yet. If your idea is inherently 732 private (health, finance), you are committing to build the 733 encryption layer — factor that into effort estimates. 734 - "Client" versus "participant" is a real axis. A client reads and 735 writes; a participant /serves/ something the rest of the network 736 consumes (feed generator, labeler, relay, AppView). Tier 4 is 737 where you graduate from client to participant — and participants 738 have far more protocol-shaping power.