bss-cli
v1.7.0Self-serve portal, operator cockpit, promo codes, and the scoped customer chat surface.
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)
/welcome. Brand bar plus Sign-in / Browse-plans CTAs.
/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.
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).
/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.
PolicyViolation messages flash back verbatim (try closing with an open ticket).
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.
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.