Getting started

End-to-end walkthrough: from an empty directory to a running app, then from the bootstrapped template toward (a subset of) the example app. Assumes setup is done — fsh, uv, node/yarn, just, and just-pm on your PATH.

The stack has four generated sides driven by jsonnet: a be FastAPI backend, a fe React/TanStack frontend, an opa Rego permissions service, and a render Hono microservice. You edit jsonnet (and a few safe-to-edit FE scaffolds); codegen emits the rest.

Scaffold a new app

fsh template pulls the codegen template into a directory and starts a fresh git repo (the template’s history is dropped, so origin doesn’t point at it):

fsh template sample
cd sample
uv sync

uv sync puts the codegen CLI on PATH via the harness pyproject.toml.

Note

The template’s deps point at sibling codegen* checkouts (editable). fsh template auto-detects whether those siblings are present. Force it either way: --editable builds from adjacent checkouts; --no-editable writes a git-ignored .env with UV_NO_SOURCES=1 so uv pulls the published packages from CodeArtifact instead.

To run multiple stacks in parallel, bake a port/DB offset in at pull time:

fsh template sample --offset 100

--offset N writes .env so every dev port shifts by N and the app targets the app_N database. It’s frozen at pull time — changing it later means editing .env and re-running DB setup.

Bootstrap the four sides

The freshly-pulled directory holds only the four bootstrap.jsonnet files (one per side) plus the harness. just bootstrap scaffolds each side into be/, fe/, opa/, render/:

just bootstrap

It runs the four *_root generators (be_root, fe_root, opa, render_root), then just-pm sync to vendor the shared just modules, then a per-side setup + lint-fix pass:

// be/bootstrap.jsonnet — the be_root toggles that decide what gets scaffolded.
{
  name: "codegen-template-app",
  module: "app",
  opentelemetry: true,   // telemetry block + init_telemetry(app)
  files: true,           // boto3 + fsh_lib.files upload helpers
  auth: true,            // auth.py stubs + auth.jwt(...) block
  psycopg: true,
  codegen_database: true,
  pgqueuer: true,        // background worker recipes
  rate_limit: true,      // rate_limit.slowapi(...) block
  comms: true,           // comms platform (requires pgqueuer)
  fsh: true,             // /fsh platform app: saved views, opt-ins
  opa: true,             // OPA gate + per-resource Rego schemas
  samples: true,         // a runnable User + Category CRUD out of the box
  links: true,           // /l/<code> short-link redirector
  reports: true,         // saved + system reports
  oauth: true,           // "Sign in with Google" (OIDC)
}

The four sides:

  • be — FastAPI + SQLAlchemy. Walks be/config/project.jsonnet → each app’s config and emits the API tree under be/_generated/.

  • fe — React + TanStack. Parses the BE’s openapi.json and emits the typed client + hooks under fe/src/_generated/.

  • opa — Rego permissions service; rules and per-resource JSON schemas sync from the backend’s emitted contract (see permissions).

  • render — Hono microservice consuming the BE’s render contract (drives PDFs and templated comms output).

Every emitted file is if_exists="skip", so re-running just bootstrap is a no-op until you pass --force to the underlying CLI. To see what a re-run would change (templates drift silently under skip):

just bootstrap-diff              # or: just bootstrap-diff --check   (CI gate)

Generate the contract surface

First run — bootstrap then regenerate every contract surface in one shot:

just fresh                       # = bootstrap + spec

After any *.jsonnet config edit, regenerate the BE + FE generated code:

just generate                    # aliases: just g / just gen  -> be gen + fe gen
just validate                    # check the be + fe configs against their schemas

just generate covers in-repo generated code. The downstream contract surfaces (OpenAPI, TS client, OPA sync, render regen) have their own recipes:

just spec                        # BE codegen -> specs/openapi.json -> FE TS client
just opa-spec                    # BE codegen -> opa sync + verify
just render-spec                 # BE codegen -> render regen

Warning

The FE yarn openapi-ts step (which produces the actual TypeScript client) reads be/specs/openapi.json, which only exists once the BE has emitted it. just spec handles the ordering; if you run the FE client step by hand, make sure the BE spec is fresh first.

Run it

just dev                         # alias: just d

Runs the BE on :8000, the FE on :5173, and the pgqueuer worker under one process group (Ctrl-C stops all). For a parallel stack, pass a port offset:

just dev 100                     # ports +100, uses the app_100 database

Sign in at http://localhost:5173/login with alice / wonderland. First run needs the DB: cd be && just db init.

From template to example (a walkthrough)

A freshly bootstrapped app has empty app config — its tracker app lists resources: [], so the BE emits only project-scope plumbing (db wiring, auth routes, the app skeleton). Below we build it toward the example app, each step linking its deep-dive guide.

After every config edit, re-run just generate (and just spec when the FE needs the refreshed client).

1. Declare project plumbing in project.jsonnet

be/config/project.jsonnet holds cross-cutting platform features: databases, auth, telemetry, rate-limiting, links, OPA, comms, reports, and the apps[] list. Each is a helper import. Databases and apps[] are the minimum:

// be/config/project.jsonnet
local auth = import "be/auth/jwt.libsonnet";
local db = import "be/db/databases.libsonnet";
local opa = import "be/opa/opa.libsonnet";
local reports = import "be/reports/reports.libsonnet";
local telemetry = import "be/telemetry/telemetry.libsonnet";

{
  databases: [
    db.postgres("primary", { default: true }),
  ],
  // ...
  apps: [
    { config: import "tracker.jsonnet", prefix: "/tracker" },
  ],
}

Layer platform features on — each one helper block, reach for the guide when you turn it on:

  • auth: auth.config({...}) with auth.password(...) / auth.oauth(...) methods — auth.

  • opa: opa.config({ roles_loader, bindings_loader })permissions.

  • comms: comms.platform({...}) — typed messages, user templates, recipient groups — emails and templates.

  • reports: reports.platform({...}) with report_fnsreports.

  • telemetry.otel(...), rate_limit.slowapi(...), links.shortener(...) — each a single block; see infra for the deployment side.

The OPA block is just its two loaders:

// be/config/project.jsonnet — permissions wiring.
opa: opa.config({
  roles_loader: "permissions.load_roles",
  bindings_loader: "permissions.load_bindings",
}),

2. Add an app under a prefix

An app is a versioned module with a resources[] list, mounted under a URL prefix in apps[]. The template’s app starts empty; the example’s tracker collects the core resources:

// be/config/tracker.jsonnet
{
  version: "1",
  module: "tracker",

  resources: [
    import "resources/project.jsonnet",
    import "resources/task.jsonnet",
    import "resources/user.jsonnet",
    import "resources/role.jsonnet",
    // ...
  ],
}

Split large apps into more { config, prefix } entries in project.jsonnet (the example adds inventory, purchasing, vendors). See resources for how apps and resources compose.

3. Add your first resource

A resource declares its model, primary key, route prefix, one or more representations (field projections) and operations (CRUD + more). The simplest is the shared name-dimension preset — one line yields full CRUD:

// be/config/resources/role.jsonnet — id + name catalogue, full CRUD.
(import "be/resources/presets.libsonnet").nameDimension("fsh.models.Role", "/roles")

A hand-rolled resource spells out its representations and operations. The example’s project resource shows the shape — representations built from fields helpers, an enum field, and a searchable list op with filters:

// be/config/resources/project.jsonnet (trimmed)
local fields = import "be/fields.libsonnet";
local list = import "be/operations/list.libsonnet";

{
  model: "fsh.models.Project",
  pk: { name: "id", type: "uuid" },
  route_prefix: "/projects",

  search: {},
  representations: [
    {
      name: "list_item",
      fields: [
        fields.id(),
        { name: "name", type: "str" },
        { name: "slug", type: "str" },
        { name: "project_type", type: "enum", enum: "fsh.models.ProjectType" },
      ],
    },
    // ...
  ],
  representation_roles: { default: "default" },

  operations: [
    { name: "get", representation: "resource" },
    list.searchable(
      representation="list_item",
      filters=[
        { name: "id", values: "self" },
        { name: "project_type", values: "enum", enum: "fsh.models.ProjectType" },
      ],
      order=["name"],
      default_order="name",
    ),
    { name: "create", fields: [/* ... */] },
    { name: "update", fields: [/* ... */] },
    { name: "delete" },
  ],
}

The enum field’s choices come from a Python StrEnum (fsh.models.ProjectType) — see enums. The filter kinds (self, ref, enum, bool) are covered in search and filters.

4. Add an action and per-op guards

Beyond CRUD, resources declare actions — named operations backed by a dotted Python callable, optionally gated by a can guard. The example’s task resource adds a complete action plus per-op controls:

// be/config/resources/task.jsonnet (operations, trimmed)
local resource = import "be/resources/presets.libsonnet";

operations: [
  {
    name: "create",
    rate_limit: "10/minute",                         // per-op override
    pre: "tracker.hooks.normalise_task_title_on_create",  // pre-write hook
    fields: [/* ... */],
  },
  { name: "delete" },
  resource.action(
    name="complete",
    fn="tracker.actions.complete_task",
    // Server-side visibility gate: available_actions omits `complete`
    // from a row when this returns False — the FE drops it automatically.
    can="tracker.guards.can_complete_task",
  ),
],

Actions, hooks, and guards are the subject of actions.

5. Layer features onto resources

With the platform wired in step 1, opt individual resources into each feature:

  • Permissions — set representation_roles: { opa: "authz" } so a chosen representation is the projection sent to OPA on each point check (permissions):

    // be/config/resources/task.jsonnet
    representation_roles: {
      default: "default",
      opa: "authz",
    },
    
  • Comms — a comms_target makes the resource a user-template target, its representation the variable surface (emails and templates):

    // be/config/resources/task.jsonnet
    comms_target: comms.target({
      representation: "comms_summary",
      recipient_resolvers: {
        watchers: "tracker.comms_resolvers.task_watchers",
        project_lead: "tracker.comms_resolvers.task_project_lead",
      },
    }),
    
  • Reportsreports.opt_in(...) with report_type + a report_fn source surfaces system/saved reports on the resource (reports):

    // be/config/resources/project.jsonnet
    reports: reports.opt_in({
      types: ["csv", "json"],
      system_reports: [
        reports.system({
          slug: "all_projects",
          name: "All projects",
          report_type: "csv",
          source: reports.list_op_source({ base_fn: "project.list" }),
        }),
      ],
    }),
    

Auditing and commenting layer on the same way (both are not yet merged).

6. Wire the frontend

Once the BE shape is set, regenerate and fill the safe-to-edit FE scaffolds. The generated client + hooks land under fe/src/_generated/ (never edited); the scaffolds under fe/src/{fields,enums,actions} and fe/src/routes.ts are scaffolded once (if_exists="skip") and are yours to fill:

just generate && just spec       # emit types + client, then the FE scaffolds
  • Field catalogs (fields) — one file per resource; give each field a cell. It won’t type-check until you do:

    // fe/src/fields/tracker/task.tsx
    import { defineFields } from "@fsh/components-library";
    import type { ListItemTaskResource } from "../../_generated/types/tracker/task.gen";
    
    export const taskFields = defineFields<ListItemTaskResource>()((field) => [
      field("title", { header: "Title", cell: (r) => r.title, table: { sortable: true } }),
      field("completed", { header: "Completed", cell: (r) => r.completed }),
    ]);
    
  • Enum displays (enums) — labels + badge variants, typed against the generated enum so a new backend value fails to compile until you label it:

    // fe/src/enums/projectType.ts
    import { defineEnumDisplay } from "@fsh/components-library";
    import type { ProjectType } from "../_generated/types/tracker/project.gen";
    
    export const projectType = defineEnumDisplay<ProjectType>({
      product: { label: "Product", variant: "neutral" },
      internal: { label: "Internal", variant: "neutral" },
    });
    
  • Action drawers (actions) — every entry defaults to null (opted out); replace a null with { label, icon, onAction } (or a view overlay) to surface it:

    // fe/src/actions/tracker/task.tsx
    export const taskActions = defineResourceActions(taskActionCatalog, {
      resource: "task",
      object: { view: null, update: null, delete: null, complete: null },
      collection: { view: null, create: null },
      bulk: {},
    });
    
  • Routes — register pages in fe/src/routes.ts with defineArea, then link them from the sidebar in Shell.tsx:

    // fe/src/routes.ts
    const assets = defineArea({
      list:   { path: "/assets",     lazy: () => import("./pages/assets/AssetListPage") },
      detail: { path: "/assets/$id", lazy: () => import("./pages/assets/AssetDetailPage") },
    });
    const areas = mergeAreas({ assets, categories, comms, settings });
    
  • Tables — column ordering, sorting, and defaults are tuned in table configuration.

Next steps

  • resources — apps, models, representations, operations.

  • fields — the field helpers and representation shapes.

  • enums — backend StrEnum → FE displays and filters.

  • search and filterslist.searchable, filter kinds, keyset paging.

  • actions — actions, hooks, can guards, FE drawers.

  • permissions — the OPA gate, roles, bindings, per-resource projections.

  • auth — JWT sessions, password + OAuth/OIDC login.

  • emails and templates — the comms platform and user templates.

  • reports — saved + system reports, report_fn sources, PDF/CSV/JSON.

  • table configuration — FE table columns, sorting, defaults.

  • auditing — change history (not yet merged).

  • commenting — per-record comments (not yet merged).

  • infra — deployment, environments, and operational concerns.