---
type: plan
title: Building a caddie app — the standalone-session runbook
slug: building-a-caddie-app
status: active
owner: rian
created: 2026-08-20
related:
  - /srv/apps/caddie/.logs/planning/02-tool-contract.md
  - /srv/apps/caddie/.logs/planning/04-interaction-standard.md
summary: >
  The document a fresh session reads when rian says "build X as a
  caddie-compatible app." Everything a standalone tool build needs, in one
  place: what a caddie app IS, the create runbook, the stack requirements, the
  two standards, and the conformance bar. LIVING DOC — being hardened by the
  first tool build (punchlist); the Learnings section at the bottom captures
  what that build teaches, and graduates into the sections above as it settles.
---

# Building a caddie app

> **Trigger:** rian starts a session with "build <name> as a caddie app /
> caddie-compatible app at <name>.bowden.works". This doc is the entry point;
> follow it top to bottom. It assumes the server-wide BW-app runbook in
> `/srv/CLAUDE.md` and the standards library at `/srv/projects/standards/`.

## 1. What a caddie app is

A **sovereign standalone BW app** on its own subdomain — fully usable without
caddie — that additionally speaks two standards:

1. **The Caddie Tool Contract** (`02-tool-contract.md`): signed launch
   handoff in, signed status reports out, scoped auto-provisioning, acked
   revocation, the host bar. *Drop-ins:* `caddie_tool.py` + `caddieHost.ts` —
   **status: not yet built; they land with caddie M2.** Until then: build the
   seams (an `external_ref` on your instance table; a status-rollup function
   mapping your lifecycle onto the contract's six states) and defer the rest.
2. **The BW Interaction Standard** (`04-interaction-standard.md`): comments,
   @mentions, attachments, the one cross-app bell. *Two halves:* the
   **front-end pack `caddie-ui`** — built, canonical at
   `/srv/apps/caddie/packs/caddie-ui/`, stamped in by the create runbook (§2)
   and worn by every app (§3a); and the **backend kit `bw_interaction`** —
   not yet vendorable; its reference is caddie's
   `main/app/services/interaction.py` (complete for 04 §3–4: threads on any
   subject, the discussion read, mentions, resolve / edit / soft-delete /
   move, the inbox). Until it ships as a drop-in, copy that module's shape
   with your own subjects and plan to swap it in.

Caddie itself is the hub — framework, roadmap, running commentary. Your app
owns its domain entirely; caddie never holds data your app would miss.

## 2. Create the app (owner session, each step idempotent)

```bash
srv-gw create-project --name <name> --type custom --domain <name>.bowden.works
srv-gw fix-permissions --project <name>        # settles a create-time race
/srv/projects/standards/new-workspace.sh <name>
/srv/system/id-auth/app-auth/new-bw-app.sh --name <name> --host <name>.bowden.works --instance-noun <noun>
/srv/apps/caddie/packs/stamp-caddie-ui.sh /srv/apps/<name>      # the shared chrome (§3a)
srv-gw app-client-register --name <name> \
  --redirect-uri https://<name>.bowden.works/auth/callback \
  --secret-out /srv/apps/<name>/.bw-auth.env
srv-gw deploy --project <name> --build
/srv/system/id-auth/app-auth/new-bw-app.sh --dir /srv/apps/<name> --check
/srv/apps/caddie/packs/stamp-caddie-ui.sh /srv/apps/<name> --check   # byte-identical, or it fails
srv-gw security-audit
```

- **Name the instance noun** to match rian's wording (the scaffolder defaults
  instances OFF unless you pass one). The noun is what clients get granted on.
- A Pattern B host is **never gated**; hide pre-launch with
  `srv-gw id-site-set --host <host> --lan-only true` if needed.

## 3. Stack requirements (non-negotiable, from the standards + caddie plans)

- React SPA + FastAPI, one Python process; Node build-stage only.
- **Postgres from day one** (compose sidecar, internal network, NO published
  port) + SQLAlchemy 2.0 + Alembic. Move the auth kit's store into the same
  Postgres via **`bw_store_sqlalchemy` managed mode — the mechanism is
  PACKS.md** (`/srv/system/id-auth/app-auth/PACKS.md`). Scout is precedent
  for kit-on-Postgres but predates the module; never copy its custom adapter.
- Sync `def` for CRUD routes; TS client generated from OpenAPI in the image
  build; TanStack Query; vendored primitives; phone-width floor; version +
  newest-first CHANGELOG surfaced at `/api/meta`.
- Domain data conventions: `client_party_id uuid NULL` for the client link
  (`with` SATELLITE-CONTRACT: store the party id, cache names only as hints,
  never store a role); append-only event tables for state changes; UI
  consumes server-decided capability booleans, never level names.

## 3a. The shell — every caddie app wears it

Rules, each with the failure that taught it, graduated here 2026-09-02 from
the first tools' client use. The pieces are the `caddie-ui` pack's (04 §6);
an app mounts them and never re-implements them.

- **Two rows.** Row 1 is GLOBAL and identical in every app — brand · locator
  ("where am I") · the bell · the account menu. When caddie launches the app,
  the host bar's "Part of {project} on Caddie · ← Back" IS the locator's
  content (02 §6). Row 2 is the APP's — its own functions for the thing on
  screen. **App functions never enter the global row**; the account chrome
  never leaves it (one build moved the bell into a toolbar to save 40px, and
  lost the one edge every app shares).
- **The app row holds five things, at most:** the navigator (where am I, what
  else is there) · a quiet state mark · the ONE primary act · the ONE panel
  toggle (with its count) · a Team menu for the team's tools. Everything else
  is not a button. Seven controls became four on the first tool and nothing
  was lost — the removed ones were scaffolding for the builder, not acts for
  the reader.
- **One side panel, content by state; it pushes the work aside, never covers
  it; it pins to the global row's MEASURED bottom** (`--cu-shell-bottom`) —
  a strip above the row (a dev banner, the host bar) once hid the panel's
  close and back controls, and nobody on the team could find the exit while
  the client, with no strip, could. Anything measured is re-measured when the
  layout moves, not only on scroll and resize. Phones keep the full-width
  overlay.
- **The conversation rules of 04 §6**: the list stays a list (Reply opens
  inline; the card goes to the subject); starters for silent subjects; labels
  are breadcrumbs; a picture opens in the lightbox; the highlight is a box
  plus a dark, blurred surround with the box cut out, and it jumps (no
  transition); Escape crosses a sandboxed frame; the panel being open IS the
  mode — never a comment/play toggle.
- **The page speaks to the person.** "Hi Dana — one thing needs you." The
  name comes from the directory (caddie: `/api/whoami` carries `first_name`
  and `display_name`; derive from the username when the directory has
  nothing). The kit's `/api/bw/me` carries no name — a logged kit ask.
- **Deep links carry state, and a mounted page re-arms on the change.**
  `?stage=`, `#c-`, `?step=`, `?panel=`, `?tour=` — every one is a link
  someone can paste, and a bell row can change only the query and hash of a
  page that is already on screen. Read the location on every change, not
  once at mount; consume each link once, so a later render never yanks the
  page back. A landing settles out of a wash, never a flash.
- **The client reads BOTH error shapes.** Routes answer `{detail:
  {error_code, summary, details}}`; the kit's middleware answers a top-level
  `{error_code, summary, details}` (401 signed out, 403 read-only View As).
  One `apiError()` reads both and shows `details` — the person who pasted a
  screenshot during a View As got "Upload failed (403)" instead of the
  sentence that was sent. Every scaffolded app has this gap until the kit's
  own client is fixed (a logged kit ask).
- **No popup gate.** A first visit opens the introduction by itself, once,
  with Skip one press away; the introduction IS a walkthrough in the panel,
  with the part of the page each point is about lit up — no coach-mark
  bubbles. It starts and closes on the project page (02 §6, D20).

## 3b. Engineering practices

- **The deploy gate is `tsc`, the full backend suite, and the pack check, from
  a clean tree.** Bump the version and add the CHANGELOG line per deploy;
  `/api/meta` proves the deploy, the loaded asset hash proves the UI.
- **A background tab freezes rAF, smooth scroll and CSS transitions.** A live
  check driven from a hidden tab must not trust any measurement that depends
  on those having run — measure DOM state, or remove the animation. Three
  "bugs" in one build were the tab, not the code; two of the three led to
  code that is better without the animation anyway.
- **Two sessions, one working tree: commit by explicit path.** `git add -A`
  from either side sweeps the other's files (a migration and a service once
  rode in someone else's commit).
- **A null guard is not optional in a shared component.** A blank page shipped
  for four minutes because a lookup ran with no subject selected; the live
  check is part of the deploy, not an afterthought.
- **Content lives in the tool's own tracker, not in code.** Stage prose,
  blurbs, walkthrough points, tour sentences — data the team edits in place.
  A one-off seed lives in a session scratchpad, not the repo.

## 4. Conformance bar (what "caddie-ready" means)

The tool contract's checklist (`02` §10) plus: empty instance reports
`not_started`; first status push after a client action is synchronous;
participants diff drops grants; the host bar mounts and survives ctx expiry;
every client-reachable page works at phone width. And the shell (§3a): the
pack stamped and `--check` green; the bell is the pack's; every subject of a
page listed in its Discussion; labels are breadcrumbs; both error shapes
read.

## 5. Learnings from the first build (punchlist, 2026-08 — append as hit)

- `create-project`'s initial auto-deploy can fail on a permission race
  (`open .env: permission denied`); harmless — run
  `srv-gw fix-permissions` and continue (the step is in §2 now).
- `app-client-register` on a fresh `*.bowden.works` host prints a
  "was domain-gated, now PUBLIC" warning — expected when only the placeholder
  nginx was there; confirm nothing real was exposed and move on.
- **First deploy fails with `open .app.env: permission denied`** when
  fix-permissions ran before the scaffolder: `new-bw-app.sh` writes `.app.env`
  0600/rian, but the gateway needs group read. Fix:
  `chgrp <name>-dev .app.env && chmod 660 .app.env`, then deploy. (Candidate
  scaffolder fix: write 0660 group-owned from the start — logged in caddie's
  v8-feedback for upstreaming.)
- **The conformance pack pulls app fixtures with `from tests.app_conftest
  import *` — and star-import SKIPS underscore-prefixed names.** An autouse
  fixture named `_fresh_domain_db` silently never registers; the suite still
  mostly passes (tests use unique usernames) and only leaks where names repeat
  (view-as's `bob`). Add `__all__ = ["_your_fixture"]` to `app_conftest.py`.
  Every app that swaps the kit store to SQLAlchemy needs such a fixture: wipe
  the SQLAlchemy DB per test — `engine.dispose()` FIRST (a pooled SQLite
  connection otherwise keeps writing into the unlinked inode), unlink, re-run
  `init_accounts_kit()`, then `Base.metadata.create_all`.
- **The managed-store swap itself is genuinely one call** (accounts.py:
  `bwstore.managed(Base.metadata, get_session_factory())` into `bwa.init`),
  plus: vendor `bw_store_sqlalchemy.py`, add `database_url` to config (Postgres
  from `.app.env` in prod, SQLite-in-data-dir default so tests need no DB
  container), and the app_conftest fixture above.
- **SQLite tests cannot see Postgres operator gaps — exercise the deployed pg
  path for anything DB-dialect-specific.** Punchlist's immutability trigger
  used `IS DISTINCT FROM` on a `json` column: green everywhere in tests,
  errored on EVERY update in prod (pg's `json` has no equality operator; cast
  `::jsonb` to compare). One live `UPDATE` via `docker exec psql` caught it in
  seconds.
- **Nullable JSON columns need `JSON(none_as_null=True)`** or SQLAlchemy
  stores the STRING `'null'` instead of SQL NULL — which breaks any
  `IS NULL` CHECK (punchlist's library-XOR-inline constraint tripped on the
  first insert). The tests caught this one.
- **Schema creation split:** `init_accounts_kit` runs `Base.metadata.create_all`
  ONLY on the sqlite dialect (tests/local); Postgres is Alembic-only, so a
  missing migration fails loudly instead of being masked at startup.
- **Real silent-SSO data point (caddie plan risk R1):** on a desktop Chrome
  holding a LIVE master IdP session, the silent probe still bounced to the
  sign-in screen (most likely a `bw_optout` cookie from a past logout in that
  browser) — but clicking "Sign in with Bowden Works" completed with ZERO
  typing. So the realistic worst case for an SSO'd user is one extra click,
  not a password. Design launch UX assuming the probe CAN bounce; measure on
  phones before promising invisibility.
- **Field-validation errors reach clients verbatim — never surface a raw
  regex.** Give pattern-checked fields a human `hint` ("it usually looks like
  123-456-7890"). Found by driving the live UI, not by tests.
- **Converting a real client email into tool workflows works, and is the best
  spec-stress-test there is.** A 16-ask onboarding email became 14 workflows +
  one set in a single AI-builder session, with zero engine changes — but it
  exposed exactly four product gaps (file upload, list sections/optional
  markers, info-only preamble content, dense per-target rows). Lesson for any
  caddie app: feed it a real artifact from the process it replaces BEFORE
  polishing the builder surface; the gaps it exposes are the roadmap.
- **(easel, 2026-09) Scaffolder re-runs still demand `--name`.** After first
  run bw-app.toml is authoritative and `--dir` alone SHOULD suffice, but the
  script dies with `need --name` before reaching the toml. Re-run form that
  works: `new-bw-app.sh --name <app> --host <host> --dir /srv/apps/<app>`.
  Candidate fix: resolve NAME from the toml when --dir is given.
- **(easel) Seed manager-side members with `all_instances=True`.** Kit
  semantics: a member with an admin-carrying level but no per-instance grant
  and all_instances=false has NO effective level on ANY instance — the manager
  side silently sees zero projects. Every app whose "staff work across all
  instances" must set the flag when seeding/migrating members.
- **(easel) The conformance pack hard-asserts the level NAMES `admin` and
  `member` exist** (test_levels.py). Don't rename levels to friendlier product
  words — keep the kit defaults and treat product roles (manager/client/...)
  as SEMANTICS carried by permissions; friendlier display naming is a kit
  feature request, not an app-side rename.
- **(easel) FastAPI ≥0.141 wraps included routers lazily** (`_IncludedRouter`
  in `app.routes`) — `len(app.routes)` stays small and paths show as None.
  Registration is fine; don't diagnose "my routers didn't mount" from it. The
  OpenAPI schema (and the conformance suite passing) is the real signal.
- **(easel) Serving user-authored scripted HTML: the reusable isolation
  recipe.** (a) `Content-Security-Policy: sandbox allow-scripts` ON THE
  RESPONSE — the iframe `sandbox` attribute alone has a direct-navigation
  bypass (open-frame-in-new-tab runs the file same-origin with the viewer's
  session); the header gives an opaque origin however the file is reached.
  (b) Auth via a short-lived signed token EMBEDDED IN THE PATH
  (`/m/{token}/{rel_path}`) so relative subresources inherit it — cookies
  into opaque-origin subresource fetches are browser-murky, tokens are not.
  (c) postMessage with the opaque-origin idiom: parent checks `event.source`
  + a per-token nonce (origin is literally "null"); inbound-to-frame checks
  the app origin; outbound-to-frame targetOrigin must be "*". Adding ANY
  sandbox token to "fix" content is the failure mode to forbid in review.
- **(easel) Interaction Standard fit datapoint:** many-threads-per-subject
  (pins at x/y on a mockup) mapped onto the §4 shapes with ZERO changes —
  one thread per pin (`subject_type="pin"`, synthetic subject_id), anchor
  data on an app-owned table beside the standard tables. The
  UNIQUE(subject_type, subject_id) constraint held. No divergence needed.
- **(easel, 2026-09) Three bugs a screenshot caught that green tests did not** —
  all in the "render someone else's HTML" family, all worth stealing:
  (a) **Seeding only the entry HTML.** Concept pages reference `img/...`
  relatively; storing index.html alone leaves every image 404. A bundle is a
  DIRECTORY — offer `webkitdirectory` upload (files arrive with
  `webkitRelativePath`) and, when seeding programmatically, walk the page's
  own references. Test it by asserting a subresource serves under the SAME
  token prefix, not just that the page 200s.
  (b) **Percentage rects are a trap for guided-tour targeting.** A rect of
  "7% tall" on a 4,600px page is a 320px band, so a hand-authored spotlight
  lands nowhere near its subject. Target a CSS SELECTOR and have the injected
  bridge measure the live element (returning document percentages, which are
  scale-invariant); keep drag-authored rects only as the fallback.
  And resolve the **first VISIBLE match, not the first match** — walking
  hidden ancestors too: hover mega-menus routinely hold laid-out duplicates
  of the very element you meant (`.rcall` matched six hidden copies of a
  phone number before `.phone` matched the visible one).
  (c) **Desktop concepts carry a min-width** (these were 1380px). Rendering
  the frame at the stage's width silently CLIPS the right edge. Render at the
  mockup's natural width and `transform: scale()` it to fit — the same
  mechanism is what makes a desktop concept legible at phone width.
- **(easel) A `useEffect` with `[]` deps cannot attach a ResizeObserver to a
  node the component has not rendered yet.** With a loading placeholder in
  front of the real UI, the effect fires against a null ref and never re-runs
  — the observer silently never attaches. Use a callback ref. (Also: widen
  the ref to `useRef<T | null>(null)` or TS calls `.current` read-only.)
- **(easel, 2026-09-02 — graduated.)** The shell and engineering rules from
  the easel build's alignment pass (its shared-UI-pack proposal and its
  fourteen-version standards review) now live above as §3a and §3b, in 04
  §4.1/§6, in 02 §2/§5/§6, and as decisions D16–D20 in 01. The verdicts on
  each proposal, and what was declined or deferred, are in
  `07-easel-coordination.md`.
