Imported from fredrivett/abode (
AGENTS.md). Install upstream withnpx skills add fredrivett/abode. Copyright stays with the author.
Agent Guidelines for abode
General Principles
- Be concise. Preserve important meaning but remove fluff, sacrifice grammar for the sake of concision.
- Understand that multiple agents may be working at once, so don't revert if you find changes you didn't make.
Public repo
This repository is public/open-source. Everything you write into git or GitHub is world-readable — commit messages, branch names, PR and issue text, code comments, and screenshots, not just code. Never put secrets, real user data (e.g. emails), private/internal URLs, or sensitive business context in any of them. The gitleaks hook and CI only scan committed code, not PR/commit text or images — those are on you.
"Sensitive business context" includes metrics. When a task has you query analytics or the DB (PostHog, Supabase, prod) and you then document what you found in committed content (code comments, commit/PR text), record only the engineering rationale — never the concrete figures that reveal business state: active-user counts, traffic/usage volumes, revenue, conversion, or growth stage. "Caps sit well above observed use" is fine; "peak was 7/day across ~2 users" is not. Keep the raw numbers in local/session notes, out of the repo.
Commands
Run from the ./app directory unless noted. bun run check:fix is the primary quality gate after any code change.
This table is verified against
package.jsonbybun run check:commands-doc(CI). Adding or renaming a script means updating this table (or the allowlist inapp/scripts/check-commands-doc.ts).
| Command | What it does | When to use |
|---|---|---|
bun run dev |
Start Next.js dev server (Turbopack) | Local development — the user usually has this running |
bun run build |
Production build (webpack) | CI / verifying a prod build — not during a dev session |
bun run check:fix |
Biome autofix + prisma format + tsc --noEmit |
After every code change (run this before considering work done) |
bun run fix |
Biome lint/format autofix | Quick format/lint pass |
bun run lint |
Biome check, no fixes | Read-only lint (matches CI) |
bun run ts:check |
TypeScript check (tsc --noEmit) |
Verify types only |
bun run test |
Unit tests (vitest, jsdom) | After logic changes |
bun run test:integration |
Integration tests (Testcontainers Postgres — requires Docker) | After DB / server-side changes |
bun run test:all |
All vitest projects | Full vitest run |
bun run test:e2e |
Playwright E2E (isolated Supabase — requires Docker) | After user-facing flow changes |
bun run test:coverage |
Vitest with coverage | Check coverage |
bun run prisma:generate |
Generate Prisma client | After schema changes / fresh install |
bun run prisma:format |
Format prisma/schema.prisma (CI checks it via check:prisma-format) |
After schema edits (check:fix runs it too) |
bun run prisma:migrate --name <name> |
Create + apply a dev migration (prisma migrate dev) |
Only per the Database Migrations policy below |
bun add <pkg> |
Install a dependency (bun only, from ./app) |
Adding dependencies |
bun run storybook |
Run Storybook on port 6306 | Component development |
Boundaries
Most "always / never" rules live in the relevant sections below. In addition, ask the user first before any of the following:
- Running database migrations or any destructive Prisma command (
prisma migrate,prisma db push,prisma migrate reset). - Any operation that touches production data or production services (prod DB writes, prod Supabase changes, deleting users or items).
- Changing auth/session logic, or email templates that are mirrored manually in the Supabase Dashboard.
- Adding a new third-party dependency or external service.
- Rewriting git history, force-pushing, or deleting branches.
- Deploying to production or promoting a deployment.
Git Commits
- Commit only when the user asks — don't auto-commit after every change.
- When you do commit, split the work into logical chunks: one commit per coherent, self-contained change (e.g. schema/migration, then lib + tests, then API route, then UI) rather than a single catch-all commit. Each commit should build and pass checks on its own.
App URL
Production URL: https://www.abode.fyi
Use getAppBaseUrl() from @/lib/url to get the environment-specific base URL:
import { getAppBaseUrl } from "@/lib/url";
const baseUrl = getAppBaseUrl();
// Local dev: http://localhost:<port>
// Vercel preview: https://{VERCEL_URL}
// Self-hosted: NEXT_PUBLIC_SITE_URL
// Production: https://www.abode.fyi
Never hardcode https://www.abode.fyi for links — self-hosted instances set NEXT_PUBLIC_SITE_URL and must link to themselves.
SEO & Indexing
Every page is a deliberate indexing choice — src/lib/seo/route-indexing.test.ts fails until a new page.tsx is one of:
- Public (in search): add its path to
SITEMAP_PATHS(@/lib/seo/indexing) and exportmetadatawith its owntitle,descriptionandalternates.canonical. Add anopengraph-image.tsxshare card built on@/lib/og/render. - Private: pages under
(app)/(auth)/(public)inheritrobots: NO_INDEX_ROBOTSfrom the group layout; anywhere else, set it on the page or layout. - Neither: an
EXCEPTIONSentry in that test, with a reason.
Other rules:
- Only the hosted abode.fyi production deployment is indexable (
isIndexableDeployment) — previews, dev and self-hosted instances (including ones on Vercel) disallow all inrobots.txt. - Comparison pages (
/compare/<slug>) are data-driven: add a file insrc/lib/comparisons/competitors/and an entry inCOMPARISONS— the page, share card and sitemap entry follow. Keep them honest: every competitor fact must come from a primary source listed insources, and bumplastCheckedwhen you re-verify. - Structured data renders through
<JsonLd>(@/components/seo/json-ld), never a raw<script type="application/ld+json">— enforced byapp/biome/no-raw-json-ld.grit. - Public user content (profiles, rooms, items) is shareable but noindex by default. A profile and its public rooms become indexable (and enter the sitemap) only via the owner's
allowSearchIndexingopt-in, throughpublicContentSeo/getIndexablePublicContentPaths— keep the two in sync. Items are never indexed (mostly third-party content).
Development Server Management
IMPORTANT: Do NOT run bun run build during development sessions when the user is watching the dev server. This breaks the running development environment.
The user typically has the development server running via bun run dev and is actively watching changes. Running build commands interrupts this workflow.
Code Quality
After making code changes, run from the ./app directory:
cd ./app
bun run check:fix
This (1) auto-fixes lint/format issues (Biome), (2) formats prisma/schema.prisma (prisma format; CI fails an unformatted schema) and (3) reports TypeScript errors. Fix any TypeScript errors that can't be auto-fixed before considering the task complete.
Run it after your last edit, right before committing. A file written after the last run (e.g. a test added at the end) goes in unformatted. The pre-commit hook (.githooks/pre-commit) runs Biome on the staged content as a backstop.
Type Safety
- Prefer type guards over type assertions — runtime checks that narrow types instead of
ascasts. - Avoid
any— useunknownwith type guards, or define proper types.noExplicitAnyis enforced as a lint error. - No suppression comments —
@ts-ignore/@ts-nocheckare banned (CI-enforced vialint:suppressions); only use@ts-expect-error: <reason>with a description when truly unavoidable. - No stub placeholders — don't leave
throw new Error("not implemented")(or similar) behind; theno-stub.gritBiome plugin flags them.
// ❌ Bad: type assertion
const type = searchParams.get("type") as "email" | "signup";
// ✅ Good: type guard
const VALID_OTP_TYPES = ["email", "signup", "recovery", "email_change"] as const;
type OtpType = (typeof VALID_OTP_TYPES)[number];
function isValidOtpType(value: string | null): value is OtpType {
return value !== null && VALID_OTP_TYPES.includes(value as OtpType);
}
const type = searchParams.get("type");
if (!isValidOtpType(type)) {
return { error: "Invalid type" };
}
// type is now narrowed to OtpType
Function Signatures
- No two positional params of the same type. When a function takes two or more args of the same type (e.g. two
strings), take a single object param so call sites are self-labeling and can't be silently transposed. A single param, or params of clearly distinct types, can stay positional.
// ❌ Bad: itemRunTags(userId, itemId) type-checks but is silently wrong
function itemRunTags(itemId: string, userId: string): string[] { ... }
// ✅ Good: the call site names each value — itemRunTags({ itemId, userId })
function itemRunTags({ itemId, userId }: { itemId: string; userId: string }): string[] { ... }
Optional Services & Graceful Degradation
abode must run on a minimal core (Postgres + Supabase) so it's genuinely self-hostable. Everything else is an optional enhancement that lights up when its key is present and degrades cleanly when it isn't. This is a core principle — follow it whenever you touch a third-party integration.
The canonical list of services and their tiers (required / recommended core / optional) is the External services table in the README. When you add or change a service, update that table.
The pattern (see isReplicateConfigured() in src/lib/embeddings.ts + its use in trigger/analyze-image.ts for the reference implementation):
- Expose an
isXConfigured(): booleanpredicate that checks for the key. Never assume an optional key is set. - Not configured → skip cleanly. No throw, an
info/logline, and continue. A missing optional key is a valid deployment, not an error. - Configured but errored → catch and continue. Log a
warn, report viacaptureServerException, but don't fail the surrounding work — one optional enhancement failing must never fail item capture or the whole enrichment task. - Don't do setup work (signed URLs, client init, fetches) for a service you're going to skip — guard first.
- Env schema (
env.server.ts): optional-service keys are.optional(), never.min(1). Only core-tier keys are required.
When you add a new integration, decide its tier, add the predicate, and wire the skip/catch paths — with tests for both the configured and unconfigured branches.
Code Formatting
- Lean towards self-documenting code, but add comments where necessary.
- When refactoring code don't leave behind a comment (e.g. "// This code now lives in ...").
- When adding comments, if it's a simple one-liner, use inline comments with no full stop.
- Prefer single line returns when the line is less than our max line length.
- You can run
bun run fixacross the project to format/lint files; no need to ask for confirmation first.
Component Usage
Button Component
Use the Button component from @/components/ui/button instead of hardcoded anchor/button elements. Use asChild prop for link functionality:
<Button asChild size="lg">
<a href="/login">Get Started</a>
</Button>
- Buttons already manage spacing between children. When adding icons, rely on the button's layout instead of adding extra margin classes to the icon.
Loading States
Use the shared IsLoading component from @/components/ui/is-loading for any loading indicator in buttons or displays instead of hardcoded ellipses (...).
Component stories and tests
Stories and tests do different jobs — they complement each other, neither replaces the other:
- Stories (
*.stories.tsx) cover appearance. Every reusable component gets one alongside it, with a named story per meaningful variant. Stories are also executed in CI as browser render tests via the Storybook Vitest addon (thestorybookproject), so they catch render crashes across variants. They're blind to logic — a story renders green even when the wrong thing renders. Follow the existing convention (title: "<Area>/<Component>",tags: ["autodocs"]) — seesrc/components/rooms/room-card.stories.tsx. - RTL tests (
*.test.tsx) cover logic. For any component with real branching or behavior (gated badges, conditional content, pluralization, interaction), add a colocated React Testing Library test asserting the rendered output per branch — seesrc/components/rooms/room-card.test.tsx. These run in the fastunit(jsdom) project. RTL is blind to appearance (jsdom has no layout engine), which is exactly why stories still carry the visual side. A purely presentational component with no branching doesn't need one — don't add ceremony where there's nothing to assert. - Pure, non-visual logic belongs in a plain unit test (
*.test.ts) as usual.
Testing
Always add tests for new logic and behavior — this is the default, not an afterthought. When you add or change a function, API route, access-control rule, or user-facing flow, ship the tests in the same change. Prefer the cheapest layer that genuinely covers the behavior: a unit test for pure logic (e.g. access/permission helpers), an integration test for DB/service code, an E2E test for a user flow. Only skip tests when the change is purely cosmetic or there is genuinely nothing to assert — and when you skip, say so explicitly in the PR rather than leaving the tests checkbox silently unchecked. Untested logic is treated as incomplete work.
Visual verification (UI changes)
When you make a styling/UI change, visually verify it yourself — don't rely on tsc/tests passing. The user sees changes live via the dev server, but you should confirm the result with a screenshot before calling it done.
Use agent-browser (headless, bundled Chrome — no extension needed) against the running dev server (port ${CONDUCTOR_PORT}, falls back to 3300):
agent-browser open http://localhost:${CONDUCTOR_PORT:-3300}
agent-browser screenshot /tmp/check.png # then Read the image to inspect it
Add --full for the whole page. Check both light and dark mode, and a narrow viewport for anything layout-sensitive. If agent-browser isn't installed: npm install -g agent-browser && agent-browser install.
Debug trace (UX jank)
For jank (grid jumping, dialogs remounting, layout shifts), use the admin debug trace instead of guessing: open any page with ?debug=1 (admins; anyone in local dev) or toggle "Debug trace" in the admin account menu. A panel records a timeline of URL writes (with call stacks), React Query fetches/invalidations, masonry reflows (which frames moved, flashed blue), layout shifts (flashed red), slow frames, and dialog/grid mount/unmount/prop changes; "Copy" exports it as JSON. Code lives in src/lib/debug/ + src/components/debug/. Instrument new suspect components with useDebugLifecycle / debugTrace (no-ops when tracing is off). When driving the app with agent-browser, read the trace via window.__abodeDebugTrace.export().events (and .clear() before reproducing) instead of the clipboard.
For automated tests, run from the ./app directory:
cd ./app
bun run test # Unit tests only
bun run test:integration # Integration tests (requires Docker)
bun run test:all # All Vitest tests
bun run test:e2e # E2E tests with Playwright (requires Docker)
Test Types
Unit Tests
- Files:
*.test.tsor*.spec.ts(e.g.,utils.test.ts) - Environment: jsdom
- Database: None
Integration Tests
- Files:
*.integration.test.tsor*.integration.spec.ts - Environment: Node
- Database: PostgreSQL test container via Testcontainers
- Longer timeouts for container startup
- Sequential execution to avoid database conflicts
E2E Tests
- Files:
e2e/*.spec.ts(unauthenticated),e2e/*.auth.spec.ts(authenticated) - Tool: Playwright
- Database: Isolated Supabase instance (started/stopped per test run via
supabase-e2e/) - Tests run against a real Next.js dev server
- Authenticated tests use Playwright's
storageStatepattern (setup project signs in via UI)
Test Infrastructure
- Integration tests:
test/db-container.ts+vitest.setup.db.ts— Testcontainers PostgreSQL - E2E tests:
e2e/supabase-setup.ts+e2e/global-setup.ts— Isolated Supabase onCONDUCTOR_PORT-derived ports
Path Aliases in Tests
@/— Maps to./src(all test types)@app/— Maps to./approot (integration tests only)
Analytics & Instrumentation
When shipping a user-facing feature, instrument it as part of the change (not later):
- Capture a PostHog event for the key action — client via
posthog.capture(...)(or a tracker insrc/components/tracking), server viagetPostHogClient()?.capture(...). - For a new flow, add or update a funnel/insight so drop-off is measurable.
- Ensure new error paths report via
captureServerException(server) or the error boundary (client).
The PR template includes a checklist for this.
Documentation
Docs ship with the feature, in the same change — not as a follow-up. When a change is user-facing or changes what a service is used for, update whichever of these it affects (or say in the PR why none apply):
README.md: the Features list and the Roadmap's Done list for a new capability; the External services table when a service gains or changes a job (including what happens without it)..env.example: the comment on any env var whose purpose changed, and every new one.- In-app help (
app/src/content/help/*.md, linked from/helpand the help nav): a short how-to for a new user-facing flow. CONTRIBUTING.md: new setup steps, install-time scripts or local-dev gotchas.- Onboarding checklist (
app/src/lib/milestones): a new way to add or use items should get a milestone so new users discover it.
The PR template includes a checklist line for this.
Dependencies
All package installations must use bun from the ./app directory:
cd ./app
bun add package-name
Never use npm or yarn. Always install from the app directory.
Supabase Email Templates
Local dev templates are in supabase/templates/ and configured in supabase/config.toml. These do NOT deploy to production. Production templates are configured manually in the Supabase Dashboard under Authentication > Email Templates.
Critical Rules
- NEVER use
{{ .ConfirmationURL }}in email templates — it bypasses our/auth/confirmroute and breaks the signup flow. - ALWAYS use
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=<type>where<type>matches the template (signup, recovery, email_change). - If you change a local template, you MUST flag to the user that the production template in the Supabase Dashboard also needs updating to match.
Database Migrations
Policy: ask first. By default only edit the schema file (prisma/schema.prisma); ask the user before running any migration command. When the user approves or instructs, create the migration from the schema:
cd ./app
bun run prisma:migrate --name your_migration_name
Migration Workflow
- Edit the Prisma schema (
prisma/schema.prisma). - With the user's go-ahead, run the migrate command — this generates the SQL migration automatically.
- Review the generated SQL in
prisma/migrations/if needed.
Critical Rules
- Always generate migrations from the schema — never hand-write migration SQL files. Prisma generates migrations by comparing your schema to the database state.
- Never edit existing migrations — they are immutable once created. If you need more changes, create a NEW migration. Editing existing migrations breaks the migration chain and causes conflicts.
- Never run
prisma migrate reset— this destroys all data. - Never run
prisma db push— this bypasses migration history and can cause drift. - Never manually create migration folders/files —
prisma:migratecreates properly timestamped folders and SQL files. - Every new table must enable RLS (default-deny) — add
ALTER TABLE "<table>" ENABLE ROW LEVEL SECURITY;in the same migration (Prisma can't express RLS in the schema, so useprisma migrate dev --create-onlythen add the SQL). Without it the table is reachable by the anon/authenticated Supabase roles. It also needs policiesTO abode_app, the restricted role whose policies only match the viewer's own rows (patterns in20261018170000_abode_app_rls): owner policies for user data (read-only or insert-only where system code owns later writes),FOR SELECT USING (true)for a table with no user data that every viewer may read, or none for system-only data. List shared and system-only tables insrc/lib/__tests__/rls-tables.ts(one list, used by both tests). Enforced byrls-coverage;rls-policies.integration.test.tschecks the owner/other-user/anonymous behaviour.
If a migration cannot be generated from schema changes (extremely rare), discuss with the team before manually writing SQL.
E2E Test Database (supabase-e2e/)
The supabase-e2e/ directory contains config for an isolated Supabase instance used by E2E tests. Its migrations/ and templates/ are symlinks to the main supabase/ directory.
- Never run write operations (
supabase migration new,supabase db diff, etc.) with--workdir ./supabase-e2e— these would write into the realsupabase/migrations/directory via the symlink. - Only
start,stop, andstatusshould be used with--workdir ./supabase-e2e. - See
README.mdfor the full port allocation table.
Lessons learned
When a recurring defect is fixed, append a one-line entry here — but first try to convert the lesson into a deterministic check (type, lint, or test) via the learn skill in .agents/skills/learn/. Use this list only for context a check can't capture.
- New Postgres tables must enable RLS (default-deny) or they're reachable by the anon/authenticated Supabase roles — enforced by
src/lib/__tests__/rls-coverage.integration.test.ts. - Server-side fetches of a user-supplied URL must use
safeFetch(@/lib/http/safe-fetch), never the globalfetch, or the SSRF gate is bypassed — enforced intrigger/**andsrc/app/api/**byapp/biome/no-raw-fetch.grit. biome.jsontakes no comments: with one present Biome (2.2 through 2.5) silently falls back to its default config (whole repo reformats to tabs) rather than erroring — document Biome config decisions in the plugin file, not inline.- User-initiated item processing must enqueue via
enqueueUserProcessing(applies the per-user concurrencyKey +USER_ACTION_PRIORITY), never rawtasks.trigger, or live runs lose priority to background work — enforced oversrc/lib/items/**andsrc/app/api/**byapp/biome/no-raw-user-processing.grit. Triggerpriorityis a positivecreatedAtoffset; a negative one delays a run into the future (strands it inqueued). - Trigger
prioritymust stay well below ~2.1M seconds: the API rejects an over-large value (priority × 1000ms overflows int32), throwingTriggerApiErrorfromtasks.triggerand breaking every enqueue that carries it. A year-longUSER_ACTION_PRIORITYsilently killed all user-initiated processing — guarded byenqueue-user-processing.test.ts(MAX_SAFE_PRIORITY). - Server code must resolve the cookie user via
getUserWithMfa(@/lib/supabase/server) or a helper built on it (getAuthUser,getAuthenticatedUser), neversupabase.auth.getUser()directly, or a 2FA user's AAL1 session (password sign-in, TOTP not completed) bypasses the challenge — API routes, server actions and public pages (owner checks) never meet the middleware's MFA redirect. Enforced oversrc/**(minus the AAL1-by-design auth pages) byapp/biome/no-raw-cookie-getuser.grit; every(app)section must be in the middleware'sPROTECTED_ROUTE_PREFIXES(middleware-protection.test.ts), and storage policies require aal2 for MFA users. - The owner connection bypasses row level security, so request code acting for a signed-in user uses
dbfrom@/lib/db— inside a viewer frame (withViewerroute exports; auth helpers record the caller) and withRLS_ENFORCED=trueit runs as the restrictedabode_approle and the database refuses other users' rows.systemDb/systemReadare the owner connection for work that genuinely spans users or runs as the system; every use insrc/**is allowlisted by path inbiome.json, enforced byapp/biome/no-unlisted-system-db.grit. - Search filters run through two matchers — SQL (
search/query-builder.ts) for search, in-memory (rooms/room-matcher.ts) for room membership — which can silently diverge (a@status:room once matched every item). The matcher's filter-type switch is exhaustive (unhandledFilterType(_type: never)) so a newFilterTypewithout a case failstsc; semantic parity is guarded by the SQL-vs-membership test inroom-service.integration.test.ts("SQL parity"). - Tap handlers must use
onPointerDown/onClick, neveronMouseDownalone — on touch the tap blurs the input / unmounts a focus-gated overlay before the synthesized mouse event fires, so it drops the tap or hits whatever is underneath (search suggestions once fell through to item delete buttons on mobile). Enforced byapp/biome/no-mouse-down-tap.grit. - Never read
navigator(platform/UA) during render — on the server Node 21+ has a globalnavigatorreporting the server's OS, so SSR and the browser disagree and hydration fails (the ⌘/Ctrl shortcut hints once broke hydration of the dashboard header for every Mac user). Render the modifier viauseModifierKeySymbol(), and give other platform-dependent UI the sameuseSyncExternalStoreserver snapshot.app/biome/no-render-platform-detection.gritflagsnavigator.platform/userAgent/userAgentData,isApplePlatform()andgetModifierKeySymbol()insrc/**/*.tsx. - Supabase Storage
list()silently returns only 100 entries unless given a limit — account deletion listed the user's folder once and removed the result, so every file past the first 100 outlived the account. List vialistAllObjectPaths/removeAllObjectsUnderPrefix(src/lib/storage-objects.ts), which page and descend into sub-folders — enforced oversrc/**+trigger/**byapp/biome/no-raw-storage-list.grit. - An item's stored files live in several places (own columns, product/tweet/Instagram JSON, tweet author avatar, document pages), and hand-kept copies of that list drifted: deleting an item leaked its cover, favicon and re-hosted images, and re-hosted tweet avatars 404'd in the image proxy. Item delete and the proxy now derive from
itemFileKeysSelect/collectItemFileKeys(src/lib/item-storage.ts), and re-capture reclaim from itscapturedFileKeysSelect/collectCapturedFileKeyssubset (deliberately without document pages, which re-capture never replaces);item-file-keys.test.tsfails when a schema column that could hold a key isn't classified, andimage-key-lookup.integration.test.tschecks the proxy resolves every key. - Adding personal-access-token support inside the shared
authenticateRequestsilently opened every route using it to tokens, so read-only tokens could save items. It now takes a requiredtokenScope("read"/"write"/null= tokens rejected), so each call site states whether tokens can reach it and with which scope, andsrc/lib/auth/token-route-access.test.tspins every calling route and its scope (failing on aliased imports or calls outside route files). Token saves are stampedcaptureSource: api+personalAccessTokenIdfrom the credential, never from the request body. - A transient DB pooler outage made the image proxy 500 for the covers loading at that moment, and since nothing retried, those tiles stayed stuck on their blur placeholder until a full reload. Two layers now absorb blips: the Prisma clients in
@/lib/dbretry queries that never reached the database (db-retry.ts— connection/pool errors only, never ones that may have executed), and every same-origin<img>retries with backoff via<Img>(@/components/ui/img) or theimgPropsspread fromuseImageLoaded— enforced oversrc/**/*.tsxbyapp/biome/no-raw-img.grit. Type transaction helpers asDbTransactionClient, notPrisma.TransactionClient(that's the un-extended client).
Trigger.dev
Background tasks live in app/trigger/. Project conventions and the full Trigger.dev v4 SDK reference are in app/trigger/AGENTS.md (path-scoped — loaded when you work there) rather than here, to keep this file lean. See also the add-trigger-task skill in .agents/skills/add-trigger-task/.
