Imported from kyh/yours-sincerely (
AGENTS.md). Install upstream withnpx skills add kyh/yours-sincerely. Copyright stays with the author.
AGENTS.md
Yours Sincerely is an anonymous love-letter app — letters written in disappearing ink.
One typed stack (oRPC · Drizzle · Postgres) behind a Next.js web app (apps/web) and an
Expo native app (apps/expo). This is the tool-agnostic guide for coding agents: it is
meant to be run, not just read. Claude also reads CLAUDE.md — that file holds the
architecture decisions you must not reverse; this one holds the workflow.
Quickstart (headless)
pnpm install
cp .env.example .env # COOKIE_SECRET may stay empty in development (see below)
pnpm db:start # local Supabase — REQUIRES Docker
pnpm db:push # REQUIRED: `supabase start` brings up an EMPTY database
pnpm dev:web # → http://localhost:3000
There is no bootstrap script; the five commands above are the whole provisioning story.
pnpm db:push is not optional. There is no packages/db/supabase/migrations directory —
the schema is declared, not migrated — so a fresh clone that skips it gets a database with
no tables and a 500 on first page load. db:push runs drizzle-kit push --force (forced so
it cannot hang on a TTY confirmation) and then applies every file in packages/db/sql/.
Liveness: there is no /api/health route. Use the home page.
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 200 when up
When pnpm db:start misbehaves
supabase start is already runningwhile the container is gone →pnpm db:stop, retry.Bind for 0.0.0.0:54322 failed: port is already allocated→ another local Supabase project owns the port. Stop it (supabase stop --project-id <other>) or work without the database;pnpm verifyandpnpm builddo not need it.
Fresh clone & remote sessions
Everything is committed except node_modules and .env. .codex/environments/environment.toml
describes the cloud runner (it runs pnpm i on clone and exposes a dev:web action) — note
it is autogenerated, do not hand-edit it. A sandbox without Docker can still run
pnpm verify and pnpm build; anything that reads or writes data cannot run there.
COOKIE_SECRET signs the session cookie, and the session cookie is the user id. Under
NODE_ENV=development or test an empty value falls back to a public dev constant, which is
why local runs work out of the box. Every other environment fails to boot without it —
that is deliberate (packages/api/src/auth/session-core.ts). Generate one with
openssl rand -base64 32 (32-character minimum).
Every other key in .env.example may stay empty. A missing one disables its feature rather
than crashing boot: no RESEND_API_KEY means auth.requestPasswordReset returns
PRECONDITION_FAILED instead of sending mail. Notifications need no key at all — the feed
is the Notification table and push goes through Expo's service unauthenticated.
Login
There is no seeded login. pnpm db:seed runs packages/db/src/seed/initial.ts, which is a
performance fixture — 200 users, ~8k letters, 40k likes, shaped to make EXPLAIN honest. It
sets no password hash, so none of those accounts can sign in, and it is not idempotent:
running it twice duplicates the whole dataset. Reach for it when profiling, not when you need
an account.
You usually do not need one. Identity here is anonymous-first: writing a letter requires no account — the server mints a credential-less user on first write. Only Settings, Profile and the blocked-writers list need a real account, and you make one in three steps:
agent-browser open http://localhost:3000/auth/sign-up
agent-browser fill '[data-test="email-input"]' dev@yourssincerely.local
agent-browser fill '[data-test="password-input"]' password
agent-browser find text 'Sign Up' click
The session comes back as a signed Set-Cookie and the browser keeps it. /auth/sign-in
takes an optional ?next=<same-origin path> to land somewhere other than /.
The HTTP API is oRPC at /api/orpc/<router>/<procedure> (e.g. auth/signInWithPassword),
POST-only — a GET gets a 404 — and the body is the RPC envelope {"json":{…}}, not a bare
payload. An Origin header naming anything but the server's own origin gets a 403; curl
and React Native send none, so neither is affected. There is no REST /api/auth/* endpoint,
and a curl carries no session cookie, so anything behind a login answers UNAUTHORIZED.
Drive the UI instead.
Verify a change end-to-end
Static gate — run before every commit. It is exactly what .github/workflows/ci.yml runs,
in the same order:
pnpm verify # typecheck · lint · format · test
pnpm test is turbo run test: the *.test.ts unit suites in @repo/api, @repo/contracts,
@repo/db and @repo/expo. The *.integration.ts suites need a live local Supabase and are
deliberately outside CI — run them yourself with pnpm -F @repo/api test:db when you touch a
router, a query or the schema.
Lint is a clean gate. oxlint.config.ts extends the ultracite presets (ultracite/oxlint/core,
react, anti-slop, with next scoped to apps/web and packages/api); every rule is an error
and lint fails on the first one. no-await-in-loop is the one deliberate override (sequential
awaits are intentional). Prefer fixing code over oxlint-disable comments; when a rule is genuinely
wrong for a line, disable that line with a -- reason.
Runtime — drive the real web UI with agent-browser. The core flow needs no login:
pnpm dev:web &
agent-browser open http://localhost:3000
agent-browser snapshot # accessibility tree with @eN refs
agent-browser fill '#post-input' 'A letter from an agent'
agent-browser find text 'Publish' click
agent-browser get text # assert the letter is in the feed
agent-browser screenshot /tmp/after.png
Stable hooks that already exist, so you do not have to add any: #post-input (the letter
textarea), [data-test="email-input"] and [data-test="password-input"] (the auth form).
The inline compose box is desktop-only — below the md breakpoint, open the floating
"New Post" button first (agent-browser find text 'New Post' click).
Don't stop at typecheck and tests. Exercise the flow and look at the result.
Platform matrix
| Surface | Dev command | Agent-verifiable at runtime? |
|---|---|---|
| Web (Next.js) | pnpm dev:web |
Yes — headless via agent-browser |
| Native (Expo) | pnpm dev:expo |
With a local simulator/emulator only |
| Legacy shell (Capacitor) | pnpm dev:mobile-ios |
No — and it has no JS of its own |
apps/mobile is android/, ios/ and a Capacitor config; it has no src/, no typecheck and
no tests, so pnpm verify structurally cannot cover it. It is not dead code — see
CLAUDE.md → "Architecture decisions". For Expo, pnpm typecheck plus pnpm -F @repo/expo test
is the static gate; a runtime check needs Xcode simulators or an Android emulator on the
machine. docs/phone-testing.md §3 is the recipe that works headlessly (direct xcodebuild
with CODE_SIGN_IDENTITY=-, simctl deep links, adb on the emulator) and doubles as the
Capacitor→Expo session-migration fixture.
Rules that matter
- Every mutation calls an invalidation policy. There is no global
MutationCacheand no defaultmutations.onSuccess— TanStack merges mutation options by spread, so a default would be silently replaced by any per-mutation handler. Add auseMutation, add its policy:@/lib/query-policies(both web and expo). Mutations go through oRPC, never a Next Server Action. - Shared domain logic lives in
packages/contracts, used by both web and expo — not duplicated per platform. - No
any, no non-null!, noascasts. Kebab-case filenames. Make illegal states unrepresentable. - The schema is declared, not migrated.
pnpm db:pushis the whole deploy.drizzle-kitdoes not diff a view's body, so every view is.existing()indrizzle-schema.tsand its DDL lives inpackages/db/sql/090-views.sql.pnpm db:push-remotewrites production — never run it to check something. - Read
CLAUDE.md→ "Architecture decisions — do not reverse" before touching sessions, RLS/the Supabase Data API, orapps/mobile. Each one has a silent, severe failure mode.
Map
apps/web(Next.js) ·apps/expo(React Native) ·apps/mobile(legacy Capacitor)packages/api— oRPC routers, sessions,env.ts·packages/contracts— shared zod schemas and pure rules ·packages/db— Drizzle schema +sql/·packages/ui— shadcnCLAUDE.md— architecture decisions and tracked constraints (read before changing anything structural) ·README.md— human-facing setup ·docs/— phone testing, release inputs, the expo/mobile parity wayfinderpackages/api/src/auth/session.ts+session-core.ts— the hand-rolled auth (not Supabase Auth) ·packages/db/src/drizzle-schema.ts— the tables