What this is
Answers is a Yahoo Answers-style Q&A site built entirely on ATProto. Every question, answer, comment, and vote is a portable record on the user's own Personal Data Server (PDS). There is no Answers-specific backend database. The site is a static frontend; writes are mediated by a shared OAuth worker; discovery piggybacks on a third-party backlink indexer.
The stack in one picture:
Architecture
Three things are moving parts here, and nothing else:
- The static frontend at
answers/— plain HTML, ES modules, no build step. Deployed to Cloudflare Pages from this branch. - The shared OAuth worker at
workers/auth/, hosted atauth.mino.mobi. Not owned by Answers — reused by sweat, music, labglass, and any other*.mino.mobisite that needs an authenticated PDS write path. - A publish workflow (
publish-answers-categories.yml) that writes the curated category taxonomy to a canonical ATProto account, where everyone reads it from.
All state is on-chain (ATProto records). There's no "Answers database" — no question table, no user table, no vote count cache. The frontend recomputes totals on each view from records fetched at display time.
Lexicons
Six custom record types, all under NSID com.minomobi.answers.*. Schema files live in answers/lexicons/.
| Collection | rkey | Purpose |
|---|---|---|
com.minomobi.answers.question |
TID | title, optional body, category strongRef, optional embed |
com.minomobi.answers.answer |
TID | strongRef to question + text |
com.minomobi.answers.comment |
TID | strongRef to answer + text. Comments only exist on answers, not questions. |
com.minomobi.answers.vote |
deterministic | strongRef to subject + direction: 1 | -1. rkey is base32 of SHA-256 of the subject URI, so re-voting rewrites and un-voting deletes. |
com.minomobi.answers.bestAnswer |
deterministic | strongRefs to the question and the chosen answer. rkey is derived from the question URI, so the asker always has exactly one active pick per question. |
com.minomobi.answers.category |
dotted slug | name, slug, optional parent strongRef. Only valid when published by the curator. rkey is the dotted-slug path (e.g. health.mental-health). |
What's reused, not invented
- Follows —
app.bsky.graph.followas-is. - Profiles / avatars —
app.bsky.actor.profile. - Moderation reports —
com.atproto.moderation.createReport(flag UI is paused pending auth-worker proxy support).
What isn't a record at all
- Resolution state (open / in-voting / resolved). Derived client-side from whether the asker has published a
bestAnswerrecord and how old the question is. No state transition record. - Vote tallies, answer counts. Recomputed from Constellation backlinks at display time.
- Points, levels, reputation. Deliberately dropped for MVP to keep it frontend-plus-atproto only. No trusted server means no trusted leaderboard, and that's fine.
Categories
Category records are the source of truth for the taxonomy tree. They're published by the curator account (currently minomobi.com, resolved at runtime) and read directly by every client. Structure of a record:
{
"$type": "com.minomobi.answers.category",
"name": "Mental Health",
"slug": "mental-health",
"description": "...",
"parent": {
"uri": "at://did:plc:.../com.minomobi.answers.category/health",
"cid": "..."
},
"createdAt": "2026-04-19T..."
}
rkey is the full dotted path of slugs (e.g. health.mental-health), making every category globally addressable and making the publish script idempotent.
Source of truth: answers/categories.json. Change it, push, and the workflow republishes.
Authentication
Answers does not implement OAuth itself. It uses the shared mino-auth worker at auth.mino.mobi, same pattern as sweat, music, and others.
- User enters their handle and clicks Continue.
- Frontend
POSTsauth.mino.mobi/oauth/startwith{ handle, origin, returnTo }. - Auth worker does the ATProto PKCE / DPoP / PAR dance against the user's PDS, then redirects to Bluesky for authorization.
- Bluesky redirects to
auth.mino.mobi/oauth/callback, which hands back a session token by redirecting the user to theirreturnToURL with?__auth_session=<token>appended. - On page load,
authInit()reads that query param, stores the token inlocalStorage, hits/api/mefor{ did, handle, scope }, resolves the user's PDS once, and caches everything.
All write requests then go to auth.mino.mobi/pds/repo/* with a bearer token. The worker attaches DPoP proofs (with nonce handling), forwards to the user's PDS, and returns the response. The frontend never touches DPoP keys.
Public reads still hit the user's PDS (or other users' PDSes) directly with no auth — no reason to round-trip through the worker for records that are public ATProto data.
Sign-out calls /api/logout to invalidate the token server-side, then clears localStorage.
Discovery
Answers live on the answerer's PDS, not the asker's. Without an indexer, there's a chicken-and-egg problem: how does the question page find all its answers?
Solution: Constellation, a relay that indexes strongRefs across the entire AT firehose. The question page queries:
GET constellation.us-east.host.bsky.network/links
?target=<question-uri>
&collection=com.minomobi.answers.answer
&path=.question.uri
…and gets back every answer record anywhere on ATProto whose question.uri field points at that question. Same pattern finds comments (on answers) and votes (on anything).
Fallback: if Constellation hasn't indexed yet, the frontend also calls listRecords on the signed-in user's own PDS and filters locally. That way you always see your own writes immediately, even during the indexer lag.
Writes
Every write routes through the auth worker's proxy:
| Action | Proxy endpoint | XRPC method |
|---|---|---|
| Ask question | POST /pds/repo/createRecord | com.atproto.repo.createRecord |
| Answer | POST /pds/repo/createRecord | com.atproto.repo.createRecord |
| Comment | POST /pds/repo/createRecord | com.atproto.repo.createRecord |
| Vote ▲ / ▼ | POST /pds/repo/putRecord | com.atproto.repo.putRecord |
| Un-vote (toggle off) | POST /pds/repo/deleteRecord | com.atproto.repo.deleteRecord |
| Mark best answer | POST /pds/repo/putRecord | com.atproto.repo.putRecord |
Vote and bestAnswer use putRecord with a deterministic rkey (hash of the subject / question URI), so toggling and changing picks is idempotent: the same rkey either rewrites or gets deleted.
GitHub Actions
| Workflow | Trigger | Effect |
|---|---|---|
publish-answers-categories.yml |
Push to answers/categories.json, the category lexicon, the publisher script, or the workflow itself. Also manual dispatch. |
Runs scripts/publish-answers-categories.py which authenticates as the curator and writes one com.minomobi.answers.category record per node. Supports --dry-run and --prune. |
deploy-auth.yml shared |
Push to workers/auth/** on main. Not triggered from this branch. |
Deploys the shared OAuth worker at auth.mino.mobi. Any change we need for Answers (e.g. adding a moderation/createReport proxy route for flags) goes through main. |
The publisher script is deliberately simple: flatten the taxonomy depth-first, write parents first so children can embed parent strongRefs, use the dotted slug path as rkey. Re-running is a no-op; editing categories.json and pushing is the only way to change the tree.
Deployment
LIVE This branch, claude/yahoo-answers-atproto-brainstorm-B6vUR, is wired to auto-deploy answers/ to Cloudflare Pages on every push. No dry-run branch, no staging. Every push ships.
| Component | Where |
|---|---|
| Frontend | answers/ → Cloudflare Pages project mino-answers |
| Lexicons | answers/lexicons/*.json — served as static JSON alongside the site |
| Taxonomy | answers/categories.json — source of truth for the category workflow |
| Publisher | scripts/publish-answers-categories.py |
| Workflow | .github/workflows/publish-answers-categories.yml |
| Auth | Shared workers/auth/ at auth.mino.mobi — no Answers-specific config; wildcard allowlist covers *.mino.mobi. |
The entry in CLAUDE.md under Hosting calls this out as a live-deploy branch so future sessions don't accidentally ship broken pushes.
Portability
Because everything is an ATProto record on the user's own PDS:
- Your writes follow you. If you stop using Answers, your questions and answers remain in your PDS. Any other client that understands the lexicons can render them.
- The site can disappear.
answers.mino.mobigoing down doesn't delete anything. Records are still fetchable, and a new frontend with the same lexicons would work against the same data. - Account migration works. If a user moves PDSes, their records move with them — ATProto handles that at the protocol layer.
- There's no "signup." If you have a Bluesky account, you already have a place to store questions and answers.
Known limits (MVP)
- Search is stubbed. No full-text index yet. Browsing is by category + direct AT-URI links; the search bar currently pops an alert.
- No global "recent questions" feed. Discovery of others' questions requires an indexer we haven't built. The homepage shows your questions; shared question URLs let others answer.
- Flags are paused. UI removed until the shared auth worker proxies
com.atproto.moderation.createReport. - Constellation lag. Freshly posted answers/comments may take a moment to show up for non-authors while the relay indexes them. Local fallback covers authors' own views.
- No OAuth scope display. The site requests whatever scopes the auth worker asks for; we don't surface them yet.