logicspike/docs

Guides

Adding a New Service to Vlozi

Last Updated: 2026-09-04 Status: Active Source of truth for this guide: the engagement-service implementation plan (2026-09-04), which audited every touchpoint below against the code before this guide was written.

A checklist and reference for registering and scaffolding a new platform service — a new Cloudflare Worker with its own tenant-facing surface (blog, forms, collections, and now engagement are the precedents). Read this before starting; it exists so the ~16-touchpoint registration chain is never re-discovered from scratch.


0. When a new service is warranted

Prefer extending an existing service when the feature is small, has no public write surface of its own, and shares a trust model with a service that already exists. Build a new service when at least one of these is true:

  • It needs its own public write door with a different trust model than any existing worker (an anonymous visitor writes data — a submission, a comment, a reaction — and the tenant is resolved from an opaque id in the URL, never from a header).
  • Its blast radius should be isolated: spam pipelines, AI calls, or high write volume that must not share compute or a database with an unrelated feature.
  • It will be reused across modules (a generic capability like reader engagement, used by blog today and collections tomorrow) rather than being one module's feature.
  • The team is committed to the surface for more than one quarter — the registration tax (this whole guide) is real, roughly 4–5 engineer-days of pure plumbing before the first feature ships.

If none of these hold, extend an existing service instead: three files (a route, a handler, a permission guard) per new capability, no new worker, no new registration chain.


1. Naming

Pick these once, consistently, before touching any file:

Thing Convention Example
App directory apps/<name>-service (newer workers) or bare apps/<name> apps/engagement-service
Worker name (wrangler name) vlozi-<name>-service (newer convention; older workers use logicspike-<name>) vlozi-engagement-service
ServiceCode short, lowercase, matches the permission prefix "engagement"
Permission prefix equals the ServiceCode unless you add an alias (see §6) engagement:
MCP namespace usually equals the ServiceCode; a few historical exceptions exist (chat for chatbot, flow for flows) engagement
Public door path /<service>/<door>/<credential>/* /engagement/e/:site_id/*
Dashboard base path /dashboard/<service> /dashboard/engagement

Inconsistent naming (permission prefix ≠ service code, dashboard path ≠ service code) is not fatal, but it costs you an entry in SERVICE_CODE_ALIASES, PERM_PREFIX, and a breadcrumb special case later. Avoid it unless you have a reason.


2. The registration chain

Sixteen touchpoints, most of them silent (no typecheck failure if you skip them). The table below is the map; the sections after it are the how-to for each row.

# Touchpoint Files Enforced by typecheck?
1 ServiceCode packages/core-types/src/service.ts no
2 Permission group packages/core-access/src/permission-catalogue.ts no (runtime: unknown ids are silently dropped from API keys)
3 Services catalog row + limits packages/core-database/src/seed.ts and apps/manager/src/seed.ts no
4 Backfill scripts packages/core-database/src/backfill-<svc>.ts, backfill-<svc>-limits.ts no
5 Provisioning lists packages/core-tenant/src/tenant.factory.ts, apps/manager/src/routes/onboarding.ts, apps/manager/src/routes/tenants.ts no
6 Gateway apps/gateway/wrangler.toml, src/types.ts, src/routes/<svc>.proxy.ts, src/index.ts partially (GatewayBindings)
7 Worker skeleton apps/<svc>/ —
8 MCP catalog packages/service-catalog/src/<svc>.catalog.ts, src/index.ts yes, for ALL_TOOLS
9 MCP dispatch / gateway packages/mcp-dispatch/src/index.ts, apps/mcp-gateway/src/{types,server}.ts + wrangler no
10 Flows apps/flows-service/src/types.ts, wrangler, packages/service-catalog/src/triggers.ts no (add a parity test)
11 Brain / copilot packages/brain/src/tools/<svc>.ts, tools/index.ts, registry.ts, apps/brain-service/src/routes/chat.route.ts yes, for TOOL_ACCESS only
12 Billing packages/core-types/src/credit-costs.ts, packages/core-billing/src/reasons.ts, packages/service-catalog/test/billing-parity.test.ts, apps/billing-crons, apps/internal-api/src/services/spend.ts tests catch most of it
13 Dashboard src/lib/service-registry.ts, src/modules/<svc>/, src/app/api/<svc>/[...slug]/route.ts, src/app/dashboard/<svc>/** no
14 Docs docs/<svc>/, apps/docs/src/lib/docs.ts (optional), public-docs/NN-<svc>/meta.md no
15 Site-sync (content services only) packages/site-sync-core, apps/site-sync/src/lib/versions.ts yes, BINDING_FOR is exhaustive
16 Turbo / root scripts turbo.json, root package.json no

Same-commit groupings

Two groups of touchpoints must land together or you create a broken intermediate state:

  • User-visible provisioning: {1 ServiceCode, 2 permission group minimum, 3 services catalog row, 13 dashboard registry entry}. If you provision a service into tenant_services before its dashboard registry entry exists, the sidebar shows a broken fallback item (see the trap in §15). Split provisioning into two commits if you must: an early commit that adds the ServiceCode and an inert services catalog row (no plan_service_limits, so rebuildEntitlements never provisions it), then a later commit that adds the limit rows, the backfill run, and the dashboard entry together.
  • MCP surface: {8 catalog, 9 dispatch map, 9 mcp-gateway binding, 10 flows binding}. A tool with nodeMeta but no flows binding fails at runtime with "Service binding missing"; a namespace missing from NAMESPACE_SERVICE_CODE silently bypasses the service-disabled check. Ship these together.

3. Worker skeleton checklist

Clone apps/forms-service, not apps/blog-service. Forms already has the anonymous-public-write trust model that most new services need (tenant resolved from an opaque id in the URL, not from a header); blog trusts headers throughout and has no public write door to copy from.

Files to copy and adapt:

apps/<svc>/
  package.json          — name, scripts (dev/deploy/check-types/generate/migrate/db:push/studio/test)
  tsconfig.json          — extends @repo/typescript-config/base.json
  drizzle.config.ts       — schema ./src/db/schema.ts, out ./drizzle, postgresql, url from <SVC>_DATABASE_URL
  wrangler.toml
  .dev.vars.example
  src/
    types.ts             — RateLimitBinding + Bindings
    context.ts            — ContextVariableMap { context; db; coreDb? }
    index.ts               — the middleware chain (below)
    db/{client.ts,schema.ts}
    lib/ids.ts             — newId(prefix) => `${prefix}_${crypto.randomUUID()}`
    utils/logger.ts
    services/{result.ts,health.ts}
    middleware/{auth.middleware.ts,db.middleware.ts}
    routes/public/…       — the public door
    routes/admin/…
    routes/internal/…     — x-internal-token guarded
  test/
    setup.ts               — createTestDb() via PGlite + real migrations
    app.ts                  — makeTestApp(db, auth, coreDb?), makeEnv(), execCtx(), allowLimiter/denyLimiter
    route-coverage.test.ts  — findUngatedMutations(app, { allow })
  vitest.config.ts

src/index.ts middleware chain, in order (deviating from this order is the single most common source of subtle bugs):

  1. app.onError — generic {error, code:"internal_error", request_id}, never err.message, on the public surface.
  2. app.get("/health") and app.get("/ready") — before the gateway guard.
  3. app.use("*", logger()).
  4. app.use("*", dbMiddleware).
  5. app.route("/internal", internalRoute) — before the gateway guard, if /internal/* needs to be reached worker-to-worker without a gateway key (guarded instead by its own x-internal-token). This is the blog/collections precedent; forms mounts its one internal route after the guard instead, gated by the gateway key — pick whichever your internal caller can supply.
  6. The gateway-key guard + context hydration, one middleware: comma-split GATEWAY_SECRET.split(","), 403 on mismatch; parse x-user-permissions as JSON; validate x-request-id against /^[a-zA-Z0-9._\-:]{1,128}$/ or mint a new one; c.set("context", { request_id, auth: { tenant_id, user_id, role, permissions, services: {} } }) from the x-tenant-id/x-user-id/x-user-role/x-user-permissions headers.
  7. The public door mount — no requireTenant; tenant comes from the row the opaque id points to, never from a header.
  8. app.use("/admin/*", requireTenant).
  9. app.on(METHOD, [paths], requirePermission("<svc>:…")) guard blocks, then the admin route mounts.
  10. /mcp/* guards (mirroring the admin ones) + the MCP route mount.
  11. /internal/flush-accumulator if the service meters usage (gateway-key gated, no dedicated token — see forms).
  12. export { app }; export default app; plus any Durable Object class exports (export { MyCounter } from "./do/my-counter") — the named app export is required for the route-coverage test.

Ports and rate-limit namespaces are collision-prone; check this table before picking one, and add your row when you do.

Dev port Worker
8787 manager
8788 gateway
8789 mcp-gateway
8790 communication
8791 blog
8792 content-engine
8793 brain
8794 contact-intelligence
8795 newsletter
8796 chat-engine
8797 internal-api
8798 collections
8799 flows
8800 engagement
8801 media
8802 forms
8803 mailbox
8804 site-sync

Four collisions were resolved on 2026-09-05 (media, forms, mailbox and site-sync each moved to a free port above 8800). They were not harmless: apps/mcp-gateway/src/transports/stdio.ts had accumulated three separate mis-mappings from copied ports — content pointed at chat's, chat pointed at collections', and media pointed at mailbox's — each of which silently proxied local MCP traffic to the wrong worker whenever the corresponding LOCAL_*_URL was unset.

wrangler.toml is gitignored repo-wide, so a port change only exists on the machine that made it. Update this table in the same commit — it is the only record that travels.

Ratelimit namespace_id only needs to be unique within one worker's wrangler.toml, but the convention groups them by worker so a copy-paste error is obvious: gateway 1001, forms/newsletter 2001–2003, comms/flows 3001, chat-engine 4001, engagement 5001–5004. Use the next free thousand-block for a new service.

Secrets: on Windows, never ... | wrangler secret put NAME — the pipe corrupts base64/UTF-8 content via a BOM. Use wrangler secret bulk <file> with a .env-style or JSON file, or wrangler deploy --secrets-file <file> on the very first deploy so secrets land atomically with the code (the worker never has a window where /ready fails because a secret is missing).

Durable Objects, if the service needs write-absorbing or scheduled work (a beacon counter, a debounce, a per-tenant poller):

  • Cloudflare's account is at both its Queues limit and its 5-cron-trigger limit — a new queue consumer or cron trigger cannot be created. DO alarms are the sanctioned scheduler for anything periodic (precedents: billing-crons CronTicker, content-engine AnalyticsScheduler, chat-engine SessionStore).
  • wrangler.toml: [[durable_objects.bindings]] name="X" class_name="X" + [[migrations]] tag="v1" new_sqlite_classes=["X"]. Migration tags are append-only — never edit or remove a historical tag; a new class gets a new tag.
  • The vitest DO stub (test-stubs/cloudflare-workers.ts, aliased via each worker's vitest.config.ts) exports only bare no-op classes with no ctx/storage. Put anything that touches this.ctx.storage.sql in a separate pure module and unit-test that; the DO class itself is only importable for the route-coverage test.
  • If the DO does scheduled Neon writes, never call deleteAll() while it still holds state you need across restarts (a dedup set, a debounce window) — deleteAll() clears every table in the DO's SQLite storage, not just the one you're done with.

4. Gateway (touchpoint 6)

Six steps, documented in full in docs/gateway/developer-guide.md §4, plus two more this guide adds:

  1. Service binding — apps/gateway/wrangler.toml: [[services]] binding="X_SERVICE" service="vlozi-x-service". Cloudflare rejects a deploy whose [[services]] targets a worker that doesn't exist yet — add this block only after the new worker is deployed once. Code that references the binding (types, proxy file) can and should merge earlier, with the binding typed optional.
  2. GatewayBindings — apps/gateway/src/types.ts: X_SERVICE?: Fetcher (optional until the wrangler block lands).
  3. Route proxy file — apps/gateway/src/routes/<svc>.proxy.ts, copied from forms.proxy.ts (or collections.proxy.ts, identical shape): a forward() helper using buildDownstreamHeaders/proxyFetch/proxyResponseArgs, a public .all("/<door>/*", forward) with no auth middleware, an admin chain authMiddleware → accessMiddleware("<svc>") → subscriptionGuard → forward, and a 404 fallback.
  4. Mount — app.route("/<svc>", svcProxy) in apps/gateway/src/index.ts.
  5. Health flag — add X_SERVICE: !!c.env.X_SERVICE to the hand-maintained boolean map in app.get("/health", …). (Several existing services are already missing from this map — check while you're there.)
  6. ServiceCode — covered in §5 below; the guide numbers it here because it's the step easiest to forget when the gateway change is the whole PR.
  7. Public-door CORS block, if the service has one — app.use("/<svc>/<door>/*", cors({ origin: "*", allowMethods: [...], allowHeaders: ["Content-Type"] })). allowHeaders: ["Content-Type"] is load-bearing: it means a browser physically cannot attach an Authorization or x-api-key header through that door, which is what makes "tenant resolved from an opaque URL id" a safe trust model. Register this block before app.use("/*", rateLimitMiddleware) (near the top of index.ts) — the rate limiter runs before every cors() block today, so a 429 from a door registered after it has no CORS headers and shows up in the browser as an opaque network error, not a readable 429. If the public door needs its own rate budget separate from the global per-IP limiter (a beacon endpoint under real traffic will trip the global limiter and throttle every other API call from the same NAT), add a path-scoped ratelimit binding and branch on it in rate-limit.middleware.ts.
  8. SPOOFABLE_IDENTITY_HEADERS — if your service reads any new header off the incoming request as trusted (rare — most services only need the standard set: x-gateway-key, x-tenant-id, x-user-id, x-user-role, x-user-permissions, x-user-services, x-request-id), confirm it's in the stripped list in apps/gateway/src/utils/proxy.utils.ts so a client can't forge it through the gateway.

GATEWAY_SECRET rotation is asymmetric — the gateway writes a single value (c.env.GATEWAY_SECRET), every downstream service reads a comma-separated list and splits it. To rotate: add the new secret to every service's list, then flip the gateway's single value, then remove the old value from every service's list.

Edge caching for public GETs is optional (apps/gateway/src/utils/cache.utils.ts); the only current implementation (cachedProxy) is hardcoded to blog's tenant-keyed cache shape — a new service needs a URL-keyed variant (site/collection id in the URL, not a tenant header) if it wants gateway-level caching on GETs.


5. Public-door pattern

If the service has a public write surface (an anonymous visitor submits, reacts, or comments), copy apps/forms-service/src/routes/public/submit.ts end to end — it is the fully-worked reference for this pattern. In order:

  1. Opaque credential, not an API key. The public door's URL carries a random unguessable id (form_<uuid>, site_<uuid>); the tenant is derived by looking up that row, never from a header. A per-isolate cache (with negative entries, ~30s TTL) avoids hammering the DB for the same id.
  2. Origin check. Two variants exist in the codebase and you should pick deliberately: the lenient one (forms, collections) treats a missing Origin header as allowed — fine for reads, because curl and server-side fetches have no Origin. The strict one (newsletter's widget.route.ts) rejects a missing Origin whenever an allowlist is configured — use this for writes, because a write burns the tenant's credits or reputation and "decorative against curl" is not an acceptable posture there.
  3. Rate limit before body parsing, always — a native [[ratelimits]] binding, keyed on <credential>:<ip> (or a target-specific key if one caller could flood one target from many IPs). Never parse the body before this check.
  4. Body size caps, checked twice: once against content-length, once against the actual parsed size (a chunked-transfer request omits content-length).
  5. Honeypot — a hidden field name from the tenant's own config; a filled honeypot returns a silent 200 with the row marked spam, never notifies, never charges.
  6. Turnstile, when configured — fails closed (any error, timeout, or misconfiguration returns "not verified", not "verified").
  7. Validation, then any credit gate (see §8 Billing).
  8. Insert, then side effects entirely in waitUntil: notifications, webhooks, events, metering. Never block the response on them. Use c.executionCtx?.waitUntil(...) with the optional-chain — the test harness's execCtx() supplies a fake context and the plain ?. guard makes the code testable without one.
  9. Stranger-safe errors. The public door renders to strangers; never leak an internal error message, a stack trace, or billing internals in a public-facing error body. Map internal errors to generic copy (forms' friendly() pattern).
  10. text/plain bodies need manual parsing. If a browser sends the body via navigator.sendBeacon (a common pattern for fire-and-forget telemetry, because it survives page unload and needs no CORS preflight), the Content-Type is text/plain, not application/json — Hono's zValidator("json") rejects it outright. Read await c.req.text(), JSON.parse in a try/catch, then validate with a zod schema by hand.

6. Permissions & RBAC (touchpoint 2)

Add a PermissionGroup to packages/core-access/src/permission-catalogue.ts:

{
  service: "<svc>",
  label: "<Human Label>",
  permissions: [
    { id: "<svc>:resource.read",  label: "…", group: "…", readOnly: true },
    { id: "<svc>:resource.write", label: "…", group: "…" },
    { id: "<svc>:settings.admin", label: "…", group: "Settings" },
  ],
}

Rules:

  • Format is service:resource.action. No bare service:action.
  • readOnly: true marks a permission as safe for a publishable (frontend-exposed) API key.
  • Suffix a permission .admin (precedent: media:files.admin) to exclude it from the default Member role — packages/core-access/src/rbac.manager.ts computes the Member role's writable permissions from MEMBER_WRITE_SERVICES (a hand-maintained set) minus anything ending .admin. Add your service to MEMBER_WRITE_SERVICES if members should get day-to-day write access; leave it out if only owners/admins should.
  • Owner, Admin, and Viewer roles derive automatically from the catalogue the moment the group is added — no code change needed for them. Only Member needs the explicit MEMBER_WRITE_SERVICES entry.
  • Every requirePermission("<svc>:…") string used anywhere in the worker must exist in the catalogue, or an API key holding that id has it silently stripped (sanitizeKeyPermissions in apps/manager/src/lib/mint-api-key.ts drops unknown ids). There is a packages/core-access/test/catalogue.test.ts (add it if it doesn't exist yet) that checks id format, uniqueness, and that each group's permission prefix matches its service field — write it once and every future typo gets caught.
  • The permission prefix equals the ServiceCode unless you add an entry to SERVICE_CODE_ALIASES (packages/core-access/src/service-permission.ts) and the dashboard's mirror PERM_PREFIX (apps/seller-dashboard/src/lib/service-permissions.ts) — keep them in sync or the sidebar's permission-based hiding logic disagrees with the gateway's.
  • The gateway's PBAC floor (apps/gateway/src/middleware/access.middleware.ts) derives from the catalogue automatically — no change needed there.
  • The dashboard's Role Builder and API-Key dialog both fetch /api/permissions/catalog at runtime, not at build time — a new group appears in both with zero dashboard code changes. But check apps/seller-dashboard/src/lib/api-key-roles.ts's hand-maintained preset sets (EDITOR_WRITE_SERVICES, PUBLIC_READ_SERVICES) — a service absent from EDITOR_WRITE_SERVICES means the "Editor" API-key preset can never write to it, even though the catalogue has the permission.
  • Route coverage. packages/core-access/src/route-coverage.ts's findUngatedMutations(app, { allow }) walks a worker's Hono routes and fails if any mutation lacks a requirePermission guard. Public-door routes are intentionally ungated — list them explicitly in the allow array of your route-coverage.test.ts.

7. Provisioning & seeds (touchpoints 3, 4, 5)

This is the part of the chain most likely to leave a service half-registered. Read it in full before writing any seed code.

The tables:

services              (code PK, name)                          — the FK target every tenant_services row points at
tenant_services        (tenant_id, service_code, enabled, user_disabled, limits, source)  — per-tenant entitlement
service_limits         (service_code, limit_key, display_name, default_value, unit)        — what a limit key means
plan_service_limits    (plan_id, service_code, limit_key, value)  — the actual per-plan numbers, -1 = unlimited

Step 1 — the services row, in both seed files. packages/core-database/src/seed.ts is canonical but deletes and reinserts plan_service_limits wholesale on every run — never run it against production. apps/manager/src/seed.ts is a separate, drifted copy (different display names, missing rows for some services, missing plan tiers for others) — add your row to both, in the same commit, or the FK from tenant_services breaks depending on which seed a given environment last ran.

Step 2 — do NOT add plan_service_limits rows yet if you want to delay provisioning. rebuildEntitlements (apps/manager/src/services/billing.ts) upserts a tenant_services row with enabled: true for every service that has a plan_service_limits row for the tenant's plan — and it runs on every onboarding and every plan change. The moment you seed plan limits, every new signup (and every existing tenant on their next plan event) gets the service in their JWT and — if the dashboard registry entry (§13) isn't merged yet — sees a broken fallback sidebar item. Sequence it: add the inert services row first (touchpoint 3, no limits), and only add service_limits + plan_service_limits in the same commit as the dashboard registry entry (§13) and the backfill run.

Step 3 — backfill scripts, for tenants that predate your service. rebuildEntitlements only fires on onboarding/plan-change events — it does not retroactively provision existing tenants who don't change plans. Write two scripts modelled on packages/core-database/src/backfill-forms.ts and backfill-forms-limits.ts:

  • backfill-<svc>.ts — insert the services row (idempotent ON CONFLICT DO NOTHING), then insert a tenant_services row for every tenant that doesn't already have one for this service code.
  • backfill-<svc>-limits.ts — insert service_limits rows (ON CONFLICT DO NOTHING) and plan_service_limits rows per plan tier (ON CONFLICT DO UPDATE SET value = EXCLUDED.value) — this script is safe to run against production; the bulk seed.ts is not.

Run both against the production core DB as part of your go-live, and tell users they need to re-login (the JWT is minted at login/refresh time, so an existing session won't see the new service until then).

Step 4 — the three (not two) default-provisioning lists. New tenants get their initial service set from whichever of these paths creates them, and the lists disagree today:

  • packages/core-tenant/src/tenant.factory.ts — the factory default list.
  • apps/manager/src/routes/onboarding.ts — the standard signup flow's platformServices list.
  • apps/manager/src/routes/tenants.ts — a third, older list (which currently references at least one code that isn't a real ServiceCode — check it still typechecks before you copy the pattern).

Add your service code to all three you touch. If you're doing meaningful work here anyway, consider exporting one DEFAULT_PROVISIONED_SERVICES array from @repo/core-tenant and having all three import it — it is the single biggest source of "why does this tenant not have the service" bugs.


8. Billing (touchpoint 12)

Full 8-step HOWTO lives in docs/billing/CREDIT-STANDARDS.md; the summary:

  1. Cost only in packages/core-types/src/credit-costs.ts — never a service-local constant. packages/core-billing/test/credit-costs.test.ts requires every value to be a positive number.
  2. Reason only in packages/core-billing/src/reasons.ts, dot-namespaced <service>.<action>[.<phase>].
  3. Ledger access via new CreditLedger(asLedgerDb(createCachedHttpDb(CORE_DATABASE_URL))) — never a bare createDb()/neon() call per request, and never a direct write to tenant_credits/credit_transactions from your service (packages/core-billing/test/no-direct-writes.test.ts scans every apps/*/src and packages/*/src directory automatically and fails the build on a direct write).
  4. Flat, one-shot charges (a publish, a claim) use chargedCall({ ledger, tenantId, cost, reason, idempotencyKey, work }). High-volume, sub-credit-unit charges (a submission, a comment, an email) use the accumulator pattern: accumulateAndMaybeSettle/canUsageProceed/flushUnbilledUsage from packages/core-billing/src/accumulator.ts, backed by a small UsageStore adapter (3 methods: increment, read, decrement with an optimistic-lock settlementSeq) over a table that lives in your own service's DB, not core — copy apps/forms-service/src/lib/credits.ts verbatim and swap the table/keys.
  5. Always pass an idempotencyKey unless the action is genuinely unrepeatable.
  6. On failure, render mcpChargeFailure/restChargeFailure — never a bespoke error body.
  7. Report creditsCharged in the success response.
  8. Add billing: { kind: "flat" | "metered" | ..., costKey, reason, ... } to the tool's CatalogEntry and add the namespace.tool name to the hand-list in packages/service-catalog/test/billing-parity.test.ts.

Daily flush. If you use the accumulator, register a job in apps/billing-crons/src/index.ts's JOBS array ({ key, path: "/internal/flush-accumulator", intervalMs: 24h, service: "<svc>" }) and add your service to the dispatch's binding/host lookup — plus give apps/billing-crons a [[services]] binding to your worker.

Internal-ops spend attribution. apps/internal-api/src/services/spend.ts's svcCase() classifies credit transactions into a spend-by-service breakdown by matching the reason column's prefix (LIKE 'blog.%', etc.) — add a WHEN reason LIKE '<svc>.%' THEN '<svc>' arm, or your service's spend silently lands in 'other'.

Pricing page rows on apps/website/app/pricing/PricingClient.tsx and apps/seller-dashboard/src/app/dashboard/settings/billing/page.tsx are hand-picked, editorial imports of CREDIT_COSTS keys — a new cost key doesn't break anything by omission, but add a row if the price is customer-facing.


9. MCP (touchpoints 8, 9)

  1. Catalog descriptors — packages/service-catalog/src/<svc>.catalog.ts, following the shape in forms.catalog.ts: each CatalogEntry has name, description, inputSchema, internalRoute ("METHOD /mcp/tools/…"), requiredPermission, and optionally outputSchema, nodeMeta (makes it a Flows node — required on any tool with nodeMeta, checked by apps/mcp-gateway/test/registry-parity.test.ts), and billing.
  2. Register the namespace — packages/service-catalog/src/index.ts: import your tools, add the export, add "<svc>" to the Namespace union, and — this is the one exhaustive map in the whole chain, typecheck fails if you skip it — add the key to ALL_TOOLS.
  3. Handlers — apps/<svc>/src/mcp/routes.ts, one handler per tool, envelope {data, error: null} / {data: null, error: message}. Guard each in src/index.ts with the matching requirePermission.
  4. Dispatch mapping — packages/mcp-dispatch/src/index.ts's NAMESPACE_SERVICE_CODE maps a namespace to its ServiceCode for the service-disabled check. It's a Partial map today, so a forgotten entry fails silently (the toggle is simply never enforced for that namespace) rather than at compile time — add your entry and double-check it.
  5. mcp-gateway — apps/mcp-gateway/src/types.ts (both the ServiceBinding union and McpGatewayBindings), src/server.ts's bindingMap(), and its wrangler.toml [[services]] block, kept "in sync with apps/gateway/wrangler.toml" per its own header comment — there's no automated check for this today, so verify by hand.
  6. Brain / copilot access, if the owner's AI copilot should be able to call these tools: a descriptor file in packages/brain/src/tools/<svc>.ts (shape: { name, service, description, parameters, requiresConfirmation, requiredPermission, execute }), registered in tools/index.ts and registry.ts's allTools; add your tool-name glob ("<svc>_*") to the relevant AgentType entries in TOOL_ACCESS (exhaustive over AgentType, typecheck-enforced); and wire an actual binding in apps/brain-service/src/routes/chat.route.ts's hand-rolled serviceCall binding map — several existing entries in TOOL_ACCESS reference services with no binding here, meaning their tools always fail with "not available"; don't repeat that mistake for a new one.

10. Flows (touchpoint 10)

  1. Triggers — append TriggerDescriptors to packages/service-catalog/src/triggers.ts's single flat array ({ type, description, configSchema, payloadSchema, filterKeys, source }). The payloadSchema must match exactly what your service puts in the payload field when it calls emitEvent(queue, { type, tenantId, source, payload }) from @repo/events — there's no automated check that they agree, only convention.
  2. Nodes — any catalog tool with nodeMeta automatically becomes an available Flows action node (listNodes() in apps/flows-service derives the palette from listNodeTools() over the whole catalog) — no separate flows-side registration needed for the node itself.
  3. Binding — but the node still needs to execute, which happens over a direct service binding (not through mcp-gateway): add X_SERVICE?: Fetcher to apps/flows-service/src/types.ts's Bindings, add it to the serviceBindings() map that turns bindings into the dispatch's BindingMap, and add the [[services]] block to flows-service's wrangler.toml. Several existing namespaces already have nodeMeta tools with no binding here and fail at runtime — write a parity test (every ALL_TOOLS namespace with at least one nodeMeta tool has a key in serviceBindings()) once, in your own PR, so it never regresses again.
  4. The dashboard's Flows builder needs no changes — it fetches /flows/nodes and /flows/triggers at runtime from the catalog.

11. Events (@repo/events)

VloziEvent = { id, type, tenantId, occurredAt, source, payload }; type is an open string, no registration needed beyond the trigger descriptor in §10. Emission is fire-and-forget: emitEvent(queue, input) never throws (it logs and resolves null on failure), and is always called from inside waitUntil. Convention: one emitXxx(queue, payload) helper per event type in src/lib/events.ts, keeping the payload-building logic in one place. An event emitted before its trigger descriptor exists in packages/service-catalog/src/triggers.ts is simply dropped by the Flows dispatcher (it looks up the descriptor by type and no-ops if not found) — safe, but worth sequencing your PRs so a trigger fires as soon as it's meaningful.


12. Dashboard (touchpoint 13)

Clone apps/seller-dashboard/src/modules/forms/ — it is the fullest-featured module (list/detail pages, an analytics dashboard, and a moderation inbox, if your service needs one).

Anatomy of a module:

modules/<svc>/
  index.ts               — export { register<Svc>Module } from "./register"
  register.ts             — idempotent reducerManager.add("<svc>", reducer)
  store/<svc>.slice.ts     — createSlice, plain setter reducers (no thunks, no RTK Query — data
                             fetching happens in components, then a setter dispatch)
  store/<svc>.types.ts      — wire types mirroring the worker's response shapes
  hooks/use<Svc>Store.ts    — typed selector with an initialState fallback (RootState is only
                             {_init} at compile time due to lazy reducer registration, so the
                             selector needs a double cast)
  api/proxy.ts              — Next.js route handler → gateway forwarder (copy verbatim from
                             modules/forms/api/proxy.ts; token via next-auth's getToken(), not auth())
  api/<svc>.admin.api.ts     — bare fetch() helpers against /api/<svc>/admin/..., no wrapper library
  pages/…                    — one component per dashboard page
  components/…

Wiring:

  • src/app/api/<svc>/[...slug]/route.ts — a 3-line re-export of the proxy's GET/POST/PUT/DELETE/PATCH.
  • src/app/dashboard/<svc>/**/page.tsx — each wrapped in <ServiceGuard service="<svc>">, with a client-only dynamic import that calls register<Svc>Module() before importing the actual page (this is what makes reducer registration lazy — the module's Redux slice never loads for a tenant who doesn't have the service).
  • The registry entry, apps/seller-dashboard/src/lib/service-registry.ts: a ServiceRegistryEntry (label, description, icon, gradient, basePath, navItems, status: "stable" | "beta" | "coming_soon"). This is what makes the service appear in the sidebar, the dashboard home grid, the mobile nav, and Settings → Services. This entry must land in the same commit/release as the provisioning step that puts the service into any tenant's session.services — a provisioned-but-unregistered service code falls back to a generic, broken-looking sidebar item (getServiceEntry()'s fallback), and status: "coming_soon" does not prevent this fallback from showing for an already-enabled tenant; it only affects whether new tenants get auto-provisioned and how the card renders on the home grid.
  • If your nav has a route more specific than the base path (an inbox, say), list it before the base path in navItems — active-item matching is prefix-based, so the more specific route must come first or both light up simultaneously.
  • If you want an unread-count badge on a nav item, SidebarMenuBadge exists as a UI primitive but nothing renders it today — you'll need to add an optional badge?: () => number | null field to ServiceNavItem and wire the render call yourself.
  • Permission checks in the UI (usePermission("<svc>:…"), <PermissionGate>) need no extra registration — they read the same runtime-fetched catalogue as the Role Builder.

13. Docs (touchpoint 14)

  • Internal docs (apps/docs, developer-facing): drop markdown files in docs/<svc>/. Folder discovery is automatic — any folder under docs/ with markdown in it is listed with zero code changes. apps/docs/src/lib/docs.ts's CATEGORY_META and orderedCategories are optional curation — a nicer display name, icon, and sidebar position — add an entry there once the docs exist, but it's polish, not a requirement. Follow the header convention every doc in this repo uses:
    # <Title>
     
    > **Last Updated:** YYYY-MM-DD
    > **Status:** Active | Draft | Deprecated
  • Public docs (apps/public-docs, customer-facing SDK/API reference), only if the service has a public integration surface: public-docs/NN-<svc>/meta.md with name as a required field — a product folder whose meta.md is missing or lacks name is silently invisible in the public docs app, not an error. Chapter folders are NN-kebab-case/ with their own small meta.md (title, summary); pages are NN-page-name.md with title/description frontmatter. public-docs/_changelog/<svc>/ entries need a date field or they're dropped as malformed.

14. Deploy runbook template

For a brand-new service, roughly this order (adapt as needed):

  1. Provision the database (a new Neon DB, or a new schema in an existing project).
  2. Run migrations against it.
  3. Write secrets via a secrets file (wrangler deploy --secrets-file on the very first deploy, or wrangler secret bulk after).
  4. Deploy the new worker.
  5. Add the gateway's [[services]] binding (only now that the worker exists) and redeploy the gateway.
  6. Deploy any upstream service that needs a new binding to call the new worker (e.g. blog calling engagement).
  7. Manually provision the entitlement for your staging/dogfood tenant (the admin door 403s until a tenant_services row exists — don't wait for the general backfill to test).
  8. Push the dashboard change (usually auto-deploys via the platform's CI/CD).
  9. Run the backfill scripts against production; tell users to re-login.
  10. If the change touches a published SDK package, go through its release pipeline (changesets, in this repo) rather than hand-bumping the version.
  11. Smoke test end to end.
  12. Redeploy every other worker that gained a new binding as part of this work (billing-crons, mcp-gateway, flows-service, brain-service, collections, site-sync — whichever apply).

Rollback posture: design each step to be independently revertible. A gateway redeploy without the new route mount should make the door 404 cleanly; a new binding on an existing service should be additive and guarded by presence (if (env.X_SERVICE) { ... }), never load-bearing for existing functionality.


15. Traps (read this list twice)

  • The sidebar fallback item. Provisioning a service into tenant_services before its dashboard registry entry exists shows a broken generic nav item to every tenant that has it. Sequence provisioning and the registry entry into the same release.
  • rebuildEntitlements only provisions services that have plan_service_limits rows. A service with no plan-limit rows (like Collections today) is never auto-provisioned by a plan change — only by the onboarding list or a manual backfill. Decide deliberately whether your service should have plan limits from day one.
  • ALL_TOOLS, BINDING_FOR (site-sync), and TOOL_ACCESS (brain) are the only three genuinely exhaustive maps in this chain — everything else (NAMESPACE_SERVICE_CODE, flows' serviceBindings, the gateway's health-check map, both seed files, the three provisioning lists) is a Partial map or a hand-maintained array that silently tolerates omission. Write a parity test for the ones that matter to you rather than trusting memory.
  • Port and ratelimit-namespace collisions are common because the convention isn't enforced anywhere — check the table in §3 before picking a [dev] port.
  • Two seed files disagree (packages/core-database/src/seed.ts vs apps/manager/src/seed.ts) on service names, limit rows, and plan tiers. Only the backfill scripts are safe to run against production; the bulk seed deletes and reinserts plan_service_limits wholesale.
  • GATEWAY_SECRET rotation is asymmetric — single value at the gateway, comma-separated list at every downstream service. Rotate in the right order (§4) or you'll lock yourself out mid-rotation.
  • MCP key-cache prefix collision history. The gateway's API-key cache and mcp-core's API-key cache share one KV namespace; they use different key prefixes (ak: vs mcpak1:) specifically because a past bug had them collide and let a scraped publishable key gain MCP write access for a cache TTL. If you ever add a third API-key caching layer, give it its own prefix.
  • x-user-id is not sent on the MCP dispatch path, unlike the gateway's REST proxy path, which does send it. If your worker needs to know "who" made an MCP call (for an audit trail, an "authored by" field), check x-agent-id instead, or don't rely on x-user-id being present.
  • A text/plain POST body (from navigator.sendBeacon, or any fire-and-forget browser telemetry) will be rejected by Hono's JSON body validator before your handler even runs — parse it by hand.
  • The rate limiter runs before every CORS block in the gateway. A public door registered after app.use("/*", rateLimitMiddleware) will return 429s with no CORS headers, which browsers report as an opaque network error rather than a readable status code. Register your CORS block earlier.

16. Copy-paste checklist

[ ] 1  ServiceCode added                              packages/core-types/src/service.ts
[ ] 2  Permission group added                          packages/core-access/src/permission-catalogue.ts
[ ]    MEMBER_WRITE_SERVICES updated (if members write) packages/core-access/src/rbac.manager.ts
[ ] 3  services row (both seeds)                        packages/core-database/src/seed.ts + apps/manager/src/seed.ts
[ ]    service_limits + plan_service_limits (deferred    (same commit as #13)
       until the dashboard entry is ready)
[ ] 4  backfill-<svc>.ts + backfill-<svc>-limits.ts      packages/core-database/src/
[ ] 5  provisioning lists (all three)                    tenant.factory.ts, onboarding.ts, tenants.ts
[ ] 6  gateway: binding, types, proxy, mount, health,     apps/gateway/
       CORS (before rate limiter), header stripping
[ ] 7  worker skeleton (forms template)                  apps/<svc>/
[ ] 8  MCP catalog + ALL_TOOLS                           packages/service-catalog/src/
[ ] 9  dispatch map + mcp-gateway binding                packages/mcp-dispatch, apps/mcp-gateway
[ ] 10 flows binding + trigger descriptors + parity test  apps/flows-service, packages/service-catalog/src/triggers.ts
[ ] 11 brain tool descriptors + TOOL_ACCESS + binding     packages/brain, apps/brain-service
[ ] 12 credit costs + reasons + billing-parity test        packages/core-types, packages/core-billing
       + billing-crons flush job + internal-api spend arm
[ ] 13 dashboard registry entry + module                  apps/seller-dashboard/
[ ] 14 docs/<svc>/ + public-docs meta (if public)          docs/, public-docs/
[ ] 15 site-sync SyncSource + BINDING_FOR (content only)   packages/site-sync-core, apps/site-sync
[ ] 16 turbo test task + dev:<svc> script                  turbo.json, root package.json
[ ]    route-coverage test + catalogue test written
[ ]    parity tests written (mcp-gateway, flows binding)
[ ]    deploy runbook written and followed
[ ]    users told to re-login after the backfill
Guides