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.

The stack has four generated services driven by jsonnet:

  • be: a fast API backend

  • fe: a React frontend

  • opa: an Open Policy Agent permissions service

  • render: a node microservice that renders emails and PDFs

At a high level, you’ll be configuring in JSON, generating Python, and then building React.

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.

Note

fsh template will automatically detect whether you have repositories like codegen cloned as siblings. If you do, it will use those in editable mode, if you don’t, it will add UV_NO_SOURCES=1 to your .env such that it is forced to pull from the registry.

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)
}

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

Generate the apps

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

Sign in at http://localhost:5173/login with alice / wonderland. First run needs the DB: just be 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" },
  ],
}

Some features:

  • 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 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.