@pylonsync/create-pylon 0.3.332 → 0.3.334

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/templates/ARCHETYPES.md +72 -74
  3. package/templates/_root/AGENTS.md +16 -16
  4. package/templates/_root/README.md +3 -3
  5. package/templates/agency/AGENTS.md +30 -30
  6. package/templates/agency/CLAUDE.md +3 -3
  7. package/templates/agency/README.md +13 -14
  8. package/templates/agency/lib/site.config.ts +9 -10
  9. package/templates/ai-chat/AGENTS.md +30 -30
  10. package/templates/ai-chat/CLAUDE.md +3 -3
  11. package/templates/ai-chat/README.md +10 -11
  12. package/templates/ai-chat/lib/site.config.ts +3 -4
  13. package/templates/ai-studio/AGENTS.md +30 -30
  14. package/templates/ai-studio/CLAUDE.md +3 -3
  15. package/templates/ai-studio/README.md +11 -12
  16. package/templates/ai-studio/lib/site.config.ts +5 -6
  17. package/templates/barebones/AGENTS.md +30 -30
  18. package/templates/barebones/CLAUDE.md +3 -3
  19. package/templates/barebones/README.md +5 -6
  20. package/templates/chat/AGENTS.md +30 -30
  21. package/templates/chat/CLAUDE.md +3 -3
  22. package/templates/chat/README.md +4 -6
  23. package/templates/consumer/AGENTS.md +30 -30
  24. package/templates/consumer/CLAUDE.md +3 -3
  25. package/templates/consumer/README.md +5 -6
  26. package/templates/creator/AGENTS.md +30 -30
  27. package/templates/creator/CLAUDE.md +3 -3
  28. package/templates/creator/README.md +11 -13
  29. package/templates/creator/lib/site.config.ts +6 -8
  30. package/templates/default/AGENTS.md +30 -30
  31. package/templates/default/CLAUDE.md +3 -3
  32. package/templates/default/README.md +8 -10
  33. package/templates/default/app/auth-shell.tsx +2 -2
  34. package/templates/default/lib/site.config.ts +26 -29
  35. package/templates/directory/AGENTS.md +30 -30
  36. package/templates/directory/CLAUDE.md +3 -3
  37. package/templates/directory/README.md +10 -14
  38. package/templates/directory/lib/site.config.ts +7 -9
  39. package/templates/local-service/AGENTS.md +30 -30
  40. package/templates/local-service/CLAUDE.md +3 -3
  41. package/templates/local-service/README.md +12 -15
  42. package/templates/local-service/lib/site.config.ts +8 -9
  43. package/templates/marketplace/AGENTS.md +30 -30
  44. package/templates/marketplace/CLAUDE.md +3 -3
  45. package/templates/marketplace/README.md +7 -8
  46. package/templates/marketplace/app/page.tsx +2 -2
  47. package/templates/restaurant/AGENTS.md +30 -30
  48. package/templates/restaurant/CLAUDE.md +3 -3
  49. package/templates/restaurant/README.md +10 -13
  50. package/templates/restaurant/lib/site.config.ts +7 -8
  51. package/templates/shop/AGENTS.md +30 -30
  52. package/templates/shop/CLAUDE.md +3 -3
  53. package/templates/shop/README.md +11 -13
  54. package/templates/shop/lib/site.config.ts +4 -5
  55. package/templates/todo/AGENTS.md +30 -30
  56. package/templates/todo/CLAUDE.md +3 -3
  57. package/templates/todo/README.md +4 -6
  58. package/templates/waitlist/AGENTS.md +30 -30
  59. package/templates/waitlist/CLAUDE.md +3 -3
  60. package/templates/waitlist/README.md +12 -16
  61. package/templates/waitlist/lib/site.config.ts +11 -12
@@ -1,12 +1,10 @@
1
- // THE single source of truth for everything business-specific in this template.
2
- // Rebrand the entire site by editing this ONE file — the marketing components
3
- // (hero, pricing, FAQ, footer, nav) all read from here and stay generic. The
4
- // `create-pylon` scaffolder and automated generators target this file too.
1
+ // Business-specific copy and settings live here. The marketing components,
2
+ // `create-pylon` scaffolder, and automated generators all read this file.
5
3
  //
6
4
  // Colors live here (applied as CSS variables on <html> in app/layout.tsx), so
7
5
  // you don't touch globals.css to re-theme the marketing pages.
8
6
  //
9
- // Fictional demo copy replace the values, keep the shape.
7
+ // Fictional demo copy. Replace the values and keep the shape.
10
8
 
11
9
  /* ----------------------------- types ----------------------------- */
12
10
 
@@ -119,7 +117,7 @@ export const siteConfig: SiteConfig = {
119
117
  domain: "acme.app",
120
118
  email: "hello@acme.example",
121
119
  footerBlurb:
122
- "The workspace where your team plans, builds, and ships together projects, docs, and automation in one place.",
120
+ "Plan, build, and ship with projects, docs, and automation in one workspace.",
123
121
  copyrightName: "Acme, Inc.",
124
122
  socials: [
125
123
  {
@@ -158,7 +156,7 @@ export const siteConfig: SiteConfig = {
158
156
 
159
157
  outcomes: {
160
158
  eyebrow: "Why Acme",
161
- headline: "Everything your team needs to stay in motion.",
159
+ headline: "Keep work moving from kickoff to launch.",
162
160
  body: "Most teams lose work somewhere between the kickoff and the ship date. Acme keeps the whole path in one place, so every idea has a clear route from planned, to in progress, to done.",
163
161
  items: [
164
162
  {
@@ -182,7 +180,7 @@ export const siteConfig: SiteConfig = {
182
180
 
183
181
  featuredTestimonial: {
184
182
  quote:
185
- "Our whole team finally works in one place. Acme made it easy to see what is happening, decide what is next, and keep everyone moving in the same direction.",
183
+ "Acme gives our team one view of the work. We can see what is happening, decide what is next, and move in the same direction.",
186
184
  name: "Maya Chen",
187
185
  role: "Head of Product, Northwind",
188
186
  },
@@ -211,7 +209,7 @@ export const siteConfig: SiteConfig = {
211
209
  paragraphs: [
212
210
  "Someone shares an idea, it lands in a list, and that is the last anyone hears of it. Acme treats every idea as the start of a loop, not the end of one.",
213
211
  "When the status moves to planned, the people who care hear about it. When it ships, they hear about it first. And every week a digest pulls them back with the work worth weighing in on.",
214
- "None of it is something you configure. Turn Acme on, and the loop starts running the same day.",
212
+ "The loop starts running the same day, without a separate configuration step.",
215
213
  ],
216
214
  items: [
217
215
  {
@@ -240,19 +238,19 @@ export const siteConfig: SiteConfig = {
240
238
  quotes: [
241
239
  {
242
240
  quote:
243
- "We finally have one source of truth for the work. The whole team can see what is happening without a status meeting.",
241
+ "The whole team sees the same work, so we spend less time in status meetings.",
244
242
  name: "Daniel Reyes",
245
243
  role: "Founder, Globex",
246
244
  },
247
245
  {
248
246
  quote:
249
- "Acme noticeably improved how we plan. Instead of piecing together five tools, we have one hub for everything.",
247
+ "We replaced five disconnected tools with one place to plan the work.",
250
248
  name: "Hannah Brooks",
251
249
  role: "Founder, OpenLane",
252
250
  },
253
251
  {
254
252
  quote:
255
- "Ever since we added Acme, people actually feel heard. It has helped us build a loyal community of users.",
253
+ "People can follow their ideas from submission to launch, which helps us keep them involved.",
256
254
  name: "Marcus Bell",
257
255
  role: "Cofounder, Initech",
258
256
  },
@@ -262,11 +260,11 @@ export const siteConfig: SiteConfig = {
262
260
  gettingStarted: {
263
261
  eyebrow: "Get started",
264
262
  headline: "Up and running in 60 seconds.",
265
- body: "No credit card, no sales call, no setup wizard. An email and a workspace name are all it takes to start.",
263
+ body: "Start with an email and a workspace name. No credit card or sales call required.",
266
264
  steps: [
267
265
  {
268
266
  title: "Create a workspace",
269
- body: "Sign up with just an email. Pick a name. That is the entire form.",
267
+ body: "Enter an email and choose a workspace name.",
270
268
  },
271
269
  {
272
270
  title: "Invite your team",
@@ -282,7 +280,7 @@ export const siteConfig: SiteConfig = {
282
280
  pricing: {
283
281
  eyebrow: "Pricing",
284
282
  headline: "Simple pricing. Every plan.",
285
- body: "Start free and upgrade when your team grows. No per-seat surprises, no annual lock-in.",
283
+ body: "Start free and upgrade as your team grows, with predictable pricing and no annual commitment.",
286
284
  plans: [
287
285
  {
288
286
  name: "Free",
@@ -336,7 +334,7 @@ export const siteConfig: SiteConfig = {
336
334
  items: [
337
335
  {
338
336
  title: "A small team",
339
- body: "No committees. Every feature ships because someone decided it was worth building.",
337
+ body: "A small team decides what is worth building and ships it.",
340
338
  },
341
339
  {
342
340
  title: "Design first",
@@ -356,10 +354,9 @@ export const siteConfig: SiteConfig = {
356
354
  finalCta: {
357
355
  eyebrow: "Start building",
358
356
  headline: "Stop losing momentum to busywork.",
359
- bodyLead:
360
- "When your team can see the work, decide what is next, and ship in one place, momentum takes care of itself. This is what ",
361
- highlight: "working in flow",
362
- bodyTail: " looks like.",
357
+ bodyLead: "Plan, decide, and ship in ",
358
+ highlight: "one shared workspace",
359
+ bodyTail: ".",
363
360
  cta: "Start building with Acme",
364
361
  footnote: "Free to start · No credit card · Cancel anytime",
365
362
  },
@@ -495,8 +492,8 @@ export const siteConfig: SiteConfig = {
495
492
  summary:
496
493
  "Keep a small team aligned as everything changes weekly. Acme gives you one place to plan, build, and ship before the next pivot.",
497
494
  sections: [
498
- { title: "One tool, not ten", body: "Projects, tasks, and docs in one place, so you are not paying for or stitching together five apps." },
499
- { title: "Set up in minutes", body: "No admin overhead. Invite the team and start working the same day." },
495
+ { title: "One workspace", body: "Keep projects, tasks, and docs together instead of stitching together separate apps." },
496
+ { title: "Set up in minutes", body: "Invite the team and start working the same day." },
500
497
  { title: "Grows with you", body: "The same workspace works at five people and at fifty." },
501
498
  ],
502
499
  },
@@ -530,9 +527,9 @@ export const siteConfig: SiteConfig = {
530
527
  slug: "teams",
531
528
  navLabel: "For teams",
532
529
  eyebrow: "Solutions",
533
- title: "Built for how your team works.",
530
+ title: "Match Acme to your workflow.",
534
531
  summary:
535
- "Whether you build, design, market, or support, Acme adapts to your process instead of forcing a new one.",
532
+ "Custom fields and statuses adapt Acme to product, design, marketing, and support workflows.",
536
533
  sections: [
537
534
  { title: "Your workflow", body: "Custom statuses and fields match the way your team already works." },
538
535
  { title: "Cross-team work", body: "Hand work between teams without it falling through a crack." },
@@ -547,7 +544,7 @@ export const siteConfig: SiteConfig = {
547
544
  navLabel: "Docs",
548
545
  eyebrow: "Resources",
549
546
  title: "Documentation.",
550
- summary: "Everything you need to set up Acme and get your team productive.",
547
+ summary: "Set up Acme, invite your team, and configure projects, automations, and integrations.",
551
548
  sections: [
552
549
  { title: "Getting started", body: "Create a workspace, invite your team, and ship your first project." },
553
550
  { title: "Guides", body: "Deep dives on projects, tasks, docs, automations, and analytics." },
@@ -646,7 +643,7 @@ export const siteConfig: SiteConfig = {
646
643
  navLabel: "Contact",
647
644
  eyebrow: "Company",
648
645
  title: "Get in touch.",
649
- summary: "Questions, feedback, or just want to say hi? We would love to hear from you.",
646
+ summary: "Contact the Acme team about sales, support, or press.",
650
647
  sections: [
651
648
  { title: "Sales", body: "Talk through whether Acme is a fit for your team." },
652
649
  { title: "Support", body: "Get help from a human, usually within a few hours." },
@@ -687,7 +684,7 @@ export const siteConfig: SiteConfig = {
687
684
  competitor: "Beacon",
688
685
  title: "Acme vs Beacon",
689
686
  summary:
690
- "Beacon is a capable tool, but it splits projects, docs, and automation across separate products. Acme brings them into one fast workspace.",
687
+ "Beacon separates projects, docs, and automation across products. Acme keeps them in one workspace.",
691
688
  rows: [
692
689
  { dim: "Projects, tasks, and docs", acme: "In one workspace", them: "Separate products" },
693
690
  { dim: "Real-time sync", acme: "Built in", them: "Add-on" },
@@ -702,7 +699,7 @@ export const siteConfig: SiteConfig = {
702
699
  competitor: "Orbit",
703
700
  title: "Acme vs Orbit",
704
701
  summary:
705
- "Orbit is flexible but slow to set up and heavy to run. Acme gives you the same power with a fraction of the overhead.",
702
+ "Orbit requires more setup and administration. Acme focuses on same-day setup and a smaller operational footprint.",
706
703
  rows: [
707
704
  { dim: "Time to first project", acme: "Same day", them: "Onboarding required" },
708
705
  { dim: "Speed", acme: "Instant, real-time", them: "Page reloads" },
@@ -717,7 +714,7 @@ export const siteConfig: SiteConfig = {
717
714
  competitor: "Tempo",
718
715
  title: "Acme vs Tempo",
719
716
  summary:
720
- "Tempo is built for managers; Acme is built for the whole team. Everyone gets a fast, shared view of the work.",
717
+ "Tempo centers management reporting. Acme gives the whole team a shared view of daily work.",
721
718
  rows: [
722
719
  { dim: "Designed for", acme: "The whole team", them: "Managers" },
723
720
  { dim: "Daily driver", acme: "Yes", them: "Reporting layer" },
@@ -1,47 +1,47 @@
1
- # AGENTS.md working in a Pylon project
1
+ # AGENTS.md: working in a Pylon project
2
2
 
3
- Operating rules for a coding agent in this Pylon app. You — the agent — are a first-class user of Pylon: one Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one process on one port, so you build, run, and ship a whole app without wiring services together or leaving the codebase. This is production infrastructure, not a sandbox — real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploy — so build like it ships. You declare entities, policies, and server functions in TypeScript; the binary does the rest. The full API reference is the **llms-full.txt** at https://docs.pylonsync.com/llms-full.txt — read it before guessing an API name.
3
+ Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one Rust process on one port. Treat this app as production infrastructure: it supports real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploys. Declare entities, policies, and server functions in TypeScript; the binary handles the runtime. Read the full API reference at https://docs.pylonsync.com/llms-full.txt before guessing an API name.
4
4
 
5
5
  ## Directory conventions
6
6
 
7
7
  **Unified SSR app:**
8
- - `app.ts` data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
- - `app/` file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
- - `app/globals.css` Tailwind v4 entrypoint (auto-compiled and injected).
11
- - `functions/` server functions, one per file, `default`-exported.
12
- - `.pylon/` local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
8
+ - `app.ts`: data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
+ - `app/`: file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
+ - `app/globals.css`: Tailwind v4 entrypoint (auto-compiled and injected).
11
+ - `functions/`: server functions, one per file, `default`-exported.
12
+ - `.pylon/`: local dev state (SQLite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
13
13
 
14
- **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated do not hand-edit.
14
+ **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated; do not hand-edit.
15
15
 
16
16
  ## The core authoring loop
17
17
 
18
- 1. **Define an entity** `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
- 2. **Write a policy** `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
20
- 3. **Author a function** in `functions/<name>.ts` `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
- 4. **Read it on the client** `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
18
+ 1. **Define an entity:** `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update; use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
+ 2. **Write a policy:** `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). Omitted actions deny by default. `pylon lint` flags wide-open development policies such as `allow*: "true"`; tighten them before shipping.
20
+ 3. **Author a function** in `functions/<name>.ts`: `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
+ 4. **Read it on the client:** `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
22
22
 
23
23
  ## Key gotchas
24
24
 
25
- - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access enforce trust with `ctx.auth` checks inside the handler, not policies.
26
- - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
- - **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
28
- - **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet use urlencoded forms + `/api/files`.
29
- - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
- - **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
- - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
- - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
- - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
25
+ - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access; enforce trust with `ctx.auth` checks inside the handler, not policies.
26
+ - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps`; they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
+ - **Anonymous output caching is opt-in and conditional.** `export const revalidate = 60` makes a page CDN-cacheable (`public, s-maxage=60`) only when the render is auth-independent: it must not read `props.auth`, set a cookie, or run with strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. When any condition fails, the page is `no-cache`. Eligible renders also use the origin disk cache at `.pylon/.cache/ssr`: a cookie-less GET without a query string is served from disk for the TTL and rerendered when stale. The cache is namespaced per deploy, cleared by each build, disabled in `pylon dev`, and invalidated by the `revalidate` TTL or the next deploy.
28
+ - **No-JS forms use `route.ts` and `<Form>`.** Add `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` from `@pylonsync/react` with plain `<input name=...>`. It uses native POST handler redirect without JavaScript and no-reload enhancement with JavaScript. The handler's `db` is read+write under the mutation trust model, so gate it on `auth`. CSRF protection is automatic through the Origin gate and SameSite=Lax. Multipart uploads are not supported yet; use URL-encoded forms and `/api/files`.
29
+ - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends; a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
+ - **`export const streaming = true` streams a page's inner `<Suspense>` boundaries.** Without it or a `loading.tsx`, the page is buffered until all suspended children resolve. With it, the shell and fallbacks flush immediately, then each boundary streams its content. Streaming commits the HTTP head before suspended subtrees finish, so the page is never CDN- or disk-cacheable; do not combine it with `export const revalidate`. Calls to `response.setStatus`, `setCookie`, `redirect`, or `notFound` only take effect during the synchronous shell render. A call from a suspended subtree is dropped and logged. An error from a deep `<Suspense>` child resolves through the nearest `error.tsx` at HTTP 200 rather than 5xx. Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
+ - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
+ - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
+ - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
34
34
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
35
- - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
- - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` use `ctx.runQuery` / `ctx.runMutation`.
35
+ - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
+ - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
37
37
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
38
- - **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`. Deferred (one-shot) execution is `ctx.scheduler.runAfter/runAt/cancel`. Recurring work is a **cron**: `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })` (import `cron` from `@pylonsync/sdk`) it fires the named function (make it `internal: true`) on the schedule; the function runs with anonymous auth its own `ctx.db.*` is server-side (not policy-gated), so write directly; only `ctx.auth.elevate({ admin: true, reason: "..." })` (reason mandatory) to chain an `internal: true` function via `ctx.scheduler`.
38
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
39
39
 
40
40
  ## Testing
41
41
 
42
- `pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/` replace them with your own.
42
+ `pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/`; replace them with your own.
43
43
 
44
- **Tier 1 pure logic (reach for this first).** Keep the decisions that matter — access/plan gating, pricing, credit math, validation, formatting in pure functions in `lib/`, and test those exhaustively. No server, instant, and it's where the real bugs live. Keep your `query`/`mutation`/`action` handlers as thin wrappers around them, so the logic is testable without a running app.
44
+ **Tier 1: pure logic (start here).** Keep access and plan gating, pricing, credit math, validation, and formatting in pure functions under `lib/`, and test them exhaustively. These tests need no server and run instantly. Keep `query`, `mutation`, and `action` handlers as thin wrappers so their decision logic remains testable without a running app.
45
45
 
46
46
  ```ts
47
47
  import { expect, test } from "bun:test";
@@ -52,7 +52,7 @@ test("unknown slug → undefined", () => {
52
52
  });
53
53
  ```
54
54
 
55
- **Tier 2 React components.** `@testing-library/react` + happy-dom are already wired (`tests/setup.ts`). Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, **mock the boundary**, then dynamic-`import` the component so the mock is in place first:
55
+ **Tier 2: React components.** `@testing-library/react` and happy-dom are already wired through `tests/setup.ts`. Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, mock the boundary, then dynamically import the component so the mock is in place first:
56
56
 
57
57
  ```tsx
58
58
  import { test, expect, mock } from "bun:test";
@@ -70,7 +70,7 @@ test("renders orgs from the query", () => {
70
70
  });
71
71
  ```
72
72
 
73
- **Tier 3 functions over HTTP (only when Tier 1 can't cover it).** A handler's full behavior (policies, `ctx.db`, auth) lives in the running app. Start `pylon dev` in another terminal and call the API; `resetDb()` from `@pylonsync/functions` clears the in-memory DB between cases (no-ops if the server's down, refuses production).
73
+ **Tier 3: functions over HTTP.** Use this tier when pure logic tests cannot cover the behavior. A handler's policies, `ctx.db` calls, and auth run in the app. Start `pylon dev` in another terminal and call the API. `resetDb()` from `@pylonsync/functions` clears the in-memory database between cases; it does nothing when the server is down and refuses to run in production.
74
74
 
75
75
  ```ts
76
76
  import { afterEach, expect, test } from "bun:test";
@@ -90,9 +90,9 @@ test("createThing then read it back", async () => {
90
90
  });
91
91
  ```
92
92
 
93
- `pylon test:security` is a separate adversarial probe it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
93
+ `pylon test:security` is a separate adversarial probe; it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
94
94
 
95
- ## Use the CLI — don't guess
95
+ ## Use the CLI
96
96
 
97
97
  | Need | Command |
98
98
  |---|---|
@@ -1,7 +1,7 @@
1
1
  # CLAUDE.md
2
2
 
3
- Agent guidance for this Pylon project lives in **AGENTS.md** the cross-editor
4
- standard that Claude Code, Cursor, Codex, and others all read. It's imported
5
- below so Claude Code picks it up automatically:
3
+ Agent guidance for this Pylon project lives in **AGENTS.md**, the cross-editor
4
+ standard read by Claude Code, Cursor, Codex, and other agents. The import below
5
+ makes it available to Claude Code:
6
6
 
7
7
  @AGENTS.md
@@ -1,12 +1,10 @@
1
1
  # __APP_NAME__
2
2
 
3
- A curated, searchable **directory** built with [Pylon](https://pylonsync.com)
4
- live full-text search + facets, community upvotes, and a moderated submit flow,
5
- all from one binary on one port. No Next.js, no search service.
3
+ A curated directory built with [Pylon](https://pylonsync.com), with live
4
+ full-text search, facets, community upvotes, and moderated submissions.
6
5
 
7
- It's the template that shows off Pylon's **full-text search**: the browse page
8
- is a live `db.useSearch` over the listing table — type in the box and results +
9
- facet counts update instantly, and vote counts tick up across every open tab.
6
+ The browse page runs `db.useSearch` against the listing table. Search results,
7
+ facet counts, and votes update across every open tab.
10
8
 
11
9
  ## Develop
12
10
 
@@ -14,9 +12,8 @@ facet counts update instantly, and vote counts tick up across every open tab.
14
12
  __RUN_DEV__
15
13
  ```
16
14
 
17
- Open http://localhost:4321 the directory seeds itself on first load. Search,
18
- filter by category, and upvote. Then **open a second tab** and upvote something
19
- watch the count rise in both.
15
+ Open http://localhost:4321. The directory seeds itself on first load. Search,
16
+ filter by category, and upvote, then open a second tab to watch vote counts sync.
20
17
 
21
18
  ## How it works
22
19
 
@@ -32,7 +29,7 @@ watch the count rise in both.
32
29
  `Submission`; the curator approves it from `/dashboard`, which copies the
33
30
  public fields into a new `Listing` (`approveSubmission`).
34
31
 
35
- ## Privacy — read this
32
+ ## Privacy
36
33
 
37
34
  The `Submission` entity holds the submitter's name + email (PII), so its policy
38
35
  in `app.ts` **denies every client read and write**. The public directory only
@@ -50,10 +47,9 @@ in with, then create that account at `/login`.
50
47
 
51
48
  ## Rebrand it
52
49
 
53
- Everything lives in **`lib/site.config.ts`** — brand, colors, hero copy, the
54
- category list, the starter listings (which seed the directory), and the submit
55
- copy. Edit that one file and the whole directory re-themes; it re-seeds on a
56
- fresh database.
50
+ Brand, colors, hero copy, categories, starter listings, and submission copy
51
+ live in **`lib/site.config.ts`**. Editing that file updates the directory, and
52
+ a fresh database seeds from its starter listings.
57
53
 
58
54
  ## Layout
59
55
 
@@ -1,11 +1,9 @@
1
- // THE single source of truth for everything business-specific on this directory
2
- // site. Rebrand the whole thing by editing this ONE file — the landing page,
3
- // layout, and the seedListings function all read from here. The create-pylon
4
- // scaffolder and Mast target this file: a whole directory is themed + seeded
5
- // from one typed object.
1
+ // Business-specific copy and settings live here. The landing page, layout,
2
+ // seedListings function, create-pylon scaffolder, and Mast all read this typed
3
+ // object.
6
4
  //
7
5
  // Colors live here (applied as CSS variables on <html> in app/layout.tsx).
8
- // Fictional demo copy replace the values, keep the shape.
6
+ // Fictional demo copy. Replace the values and keep the shape.
9
7
 
10
8
  /* ----------------------------- types ----------------------------- */
11
9
 
@@ -68,7 +66,7 @@ export const siteConfig: DirectoryConfig = {
68
66
  domain: "stacked.dev",
69
67
  email: "hello@stacked.example",
70
68
  footerBlurb:
71
- "A hand-checked directory of the tools developers actually reach for. Search it, sort by votes, and submit the ones we're missing.",
69
+ "A hand-checked directory of developer tools. Search it, sort by votes, and submit missing entries.",
72
70
  copyrightName: "Stacked",
73
71
  socials: [
74
72
  {
@@ -89,9 +87,9 @@ export const siteConfig: DirectoryConfig = {
89
87
 
90
88
  hero: {
91
89
  tagline: "The dev tools directory",
92
- headline: "The tools developers actually use.",
90
+ headline: "Developer tools, checked by hand.",
93
91
  subcopy:
94
- "Search a hand-checked directory of developer tools, filter by category, and upvote the ones that earn it. Live results, live votes no signup to browse.",
92
+ "Search a hand-checked directory, filter by category, and upvote useful tools. Results and vote counts update live, and browsing requires no account.",
95
93
  ctaLabel: "Submit a tool",
96
94
  searchPlaceholder: "Search tools — try “database”, “deploy”, “auth”…",
97
95
  },
@@ -1,47 +1,47 @@
1
- # AGENTS.md working in a Pylon project
1
+ # AGENTS.md: working in a Pylon project
2
2
 
3
- Operating rules for a coding agent in this Pylon app. You — the agent — are a first-class user of Pylon: one Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one process on one port, so you build, run, and ship a whole app without wiring services together or leaving the codebase. This is production infrastructure, not a sandbox — real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploy — so build like it ships. You declare entities, policies, and server functions in TypeScript; the binary does the rest. The full API reference is the **llms-full.txt** at https://docs.pylonsync.com/llms-full.txt — read it before guessing an API name.
3
+ Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one Rust process on one port. Treat this app as production infrastructure: it supports real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploys. Declare entities, policies, and server functions in TypeScript; the binary handles the runtime. Read the full API reference at https://docs.pylonsync.com/llms-full.txt before guessing an API name.
4
4
 
5
5
  ## Directory conventions
6
6
 
7
7
  **Unified SSR app:**
8
- - `app.ts` data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
- - `app/` file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
- - `app/globals.css` Tailwind v4 entrypoint (auto-compiled and injected).
11
- - `functions/` server functions, one per file, `default`-exported.
12
- - `.pylon/` local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
8
+ - `app.ts`: data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
+ - `app/`: file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
+ - `app/globals.css`: Tailwind v4 entrypoint (auto-compiled and injected).
11
+ - `functions/`: server functions, one per file, `default`-exported.
12
+ - `.pylon/`: local dev state (SQLite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
13
13
 
14
- **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated do not hand-edit.
14
+ **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated; do not hand-edit.
15
15
 
16
16
  ## The core authoring loop
17
17
 
18
- 1. **Define an entity** `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
- 2. **Write a policy** `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
20
- 3. **Author a function** in `functions/<name>.ts` `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
- 4. **Read it on the client** `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
18
+ 1. **Define an entity:** `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update; use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
+ 2. **Write a policy:** `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). Omitted actions deny by default. `pylon lint` flags wide-open development policies such as `allow*: "true"`; tighten them before shipping.
20
+ 3. **Author a function** in `functions/<name>.ts`: `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
+ 4. **Read it on the client:** `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
22
22
 
23
23
  ## Key gotchas
24
24
 
25
- - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access enforce trust with `ctx.auth` checks inside the handler, not policies.
26
- - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
- - **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
28
- - **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet use urlencoded forms + `/api/files`.
29
- - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
- - **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
- - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
- - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
- - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
25
+ - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access; enforce trust with `ctx.auth` checks inside the handler, not policies.
26
+ - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps`; they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
+ - **Anonymous output caching is opt-in and conditional.** `export const revalidate = 60` makes a page CDN-cacheable (`public, s-maxage=60`) only when the render is auth-independent: it must not read `props.auth`, set a cookie, or run with strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. When any condition fails, the page is `no-cache`. Eligible renders also use the origin disk cache at `.pylon/.cache/ssr`: a cookie-less GET without a query string is served from disk for the TTL and rerendered when stale. The cache is namespaced per deploy, cleared by each build, disabled in `pylon dev`, and invalidated by the `revalidate` TTL or the next deploy.
28
+ - **No-JS forms use `route.ts` and `<Form>`.** Add `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` from `@pylonsync/react` with plain `<input name=...>`. It uses native POST handler redirect without JavaScript and no-reload enhancement with JavaScript. The handler's `db` is read+write under the mutation trust model, so gate it on `auth`. CSRF protection is automatic through the Origin gate and SameSite=Lax. Multipart uploads are not supported yet; use URL-encoded forms and `/api/files`.
29
+ - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends; a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
+ - **`export const streaming = true` streams a page's inner `<Suspense>` boundaries.** Without it or a `loading.tsx`, the page is buffered until all suspended children resolve. With it, the shell and fallbacks flush immediately, then each boundary streams its content. Streaming commits the HTTP head before suspended subtrees finish, so the page is never CDN- or disk-cacheable; do not combine it with `export const revalidate`. Calls to `response.setStatus`, `setCookie`, `redirect`, or `notFound` only take effect during the synchronous shell render. A call from a suspended subtree is dropped and logged. An error from a deep `<Suspense>` child resolves through the nearest `error.tsx` at HTTP 200 rather than 5xx. Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
+ - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
+ - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
+ - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
34
34
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
35
- - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
- - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` use `ctx.runQuery` / `ctx.runMutation`.
35
+ - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
+ - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
37
37
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
38
- - **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`. Deferred (one-shot) execution is `ctx.scheduler.runAfter/runAt/cancel`. Recurring work is a **cron**: `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })` (import `cron` from `@pylonsync/sdk`) it fires the named function (make it `internal: true`) on the schedule; the function runs with anonymous auth its own `ctx.db.*` is server-side (not policy-gated), so write directly; only `ctx.auth.elevate({ admin: true, reason: "..." })` (reason mandatory) to chain an `internal: true` function via `ctx.scheduler`.
38
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
39
39
 
40
40
  ## Testing
41
41
 
42
- `pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/` replace them with your own.
42
+ `pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/`; replace them with your own.
43
43
 
44
- **Tier 1 pure logic (reach for this first).** Keep the decisions that matter — access/plan gating, pricing, credit math, validation, formatting in pure functions in `lib/`, and test those exhaustively. No server, instant, and it's where the real bugs live. Keep your `query`/`mutation`/`action` handlers as thin wrappers around them, so the logic is testable without a running app.
44
+ **Tier 1: pure logic (start here).** Keep access and plan gating, pricing, credit math, validation, and formatting in pure functions under `lib/`, and test them exhaustively. These tests need no server and run instantly. Keep `query`, `mutation`, and `action` handlers as thin wrappers so their decision logic remains testable without a running app.
45
45
 
46
46
  ```ts
47
47
  import { expect, test } from "bun:test";
@@ -52,7 +52,7 @@ test("unknown slug → undefined", () => {
52
52
  });
53
53
  ```
54
54
 
55
- **Tier 2 React components.** `@testing-library/react` + happy-dom are already wired (`tests/setup.ts`). Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, **mock the boundary**, then dynamic-`import` the component so the mock is in place first:
55
+ **Tier 2: React components.** `@testing-library/react` and happy-dom are already wired through `tests/setup.ts`. Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, mock the boundary, then dynamically import the component so the mock is in place first:
56
56
 
57
57
  ```tsx
58
58
  import { test, expect, mock } from "bun:test";
@@ -70,7 +70,7 @@ test("renders orgs from the query", () => {
70
70
  });
71
71
  ```
72
72
 
73
- **Tier 3 functions over HTTP (only when Tier 1 can't cover it).** A handler's full behavior (policies, `ctx.db`, auth) lives in the running app. Start `pylon dev` in another terminal and call the API; `resetDb()` from `@pylonsync/functions` clears the in-memory DB between cases (no-ops if the server's down, refuses production).
73
+ **Tier 3: functions over HTTP.** Use this tier when pure logic tests cannot cover the behavior. A handler's policies, `ctx.db` calls, and auth run in the app. Start `pylon dev` in another terminal and call the API. `resetDb()` from `@pylonsync/functions` clears the in-memory database between cases; it does nothing when the server is down and refuses to run in production.
74
74
 
75
75
  ```ts
76
76
  import { afterEach, expect, test } from "bun:test";
@@ -90,9 +90,9 @@ test("createThing then read it back", async () => {
90
90
  });
91
91
  ```
92
92
 
93
- `pylon test:security` is a separate adversarial probe it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
93
+ `pylon test:security` is a separate adversarial probe; it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
94
94
 
95
- ## Use the CLI — don't guess
95
+ ## Use the CLI
96
96
 
97
97
  | Need | Command |
98
98
  |---|---|
@@ -1,7 +1,7 @@
1
1
  # CLAUDE.md
2
2
 
3
- Agent guidance for this Pylon project lives in **AGENTS.md** the cross-editor
4
- standard that Claude Code, Cursor, Codex, and others all read. It's imported
5
- below so Claude Code picks it up automatically:
3
+ Agent guidance for this Pylon project lives in **AGENTS.md**, the cross-editor
4
+ standard read by Claude Code, Cursor, Codex, and other agents. The import below
5
+ makes it available to Claude Code:
6
6
 
7
7
  @AGENTS.md