Start here — the whole shared inventory, in one place.
Every @broberg/* package by category, and every hard-won tip we've captured. Reuse > re-roll — skim this before you wire anything, and enroll when you adopt.
Packages by category
@broberg/themev0.7.0The design-token foundation every surface inherits
@broberg/stack-b-baseplannedA ready-to-run base scaffold for Stack B apps (Bun · Hono · Preact · Tailwind v4) so a new lightweight service boots with the house wiring already …
@broberg/stack-a-baseplannedThe Stack A counterpart
@broberg/configv0.2.0The fleet's single-source config helper
@broberg/mailv0.14.0The fleet's thin Resend send primitive, plus a status lookup that answers whether a mail actually arrived
@broberg/mediav0.2.1The fleet's provider-agnostic media-storage facade
@broberg/mail-corev0.8.1DELIVERED by @broberg/mail-core (F023.1)
@broberg/media-transformv0.1.0The fleet's server-side image-transform primitive
@broberg/cronv0.2.0The fleet's typed self-service client for cronjobs.webhouse.net
@broberg/chatv0.6.3The fleet's AI-chat engine: a conversation loop with a tool registry, streaming typed frames, knowledge from Trail, and history management
@broberg/webpushv0.5.0The fleet's storage-agnostic Web Push primitive
@broberg/mcpv0.5.0The fleet's genuinely-reusable (NOT slim) MCP-server toolkit
@broberg/secret-scanv0.9.3Pure, dependency-free secret/credential redaction
@broberg/lensv0.1.3A headless POST /api/lens-session mint endpoint (+ thin Next.js / Hono adapters) that issues a short-lived, read-only Playwright session so Cardmem…
@broberg/authv0.6.2Email+password, magic-link, six social providers, passkeys and 2FA by authenticator app
@broberg/apikeyv0.3.1The fleet's inbound API-key primitives
@broberg/event-logv0.1.0A GDPR-aware event and activity log
@broberg/gravatarv0.2.1Isomorphic Gravatar helper (SHA-256 via crypto.subtle) + initials fallback
@broberg/device-statsv0.3.0Which devices your users are actually on: desktop web vs mobile web vs INSTALLED PWA, OS + browser major version, and screen bucket, derived server…
@broberg/consent-cookiev0.1.0A consent and cookie banner
@broberg/themev0.7.0DELIVERED by @broberg/theme (0.3.1)
@broberg/ui-controls-corev0.2.3The custom-control kit
@broberg/cmdkv0.1.0A Cmd+K command palette
@broberg/i18nv0.1.0i18n and a language switch
@broberg/seoplannedSEO and metadata helpers for Stack A
@broberg/mail-corev0.8.1Branded HTML email shell whose brand values are validated and whose fill() escapes
@broberg/speech-dictionaryv0.1.3Fleet STT vocabulary + correction primitive
@broberg/smsv0.12.0One SMS send primitive for the fleet
@broberg/notificationsv0.4.0The fleet's in-app notification list core: ONE counting rule shared by every app, so the list, the bell and the OS badge cannot disagree
@broberg/notifyv0.1.0Dark-ship team-chat webhook notifications
@broberg/lens-enginev0.10.0The shared Playwright capture + flow engine for the cardmem-lens fleet
@broberg/lens-clientv0.1.0Thin, typed client for the HOSTED Lens (lens.cardmem.com)
@broberg/bodymapv0.12.0Interactive body pain-map
@broberg/stripev0.4.1The fleet's one Stripe chokepoint, and the one place that knows where Stripe MOVED a field to (invoice.subscription is gone, so is the period)
@broberg/pwav0.4.0The fleet's PWA primitive
@broberg/forms-turnstilev0.3.0A spam-protected public-form pipeline
@broberg/loggerv0.2.3Structured server logging for the fleet
@broberg/httpv0.1.0Framework-free, zero-dep HTTP header/response primitives for Node/Bun/edge
@broberg/greppablev0.3.0A cc-session grep is NOT /usr/bin/grep
@broberg/seti-clientv0.4.0The SETI streaming-chat client
@broberg/seti-serverv0.2.5The SETI proxy router
@broberg/soundkitv0.1.0SoundKit
@broberg/deploy-corev0.3.2The fleet's shared deploy execution layer
@broberg/changelogplannedAuto product-changelog from git history
@broberg/email-shellplannedNOT A PACKAGE YET
@broberg/db-sdkv0.1.0The fleet Data SDK
@broberg/ai-sdkv0.47.0The fleet LLM SDK
@upmetrics/sdkv0.4.1The fleet telemetry SDK
upmetrics-swiftv0.1.0Native iOS/macOS error + crash reporting
@broberg/fleet-clientv0.1.0The typed fleet-comms client
@broberg/fleet-contractsv0.1.0The fleet-comms contracts
@broberg/complimenta-sdkv0.3.0Typed client SDK for the Complimenta booking API
@broberg/cms-inline-editv0.12.0Inline editing on live @webhouse/cms-sites including structural LIST editing (add/remove items), plus self-healing page links
@broberg/cms-chat-clientv0.4.20Shared quick-action cache-client for the CMS chat surface
Shipped, but not an npm package (3)
Trail — second-brain / RAGnot on npmThe fleet's shared long-term memory: one session saves its reasoning as Neurons and any other session finds it again with RAG
L3 · owner: trail — you call it, nothing to install
voice-engine — speech, voices & who-spoke-whennot on npmDanish speech-to-text, who-spoke-when and speaker identification on audio that never leaves a machine we control
L3 · owner: voice-engine — you call it, nothing to install
Podcast engine (article to two-host audio)not on npmA podcast engine any site drives over HTTP: article to two-host script to human approval to audio, the script doubling as the subtitles
SDK · owner: cms — you call it, nothing to install
Status not recorded (14)
User management + invitationstatus unknownUser management and invitation flows
L1 · owner: cms — ASK THEM whether it is usable; we have not recorded one
Profile + image uploadstatus unknownProfile editing and image upload
L1 · owner: xrt81 — ASK THEM whether it is usable; we have not recorded one
Settings — tabbed config shellstatus unknownA tabbed settings shell
L2 · owner: cms — ASK THEM whether it is usable; we have not recorded one
PWA setupstatus unknownL2 · owner: xrt81 — ASK THEM whether it is usable; we have not recorded one
PWA update bannerstatus unknownL2 · owner: cardmem — ASK THEM whether it is usable; we have not recorded one
User menu (account dropdown)status unknownThe account dropdown in the top bar
L2 · owner: cms + xrt81 — ASK THEM whether it is usable; we have not recorded one
Chat / chatbot UIstatus unknownL3 · owner: cms — ASK THEM whether it is usable; we have not recorded one
Contract Manager — draft + sign customer contractsstatus unknownDraft a customer contract from a template for a human to send
L3 · owner: contract-manager — ASK THEM whether it is usable; we have not recorded one
Beacon — Hue signal lights (local)status unknownPhysical signal lights + Philips Hue control for the fleet: raise an alarm and the house shows it
L3 · owner: beacon — ASK THEM whether it is usable; we have not recorded one
Podcast manager / makerstatus unknownL3 · owner: cms — ASK THEM whether it is usable; we have not recorded one
Multi-tenant managementstatus unknownL4 · owner: cms — ASK THEM whether it is usable; we have not recorded one
Native mobile boilerplatestatus unknownL4 · owner: cms — ASK THEM whether it is usable; we have not recorded one
Greenfield scaffolderstatus unknownL4 · owner: cardmem — ASK THEM whether it is usable; we have not recorded one
create-app CLI + manifeststatus unknowncreate-app CLI plus a machine-readable manifest
L4 · owner: cms — ASK THEM whether it is usable; we have not recorded one
Planned — not built yet (1)
Deployment Mgmt (observe)not built yetThe observe half of deployment management
L3 · owner: upmetrics — ask them before building a second one
Tips & tricks — 128 across 15 platforms
Fly.io26
- build-stamphealth.build — stamp the running commit on your health endpoint, and say how much to trust it. Convention (beacon, measured on their own service): GET /v1/health returns build:{ commit, dirty, source } where source is env (a deploy declared its sha, e.g. BUILD env var — declared, not observed) then git (the process runs from a work tree and git was asked — observed) then unknown (commit MUST be null). Five decisions, each one a trap somebody already hit: (1) read it ONCE at startup, never per request — the question is what the process was started from, and a git lookup per request answers about the tree as it is NOW, i.e. code the process never loaded; (2) UNTRACKED files count as dirty — the process runs what is on disk, and an uncommitted file is still one it could load; (3) never invent a sha — null plus source unknown, because a sha that looks like an answer is worse than no answer; (4) dirty is not decoration, it says how far to trust the sha: a true sha on a dirty tree describes code that is not running; (5) for the CONSUMER of the field — when comparing two sides (a page against the service that serves it), a dirty on EITHER side must make the comparison SILENT. A bundle is stamped when it is built, i.e. before the commit that contains it, so during ordinary work the shas disagree all day; warn on that and the warning is ignored within a week. Two CLEAN builds that disagree is the thing worth flagging. And say what a difference means: beacons service ran 732e4241 while HEAD was 637bae6, a real 48-hour drift — but that commit only touched CLAUDE.md, so the drift was harmless. A field that reports difference without significance becomes why is my service always red.— beacon
- regionRegion is ALWAYS arn (Stockholm). Set primary_region = "arn" — never US/Amsterdam.— components
- costIdle-cheap services: auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0. Cold-start ~1s.— components
- deployfly deploy --remote-only builds on Fly's builder — no local Docker daemon needed.— components
- secretsSecrets via flyctl secrets set KEY=val (encrypted, injected at runtime). Never bake into the image or commit.— components
- secrets-visibilityNEVER derive "is X configured?" from flyctl secrets list — ASK THE PROCESS, not the configuration. The list answers "what did someone set, that I can see", which is a different claim from "what is in this process's environment". Measured by sanne 2026-09-11 on a live app: three values side by side in the running process env, two listed by secrets list and SA_RESEND_API_KEY not — with fly.toml [env], Dockerfile ENV/ARG, the machine's config.env and the entrypoint all ruled out FIRST, so it arrives by Fly's own secret injection and the list omits it. Cause still open: a flyctl gap, or a secret set by a DIFFERENT principal and therefore invisible to yours (org-owned app, a separate admin UI that can also deploy). The rule holds either way — both produce the same confident FALSE NEGATIVE, and they were two steps from reporting a customer-mail outage that did not exist.— sanne
- tlsCustom domain: fly certs add <domain> first; the cert validates by itself once the DNS record resolves.— components
- opsDebug live: fly logs -a <app>, fly ssh console -a <app>, fly status. Health check on /health in [[http_service.checks]].— components
- ssh-not-shellflyctl ssh console -C does NOT parse as a shell — argv is split, so &&, ;, |, >, * become LITERAL args to the binary. One -C wiped all of /data once (rm got four path-args). One command per -C; for more, sftp a script then -C 'bash /tmp/x.sh'.— trail
- destructive-opsBefore any rm -rf on a prod volume: snapshot first (flyctl volumes snapshots create <vol>) — auto-snapshots are only 5-day retention. Prefer find <path> -maxdepth 1 -name X -exec rm -rf {} + so a metachar can't widen the blast radius.— trail
- auth-warm-machineStateful auth-apps need min_machines_running = 1 — autostop cold-starts lose in-flight WAL writes and drift OAuth state-cookies across instances (sessions drop mid-flight). ~$2-5/mo kills the bug class.— trail
- sizingA cc-session in a Fly container needs >=2gb RAM — less and the OOM-killer hits it under prompt-load. Use machine-managed launch for long-running edge agents.— buddy
- deploySingle-machine app: deploy with --ha=false, else Fly spins a 2nd machine you didn't ask for. The 'not listening on expected address' smoke warning is transient — 'reached good state' + 'DNS verified' are what count.— upmetrics
- statefulFilesystem-stateful app (one volume) must NEVER fly scale count >1 / run multiple machines on the same volume — each gets its own copy then silent data divergence. Stay single-machine until state moves to a shared DB (Turso).— cms
- sqliteSQLite + Litestream = ONE writer. Single volume + --ha=false; never multiple machines against the same volume (corruption).— upmetrics
- permissionsflyctl ssh writes as root, so files become root-owned and a non-root runtime user (e.g. uid 1001) gets EACCES writing them. Don't write runtime-writable paths via SSH; use the app's HTTP API, or chown -R in the same session (or at boot via gosu in the entrypoint).— cms
- deploy-resilienceCI builder down (depot timeout)? Build arm64 locally then a Dockerfile.prebuilt that COPYs the prebuilt dist (skips vite-under-qemu) + flyctl deploy --local-only. Rescues prod when CD is red.— cardmem
- spa-cacheSPA shell (index.html) MUST be Cache-Control: no-cache, else a stale index serves the old bundle after deploy. Verify on bundle-hash/content-marker, never curl-200.— cardmem
- ssh-secretsRun one-off in-container scripts without leaking secrets: base64-encode a small script and -C 'sh -c ...base64 -d > /tmp/x.js && bun /tmp/x.js; rm /tmp/x.js'. Secrets stay in the container; only the result comes out.— upmetrics
- docker-diskRepeated local Docker builds (e.g. fly deploy --local-only during a CI outage) fill the Docker VM's disk via build-cache → 'No space left on device' mid-build. Fix: docker builder prune -f && docker image prune -f (frees only unused; ~12GB back). Better: a prune step BEFORE each bypass-deploy, or bump Docker Desktop's disk allocation.— cardmem
- deploy-resilienceThe CI-outage deploy bypass (Dockerfile.prebuilt + a fly.toml dockerfile-override + .dockerignore negation) is TEMPORARY — NEVER commit it, it breaks normal CD. Revert to depot / normal CD the moment the builder is back.— cardmem
- warm-for-queriedmin_machines_running=1 ALSO for a service the fleet QUERIES or health-probes (e.g. discovery.broberg.ai), not just stateful auth-apps: with min=0 the first request after autostop scales-to-zero eats the cold-start or times out at the edge → a user reads it as DOWN. ~$2/mo keeps one warm. (broberg-discovery hit exactly this.)— components
- secrets-flipEmergency flag-flip without a rebuild: flyctl secrets set KEY=val applies via a ~30s machine restart (overrides image-baked ENV). Good to unstick prod fast — then fix the durable source (fly.toml [env]) so a later deploy doesn't revert it.— cardmem
- chromium-on-flyHeavy SYNCHRONOUS CPU work in an API route on shared-cpu-1x/1GB (Chromium screenshot→PDF: large PNG frames + pdf-lib.embedPng) can take down the WHOLE machine — it blocks the single core's event loop long enough that Fly's port health-check fails → the proxy 503s ALL traffic ('could not find a good candidate within 40 attempts') and the Chromium child is OOM-reaped. Fix (all three): (1) deviceScaleFactor:1 not 2 — ~4× lighter frames + far faster embed, fidelity fine for PDF (biggest single win); (2) serialize jobs via a module-level promise-chain so only ONE render runs at a time (no parallel Chromium); (3) cache the output on the PERSISTENT volume, mtime-invalidated (regenerate only if a source file is newer) — repeated calls went 52s → 0.3s. Verify the machine STAYS UP by polling a cheap endpoint (/login) every 6s DURING generation — prove it stays 200, not just that the PDF came back. (An app's OWN runtime render legitimately uses playwright-core; the no-raw-Playwright rule — cardmem Lens F112 — governs VERIFICATION automation, not a shipped render feature.)— pitch-vault
- remote-builder-esbuildFly's REMOTE builder can't run esbuild — the esbuild service dies mid-build (EPIPE / 'service was stopped') under BOTH bun and node, so a Vite/esbuild bundle step INSIDE `flyctl deploy --remote-only` fails. Fix: build the Vite/esbuild dist on the host or in CI FIRST, then COPY the prebuilt dist into the image (the Dockerfile does no esbuild) — let the remote builder only assemble the image, not bundle.— happy-little-place
- server-deploy-workflowServer-app Fly deploy (Bun/Hono + custom Docker) → reuse the fleet-shared reusable workflow instead of hand-rolling a per-repo deploy job: `uses: broberg-ai/components/.github/workflows/fly-server-deploy.yml@main` (workflow_call). It does the DEPLOY half — optional host-build (the esbuild-on-host workaround) → flyctl deploy --remote-only → health-verify (polls your health_url for 200, fails if never) → optional required-secrets presence check. Ship-dark: no FLY_API_TOKEN → deploy skipped, job stays green. The CALLER owns the test gate (`deploy: needs: test`). F033.8 — every deploy is now built with `--build-arg GIT_SHA=<7-char sha>` so your app can tell a deploy-register WHICH version it is running. But a build-arg is BUILD-time only: add `ARG GIT_SHA=unknown` + `ENV GIT_SHA=$GIT_SHA` to YOUR Dockerfile, or the container boots with nothing and reports ‘unknown’ — which a register accepts, writes ONCE, and (being idempotent on deploy_id) can then never correct: a wrong row that reads as a healthy report. Measured 2026-08-19: a Dockerfile without those two lines still builds green when the arg is passed (exit 0), so the passthrough is unconditional and breaks nobody. See components F033.7 + F033.8.— components
Cloudflare13
- dnsCNAME → a Fly app MUST be DNS-only (grey cloud), not proxied (orange) — else Fly's TLS cert validation fails.— components
- turnstileTurnstile site-key from a runtime config endpoint (not a build-time env) → rotate keys without rebuild/redeploy.— xrt81
- turnstileTurnstile sites are domain-scoped — each project needs its own site (keys aren't reusable across domains).— xrt81
- storageObject storage = R2; consume via @broberg/media (provider-agnostic facade, R2 provider) rather than rolling raw S3 calls.— components
- dnsPrefer CNAME over A/AAAA when pointing at Fly — survives Fly IP changes, no hardcoded IPs. TTL auto (Cloudflare-managed).— buddy
- gdprR2 endpoint MUST be the .eu. host (https://<acct>.eu.r2.cloudflarestorage.com) for EU residency — without .eu. you get US. Presigned GET (no public bucket); multi-tenant via key-prefix.— cardmem
- tokensAn app-scoped CF_API_TOKEN (Pages/DNS/Turnstile) does NOT carry R2 Storage:Edit or User API Tokens:Edit — R2 needs a separately-scoped token.— cms
- tlsIncomplete TLS cert chain (wrong/missing intermediate) makes Node/Bun strict TLS fail 'unable to verify the first certificate' while curl/browsers tolerate it. Symptom: works in curl, fails in a server-runtime fetch.— fdaa
- cert-orderingCustom-domain cert ordering (GitHub Pages et al.): set DNS FIRST, wait ~30s to propagate, THEN attach the custom domain — the platform runs its DNS check at attach-time and queues the cert immediately; reverse order parks the request 25+ min.— cms
- dns-verifyA local dig/curl returning NXDOMAIN can be a STALE macOS mDNSResponder negative-cache (shared by every local session), not a real missing record. Verify against a public resolver — dig @1.1.1.1 <host> / curl --resolve — before calling a domain dead. (This nearly stalled a 15-repo rollout on a false alarm.)— cardmem
- r2-provisioningNeed an R2 bucket? Provision it 100% programmatically (NO dashboard) via dns-mcp's R2Client / MCP tools: r2_list_buckets · r2_create_bucket · r2_create_scoped_token. Creates an EU-jurisdiction bucket + scoped S3 creds (access_key_id / secret / endpoint). EU jurisdiction is set AT creation and is IMMUTABLE → endpoint https://<acct>.eu.r2.cloudflarestorage.com. Proven live (bucket vnleker + read_write creds, S3-list 200).— buddy
- r2-tokenR2 provisioning needs a token scoped Workers R2 Storage + User API Tokens Write (dns-mcp's CF_BOOTSTRAP_TOKEN, separate from CF_API_TOKEN). The ordinary DNS/zone-scoped CF_API_TOKEN CANNOT do R2 — you get an auth error. Don't waste time debugging the wrong token.— buddy
- r2-creds-secrecyRaw S3 creds from a scoped-token mint (access_key_id / secret / endpoint) go straight into the consumer's gitignored .env — NEVER over intercom or any chat surface. Treat them like any other secret.— buddy
Resend12
- email-htmlEmail HTML: use a hosted PNG for the logo rather than a data-URI. VERIFIED (xrt81, 2026-08-10): the hosted PNG renders, and it is the safer default whatever the cause — it works everywhere, it caches, and it can be swapped without resending the mail. REPORTED BUT NOT MEASURED: that Apple Mail blocks data:image/* when preview-mode is off. That claim originates in a sanneandersen code comment (site/src/lib/auth/email-templates.ts) and nobody in the fleet has yet sent a data-URI mail to a real Apple Mail account and watched it fail — treat it as a reason to prefer the hosted PNG, not as an established fact. Separately VERIFIED and unambiguous: mail clients do not resolve CSS variables, so a shared email template must take literal hex colours as ARGUMENTS and cannot consume @broberg/theme tokens — which is why a shared email-shell would ship the slot contract and the table skeleton but never the palette.— xrt81 (data-URI claim: sanneandersen)
- reuseUse @broberg/mail — don't hand-roll a Resend client. createMailer({apiKey, from, allowlist}) keeps your own env-var names.— components
- inboundINBOUND MAIL IS TWO DIFFERENT CAPABILITIES AND THEY SHARE ONE NAME. Searching Discovery for "receive mail" finds @broberg/mail (SEND only) and @broberg/mail-core (templates); the only inbound thing is Resend's status webhook ON MAIL WE SENT. A session that then reads CLAUDE.md finds cardmem's shared Gmail reader and concludes the question is answered — right for FLEET-INTERNAL mail, wrong for a product receiving a CUSTOMER's mail, and nothing said they were different. (1) FLEET-INTERNAL inbound: cardmem's shared Gmail reader, configured per project in Settings → Mail (sender or keyword rule → that project's Inbox). No repo runs its own Gmail client. (2) PRODUCT inbound — a customer writing to a tenant address: THERE IS NO SHARED PRIMITIVE YET, and Resend Inbound is NOT a candidate on present evidence. MEASURED by helpdesk 2026-09-10 on our own account: GET /emails/inbound answers 200 with an empty list envelope, and GET /emails/<not-a-uuid> answers 422 "The `id` must be a valid UUID" — the control is what makes the 200 readable, because without it `inbound` could have been parsed as an id. So the ROUTE exists. But Resend's own documentation names no inbound product anywhere (quickstarts, Dashboard, transactional and marketing all read), and an empty list cannot answer the three things that decide it: the MX value, the REGION, and what happens when our endpoint is down. Building a product's mailbox on an undocumented endpoint — as the fleet's first user, with PATIENT data, and no documented region — is not a decision an empty 200 supports. It needs an answer from Resend, not from their API surface. helpdesk is first and hit exactly this edge: the fleet reader serves OUR mailboxes and cannot serve a tenant's support address, because we do not own the customer's Workspace.— buddy
- inboundAN MX RECORD REPLACES THE RECIPIENT — IT DOES NOT SIT ALONGSIDE ONE. Point a customer's APEX domain at an inbound-mail provider and their existing inbox stops receiving, with no error anywhere: not a bounce they see, not a log we see. helpdesk was two steps from ordering exactly that on a medical clinic whose staff mail lives on the apex. So: NEVER a customer's apex — always a dedicated subdomain (support.<kunde>.dk), which is also where SPF and DKIM for that flow belong. This is the only line in this note that prevents something irreversible; the rest is routing.— buddy
- domainsSend only From a VERIFIED domain (Resend → Domains). An unverified From fails or tanks deliverability.— components
- safetyDev/preview: keep MAIL_LIVE off + an allowlist so test mail never reaches real users (fleet admins always pass).— components
- mail-live-prod-authPROD load-bearing trap (@broberg/mail 0.3.0+ defaults NOT-live): set RESEND_API_KEY but FORGET MAIL_LIVE=true → every send to a non-admin recipient is SILENTLY skipped, returning {ok:true,skipped:true} → it LOOKS green while real users never get the mail. On an auth path (magic-link / verify / reset) that's a broken login that passes every check. Two musts: (1) MAIL_LIVE=true as a prod secret; (2) treat skipped as a HARD error on auth mail (if(!r.ok||r.skipped) throw) + seal it with a RED test. Bit cms + cardmem.— components
- type-is-not-a-contractA PROVIDER TYPE IS NOT A CONTRACT — it is the provider's CLAIM about their contract, and the two can disagree. MEASURED by cardmem 2026-09-08 against api.resend.com, with a control beside it so the difference is attributable to one field: `filename: false` is EXPLICITLY in Resend's published Attachment type (resend-node, create-email-options.interface.ts: `filename?: string | false | undefined`) and their SERVER answers 422 — "The `attachments, filename` field must be a `string`." The control with a real filename returned 200. So the type compiles, typechecks green, and fails in production at the first consumer. A SECOND EXAMPLE, so this is a class and not an anecdote: @broberg/ai-sdk's resolveModel() is fail-open by default — an id the registry does not know comes back ok:true, status:"unknown", with your own input echoed back as the model. A consumer followed the docs, passed the gate, and sent the literal string "cheap" to a provider as a model id. Both are the same shape: a SUCCESS-SHAPED answer from a layer that never checked. WHAT TO DO: before building a package feature on a provider field you have never sent, SEND ONE — with a control that differs in exactly that field — and read the RESPONSE, not the type. A green typecheck is evidence about your code and none at all about their server.— cardmem (measurement) + components (framing)
- gotcharesend.batch.send strips attachments — send per-recipient when you embed inline cid: images.— sanne
- webhooksWire the Resend webhook (Svix-signed) for delivered/opened/bounced/complained events.— sanne
- restricted-keyA send-only (restricted) API key 401s on GET /domains ({restricted_api_key}) — you CANNOT list verified domains with it. Check the dashboard then Domains, or just send: HTTP 200 from POST /emails confirms the From domain is verified.— fdaa
- verified-sender-envKeep the sender in an env var (RESEND_FROM), never hardcoded — a later domain switch (after SPF+DKIM+DMARC) is one secret-flip, zero code change.— trail
Stripe7
- framesHOSTED checkout (checkout.stripe.com) renders card fields in the TOP-FRAME, not PCI-iframes — do NOT use @frame. @frame-chain is only for EMBEDDED Stripe Elements on your own page.— sanne
- lensLens E2E primitives (lens_run_flow): clickSelector, fillSelector (CSS + value + optional frame), waitForUrl (redirect-back assert), inspect (CSP-safe DOM dump for selector discovery). No js-eval step exists.— sanne
- selectorsHosted da-locale selector set: pick card via the ROW [data-testid='card-accordion-item'] (NOT -button/-radio, which are 'not visible'); fields input[name='cardNumber'] · input[name='cardExpiry'] (value '12 / 34') · input[name='cardCvc'] · input[name='billingName']; pay [data-testid='hosted-payment-submit-button']; assert waitForUrl '/shop/receipt/'.— sanne
- shippingPhysical goods (shipping_address_collection): input[name='shippingName'|'shippingAddressLine1'|'shippingAddressLine2'|'shippingPostalCode'|'shippingLocality'] + select[name='shippingCountry'] (DK default). cardUseShippingAsBilling is usually checked → billingName hidden; fill shipping only.— sanne
- link-otpGotcha — Stripe Link: an email already known to Link shows an OTP 'confirm it's you' instead of the card form. Use fresh plus-addresses (cb+testN@domain) → no Link prompt.— sanne
- accordionGotcha — accordion: with multiple methods enabled (Card + Klarna) the card fields stay COLLAPSED until 'Kort'/'Card' is selected. Click the card row first.— sanne
- verifyVerify post-payment via sk_test: GET /v1/checkout/sessions?expand[]=data.payment_intent — application_fee_amount is ONLY present on a PAID session (on an open one payment_intent=null). commission / transfer_data / amount_total / shipping_address_collection all readable.— sanne
Supabase9
- regionProvision in region arn (Stockholm) — same as Fly.— components
- lensAuthed Lens capture → @broberg/lens; keep only your signInWithPassword in createSession, package owns the rest.— components
- gotchaCookie-domain trap: behind a proxy the Host header is 'localhost' → cookie never reaches the real domain. Pin LENS_COOKIE_DOMAIN.— fds
- securityservice_role key is server-side ONLY — never ship it to the browser. Use a read-only/anon key client-side.— components
- auth-scannerEmail-security scanners (Outlook SafeLinks, Mimecast) PRE-FETCH confirmation/invite/recovery links, so the token is consumed on the scanner's GET before the user clicks and the user's click then fails ('link broken'). Fix: Click-to-Verify — GET renders a button page (consumes nothing); a POST consumes the token only on a real user click. Scanners only follow GET.— fds
- rls-observabilityRLS silently drops pre-login audit events: events that fire before login (signup-fail, scanner-detected, verification-failed) hit an INSERT policy requiring auth.uid() IS NOT NULL, get rejected, and a swallowing catch hides it = zero history. Use a service-role admin client for legitimate unauth events + replace the silent catch with explicit console.error.— fds
- grantsSupabase removes auto-grants for new tables (Oct 30 2026). Always add explicit GRANT … TO service_role, authenticated (anon only if needed). SECURITY DEFINER fns: SET search_path = public, pg_catalog + REVOKE EXECUTE FROM anon, authenticated unless it IS an RPC.— fds
- ssr-cookie-proxy@supabase/ssr cookie behind a proxy: sb-<ref>-auth-token domain is derived from request Host; behind Apache/nginx/Fly that can be 'localhost'/'0.0.0.0' so the browser NEVER sends the cookie (silent false-green). Pin the cookie domain. Bonus: the cookie SPLITS into .0/.1 chunks when large — handle as an array.— fds
- revoke-anonREVOKE ALL ON FUNCTION … FROM PUBLIC does NOT strip anon. Supabase installs a default-privilege that grants anon EXECUTE on every new function in public, and a revoke from the PUBLIC pseudo-role never touches an explicit role grant — so the SQL file reads locked-down while the database still lets anon call it (measured 2026-08-24, fd-sundhed). Assert on pg_proc.proacl (the function's actual ACL), never on the migration file; the real fix is an explicit REVOKE EXECUTE ON FUNCTION … FROM anon (and authenticated unless it IS an RPC).— fds
Turso / libSQL5
- reuseConsume via @broberg/db-sdk (libSQL transport) rather than a bespoke client.— components
- regionPrimary DB in arn; add embedded replicas for fast multi-region reads.— components
- fitRight tool when state outgrows a per-machine Fly volume but doesn't need full Postgres.— components
- migration-not-appliedA drizzle migration recorded in __drizzle_migrations is NOT proof the DDL landed. Verify BOTH the hash in the migrations table AND the actual effect (SELECT name FROM pragma_table_info('t') WHERE name='col'). A green migrate can leave the column absent.— trail
- drizzle-gotchadb.update().set() on bun:sqlite can SILENTLY drop a new column (value-independent, while sibling writes land) — workaround: a raw SQL UPDATE. Verify DB ground-truth via flyctl ssh, not the ORM return value.— cardmem
npm / OIDC publishing13
- npm-view-is-cachedRIGHT AFTER A PUBLISH, `npm view <pkg> version` IS NOT AUTHORITATIVE — it can serve a cached answer and report the OLD version for minutes, and `npm i <pkg>@<new>` then fails with ENOTARGET ("a package version that doesn't exist") for a version that plainly does. Measured 2026-08-31 on @broberg/mail 0.8.1: the OIDC publish step was green, npm view said 0.8.0, and the install refused — three signals agreeing on a false conclusion. The registry itself was already correct. So the instrument lied, not the publish, and the failure shape is the dangerous one: it looks exactly like a publish that silently did not happen. VERIFY AGAINST THE REGISTRY DIRECTLY: `curl -s https://registry.npmjs.org/<pkg> | jq -r '."dist-tags".latest'` (scoped names need the slash URL-encoded: registry.npmjs.org/@scope%2Fname works, and the plain @scope/name form also resolves). To install through a stale cache, add `--prefer-online`. Do not re-tag, bump the version again, or announce a failed release on the strength of `npm view` alone. REFINEMENT measured the same night on 0.9.0: the REGISTRY lags briefly too — it answered 0.8.2 for about a minute after a green 'Publish (OIDC)' step, then 0.9.0. So the registry is AUTHORITATIVE but not INSTANT. The signal that settles it is the publish job's own step conclusion; the registry then converges within a minute or two. Read the job before concluding anything, and re-check the registry rather than acting on one reading. AND COMPOSE IT WITH THE PIN CHECK ABOVE, super's point and the completion of both: the two failures sit on the same axis and can fire TOGETHER without contradicting each other — a `^0.x` pin says what you CAN reach, `npm view` says what EXISTS but answers about the past, so a fresh pin measured against a stale "newest" looks perfectly healthy from both sides. Measure them together, against the registry: `LATEST=$(curl -s https://registry.npmjs.org/<pkg> | jq -r '."dist-tags".latest'); npx semver -r "<your pin>" "$LATEST"`. Verified on @broberg/mail 0.8.1: ^0.8.0 -> 0.8.1 (reaches it), ^0.7.0 -> empty (can never). Never `semver -r "<pin>" $(npm view …)` — that verifies your pin against a number that may itself be old, and returns a green answer to the wrong question.— components + super
- caret-0x-locks-minorA CARET ON A 0.x DEPENDENCY LOCKS THE MINOR, so the pin looks current and can never reach the fix. `^0.6.0` means `>=0.6.0 <0.7.0` — it resolves 0.6.1 and NOT 0.7.0, forever, and `npm install` reports success every time. Measured 2026-08-31: @broberg/logger was pinned `^0.6.0` on @broberg/secret-scan and was therefore redacting production logs with the version where {"password":"hunter2"} passed through UNTOUCHED — the exact leak the newer version closes. super hit the identical trap the same day on @broberg/ai-sdk: pinned ^0.34.0, installed 0.34.0, newest 0.36.6, and they could not reach the EU-residency functions they had just verified existed. Their sentence for it is the useful one: the claim was true about the package and false about our access to it. THE ONE-SECOND CHECK, verified here before repeating it — `npx semver -r "<your pin>" <newest published>`; an EMPTY answer means the pin can never get there (`^0.6.0` against 0.7.2 prints nothing, while `^1.2.0` against 1.3.0 prints 1.3.0, because caret behaves normally above 1.0). Do not sweep for one package name when a release goes out — EVERY `^0.x` pin in the repo has this, and it is silent.— components + super
- inventory-freshnessA release is not done when npm has the package — it is done when the shared inventory stops lying. Discovery cannot know it has become wrong; only the owner of the thing it describes can. THREE PRECEDENTS IN ONE WEEK (2026-08-05..11), all caught by the owner reporting back, none by anyone noticing: (1) an ai-sdk adoption note told every consumer to bump and set the key — correct for the version it described, and once cost-tracking became on-by-default it would have DOUBLE-COUNTED spend in production for any repo that already self-reported; (2) our entry said there was no EU route for video analysis, which was the honest answer on 09-08 and was contradicted by a shipped capability on 11-08; (3) a not-yet-live-verified caveat on the Vertex adapter stood from June until the blocking credentials quietly appeared. So when you publish, ask not only what does this ADD but WHAT DOES THIS MAKE FALSE — a note that was true when written is exactly the kind that survives for years. And verify against npm rather than a green workflow: the registry can lag a successful publish, so a green run is not proof the package can be installed. Owner-initiated, because the alternative is a consumer discovering it in production. AND THE HALF THAT MAKES IT ACTIONABLE (ai-sdk, after the same week): phrase a finding as a DATED MEASUREMENT, never as a state. There is no EU route for video analysis reads as permanent; measured 2026-08-10: no EU route carries its own expiry, so the next reader can see that something may have moved. That single difference is the mechanism behind all three stale notes above — the information was right, the FORM made it look permanent. It generalises well past inventories, to every answer sessions give each other: a state is forever, a measurement has a date.— ai-sdk + components
- oidcOIDC + --provenance REQUIRES a repository.url in package.json matching the GitHub repo, else npm 422s. (theme's first OIDC release hit exactly this.)— components
- semver-0xA caret on a 0.x version LOCKS THE MINOR: ^0.21.1 can never resolve 0.24.0 — caret only allows patch below 1.0. Every @broberg/* package is still 0.x, so a consumer that believes it follows along is frozen until someone bumps by hand. buddy found THREE different versions of @broberg/ai-sdk (^0.10.4 · ^0.21.1 · ^0.21.1) inside ONE monorepo, which produced a real type break the moment the files were touched. Audit workspace-wide (grep every package.json), not per-app — and note that a per-session Discovery enrollment reports only ONE version, so a split monorepo looks up to date.— buddy
- ciDo NOT set version: on pnpm/action-setup when the root package.json has a packageManager field — they conflict and the publish job fails.— components
- release-gotchagit push --follow-tags only pushes ANNOTATED tags. A lightweight git tag vX won't trigger a tag-gated publish workflow, so the release just doesn't happen. Use git tag -a … or push the tag explicitly (git push origin <tag>).— ai-sdk
- publish-timingRight after publish, npm view / npm i can 404 for minutes — Fastly negative-cache, NOT a failed publish. The publish success line is authoritative; verify npm view <pkg>@<version> before claiming live (each probe re-seeds the negative cache, so don't hammer it).— ai-sdk
- native-dep-isolationIf a package's ROOT entry transitively imports a runtime builtin (bun:sqlite, node:zlib), a BROWSER build hard-fails. Ship a browser-clean subpath export (separate tsup entry + exports['./x']) and PROVE it with bun build --target=browser.— ai-sdk
- first-publishFIRST publish of a brand-new name is chicken-and-egg: npm's Trusted Publisher can't be configured until the package EXISTS, so v0.1.0 must be a token publish that CREATES it. Keep the org publish-token in ONE place (components) and let it bootstrap-publish first versions for the whole fleet — ping components (intercom) when your package is built, rather than copying a publish-everything token into N repos' .env. After v0.1.0 exists, Christian adds the Trusted Publisher and every later release is token-free.— components
- monorepoPublish a @broberg package from a MONOREPO subdir (not its own repo): add a tag-prefixed job (on push tag e.g. complimenta-sdk-v*) with working-directory: packages/<name>, permissions id-token:write, build+test, then `npm publish --provenance`. The Trusted Publisher points at THAT repo + the workflow filename — so one monorepo ships many independently-tagged @broberg packages. (broberg-ai/fdaa → @broberg/complimenta-sdk is the first.)— components
- trusted-publisherTrusted Publisher setup (Christian, ~30s, ONLY after v0.1.0 exists): npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions → Organization + Repository (e.g. broberg-ai/<repo>), Workflow filename (publish.yml), Environment left blank. Then a tag push publishes token-free with provenance — his single manual step per new package.— components
- provenance-privatenpm publish --provenance FAILS for a PRIVATE source repo → npm E422 'Unsupported GitHub Actions source repository visibility: private' (OIDC auth + the signed provenance statement still succeed; only the registry's provenance-verification rejects). Rule: PUBLIC source repo → keep --provenance; PRIVATE repo → omit it. (broberg-ai/fdaa hit this on complimenta-sdk's first OIDC tag-release.)— fdaa
Pitch Vault7
- reuseNeed a customer pitch? Use Pitch Vault, don't roll your own. POST /api/cli/push (multipart, x-api-key) with a self-contained HTML pitch → get a shareUrl back. Search existing pitches first: GET /api/v1/pitches?q=<term> (also ?folderId=).— components
- idempotencySlug = the idempotent UPDATE key. Send the SAME slug to /api/cli/push to overwrite a pitch in-place (there's no separate edit endpoint); omit slug → a new pitch each time. Version via naming (e.g. -v2), not the API.— components
- publishisPublished defaults to FALSE — pass isPublished=true in the push to publish immediately, else the pitch exists but viewer/share links 404.— components
- foldersOrganize via folderId: GET /api/v1/folders first for the tree, then pass folderId in the push (null/omit = root). Folders are created in the web UI, not the API.— components
- self-containedPitch HTML MUST be self-contained — inline <style>, base64 data-URIs for images, NO external CDN/API calls (they fail in the sandboxed viewer). Same F122 rule as our inventory mockups.— components
- generateDon't write from scratch: POST /api/generate (Claude Haiku) turns a brief into a complete self-contained HTML pitch, optionally styled from a template pitch. Real examples live in the repo's pitches/ dir.— components
- delete-and-authNo delete API (web-UI only). To delete programmatically, ask the pitch session via intercom — it has the Fly-volume access. The x-api-key is a Fly secret; never commit it.— components
Image processing (sharp / HEIC)6
- reuseUse @broberg/media-transform for HEIC→JPEG + responsive WebP/JPEG + EXIF orient/strip — don't hand-roll a sharp pipeline per app. transformImage(bytes, {heicToJpeg, keepOriginal, variants:[{name,maxEdge,format,quality}]}) → {variants:[{name,bytes,contentType,width,height}], orientationFixed}. Pipe each variant.bytes into @broberg/media.upload().— components
- heic-hevcsharp CANNOT decode iPhone HEIC: its prebuilt libheif reads the HEIF container + the AVIF decoder but NOT HEVC (sharp.format.heif.input.fileSuffix shows only ['.avif']). sharp(heic).metadata() SUCCEEDS yet .toBuffer() throws 'Decoder plugin error / bad seek'. So a metadata() capability-probe is a false positive — route HEIC through heic-convert (bundles its own HEVC decoder, pure-JS, works on glibc/musl/Bun, applies rotation on decode).— components
- exif-privacyPrivacy: strip EXIF from EVERY output, including the kept original — read any EXIF you need (GPS, capture time) BEFORE transform; never let location survive on a stored/downloadable file. sharp drops metadata by default on encode; .rotate() bakes orientation in and removes the tag.— components
- bunsharp itself loads + runs fine under Bun (verified 0.35 on Bun 1.3) for resize/encode/orient — only the HEVC-HEIC decode needs heic-convert. No wasm/sidecar needed for the rest. Keep sharp/heic-convert as external (native) deps; never bundle them.— components
- test-fixtureGenerate a real HEIC test fixture locally with macOS sips: `sips -s format heic in.jpg --out out.heic` (produces HEVC HEIF). Lets you verify a HEIC decode path with hard runtime proof instead of assuming.— components
- memoryIn-process transform OOMs a small box (512MB) on large photos — sharp/libvips holds the decoded bitmap in RAM. For a batch/backfill, run ONE fresh process per photo (a shell loop) for memory isolation, or bump the machine RAM. A long-lived server transforming one upload at a time is usually fine.— xrt81
GitHub Actions / CI-CD6
- test-gateBlocking test-gate: give the deploy job needs:[test], where test runs the suite (turbo → bun test). One red test → deploy blocked. Closes the hole where a regression ships past 'green-but-never-run' tests.— buddy
- pnpm-setuppnpm/action-setup@v4 fails hard ('Multiple versions of pnpm specified') if you set the version in BOTH the action (with: version:) AND package.json (packageManager: pnpm@x). Fix: drop with:version: entirely, let the action read packageManager.— buddy
- paths-filterA workflow whose paths: filter does NOT include .github/workflows/** does NOT re-trigger when you edit the workflow file itself — so a CI-fix commit 'does nothing' until the next qualifying push. Test a workflow change in isolation with gh workflow run <wf> --ref main (workflow_dispatch).— buddy
- deploy-verifyProve a deploy is really live: match the flyctl releases timestamp against the commit time (release v79 06:13 UTC, 1 min after commit 06:12 = real). A cheap truth-check against a compaction summary that claims 'shipped'.— trail
- probeCheap post-deploy signal: an unauthenticated request returning 401 (not 404) proves the route is mounted + auth-gated without a full authed flow. 404 = not deployed yet; 000 = host not up — never raise a real incident on 000 (false alarm).— cardmem
- autostash-trapgit add → rebase --autostash → commit can DROP your modifications → empty commits → CD rebuilds the OLD code. Verify against the SERVED artifact (bundle hash / content marker), not your working tree.— cardmem
Frontend (Preact / Next / PWA / web)7
- preact-renderPreact ≠ React: NO setState-under-render bailout. Calling setState during render to 'reset' on a prop change makes Preact PAINT the intermediate state first → a visible blink. DERIVE state from an id (openId === curId), never set it during render.— xrt81
- media-flickerThe real 'flicker' in a media viewer is the image RESIZE, not the image swap. In a flex-column layout, if anything under the image changes height on swap (e.g. a lazy detail-fetch blanking the bottom bar) the image grows/shrinks → reads as flicker. Fix: fixed-height bottom bar; read title/date from the LIST row already in memory (not the lazy detail call); float foldable panels in an overlay ABOVE the image so they never touch layout height.— xrt81
- carousel-keyCarousel swap-flash: key your slides (prev|cur|next) on the media id → Preact MOVES the <img> nodes instead of swapping their src (src-thrash = repaint = flash).— xrt81
- ios-fullscreenFullscreen media viewer: use a FULLY opaque layer (position:fixed; inset:0; background:#000; height:100svh) — a translucent scrim lets the app-shell bleed through during transitions. Kill iOS pull-to-refresh with overscroll-behavior:none + touch-action:none + a body-scroll-lock.— xrt81
- gdpr-fontsGDPR-clean webfonts: use fonts.bunny.net, not Google Fonts — Paris-hosted, drop-in compatible with Google Fonts' CSS query API, no visitor data to the US. (On cardmem's mockup allow-list too.)— vn-leker
- lens-touchLens has NO native pinch gesture and synthetic TouchEvents don't reproduce iOS pinch reliably (esp. WebKit) → touch-gesture features (pinch-zoom) CANNOT be auto-verified; say 'device-test required' instead of claiming verified. Prove layout stability instead: assert getBoundingClientRect().height is identical before/after a swipe / panel-open.— xrt81
- next-cache-coherenceNext.js runs MIDDLEWARE and /api/* ROUTE-HANDLERS as SEPARATE module instances → separate module-level state. A `let _cached` in a lib imported by both has TWO copies: a write via the route-handler is INVISIBLE to the middleware's copy until a shared signal re-checks. Symptom (cms, prod): a new site added via a route-handler returned 200 from the API but the middleware router 404'd ALL its pages until restart — looked like a routing bug, was cache-coherence. Fix: any module-level cache backing BOTH middleware AND route-handlers must invalidate on a SHARED signal — file mtime (cheap fs.stat per call) or explicit cross-call invalidation. NEVER cache forever.— cms
AI / LLM providers (@broberg/ai-sdk)8
- residency-per-capabilityCLEARING THE CHAT MODEL DOES NOT CLEAR THE SDK — check every capability you will use, not the one you thought of first. The text tiers are Mistral EU (fast/cheap = mistral-small, smart/powerful = mistral-large), so a GDPR review of the chat path comes back clean and the question feels closed. It is not: `embedding` and `video` still leave the EU. Reported by vn-leker while designing a customer assistant for a Nordic B2B shop — they had verified the chat model, concluded EU-safe, and semantic PRODUCT SEARCH over customer text would have sent personal data out of the EU without anyone touching the chat. The failure needs no mistake: one capability is checked, and the rest inherit a clearance nobody granted them. So treat residency as a per-capability question, and read usage.provider/usage.model off the RESPONSE — resolveModel reports provider and model, never region, and a response with no tier means a fallback was taken.— vn-leker
- ground-before-the-callGround BEFORE the first model call — do not hand the model a retrieve TOOL and instruct it to use one. Two failures nobody sees. (1) THE MODEL DISOBEYS AND IT IS INVISIBLE: measured over 178 real messages in a live customer chat, only 58 percent of 55 clearly professional/health questions were grounded, and the SAME message word for word (Jeg sover ikke godt) was looked up 13 times out of 19. Among the ungrounded: can reflexology cure my mother’s cancer (4x) and I have severe chest pain. An ungrounded answer looks exactly like a grounded one — no error, no log, nothing to see. (2) It costs a whole extra round trip: one call to ASK for the lookup, one to answer, each carrying the full system prompt, while the lookup itself took 8-116 ms and the round cost ~10 s. THE NON-OBVIOUS PART, and it is the whole pattern: do NOT classify is this a knowledge question. INVERT IT — ground by DEFAULT, with a short exception list for what unambiguously is not (greetings, booking, prices). The asymmetry decides it: missing a knowledge question yields an ungrounded answer nobody can see, while an unnecessary lookup costs ~10 ms. Measured against all 178: 148 ground, 30 skip, ZERO professional questions lost — a recognise-the-professional rule would have caught a subset and looked just as green. THREE TRAPS a re-user will hit: (a) the prompt still ORDERS the tool, so the model looks it up AGAIN on top of the injected context — measured 2 of 2 runs, the entire saving gone; the context must carry do NOT call the tool for this, AND the prompt must know the lookup may already have happened; (b) a FAILED pre-lookup must never cost the user their answer — fall back to the old behaviour rather than failing; (c) THREE states, not two: found / the knowledge base answered and has nothing / the knowledge base could not be reached — collapse the last two and the model answers professionally with no knowledge behind it. A/B on identical setups, 3 runs each: 19.9s→7.6s, 16.8s→9.6s, 9.1s→6.5s, and grounding went 58 → 100 percent. Speed was the goal; RELIABILITY was the finding. Deliberately NOT a @broberg/* package: the reusable part is the SHAPE, while the exception list is domain-specific (Danish symptom words here) and that is where all the risk lives. Reference implementation ~70 lines: sanneandersen site/src/lib/eir/grounding.ts (3d97126).— sanne
- eu-routingUsing an EU MODEL is not the same as data staying in the EU — the ROUTE decides, and only the route. Measured by xrt81 (2026-08-10): their PII path sent { provider: openrouter, model: mistral/mistral-large-latest } — a Mistral model reached THROUGH OpenRouter, a US company. The model name read as compliance; the transfer out of the EU was still there, and it carried club photos (faces + EXIF geolocation) and members personal finances. Their own plan-doc had explicitly forbidden that route and the implementation did it anyway: their tenant-AI builder registered only openrouter+openai, so a provider:mistral route would have thrown, and they concluded the adapter did not exist. It did — mistralAdapter is exported in @broberg/ai-sdk 0.6.0, the version they were already running. Nobody checked. Three rules out of it: (1) put the PII-routing decision in ONE file that owns it, never per call-site; (2) when the EU key is absent do NOT silently fall back to the US route — log a warning that NAMES the exposure, so the hole sits in the log instead of behind a model name that sounds European; (3) verify the adapter exists by reading the package d.ts, not by inferring it from what your own factory happens to register. A provider:openrouter carrying a mistral/* model name reads as compliance in a code review and is not. TWO THINGS ai-sdk MEASURED when this was escalated (2026-08-10), both worth knowing before you route PII: (a) a MISSING EU key does NOT silently reroute — the Mistral adapter throws API key not set, and the SDK never picks another provider on its own, so the absence of a key is a hard failure rather than a quiet US transfer; (b) BUT a fallback chain the CALLER supplies (input.fallback) WILL route to whatever it names when the EU provider fails — so a fallback chain is a GDPR DECISION, not a robustness detail, and on a PII path it must be either EU-only or absent. And the reason this is worth a guard rather than only a tip: both the fleet CLAUDE.md and the consumers own plan-doc already forbade the openrouter route in writing. Both documents were right, and the code did something else for months without anyone being able to see it.— xrt81
- cost-tracking@broberg/ai-sdk cost-tracking is ON BY DEFAULT from 0.24.0, and adoption is PER-REPO + OPT-IN — there is no fleet-wide rollout (it was cancelled 2026-08-07 because a blanket set-the-key instruction springs two traps). Trap 1: a repo that ALREADY reports its own usage gets the SDK sink on top and double-counts every call in production, with no error anywhere — pass createAI({costSink: null}) there, and note the opt-out only exists from 0.26.0. Trap 2: test runners auto-load .env, so merely IMPORTING a module that holds a module-level createAI() POSTs invented usage into production telemetry — strip UPMETRICS_* in the runner by PREFIX (not a name list) and prove it with a control that can go red.— ai-sdk + buddy
- capability-probe$0 capability-probe: to check whether a model/capability is actually served in a region/project WITHOUT spending, POST an EMPTY {} body. A 400 INVALID_ARGUMENT 'Empty instances' means the model IS served (it failed validation BEFORE generating → free); a 403 means a setup gate (billing / API not enabled), not a missing capability. Confirmed Veo-3.x in 5 EU regions (europe-west1/3/4/9, europe-north1) + Azure TTS live for $0. Always settle capability cheaply before committing an adapter.— ai-sdk
- gcp-project-trapGCP 'two projects, same display name' trap: AI Studio AUTO-creates a hidden gen-lang-client-XXXX project BEHIND a Gemini API key — distinct from a self-made project with the same display name. Probing the wrong (empty, no-billing) one gives a misleading 403. Run `gcloud projects list` and use the BILLING-enabled project (gen-lang-client- prefix = AI Studio's).— ai-sdk
- tts-routingTTS/speech does NOT go through OpenRouter — it proxies only LLM chat/completions, no speech synthesis. Azure Neural TTS = Azure Speech (Cognitive Services): a separate SSML REST endpoint ({region}.tts.speech.microsoft.com/cognitiveservices/v1, header Ocp-Apim-Subscription-Key). EU-pin the region (swedencentral / westeurope) for GDPR — same discipline as BFL's api.eu. ai.tts is live in @broberg/ai-sdk (Azure adapter for EU neural Danish da-DK).— ai-sdk
- cost-readbackCost read-back > local re-aggregation: Upmetrics OWNS cost aggregation (summary / timeseries / fleet, micro-USD, per-tenant groupBy via its cost read-API). Don't build a local roll-up in a consumer app — write runs to Upmetrics AND read the aggregated cost back from it (ai-sdk's upmetricsCostClient does exactly this). One canonical source, no drift.— ai-sdk
Fleet ops (cc-session auth · edge · inter-session comms)6
- background-jobsLong-running jobs go in the BACKGROUND — and this is about the owner's time, not about limiting you. Christian, 31/8: "Det er MIG der spilder min tid med at vente - en LLM er ligeglad." That sentence is the whole rule. A session waiting in the foreground loses nothing itself; what it costs is that the PROJECT LEAD cannot talk to it. He is orchestrating a fleet, and a session that stops answering for 20 minutes has taken one of his agents off the board while he sits there. THE UNIT IS NOT SECONDS, IT IS INTERRUPTIONS: a 20-minute deploy at 03:00 with nobody waiting costs zero; two minutes of silence while he is mid-conversation costs him two minutes AND the experience of being ignored. So do not reason about how long a command takes — reason about whether a human might want to reach you while it runs. WHAT TO DO: pass run_in_background: true on deploys, CI waits, builds, publishes and poll-loops (flyctl deploy, gh run watch, docker build/push, npm/pnpm publish, xcodebuild, eas/expo build, terraform apply, and any `while/for … sleep` that is just waiting). YOU LOSE NOTHING BY DOING IT — measured by buddy with a deliberate `exit 7`: the exit code lands in the output file AND you are woken with an explicit failed status, so `gh run watch --exit-status` in the background still gives you the same proof it gives you in the foreground. You keep answering the whole time, and you can Read the output file as it grows. THE GUARD IS NOT A PUNISHMENT AND NOT A SUBAGENT: no new pane, no new login, same session, same context — it is one field. It fails OPEN by design (cannot decide ⇒ allows), because a guard that blocks work when unsure gets switched off and is then worse than none. If you genuinely need a short foreground run to answer the owner right now, say so to him first — the deliberate absence of a silent back door is the point. MEASURED PROPERLY, on the right unit (buddy, 330 transcripts): the question is not how many hours ran in the foreground but how often the OWNER was actually sitting there. Counting only calls with a typed human message within 15 minutes either side — injected channel messages excluded, or every session looks staffed around the clock: deploys 706 of 2,904 (24.3%), long wait-loops 321 of 1,460 (22.0%), short retry-loops 197 of 905 (21.8%). 1,224 calls, 21.9 hours OF HIS TIME. Their first instrument returned 0 in all three classes and that was the instrument, not reality — a foreground call blocks the turn, so a message typed during it is only written to the transcript afterwards. Caught by checking the instrument could see anything at all (6,056 typed messages), which is why a suspicious zero gets probed rather than published. TWO THINGS THAT FALL OUT OF IT, and both are why this is calibration rather than a blanket ban: presence is CONSTANT across the classes (21.8 vs 22.0), so it cannot discriminate and duration is what is left — cost is roughly a fixed 22% times duration. And short retry-loops (sleep 1-4, median 18s) are deliberately NOT blocked: a background round-trip on an 18-second job costs more turns than it saves and would make his conversations SLOWER. That one hour is left on the table on purpose. If you meet a rule here that would slow the owner down, it is a bug in the rule.— components
- port-allocationA vacant-port API can hand you an OCCUPIED port, and the result looks exactly like success. Measured twice on the same Mac (components + buddy, 2026-08-11): cl.broberg.dk/api/vacant-port returned {port:3018, ports:[3018..3022]} while 3018 and 3019 were both LISTENing (bun pid 1693, node pid 37747). The service answers from a register of who has been ALLOCATED a port, so a process that simply started on one is invisible to it. WHY IT IS WORSE THAN A NORMAL WRONG ANSWER: the squatter on 3018 was a SPA that returns 200 for EVERY path, so the usual is-my-server-up check came back green while the real server had never started at all (Address already in use). One step from running a browser verification against a completely different product and believing it was ours. THREE CHECKS THAT CATCH IT, none of which is a status code: (1) check the TITLE, not the status — curl -s $URL | grep -o <title>…</title> named the wrong app instantly; (2) ask for a path that CANNOT exist — a real static server answers 404, a SPA catch-all answers 200, and if it does then your 200 means nothing; (3) verify vacancy with lsof -nP -iTCP:$p -sTCP:LISTEN rather than by asking an API. The fix the service needs is to actually BIND the port and release it before reporting it free. Until then, treat the answer as a suggestion and check it. A NEGATIVE CONTROL CAN BE INSUFFICIENT RATHER THAN ABSENT, and that is harder to spot than having none (ai-sdk, 2026-08-11, after declaring a live voice dead). They probed GET /v1/voices/{id}, got voice_not_found 3 times out of 3, ran a live control that returned 200, and wrote the voice off in the registry, in a cards evidence and in a commit message. It was not dead: POST /v1/text-to-speech/{the same id} returns 200 and real audio. The endpoint answers is this voice saved in our ACCOUNT, not can we USE it. THE CONTROL WAS REAL AND STILL PROVED THE WRONG THING: a made-up id fails that same endpoint, so the control established that the probe could distinguish SOMETHING — never that it distinguished the property being asked about. So the rule is sharper than prove-the-red-first: A NEGATIVE CONTROL MUST SEPARATE THE PROPERTY YOU ARE MEASURING FROM THE ONE YOU THINK YOU ARE MEASURING, not merely separate an answer from no answer. That needs a case where the two properties DISAGREE — here, a voice absent from the account that still synthesises. They found it by accident, because three replacement candidates failed the same lookup and then worked fine. Same family as the raw-NUL finding one layer up: the instrument responded, it just responded about something else. SHUT DOWN 2026-08-11 on Christians order: cl-web scaled to 0 machines, and cl.broberg.dk no longer answers. The measurement inverted the question before the decision was made — three repos called it (CMS preview-serve, CMS mobile-preview, pm2-launchers installer) and the service turned out to be MORE dangerous up than down: both CMS call-sites already fall back to letting the OS pick a port, but that fallback fires only when the fetch FAILS, never when it answers WRONGLY, so while the service was up they trusted an occupied port. With it gone they use the correct method. pm2-launchers installer threw outright and was given the same fallback first. AND STOPPING THE MACHINE WAS NOT ENOUGH: auto_start_machines was true, so Fly woke it on the next request and the third probe returned 200 again — reporting it closed on the strength of the stop command would have been a claim the next probe disproved. Scaling to zero is what closes it; five probes over fifteen seconds then fail to connect. App, config and certificate remain, so scale count 1 restores it.— components + buddy
- mac-edge-keychainMac-edge keychain trap: a LOCKED macOS login-keychain makes cc fail with 'Not logged in · Please run /login · security unlock-keychain' — and it masquerades as a rate-limit wall, a login bug AND a launcher bug at once (cost 4h to diagnose). Root: when CLAUDE_CODE_OAUTH_TOKEN isn't set in the session env, cc falls back to the keychain cred → a locked/asleep Mac = locked out of auth. Fix (verified on cb-2): `security delete-generic-password -s "Claude Code-credentials"` + run on the env setup-token (CLAUDE_CODE_OAUTH_TOKEN from ~/.buddy/.env). The fleet runs on the setup-token, never the keychain.— buddy
- intercom-voicemailIntercom answering machine (buddy F177): an intercom to a SLEEPING/offline cc-session is no longer lost — it's enqueued durably (survives a buddycloud restart) and flushed on wake, chronologically, exactly-once. Fleet-design consequence: stop checking 'is the peer awake?' before ask_peer/announce — just send; buddy holds it until they wake. Slept ≥90s → it renders as a 'voicemail' card in the recipient's chat.— buddy
- search-historyNew fleet-wide MCP tool: search_history — full-text search (FTS5/BM25) over each edge's 'fermented' layer-2 dialog history, on the buddy channel. Complements trail_search: keyword-hit recall vs. trail's distilled/summarized knowledge. Live in already-running sessions after the next relaunch_fleet; new sessions get it from source automatically.— buddy
- file-pipingNew fleet-wide MCP tool: pipe_file (buddy F200) — move a FILE between edges or up to R2 without the bytes ever passing through a session's context. Two modes: (1) pipe_file({to_edge:'cb-ubuntu', path:'/abs/path.pdf'}) → lands in the target edge's ~/Downloads, sha256-verified both ends, never overwrites (collision → ' (1)' suffix), 25MB inline cap. (2) pipe_file({target:'assets', path:...}) → staged straight to R2 via a hub presigned PUT (NO size cap) → returns a short-TTL URL to hand to cardmem_create_asset({url}). Curl path also exists: POST 127.0.0.1:4123/api/pipe/send. Not an npm package (daemon routes + MCP tool on the buddy channel). Use it instead of base64-ing a file through chat or a one-off scp. Live in running sessions after the next relaunch_fleet.— buddy
Document generation (PDF)2
- opacity-is-not-textrsvg-convert: `opacity` (or fill-opacity) on a <text> element turns that text into a SHAPE in the PDF — it looks exactly right in every viewer, and pdftotext cannot find it. rsvg puts the element in a transparency group and it stops being text. Measured in an isolated control: two identical <text> nodes, one with opacity=0.92, and only the opaque one comes back out. IT FAILS GREEN ON EVERY LAYER AT ONCE, and sanne had guards on all of them: 'the PDF contains fonts' passes (fonts prove SOMETHING is text, not that ALL of it is), 'the SVG has N <tspan>' passes (the element IS in the SVG; the loss happens in the conversion), and a visual check passes because it looks correct. The ONLY thing that can see it is reading the specific string back out of the built PDF with pdftotext and comparing it to the source. What it cost: the two dimmed lines in a print-ready brochure were the address, phone and email — the exact fields a printer must be able to edit, and the only ones with opacity in the whole generator. The customer had the file for five days. Dim with a pre-blended colour instead (92% white over #0f7391 = #ecf4f6).— sanne
- pdf-fontspdf-lib standard fonts are WinAnsi-encoded, so ONE emoji anywhere in the content THROWS mid-render — æøå are fine, ⭐ and 🍽️ are not. Because it fails halfway through drawing, it presents as a layout/renderer bug rather than a character-set bug. Filter non-WinAnsi characters before drawing, or embed a Unicode TTF via fontkit if you genuinely need the glyphs.— xrt81
Native macOS/iOS apps (Swift — codesign, entitlements, TCC)1
- hardened-runtime-entitlementsHardened Runtime + missing entitlement = SILENT permission denial, no TCC prompt ever shown. Symptom (Trail Ambient, Swift menubar app): AVCaptureDevice.requestAccess(for:.audio) returned with no dialog, no crash, and the app never appeared in System Settings › Privacy › Microphone. Root cause: signed with `codesign --options runtime` (Hardened Runtime) but no `--entitlements` plist — under Hardened Runtime, mic access requires `com.apple.security.device.audio-input` IN THE SIGNATURE ITSELF. `NSMicrophoneUsageDescription` in Info.plist is necessary but NOT sufficient on its own — without the entitlement, macOS denies access before it ever asks. Fix: sign with `codesign --options runtime --entitlements <plist> ...` where the plist sets `com.apple.security.device.audio-input = true` (camera = `com.apple.security.device.camera`; Screen Recording is TCC-only, no entitlement exists for it). ALWAYS verify post-build with `codesign -d --entitlements - <App>` — an empty entitlements block alongside `flags=0x10000(runtime)` in `codesign -d` output is exactly this trap. Cost Trail ~1h to diagnose.— trail