Imported from pylonsync/pylon (
packages/create-pylon/templates/waitlist/AGENTS.md). Install upstream withnpx skills add pylonsync/pylon --skill waitlist. Copyright stays with the author.
AGENTS.md: working in a Pylon project
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.
First: replace the placeholders
This template ships with placeholder branding, copy, and demo data — the example
brand Acme, example.com links, lorem-style text, sample records, and (in the
marketing templates) fake "Acme vs …" comparisons. Before building features, do
one pass to make it the real product:
- Brand — replace every "Acme", its logo, the
example.comdomain, the support email, and social links with the real name. Find them:grep -riE "acme|example\.com" . - Copy — rewrite hero, feature, marketing, and comparison text for the actual product. Ship no lorem and no "Acme".
- Demo data — replace sample/seed records with the real domain's, or remove them.
The manifest name (buildManifest({ name })) is already the project name — the
placeholders live in the CONTENT. A scaffold that still says "Acme" isn't done; make
this pass before the user has to ask.
Directory conventions
Unified SSR app:
app.ts: data model + manifest (entity()+field.*, queries/actions/policies,routes: await discoverAppRoutes()). Ends withconsole.log(JSON.stringify(manifest))— that line is the manifest handoff (the CLI parses this file's stdout), not debug output. Never remove it.app/: file-based SSR routes.app/page.tsx→/,app/about/page.tsx→/about,app/blog/[slug]/page.tsx→/blog/:slug. A(group)directory is stripped from the URL —app/(marketing)/about/page.tsxstill serves/about— so a group'slayout.tsxgives one section its own chrome (nav, footer) without changing any path; routes outside the group render without it.app/layout.tsxis the document shell;app/error.tsx/app/not-found.tsxare boundaries.app/globals.css: Tailwind v4 entrypoint (auto-compiled and injected).functions/: server functions, one per file,default-exported..pylon/: local dev state (SQLite, jobs, sessions, uploads). Created bypylon dev. Do not commit.
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.
The core authoring loop
- 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 forauthorId/orgId),.serverOnly()(never in HTTP responses),.encrypted()(AEAD at rest, needsPYLON_ENCRYPTION_KEY),.crdt("text")(collaborative).field.json()stores an arbitrary JSON value (object/array/scalar), parsed-on-read on every surface; validator twin isv.json(). - Write a policy:
policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })with CEL-like expressions overauth.*/data.*(e.g."auth.userId == data.authorId"). Omitted actions deny by default.pylon lintflags wide-open development policies such asallow*: "true"; tighten them before shipping. - Author a function in
functions/<name>.ts:query(read-only),mutation(transactional read+write), oraction(external I/O, no directctx.db). Import{ query, mutation, action, v }from@pylonsync/functions.authdefaults to"user"(secure-by-default); set"public"explicitly for unauthenticated access. Usectx.db.*,ctx.auth.userId,ctx.error(code, msg). - Read it on the client:
db.useQuery("Thing")(live, re-renders on any write) ordb.useQueryOne("Thing", id). Call functions withdb.fn(name, args)/callFn. On SSR pages, read viause(serverData.list("Thing"))inside<Suspense>.
Key gotchas
-
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 withctx.authchecks inside the handler, not policies. -
Type page props from the SDK, don't hand-roll them.
import type { PageProps, Metadata } from "@pylonsync/react". Every page/layout gets{ pathname, params, searchParams, auth, response, serverData };PageProps<{ slug: string }>types a[slug]route's params. Request headers/cookies are intentionally NOT onPageProps; they're server-only and stripped from hydration, so reading them in the render would mismatch. -
Anonymous output caching is opt-in and conditional.
export const revalidate = 60makes a page CDN-cacheable (public, s-maxage=60) only when the render is auth-independent: it must not readprops.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 isno-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 inpylon dev, and invalidated by therevalidateTTL or the next deploy. -
No-JS forms use
route.tsand<Form>. Addapp/.../route.tsexportingexport 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/reactwith plain<input name=...>. It uses native POST → handler → redirect without JavaScript and no-reload enhancement with JavaScript. The handler'sdbis read+write under the mutation trust model, so gate it onauth. CSRF protection is automatic through the Origin gate and SameSite=Lax. Multipart uploads are not supported yet; use URL-encoded forms and/api/files. -
loading.tsxstreams a skeleton while the page's data resolves. Dropapp/.../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-leveluse(serverData…)resolves (no blank page). It only shows when the PAGE suspends; a page that wraps its own<Suspense>around a child (like/dashboardin this template) handles that itself. The skeleton is SERVER-ONLY: don't readserverDatain it. A page with noloading.tsxis buffered (unchanged). -
export const streaming = truestreams a page's inner<Suspense>boundaries. Without it or aloading.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 withexport const revalidate. Calls toresponse.setStatus,setCookie,redirect, ornotFoundonly 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 nearesterror.tsxat HTTP 200 rather than 5xx. Type the config withimport type { RouteSegmentConfig } from "@pylonsync/react". -
error.tsx/not-found.tsxboundaries are HYDRATED (interactive).app/.../error.tsxcatches 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.tsxrenders at 404 (also forresponse.notFound()) and gets the page props (NotFoundProps), noreset. Both run useState/onClick/hooks. -
Client navigation hooks live in @pylonsync/react.
useRouter()→{ push, replace, back, forward, refresh, prefetch };useSearchParams()→ reactiveURLSearchParams;usePathname()→ reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read thepathname/searchParamspage props (pathnameis the PATH only — the query is already parsed intosearchParams, so never try to read a query parameter back out of it; the olderurlprop is the same value and is deprecated). -
Dynamic + catch-all routes follow Next conventions.
app/blog/[slug]/page.tsx→params.slug.app/docs/[...path]/page.tsxis a catch-all (matches/docs/a/b/c;params.path === "a/b/c";.split("/")for segments).app/shop/[[...filters]]/page.tsxis an optional catch-all (also matches the bare/shop, withparams.filters === ""). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap. -
Never wrap
serverDatacalls inPromise.all. Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, souse()returns synchronously.Promise.allbuilds a new, pending, uncached promise on every render, souse()suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the firstuse(), thenuse()each handle — the reads overlap and the replayed render finds each one cached:const orgPromise = serverData.get<Org>("Org", auth.tenant_id); const projectsPromise = serverData.list<Project>("Project"); const org = use(orgPromise); const projects = use(projectsPromise);Reading them one at a time (
use(serverData.a()); use(serverData.b());) is correct but serial: each read waits for the one above it. -
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. -
response.*/response.redirect()/response.notFound()must fire in the synchronous shell render, before anyawait/<Suspense>. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, andredirect/notFoundthrown below a Suspense boundary are swallowed. -
ctx.llm,ctx.rooms, andctx.connectionsare on mutation + action only, NOT query (reactive purity).actionhas no directctx.db; usectx.runQuery/ctx.runMutation. -
ctx.llm.stream(request, onEvent)streams tokens as they generate and still resolves with the full response, sostop_reason === "tool_use"drives an agent tool loop. Events aretext_delta/tool_use_start/tool_input_delta/done. Same auth + model-allowlist gating asctx.llm.complete. Streaming does NOT extend the call deadline (PYLON_FN_CALL_TIMEOUT, 30s default) — settimeout: <secs>on the def for a long run. -
ctx.stream.write(text)streams to the caller — and every fn stream is RESUMABLE: the server buffers frames under a stream id (streamFn'sonStreamId), so a dropped connection or closed tab catches up viaresumeStream(id), including the final result after the handler finished.ctx.rooms.broadcast(room, topic, data)fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves{ delivered: false }when the room has no members — a no-op, not an error. Clients receive withuseRoomMessages(room, cb)from@pylonsync/react;useRoomis the send side. -
It's
db.useQueryOne, notuseOne. Validators and field types have aliases:v.bool/v.boolean,v.float/v.number. -
Use the supported file and scheduling APIs. Files go through
<FileUpload>and/api/files/*; an upload is readable by its uploader only, so passvisibility: "public"touploadFileor<FileUpload>for files every visitor sees; there is noctx.files. One-shot work usesctx.scheduler.runAfter,runAt, orcancel; there is nodefineWorkflowordefineJob. Recurring work usescron("0 * * * *", "fnName")inbuildManifest({ crons: [...] }), imported from@pylonsync/sdk. Make the target functioninternal: true. It runs with anonymous auth, but its ownctx.db.*calls are server-side and bypass policies. Usectx.auth.elevate({ admin: true, reason: "..." }), with a mandatory reason, only when chaining another internal function throughctx.scheduler.
Testing
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.
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.
import { expect, test } from "bun:test";
import { productBySlug } from "../lib/site.config";
test("unknown slug → undefined", () => {
expect(productBySlug("nope")).toBeUndefined();
});
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:
import { test, expect, mock } from "bun:test";
import React from "react";
import { render, screen } from "@testing-library/react";
mock.module("@pylonsync/react", () => ({
db: { useQuery: () => ({ data: [{ id: "1", name: "Acme" }], loading: false }) },
}));
const { OrgList } = await import("../app/orgs/org-list"); // your component
test("renders orgs from the query", () => {
render(<OrgList />);
expect(screen.getByText("Acme")).toBeDefined();
});
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.
import { afterEach, expect, test } from "bun:test";
import { resetDb } from "@pylonsync/functions";
const BASE = "http://localhost:4321";
afterEach(() => resetDb(BASE)); // or installTestIsolation(BASE) once at top-of-file
test("createThing then read it back", async () => {
const t = await fetch(`${BASE}/api/fn/createThing`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "hello" }),
}).then((r) => r.json());
const rows = await fetch(`${BASE}/api/entities/Thing`).then((r) => r.json());
expect(rows.some((r: { id: string }) => r.id === t.id)).toBe(true);
});
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).
Use the CLI
| Need | Command |
|---|---|
Run the app (SSR + API, hot reload, one port :4321) |
pylon dev (or npm run dev) |
| Regenerate manifest + typed client | pylon codegen (Swift client: pylon codegen client --target swift) |
| Validate / diff / push schema | pylon schema check | diff | push |
| Migrations | pylon migrate create <name> | plan | apply |
| Lint policies (PYL001–PYL004) | pylon lint --strict |
| Tests | pylon test |
| Adversarial security probe | pylon test:security |
| Inspect cloud request logs (agent-safe) | pylon logs --json --limit 50 |
| Inspect data / entities | pylon data entities | pylon data list <Entity> |
| Call a function | pylon fn <name> key=value |
| Health snapshot | pylon status |
Build for prod (writes dist/) |
pylon build |
| Run the production build | pylon start dist |
| Deploy (Pylon Cloud by default) | pylon deploy |
| Look up an error code | pylon explain <CODE> |
--json works on every command for machine-readable output. Prefer one-shot/agent-safe flags (pylon logs --limit N, not a blocking --follow).
For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: https://docs.pylonsync.com/llms-full.txt.