The portals

The platform is the story; since v1.6 the chrome keeps up. A magic-link login wall (v0.8), a named-token perimeter that flows service_identity into every audit row (v0.9), direct-API post-login self-serve with step-up auth on every sensitive write (v0.10), a chat surface scoped to the logged-in customer with a hard ownership trip-wire (v0.12), the v0.13 operator cockpit (REPL canonical, browser sharing the same Conversation store) — and, as of v1.6, a full operator CRM around that chat: customers, cases, orders, catalog and subscription screens with direct policy-gated CRUD, a real design system, and a fixed-viewport app shell that holds up on an iPad.

Two small server-rendered portals sit next to the CLI: a self-serve customer portal on :9001 and an operator cockpit on :9002. Both are HTMX plus Jinja, no SPA framework, vendored htmx.min.js, which keeps the dependency surface tiny and lets streamed tool calls land in the page over SSE without fighting a client-side router.

Since v0.11, the signup funnel writes directly through bss-clients from the route handlers — one route, one BSS write, no orchestrator hop. Wall time dropped from about 85 seconds to under five. The customer chat widget is the only orchestrator-mediated surface that remains, and v0.12 narrowed its reach with the customer_self_serve tool profile (see the next section).

Self-serve portal (customer-facing)

Self-serve public landing page at /welcome: brand bar with bss-cli wordmark, tagline, and Sign in / Browse plans CTAs on a dark background.
Public landing — /welcome. Brand bar plus Sign-in / Browse-plans CTAs.
Self-serve plan picker at /plans: three cards (PLAN_S, PLAN_M, PLAN_L) showing Data, Voice, SMS, and Roaming allowance rows. PLAN_S has no roaming (em dash); PLAN_M ships 500 MB roaming; PLAN_L ships 2 GB.
Plan picker — /plans. Three plans, four allowance rows aligned (Data / Voice / SMS / Roaming, the last added in v0.17 telco-hygiene release).

Operator cockpit — chat centrepiece (v1.6 CRM)

Since v1.6 the browser on :9002 is a full CRM around the chat, not a veneer over it. Reads are screens: customer 360s with live balance bars, a case queue, a cross-customer order queue with the COM→SOM decomposition, the catalog, subscription detail. Writes are direct, one route → one policy-gated bss-clients call — and anything destructive or money-moving (terminate, case close, order submit, VAS, renew-now) sits behind a two-step confirm panel that states the consequence before the red button appears; the route refuses a POST that didn't come through it. The conversational surface keeps its own propose-then-/confirm contract, and every screen carries an "Ask the agent" handoff that opens a focused session with the request drafted — never auto-sent.

Live cockpit conversation: operator asks 'show me customer CUST-204d11ad'; the agent calls customer.get and renders an ASCII 360 card (status, contact, subscriptions, open cases, recent interactions) followed by a terse 'Done.' bubble. The compose box is pinned at the bottom of the viewport.
The centrepiece — one operator prompt, one customer.get, one deterministic ASCII 360 card. The compose box is always on screen: the v1.6 app shell sizes itself off visualViewport, and turns survive dropped connections (the agent runs in a detached task; a watchdog re-attaches and renders the persisted reply).
Cockpit conversation pinned to Sofia Lim: the operator asks to terminate SUB-0234; the subscription.terminate tool returns DESTRUCTIVE_OPERATION_BLOCKED and the assistant bubble reads 'Proposed subscription.terminate(subscription_id=SUB-0234). Type /confirm to authorise.'
Propose-then-/confirm, live — the destructive gate returns DESTRUCTIVE_OPERATION_BLOCKED, the proposal is staged, and the bubble tells the operator exactly what will run. Nothing terminates until /confirm.
Customers screen: search box and state filter above a dense table of customers (ID, name, status badge, KYC, MSISDN, email) with a chat shortcut per row.
Customers — search by name fragment or MSISDN, filter by state, jump into a pinned chat session from any row.
Customer 360 for Sofia Lim: blocked PLAN_S subscription with a fully-consumed data bar at 100%, completed order, an in-progress high-priority case, identity and contact-medium edit forms, and the card on file — plus 'Open chat session', 'Ask the agent' and a guarded 'Close customer…' button.
Customer 360 — subscriptions with live balance bars (this one block-on-exhaust at 100%), orders, cases, interactions, card-on-file, and identity/contact CRUD in the side rail. The red verbs expand a consequence panel before they fire.
Case workbench: an in-progress high-priority case 'Data stopped mid-day — line shows blocked' with state-transition buttons, a priority selector, a guarded Close case panel, a child ticket assigned to an agent with per-row actions, and a notes thread with an add-note box.
Case workbench — take / await / resolve transitions, priority, child tickets with assign / resolve / cancel per row, notes, and a guarded close. PolicyViolation messages flash back verbatim (try closing with an open ticket).
Order detail: COM order header with state badge and items table, and the SOM decomposition panel showing the service order with its CFS/RFS services and states.
Order detail — the COM header and items above the live SOM decomposition (service order → CFS → RFS), each node with its own state. Submit and cancel are confirm-gated; the queue page creates orders cross-customer.
Subscription detail for a heavy user: data bar at about 80% consumed, unlimited voice/SMS rows, SOM services table, recent usage tail, and side-rail panels for VAS top-up, plan change scheduling, renew-now and a guarded Terminate.
Subscription detail — balance bars, SOM services, the rated-usage tail, eSIM re-display, plan-change scheduling on the renewal boundary (never terminate-and-recreate), and confirm-gated money verbs.
Catalog screen: plans table (PLAN_S/M/L with price, data, voice, SMS, roaming columns), VAS top-ups table, and promotions table with the demo promotions — plus an add-offering admin form.
Catalog — plans, VAS, promotions, and the add-offering / add-price / set-window admin forms. Promo assignment deliberately stays conversational: bss promo assign composes the loyalty pairing a bare form would skip.

Promo codes (v1.1)

An adapter, not a dependency. Promotions are delivered through an optional loyalty-cli entitlement engine. BSS-CLI owns the money terms and the eligibility gate; loyalty-cli owns the entitlement state machine. When no loyalty token is configured the promo subsystem turns off and signup, COM, SOM and the portal all run exactly as before — the discount path is the only thing that goes quiet. Graceful degradation is the contract, not an afterthought.

Two audiences, one rule that promos never stack. Public typed codes — the customer types a code at signup (e.g. WELCOME10); the portal previews the discounted price live. Targeted codes — a real loyalty code paired with a BSS-side eligibility list; BSS is the gate, and an eligible customer sees the cheapest applicable offer pre-applied (with a remove toggle). Either way the discount composes on the v0.7 price snapshot — never the live catalogue — and charges the effective amount for one, N, or perpetual periods. Used vouchers drop off; renewals step the remaining periods down until the price reverts.

Self-serve signup form for PLAN_M with a Discount section: the customer has typed WELCOME10-style code DEMO15 into the promo field, and a live preview reads '🎉 15% off code applied — was SGD 25.00/mo, now SGD 22.50/mo'.
Signup — the promo field previews the discounted price the moment a code is entered (catalog does the loyalty lookup; the portal holds no loyalty token).
Self-serve dashboard 'My account' showing an active Standard line (90000724) with allowance bars and a discount note: '🏷️ 15% off · paying SGD 21.25/mo · 2 more renewals at this price, then SGD 25.00/mo'.
Dashboard — the applied discount rides on the subscription: effective price, periods remaining, and the price it reverts to, read straight off the snapshot.

The operator side is CLI-native and not in the customer profile — a customer redeems a code, they never mint one. bss promo create runs a two-step saga (BSS money terms + loyalty entitlement registration); bss promo assign adds customers to a targeted promotion's eligibility list; bss promo show renders the terms, loyalty link and state.

$ bss promo create --id PROMO_WELCOME10 --type percent --value 10 \
      --duration multi --periods 3 --audience public \
      --code WELCOME10 --code-kind multi_use --name "Welcome 10%"
✓ Created promotion PROMO_WELCOME10 (audience=public, code=WELCOME10) — state=active, OD=OD_PROMO_WELCOME10

$ bss promo show PROMO_WELCOME10
          Promotion PROMO_WELCOME10
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ field               ┃ value               ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ name                │ Welcome 10%         │
│ state               │ active              │
│ audience            │ public              │
│ code                │ WELCOME10           │
│ offerDefinitionId   │ OD_PROMO_WELCOME10  │
│ discount            │ percent 10.00       │
│ duration            │ multi (periods=3)   │
│ applicableOfferings │ all                 │
└─────────────────────┴─────────────────────┘

The chat surface (v0.12)

The chat widget is one modality of access, not a privileged path. It calls the same typed tools through the same policy layer as the CLI — just through a tighter prompt-visible window. The architectural point is the chokepoint: the LLM cannot reach a tool the customer's direct UI doesn't already expose. If the chat ever has a capability the dashboard doesn't, that's a doctrine bug, not a feature.

The window is a tool profile, customer_self_serve — sixteen curated tools: eight read wrappers (subscription.list_mine, get_balance_mine, usage.history_mine, etc.), four write wrappers (vas.purchase_for_me, schedule_plan_change_mine, cancel_pending_plan_change_mine, terminate_mine), three public catalog reads, and one escalation tool (case.open_for_me). No *.mine tool accepts a customer_id parameter; the actor is bound from auth_context.current() and a startup self-check refuses to boot if any signature drifts.

  customer chat  ──▶  ┌─────────────────────────────────────────┐
                      │  Layer 1 — server-side policies         │
                      │  (the primary boundary, unchanged)      │
                      ├─────────────────────────────────────────┤
                      │  Layer 2 — *.mine wrapper pre-check     │
                      │  customer_id bound from auth_context;   │
                      │  cross-customer ids refused at the seam │
                      ├─────────────────────────────────────────┤
                      │  Layer 3 — output ownership trip-wire   │
                      │  every emitted tool result scanned for  │
                      │  cross-customer fields; mismatch → P0   │
                      └─────────────────────────────────────────┘
                                      │
                              generic safety reply
                              on trip — no leaked tool name

Caps bound the blast radius of a runaway customer (or a runaway prompt). Twenty requests per hour and two dollars per month by default, configurable via BSS_CHAT_*_PER_* environment variables, accounted from OpenRouter usage metadata into an audit.chat_usage row per customer per period. Cap checks fail closed: a database error means the chat refuses to call astream_once, not the other way round.

Five categories the chat is not allowed to resolve on its own — fraud, billing_dispute, regulator_complaint, identity_recovery, bereavement. The system prompt names them with examples; when the agent recognises one, it calls case.open_for_me, which writes a CRM case linked to a SHA-256 hash of the conversation. The transcript itself lives in audit.chat_transcript, addressed by hash, append-only. The operator opens the case in the v0.13 cockpit and reads the conversation in the "Chat transcript" panel.

Pre-signup browse mode is the same widget with a different system prompt. Any verified-email visitor without a customer record yet sees the chat on /welcome and /plans; the *.mine wrappers refuse cleanly because there is no actor to bind. The visitor can ask "what plans do you have?" and get an answer from the public catalog reads without being forced through signup first.

Continue: Doctrine → Versions → Architecture →