/ 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.