SCN Chat #
An AI assistant chat app where each conversation is an atproto permissioned space on the user's own PDS. Each feature has a spec in specs/, which is the source of truth for its behavior.
Layout and commands #
lexicons/holds our lexicon JSON, andlexicons/upstream/the vendoredcom.atprotolexicons the server calls.packages/lexiconsis the generated lexicon code and thensidmodule.packages/plugin-apiis what plugins build against.apps/serveris the Hono server.src/server.tswires every service together.apps/webis the TanStack Start app, which the server runs inside Hono. Its code is grouped by feature insrc/features/, with thin routes insrc/routes/and cross-feature code insrc/shared/.src/parts/holds the views that draw message parts, andsrc/store/is the browser's local SQLite copy.plugins/holds the shipped plugins.docs/architecture.mdis the human-facing architecture overview, anddocs/plugins.mdthe plugin author guide.README.mdand everything indocs/are for people, so a person has the final word on them. When a change affects what they describe, draft the update and point it out, so the person can rewrite it in their own words.CHANGELOG.mdrecords each change a user, admin, or forker would notice: add its line under Unreleased in the same commit.docs/releasing.mdhas the versioning rules and the steps for cutting a release.GLOSSARY.mdholds the project's terms, anddocs/adr/the decisions behind its less obvious choices. Read the relevant ADR before changing what it decided.pnpm devruns the server, which serves the web app through Vite, as one process, andpnpm dev --scanalso loads React Scan in the browser.pnpm previewbuilds the web app and runs the production build locally.pnpm dev:remoteandpnpm preview:remotedo the same behind a localhost.run tunnel over ssh, with the tunnel's HTTPS URL asPUBLIC_URL, for testing from a phone.pnpm startruns the production build for a deployment.pnpm checkruns lint, type checking, and every test.pnpm codegenregenerates the lexicon code after a lexicon change, andpnpm publish-lexicons <handle>publishes the lexicons from the lexicon authority account.pnpm keysprints fresh secrets.- Local setup: copy
.env.exampleto.env, fill in the secrets frompnpm keys, and put your DID inADMIN_DIDS. Plugins, models, roles, and every other runtime setting are configured in the admin area at/admin. - Server tests run on both SQLite and PGlite through
tests/helpers/db.ts.tests/helpers/fake-pds.tsis a fake spaces PDS with real error codes, policies, and signed commits.
The founding principle #
The user's PDS is the source of truth for every chat. This is the reason the project exists. The server holds no copy of chat content for spaces users: no message tables, no caches that outlive a request, no search index. It reads from the PDS when it needs data, and it keeps only what it needs to operate, such as sessions, sync cursors, reply claims, and encrypted API keys. The local database fallback is a temporary stand-in for a PDS while spaces are in alpha, shaped like a PDS so it can be deleted later. Any design that makes the server a second source of truth, even as a cache, is wrong.
Architecture decisions #
These are settled. Raise a concern before working against any of them.
- Every record belongs to the user, including the assistant's replies. The running app never writes records under its own DID. The one exception is publishing the lexicons, which the maintainers do from a lexicon authority account, outside the app.
- One space per conversation, of type
network.sharedcomputer.chat.conversation. Sharing a chat means changing that space's read policy. Fallback users cannot share or view shared chats. - There is no public HTTP API, apart from XRPC procedures defined in our own lexicons, currently
requestSync, and routes opened to admin-issued API keys, currently cron and site config. The user's PDS is the API: a client can hold a whole conversation by writing records directly. The conventions for that are inlexicons/README.md, and the server must honor them exactly. - Users whose PDS does not support spaces fall back to local storage in SQLite or Postgres. It sits behind the same PDS-shaped record interface as the spaces backend, so it can be deleted once PDSs support spaces. Local rows store the exact lexicon record JSON under the same space and record keys, so migrating a user to their PDS is a replay.
- Each conversation has an entry record in the user's settings space. Together the entries are the user's chat index, which is how the server and browser find conversations and notice changes with one call per user.
- The browser keeps its own local copy of the user's chats in SQLite compiled to WebAssembly, for the chat list, the open conversation, and search. That copy lives on the user's device, not on the server.
- BYO API keys are stored encrypted in the app database, never in records.
- Providers, tools, file ingesters, and turn hooks are plugins. Web search, fetch, and image generation are plugins, not core.
- The web app has no plugin system. Programmers extend it by changing the code in their fork, and nothing third-party loads in the browser at runtime.
- Messages in the browser come from the local copy. No chat client library holds them, and the stream is an overlay until the record arrives.
- The project is meant to be forked. Code refers to lexicon NSIDs only through one generated module. URLs and OAuth settings come from the environment, and the app name and admin models from the admin area.
- Each setting lives in exactly one place: the environment for what the server needs before its database is open, and the app database, edited in the admin area, for everything else. Never let both hold the same setting.
Planned stack #
TypeScript throughout, as a pnpm workspace on Node 24 LTS, run natively with Node's type stripping and no compile step. Hono server with server-sent events for streaming. Kysely over SQLite and Postgres. TanStack Start with React for the frontend, run inside the Hono server, with TanStack Router, Query, and Form. Hono is the only API, so there are no Start server functions. Vitest for tests. Provider plugins use the Vercel AI SDK internally, behind our own plugin interface.
Prefer existing libraries over writing our own, especially the official @atproto/* packages for anything protocol related. Dependencies must have licenses compatible with MIT.
Admins configure plugins, global models, roles, access, and every other runtime setting in the admin area, and changes apply without a restart. .env holds the secrets and the settings needed at startup, including ADMIN_DIDS, the admins no edit can remove. A registration mode decides who may create an account, and once someone has one they keep access until suspended. Roles gate invites, admin models, and admin-paid tools such as the admin's web search engine. Installing plugin packages stays outside the web UI: a plugin is a dependency of the root package.json with the scn-chat-plugin keyword.
The web UI stays easy for a forker to change. Its look comes from the design tokens in apps/web/src/styles/theme.css, and each component styles itself with Tailwind utilities that use only those tokens, never raw colors or arbitrary lengths. A reused look is a component in src/shared/ui/, not a global class. specs/styling.md has the rules, and ADR 0004 the reasons.
React components take props: Props, declared as an interface, and read props.example, rather than destructuring props in the signature. Destructure only the values the component builds other variables from.
When a component renders different things depending on a condition, return early with if (condition) return <Something /> rather than returning a ternary.
Lexicons #
- Lexicons live in
lexicons/network/sharedcomputer/chat/. Once published, only add optional fields, new union members, or new record types. Never rename, remove, or tighten anything. - The atproto data model has no floats. Anything that may contain them, like tool arguments, is stored as a JSON-encoded string.
- Under encryption, user-written content goes inside
encryptedContentand metadata stays in the clear. - Assume readers of lexicon descriptions are atproto experts. Descriptions only state conventions specific to this app.
atproto spaces alpha #
- The spaces SDK is the
@atproto/*packages at0.0.0-spaces-alpha-*versions. Pin exact versions, since breaking changes land without notice. - These packages only install with pnpm. Every
@atproto/*package with an alpha release must be pinned to the exact same alpha version throughoverridesinpnpm-workspace.yaml. A range like^0.0.0-spaces-alpha-*also matches the plain0.0.0releases, which were published broken. - Reference material: the permissioned data proposal (
bluesky-social/proposals,0016-permissioned-data), thepermissioned-data-alphabranch ofbluesky-social/atproto, and thebluesky-social/bulletinexample app.
Server build-out notes #
- To see writes it did not make, the server calls
registerNotifyon each user's settings space, which holds the chat index, and renews it before its one-day expiry.registerNotifyonly accepts a space credential, even for the owner's own space. It also needs a did:web identity for the app and inbound XRPC routes fornotifyWriteandnotifySpaceDeleted, as in bulletin. A did:web on a loopback URL cannot be resolved by a PDS, so notifications never arrive in local development. There, sync relies on the safety net, the sync button, and opening a chat. - A PDS without spaces silently drops space permissions and login still succeeds. Detect spaces support after login from the granted scope, and fall back to local storage when the space permissions are missing.
- The OAuth client must be confidential (
private_key_jwtwith a keyset). Public clients get two-week sessions, and the server needs long-lived sessions to write replies in the background. - Write the conversation's
inforecord when creating the space.listSpacesonly returns spaces the user has written to, so an empty conversation would be invisible. - The alpha PDS caps a write request at 1 MB, and
putRecordhas no compare-and-swap. UsecreateRecordwith an explicit key and catchRecordAlreadyExistswhen a write must not clobber. Large tool output, like a fetched page, goes into a blob instead of the record. - New record types must be added to both the space declaration and the permission set. Existing sessions pick up changes to the same permission set on token refresh, but a new permission set NSID needs a fresh login.
- Blobs in spaces are private on the reference PDS. Verify that on any other PDS implementation before relying on it.