Project rules (AGENTS.md)
Guide for AI agents and new contributors. Read this before changing code.
What this is
Section titled “What this is”A bare React Native 0.87 template. Decisions and their reasons are in docs/decisions/ (ADRs).
Never change a recorded decision silently: propose a new ADR.
Commands
Section titled “Commands”| Task | Command |
|---|---|
| Install | pnpm install (pnpm only; supply-chain policy in pnpm-workspace.yaml) |
| iOS pods | pnpm pods, and again after every pnpm install that changes node_modules (Skia copies its iOS binaries in at pod install) |
| Run | pnpm android / pnpm ios (dev); :staging, :prod variants |
| All checks | pnpm check (typecheck, lint, format, depcruise, tests) |
| Rename the app | pnpm bootstrap --name Acme --bundle-id com.acme.app |
| Remove the demo (feed, showroom) | pnpm strip-examples (once, on a fresh copy) |
| Icons / splash | pnpm icons, pnpm splash |
| Release builds, version bump, uploads | bundle exec fastlane lanes (docs/releasing.md) |
| Scaffold code | pnpm gen feature <name>, pnpm gen screen <feature> <name>, pnpm gen endpoint <feature> <name> (docs/generators.md) |
Agent skills
Section titled “Agent skills”Callstack’s React Native agent skills are vendored in .claude/skills/ (pinned, see skills-lock.json;
update with pnpm skills:sync <ref> and review the diff). The template’s own skills (add-feature,
add-screen, add-endpoint) live there too; skills:sync leaves them alone. Claude Code loads them automatically; other
agents should open the SKILL.md named below before the matching work.
| Work | Skill |
|---|---|
| Adding a feature, a screen or an endpoint (this template’s own skills) | add-feature, add-screen, add-endpoint |
| Any React Native code: screens, lists, state, animations, native modules, bundle size, startup | react-native-best-practices |
| Navigation: stacks, tabs, headers, sheets, safe areas | react-navigation |
| Tests with React Native Testing Library | react-native-testing |
| Upgrading React Native or native dependencies | upgrading-react-native |
| CI build artifacts for simulators and emulators | github-actions |
| A local native module or view | create-react-native-library |
| User-facing docs (README usage, guides, release notes) | writing-user-docs |
Driving a device, exploratory QA (needs the agent-device CLI) |
agent-device, dogfood |
| Profiling renders and CPU on a device (needs Argent, ADR 0013) | argent-react-native-profiler, argent-native-profiler |
| TV apps, brownfield or migration work (not used by this template) | react-native-tv-best-practices, react-native-brownfield-migration, assess-react-native-migration |
Apply react-native-best-practices to all code you write, not only when asked to optimize. Where a skill
and this file disagree, this file wins; the known cases:
- Installs: pnpm with exact versions (
pnpm add x@1.2.3,pnpm dlx x@1.2.3). Nevernpx …@latest: it skips the supply-chain policy inpnpm-workspace.yaml. - Measure first (the skill’s own rule): no
useMemo/useCallback/memowithout a measured problem. The React Compiler is on and memoizes for you. - Imports: import from the module that defines the thing (
@/shared/ui/primitives/Button), never from a barrelindex.ts. A feature’sindex.tsis its public API forapp/only. Lint enforces both. - Accessibility props: ARIA props (
role,aria-label,aria-selected,aria-disabled,aria-live,aria-hidden,aria-valuetext) over the legacyaccessibility*props; lint rejects the old ones.accessibilityHinthas no ARIA equivalent and stays. Third-party native views (react-native-svg, FastImage) don’t translatearia-*, so their wrappers inshared/uikeep the old props. - Styling: Unistyles dynamic functions, not
useVariants(see Styling below). - Intl polyfills:
intl-pluralrulesandrelative-time-format, not the FormatJS set the skill shows (see Intl on Hermes below). - Profiling: use Argent’s profiler, not the skill’s
agent-deviceReact profiling. Never runagent-react-devtools initor any setup that editsindex.js,metro.config.jsorpackage.jsonto profile (ADR 0013). React Native DevTools (Metroj) works everywhere.
Driving the app (ADR 0013)
Section titled “Driving the app (ADR 0013)”- agent-device for running the app, tapping through it and checking a change. Prefer its text snapshots; take a screenshot only when the layout itself is in question.
- Argent only for profiling. Don’t use both in one task.
- Both read the accessibility tree: give interactive elements
aria-label, and toggles and tabsaria-checked/aria-selected, so agents don’t fall back to screenshots and coordinates. - Saved end-to-end tests are Maestro flows (ADR 0012), not agent sessions.
Architecture rules
Section titled “Architecture rules”src/layers, imports go downward only:app → features → entities → data → shared(ADR 0005).- A feature never imports another feature. Move shared code down to
entities/orshared/. - Import with the
@/alias. No deep relative imports, noreact-native/Libraries/*imports. - Configuration is read only in
src/shared/config/env.ts..env.*values are public; no secrets. - Server data belongs to TanStack Query; Zustand holds only small client state (ADR 0006).
- Screens never call the HTTP client directly; they use feature hooks.
- Native or vendor libraries are used only through ports in
src/shared/ports, viaservices(). - Icons come from
@/shared/ui/icons(add new ones there). Never import values from thelucide-react-nativeroot. - UI text comes from
t('…')with keys insrc/shared/i18n/locales/en.json(andar.json). Lint rejects hard-coded JSX text. Usestart/end, neverleft/right, so right-to-left layouts work. - Style with Unistyles (
StyleSheet.create(theme => …)) and theme roles (theme.colors.text), never hex values.
See docs/architecture.md for data flow and the reference mutation pattern.
ESLint (eslint-plugin-boundaries) and dependency-cruiser enforce the layer rules. Fix the import, never
disable the rule.
Offline
Section titled “Offline”Every write picks one offline pattern (ADR 0006, after Expensify):
| Pattern | Behaviour | Use for | How |
|---|---|---|---|
| A | Optimistic, queued, no feedback | Low-stakes toggles | defineQueuedMutation |
| B | Optimistic, queued, shows “Waiting to sync” | Likes, comments, edits | defineQueuedMutation + useWaitingWrites (see Writing data) |
| C | Form disabled while offline | Sign-in, payment | OfflineNotice + disabled={!useIsOnline()} |
| D | Content replaced while offline | Account deletion, checkout | <OnlineOnly> |
- Plain
useMutationwrites fail fast offline (networkMode: 'always'). OnlydefineQueuedMutationwrites wait, survive a restart, and replay in order with oneIdempotency-Keyper write. - Define queued writes at module level and do cache updates in their callbacks: after a restart the screen that started the write is gone. Keep variables small and JSON-safe (ids and flags).
- Import every module that defines a queued write from
src/app/queued-writes.ts. Metro loads modules lazily (inlineRequires), so without it a write saved before a restart has no definition when the cache comes back. A test fails if an import is missing;resumeQueuedWritesdrops and reports any write it can’t run. - The banner under the header comes from
app/navigation/offline-layout.tsx(screenLayout). - Mock mode can’t simulate a dead network (requests never leave the app). To test offline, turn on airplane mode: NetInfo drives the queue either way.
Production services (Phase 3)
Section titled “Production services (Phase 3)”Everything here runs without accounts; docs/going-live.md says what to add and what it switches on.
- Firebase (crash, analytics, flags, push):
services()picks Firebase adapters only when the native app started Firebase (src/shared/adapters/firebase-app.ts); otherwise logging/static adapters. Never import@react-native-firebase/*outsidesrc/shared/adaptersandsrc/app/background.ts. - Flags: every key and its default lives in
src/shared/config/flags.ts. Read withservices().flags.getBoolean('key', FLAG_DEFAULTS.key). - App status:
min_supported_version/maintenance_modeswap the whole app for a blocking screen (src/entities/app-status, checked at boot and on foreground). - Push: use
services().push(port insrc/shared/ports). A notification’sdata.linkis a deep link; taps go throughsrc/app/navigation/linking.ts. Never prompt at first launch; the Settings row asks. - Deep links: add
linking: 'path/:param'to the screen inRootNavigator.tsxand the path toLINK_PATHSinscripts/links-files.ts. Links open on top of the tabs, and a link that arrives while signed out opens after sign-in (UNSTABLE_routeNamesChangeBehavior, covered byApp.test.tsx). - Native config from
.env:APP_SCHEME,APP_LINK_DOMAIN,IOS_APS_ENVIRONMENTfeed Gradle andscripts/ios/configure-environments.rb. Re-run that script after changing them.
Styling
Section titled “Styling”Unistyles 3 with the React Compiler on. For styles that depend on props or state, use a dynamic function
(field: (state: FieldState) => ({ … }), used as style={styles.field(state)}), not variants +
styles.useVariants(). The compiler memoizes style={[styles.x, …]} without knowing that useVariants
changed styles.x, so the old variant stays on screen (a focus border that never clears, a stale selected
tab). A dynamic function’s arguments are ordinary dependencies, so it stays correct and memoized. ESLint
rejects useVariants. Theme switches don’t re-render either way: Unistyles updates native views directly.
Demo code
Section titled “Demo code”The feed, posts mocks and showroom are demo content that pnpm strip-examples removes. Mark any new demo-only
code: // @example deletes a line, // @example:<id> replaces it, and // @example-start[:<id>] …
// @example-end covers a block (ids are defined in scripts/strip-examples.ts).
scripts/ci/check-strip-examples.sh strips a scratch copy and runs every check on it.
Native changes
Section titled “Native changes”Any edit under android/ or ios/: mark it with a [bareship] comment and add a row to
docs/native-changes.md with its Expo equivalent.
Adding a dependency
Section titled “Adding a dependency”Check it builds with RN 0.87, the New Architecture, AGP 9 and Xcode 27, and record it in docs/version-matrix.md.
New versions younger than 3 days are refused by pnpm; don’t bypass this without a reviewed exclude entry.
Jest + React Native Testing Library 14. render, fireEvent and userEvent are async: always await them.
Prefer userEvent.setup() and getByRole / getByLabelText queries.
- HTTP is answered by the MSW mock backend in
src/mocks(the same handlers powerAPI_MOCKS=truein dev). Override a response in one test withserver.use(http.get('*/me', () => …)). src/test/setup.tsmocks native modules and resets services, session, DB and query cache before each test.src/test/app.ts:signInDemoUser(), in-memory SecureStore.src/test/render.tsx:renderWithProviders().- Coverage gate: 80% lines/functions on
src/shared/andsrc/data/.
Mock backend
Section titled “Mock backend”API_MOCKS=true in .env.dev serves the API from src/mocks inside the app (development builds only; release
bundles drop it). Demo account: demo@example.com / password123. Set API_MOCKS=false to hit API_URL.
- In the app,
src/mocks/native.tsis an axios adapter that runs the MSW handlers withgetResponse(). It doesn’t patch XHR/fetch, so other traffic (images, Metro) is untouched. Unmatched requests go to the network. - The mock DB resets on every JS reload. Tokens are self-describing, so a signed-in session survives reloads.
- Demo release builds (
pnpm build:android:demo) bundleindex.demo.js, which starts the mocks and hands the adapter toboot()viasrc/app/mock-backend.ts. Never importsrc/mocksfromindex.jsor app code outside a__DEV__-first condition:scripts/ci/check-no-mocks-in-release.shfails if mock code reaches a normal release bundle. - Add an endpoint with
pnpm gen endpoint: it puts the handler insrc/mocks/<feature>.ts(registered inhandlers.ts), with snake_case DTOs and a test against it. Shared helpers (wait,authenticate,error) are insrc/mocks/helpers.ts.
Intl on Hermes
Section titled “Intl on Hermes”Hermes ships only Intl.Collator, DateTimeFormat and NumberFormat.
PluralRules(i18next plurals):intl-pluralrules, loaded insrc/shared/i18n/intl-polyfills.ts. It covers every language.- Relative times (
formatRelativeinsrc/shared/lib/format.ts):relative-time-format. Adding a language means adding its locale there; a test fails if you forget. - Not FormatJS, although the best-practices skill shows it: FormatJS needs
Intl.Localetoo, ~390 KB of polyfills against ~50 KB for these two (docs/best-practices-audit.md).
Jest runs Node’s full Intl, so device-only Intl bugs don’t show up in tests: test the polyfill itself (see
i18n.test.ts) and check new Intl APIs on a device.
Commits
Section titled “Commits”Conventional Commits (feat:, fix:, chore: …), enforced by commitlint.