Support — one spine,three faces
Support is not a screen. It is one intake, one triage, and as many faces as the person needs — in the app, on the open web, and through their agent. Six doors already ship; the only one inside the product is the only one that reaches nobody.
01 The doors that already exist
Before drawing anything, the audit. Every row below is shipped code on main today. Read the last column first: five of the six doors reach a human being, and the sixth is the one a signed-in user would actually look for.
| Door | Where | Signed in? | What it can carry | Where it lands |
|---|---|---|---|---|
| Public contact form drawn in full → | relayctx.com/support | No | name, email, topic, subject, message, ref, source URL, Turnstile | Freshdesk ticket |
| Feedback screen | relayctx.com/feedback | Optional | category, subject, message, severity, rating, page URL | feedback row → admin email → Freshdesk |
| Rating widget | any page | Optional | rating, comment | same as above |
| The agent | relay_feedback · MCP | Yes | category, subject, message | same as above |
| Docs | docs.relayctx.com | No | — read-only | — |
| App screen | app.relayctx.com/support | Yes + flag | nothing — it is static | nowhere |
The first door already has its own concept — the public support form works through every state the shipped page has to survive, prefill contract included. This page does not redraw it; it asks what the other five doors have to become for that one to stop being the whole answer.
Three findings, read from the code
Two intakes, two contracts — and only one can carry a diagnostic
The public contact form accepts a ref field (max 16 chars) and puts it on the ticket. The feedback intake has no field for one at all. So the build brief’s step 2 — surface the last CE-… error ref on the app screen — has nowhere to put it on the intake that step targets. The ref would have to be smuggled into the message body, where triage cannot query it.
relay-platform · routes/support.py _MAX["ref"] · routes/misc.py handle_feedback
An app panel would be filed as the anonymous rating widget
handle_feedback coerces any source outside {widget, web} to "widget". A panel inside the app posting source: "app" is therefore recorded as a drive-by star rating, and no triage query afterwards can separate the two populations. This fires silently — a 201 comes back either way. Try it in §4.
relay-platform · routes/misc.py — if source not in ("widget", "web"): source = "widget"
The only door inside the product is the only one that reaches nobody
The shipped app screen renders commitment copy, an urgent-cases list and three outbound links. It calls no API and holds no state — deliberately, and it says so on its own face rather than showing a dead form. It is also gated dark. Everything in stages S2 and S3 is the work of joining it to the five doors that already work.
relay-app · web/src/support.ts — static render, no API calls · gate support_enabled
The asymmetry is the design — do not “complete” the gate. support_enabled covers the app screen and nothing else. The public form, the feedback screen, support@relayctx.com and the docs stay open to everyone, signed in or not, flag on or off.
The reason is one line, and it survives every stage below: a user who cannot reach the app is exactly the user who most needs support. Set the viewer switch in §3 to Locked out at any stage and watch what stays reachable.
02 The spine
One intake contract. One submission record. One triage state machine. Two back offices — the console for us, the desk for the conversation with them. Everything a person touches is a face on that spine, and a face is cheap precisely because it holds nothing.
Four laws the faces obey
- L1Every face keeps a signed-out route.Carried from the shipped screen’s own rule. No stage may remove the open door, and the portal in S3 is the open door widened, never a gate placed in front of it.
- L2Deflect before you ticket, never instead of.Docs and known answers surface as you type. The route to a human stays visible, undelayed and one action away the whole time — a deflection that hides the ticket is a trap.
- L3The face attaches what it already knows.Signed in: who, which org, which plan, current route, build, last error ref. Never ask a person to retype what the surface can read. The mobile brief states this as the reason Support travels mobile-first — “it can attach device, build and object unasked.”
- L4A stub says it is a stub.The shipped screen already does this and it is why it survives review: two panels state plainly that they are not wired rather than showing a form that swallows what you type.
03 Three stages, live
The same surface at three moments. S1 is drawn 1:1 from what shipped. S2 and S3 are proposals — nothing in them is built. Switch the viewer to see which parts survive when the person cannot sign in, which is the test the asymmetric gate exists to pass.
04 The intake contract
The build brief’s step 1 says to check the payload shape against triage before designing the form. This is that check, live: fill the composer, switch the face, and read the body that would actually go on the wire. The warning under it fires on the real coercion rule in handle_feedback.
- —
Contract v2 — additive, nothing breaks
Four changes close F1 and F2 without touching a single existing caller. The rating widget’s original contract — rating plus comment — keeps working untouched, which is the constraint that rules out a rewrite.
| Change | Why | Closes |
|---|---|---|
Widen source to widget · web · app · agent · portal | Five faces are already distinguishable at the call site; the coercion is what throws that away. Triage cannot serve a population it cannot see. | F2 |
Add ref — max 16 chars, same shape as the contact form’s | Carries the CE-… error ref so a report is diagnosable instead of a guess. One field, mirrored from a contract that already exists. | F1 |
Add context — {route, build}, client-declared | Law L3. Plan, org and identity stay server-attached from the session, exactly as they are today — never client-declared. | L3 |
Return ticket_id alongside id in the 201 | The face cannot show “your ticket” without it, and the desk ticket is already created on this path. Prerequisite for the portal’s ticket list — see C2. | S3 |
05 The desk seam
Freshdesk is the desk today. It should stay behind one adapter with one interface, for the same reason storage sits behind a region backend: the desk is a vendor, and a vendor is a thing you replace.
| Field on the wire | Today — shipped | Portal needs |
|---|---|---|
| subject | [Bug] <subject> — category prefix | unchanged |
| description | the message, plus a thank-you preamble | unchanged |
| requester — the identity the thread hangs on | unchanged — and it is the lookup key when signed out | |
| priority | derived from severity, bugs only | + derived from entitlement, not just severity |
| tags | relay-feedback, category-*, severity-* | + source-* once F2 is closed |
| private agent note | submission id, org, page URL, submitter | + the CE- ref once F1 is closed |
| custom_fields | none set from Relay | the product-id field — open call C4 |
| ticket read-back | none — the wire is write-only | the whole of S3. A portal that shows your tickets needs list-by-requester and reply-on-thread |
The write-only wire is the real gap between S2 and S3. Everything else in the portal is composition. Relay creates desk tickets today and never reads one back, so no surface can show a person the state of their own request — the state lives in an inbox they cannot see.
Two ways to close it, and the choice is C2: read the desk directly per request, or persist freshdesk_ticket_id on the submission row and mirror state on the desk’s webhook. The second is more work and is the one that keeps working when the desk is replaced.
06 The platform cut
Support is framework, not product. The feature-disposition map states the test in one line: strip every Relay noun from it — is there anything left, and does that remainder carry authority? For support intake the answer is yes and yes, so the split below is the shape the code should already be built in, before a second product ever asks for it.
Core Launchpad — domain-free
- The intake contract — category, severity, source, ref, context, and the identity-attached-server-side rule.
- The submission store and triage states —
new → reviewing → actioned → closedhas no Relay noun in it. - The desk adapter — create, read-back, reply, webhook. One interface, Freshdesk as the first implementation, exactly the Stripe/Resend pattern the brief already names for integrations.
- The deflection index — match text against a docs corpus and surface answers before the ticket. Every product has a corpus.
- The entitlement → response-commitment resolver — which tier gets which promise. It reads the feature-entitlement resolver already nominated for core.
- The asymmetric-gate posture — screen gated, help never. This is the scar tissue, and it is the part worth porting.
Product Relay — the words and the corpus
- The urgent-cases list — can’t sign in, can’t connect, can’t receive, sent to the wrong person. Every noun in it is Relay’s.
- The commitment copy — the priority-user promise, whose source of truth stays the support-tiers doc.
- The docs corpus itself, and every known answer in it.
- Agent-side intake via the Relay verbs — the tool names are product nouns and stay.
- The escalation ladder’s top rung — who a case reaches, and when.
Rule of three still applies — the action now is the shape, not the port. Nothing enters core on Relay’s need alone. What this section asks for is that the S2 work is built along this seam and marked, so the second product pulls a capability rather than a rewrite. Building the panel as one more hardcoded form is precisely what would make it unportable later.
There is already a decision open on this seam: the desk’s product-id custom field, which core proposes to make configurable per product manifest so each product names its own and Relay’s ticket history keeps working. That decision and this concept are the same seam seen from two ends — see C4.
07 Open calls
This concept does not make these. Each blocks something specific, named.
- Is a priority flag on the account worth building before the first non-EAP signup?Blocks the priority row in S2. The cohort is computed from a roster rule today, which stops working the day someone outside it signs up. Carried unchanged from the build brief — nothing else here depends on the answer.
- Ticket read-back: read the desk live, or mirror its state?Blocks S3 entirely. Live reads are cheaper to build and bind the portal to the vendor; mirroring costs a webhook and an id column and survives the vendor being replaced.
- Where does the portal live — its own host, or under the path that already exists?The public form’s own code comments say the path stays put “so a future help center can grow around it”. Growing in place inherits the open-by-default posture for free; a separate host has to be given it deliberately.
- The desk’s product-id custom field — rename now, or make it configurable?Open with framework leadership already. The recommendation on the table is configurable per product manifest: Relay keeps its current value, so no ticket history or desk automation breaks, and each product names its own.
- Does the app panel stay screen-gated at S2?It can, and the asymmetry is deliberate. At S3 it cannot — a portal is public by definition, so the gate must be understood as covering the in-app face only, and never the intake behind it.