API reference
Every AI Support endpoint — forty-eight admin routes with the permission on each, grouped by the screen that calls them, and the eight customer routes — plus the conventions that mislead a client.
Two surfaces.
Everything under /api/admin/ai/support carries an explicit permission and
is checked twice — once by the page gate that opens the screen, once by the route
itself. Everything under /api/ai/support is the customer's own side: signed
in, scoped to the caller, and carrying no permission at all, because a customer
holds none.
There are forty-eight admin routes and eight customer ones. Eight of the
forty-eight are the admin assistant's own console, all gated on one key; they are
grouped together below. The addon declares no WebSocket route of its own: the
Live Inbox rides the core support socket at /api/user/support/ticket,
subscribing to ticket-{id} like the desk console does.
Conventions that will catch you out
The platform's CORS middleware writes response headers before any route handler
runs, and uWebSockets locks the status line at 200 OK on the first header
write. So a refusal declared as 403 or 429 in the code below usually arrives as
an HTTP 200 whose body is { "message": "…", "statusCode": 403 }.
The frontend's own fetch wrapper compensates by treating a 2xx body carrying
statusCode >= 400 as an error. A client of your own must do the same, or a
403 on the settings write will read as a successful save.
Do not "fix" this by changing status codes: every existing consumer, including the whole admin panel, is written against 200-plus-body.
The coverage endpoint is answer-coverage/, not coverage/. That is not a
naming preference. .gitignore carries a bare coverage/ — the test runner's
output directory — and a bare pattern matches a directory of that name anywhere
in the tree. A route file under coverage/ works perfectly on the machine that
wrote it, is silently untracked, and does not exist in a fresh clone, in the
build, or on any buyer's install. The route loader would report nothing and the
screen would simply 404.
The catalogue routes are the source of truth, not the console. guide/,
operation/ and workflow/ each return what this build ships. Only
workflow/ also reports why an entry cannot currently run; guide/ and
operation/ return the entries alone, with no availability or blocked-reason
field on them. A static list in the admin form would be a second
copy that drifts, and it would drift in the worst direction: a workflow present
in the backend and absent from the screen is one the operator can never enable.
console/catalogue is the same contract for the admin side, with one addition:
it reports why an entry is unavailable in three separate fields, because
"the operator has not ticked it", "your role does not hold the key" and "this
platform is missing the addon" have three different remedies and three different
people who can apply them.
A 429 has three reasons, not two. Every route that calls a model consults the
budget first — every one except knowledge/search, which is the exception
documented below — and the gate refuses for daily_budget, monthly_budget or
unpriced_model. The third is the one a client will not expect: when a cap is
set and any model call this month moved tokens and recorded no cost, the gate
refuses rather than assuming those calls were free. It is the case of an install that pointed the
compatible adapter at a metered endpoint and was told $0.00 every day while the
real bill grew. The way out is to price the endpoint with
AI_SUPPORT_OPENAI_INPUT_USD_PER_MTOK and AI_SUPPORT_OPENAI_OUTPUT_USD_PER_MTOK,
or to set the caps to 0 — which has always meant "no ceiling", and is the
documented escape hatch for an install whose per-token cost really is zero.
Retrying will not clear it.
Settings are written changed-keys-only. See below — this one has teeth.
Admin endpoints
Master switch
Body: { "enabled": true, "autonomy": "COPILOT" | "AUTO_TICKET" | "AUTO_ALL" }.
enabled is required. autonomy is accepted by the schema and then ignored: the handler writes COPILOT unconditionally, so a new agent always starts in copilot and is promoted afterwards through the per-agent PUT, which is where the promotion gate lives.
It exists because both keys are in the platform's protected list, so the addon's own settings PUT refuses them — and no screen anywhere rendered them as a field. The shipped experience was an addon that could not be switched on.
It also refuses to switch on something that cannot answer: enabling with
aiSupportProvider still null, or with the selected provider's credentials
missing from .env, returns a 400 naming what is absent rather than producing an
install that reports itself on and fails every turn.
The response reports back enabled, autonomy, provider and the two channel
switches, because both channels being off is the second way to get silence.
Overview and reporting
escalation takes ?days= — 1 to 365, defaulting to 30.
answer-coverage replays recent ticket subject lines through the live index and
reports how many clear the retrieval floor and the operator-policy gate — the
same two decisions the agent makes at answer time. It calls no model. Failures
are clustered by meaning, and each cluster states what documenting it would be
worth in percentage points.
The Overview page opens on access.ai.support while every figure on it comes
from a view.ai.support.analytics route. A role with one and not the other gets
an empty dashboard and no error — the most common permission complaint in this
addon.
Live Inbox
{id} on the session routes is the ticket id, not the session id.
inbox takes:
| Parameter | Values | Default |
|---|---|---|
scope |
open, waiting, mine, ai, closed, all |
open |
sort |
priority, activity, created, importance, turns, cost, subject, customer |
priority |
order |
asc, desc |
the sort key's own direction |
page |
1-based; clamped to the last page with rows | 1 |
perPage |
5–200. limit is an alias |
50 |
search |
Subject, customer name or email, escalation reason, or a whole ticket id | — |
state |
One session state, narrowing whatever scope selected |
— |
An unrecognised sort, order or scope falls back to the default rather than
failing, and the response echoes scope, sort, order and page so a caller
can tell what it was actually served.
Alongside items it returns total (rows matching this scope and search, across
every page), pages, perPage, and counts — a tally per scope computed over
the whole population, narrowed by search but never by scope.
Rows carry state, escalationReason, turnCount, costUsd, channel,
ticketStatus, importance, createdAt, waitingSince, humanAgentId and
ageMinutes — described in
Two inboxes, one set of tickets.
draft takes { "action": "generate" } or
{ "action": "send", "text": "…", "turnId": "…" }. generate is draft-only on
every install, including one configured to answer autonomously: it never appends
to the ticket and never mails the customer, so the text always comes back to the
composer for a person to send. Sending records how far the human moved the text,
and that edit distance is the evidence the promotion gate reads — see
Autonomy.
release takes keepAiOff, note and announce. takeover takes announce
only; both announce by default.
workflow/{id}/cancel closes only the step the customer has not confirmed yet.
Steps already completed are not undone. A note is posted into the
conversation so the customer is not left with a button that silently does
nothing.
Provider
provider never returns a credential value — only a short prefix, enough to
confirm which key is loaded without the key being readable.
test takes { "provider": "anthropic" | "mashdiv" | "openai_compatible" } and
passes only if the generation step succeeded, because that is the step that
corresponds to a customer being answered.
activate takes { "provider": "anthropic" | "openai_compatible" | "mashdiv" | "null" },
refuses a provider whose credentials are missing, and translates the model
settings into the new provider's vocabulary by tier — aiSupportModel,
aiSupportRerankModel and aiSupportClassifierModel all move, so a stored value
cannot disagree with what actually runs.
Agents
Create requires name. The rest is optional: avatar, persona,
disclosureText, model, effort, maxTokens, autonomy, toolsEnabled,
channels, languages, workingHours, timezone, status.
Sources and articles
knowledge/search takes ?q= (required) and ?rerank=false. It is the
retrieval tester: it shows exactly what the agent would have been given, its
confidence, and whether that clears the floor below which it refuses to answer.
The reranker is a model call. Retrieval runs it whenever the first pass finds
more candidates than aiSupportTopK, which on any real corpus is every query, so
the default path here bills a small generation per search.
Two things make that sharper than it sounds. The rerank parameter overrides
aiSupportRerankEnabled rather than being narrowed by it, so this route reranks
even on an install that has switched reranking off everywhere else. And the route
checks no budget and writes no turn row — it is the one model-calling admin route
with no ceiling in front of it, and its spend is invisible to the figure that
caps every other one.
Pass ?rerank=false to get the BM25 ordering instead. The response shape is
identical, reranked comes back false, and it genuinely costs nothing.
reindex is safe at any time — sources are replaced atomically, so retrieval
never sees a half-written corpus.
Article create requires question and answer; category, productSlug,
status (DRAFT or PUBLISHED) and sourceTicketId are optional. How the
index is assembled is in Knowledge pipeline.
Gaps
gap/draft POST takes { "limit": n }, 1 to 25, defaulting to 10. It
re-retrieves each open gap against the current corpus and drafts only where the
documentation now supports an answer; gaps that need your own policy are reported
rather than guessed.
gap/{id} PUT takes status (OPEN, DRAFTED, RESOLVED, IGNORED),
answer — which publishes an article and marks the gap resolved in one step —
plus question, category and addToQuickReplies.
gap/clusters exists because the gap table dedupes on a sorted-token
fingerprint, so "how do I enable 2FA" and "turn on two factor auth" are two rows
of one each. Clustering groups them by meaning, so the count you rank by is the
number of customers who asked. Nothing is merged in the database; it is a view.
Settings and the Actions console
The GET returns settings, defaults, protectedKeys, isSuperAdmin, budget
and cache. isSuperAdmin is a hint for rendering, not the gate — the PUT
resolves the caller's role itself on every request. On a MashDiv install the
three model ids are masked to their tier in both settings and defaults,
because the tier is the product.
The Settings screen submits only the fields whose value actually differs from what is stored. That is not a nicety: the backend treats every submitted key as a write request, and a resubmitted-but-unchanged protected key is refused exactly like a changed one.
The route collects every refused key and throws one 403 for the entire batch. It does not drop the offender and save the rest. So a client that helpfully posts the whole settings object back will fail on every request, and an admin who changes the retention period and touches a protected key loses the retention period too.
Two different refusals, worded differently on purpose:
- "These settings cannot be changed here" — the key is outside this addon's allowlist, or it is another addon's protected key. No role accepts it here.
- "Only a Super Admin can change these" — one of this addon's own protected
keys, and the caller's role is not
Super Admin. The message says so, and adds that nothing was half-applied.
Keys outside the allowlist are refused, never silently dropped — silently dropping is how an operator ends up believing they changed something they did not.
Two fields are validated at write time and nowhere else, because this is the only
moment a human is present to be told: aiSupportOfficeHours must parse (one line
per day, mon 09:00-17:00, closed for a shut day) and must not be closed on
every day; aiSupportOfficeHoursTimezone must be an IANA name the server
recognises, so a typo is rejected instead of silently becoming UTC.
A successful write invalidates the settings reader, the cached install profile,
the route catalogue and the platform settings cache. Editing the settings table
directly reaches none of them.
workflow returns a catalogue-wide blocked — "provider" when the active
provider cannot act (actions are gateway-only), "disabled" when
aiSupportOperationsEnabled is off, null otherwise — and per entry an
installed flag derived from the enabled-extensions list, its requires, its
steps, and a store link when the addon it needs is genuinely absent. Unavailable
entries are reported, not hidden: a row you cannot see is indistinguishable
from a build that does not ship it.
operation returns key, label, description — the exact sentence the
customer reads before confirming — and requiresStepUp, which is false on all
three today and is surfaced because the customer-side confirm route refuses a
true with a 501 rather than issuing a challenge.
guide returns every guide including the ones currently switched off, with each
stop's anchor, title and body, because the words are describing your product
and an allowlist you cannot see everything in is one you cannot enable anything
from.
Handbook
Body { "question": "…" }, up to 1,000 characters. It reads no customer account
data and has no tools. With no passages retrieved it returns a refusal rather
than an ungrounded answer, and grounded: false says so.
aiSupportAccountToolsEnabled is deliberately not checked here — that switch
is docs-only mode, and this route uses no account data under any configuration.
The admin assistant's console
Eight routes, and every one of them declares access.ai.support and nothing
else. That key is the door, not the room: each catalogue behind it is filtered
through the calling administrator's own permission set, so a role holding this
key alone gets an assistant that answers from the operator documentation, can
point at exactly one screen — the assistant's own Overview, the only entry in the
120-screen catalogue gated on this key — counts no queue and offers no action.
The strongest thing the console can do is exactly what the caller could already
have done by clicking.
Asking
console POST takes { "question": "…", "sessionId": "…", "screen": "…" }.
Only question is required, and it is capped at 1,000 characters.
sessionId continues a conversation. An id that is unknown, or belongs to
another administrator, starts a fresh one rather than returning an error —
telling a caller whether a session id exists would be an oracle over somebody
else's history. The last six exchanges are replayed into the prompt, truncated,
because every replayed turn is input tokens on this call and on all of the ones
after it.
screen is the admin path the operator is looking at. It is advisory and heavily
bounded: it is matched against the admin route catalogue and dropped unless this
administrator could open that screen, so a path from a request body can never put
arbitrary text in front of the model.
The response is sessionId, answer, grounded, costUsd, sources,
screensAvailable — a count of the catalogued screens this caller can be sent
to, not the list — and at most one each of screen, action and procedure.
Those three are the assistant's proposals, and they are lifted straight out of
the catalogue rather than parsed back out of the answer: what the approval card
renders is a constant, and the model's prose sits beside it without being able
to alter it.
400 when the addon is off, and a distinct 400 when aiSupportAdminAssistantEnabled
is off. 429 when the budget gate refuses. 502 when the model returns nothing.
The tool loop runs at most three rounds per question and the budget is checked once, at the top of the request — so a single question can bill more than one generation before any ceiling is consulted again.
The rail
console/pending is polled on every admin page, so it is audit: false and it
always returns the whole shape:
{ enabled, action, procedure, overview, inbox }.
enabled is explicit and is the first thing to read. It is false when the
addon or the admin assistant is switched off, and the rail is expected to draw
nothing at all rather than an Ask box whose every answer is a refusal.
inbox is { open, waiting, mine, screen, title } — counts, never rows — and it
is null, not zero, for an administrator who cannot open the Live Inbox. A
zero would be a claim about the platform; null is "not yours to know". overview
is null on the same rule.
procedure carries a receipt as well as a running step. For ten minutes
after a procedure's last step completes, the same envelope comes back with
state: "COMPLETED", every entry in steps marked done, and — this is the
discriminator — step: null.
Branch on step, not on procedure. A client that reads any non-null
procedure as work waiting for approval will draw an approval card with no
proposal id behind it for ten minutes after every success.
The shape exists because the alternative is worse: answering only "is there a PROPOSED row?" makes the panel that followed the administrator across five screens vanish at the exact moment the procedure succeeded, taking with it the only thing that said so.
A standalone proposal stops being offered 30 minutes after it was made; a procedure is aged by its first step and stops after 24 hours, because a step's own row is written when the previous one completes. Both are re-derived through the live catalogue on every poll, so an action the operator has since withdrawn from the allowlist, or one this administrator has lost the permission for, disappears from the rail instead of sitting there until it is pressed.
The catalogue
console/catalogue returns demo, trialUrl, enabled, actionsEnabled,
canConfigure, actions, procedures and screens.
An action carries allowed (the operator ticked it) and permitted (this role
holds the key). A procedure carries permitted — true only when every step is —
blocked (the platform is missing the addon that procedure needs) and
offerable, which is all of those together plus the admin assistant being
switched on. They are separate fields because each kind of "no" has a different
remedy and a different person who can apply it, and a single boolean produces a
screen that says "off" next to something the reader cannot switch on. Each step
repeats its own permission, permitted and actionDisabled, so the screen can
say which step is the problem rather than only that there is one.
screens is { total, reachable, byArea }, and byArea lists only the screens
this administrator may actually be sent to. Naming the rest would turn the
response into an enumeration of the whole admin surface, which is the thing the
per-caller filter exists to prevent.
Approving and dismissing
{id} on both console/action routes is the proposal id from the assistant's
card. Neither takes a body.
The PUT is the only place an admin action ever executes. propose_admin_action
writes a row in PROPOSED and returns a description — that is the model's entire
capability, and no runner is reachable from a tool call. The permission is
re-derived from the live catalogue and checked against the approving session; the
key snapshotted on the row is for the audit trail only, so re-gating an action to
a stricter key takes effect on proposals written before the change. approvedBy
and approvedAt are written in the same conditional UPDATE that moves the row out
of PROPOSED, which is what makes the audit claim a query rather than an
argument: no row reaches COMPLETED or FAILED without naming who approved it.
It returns { message, state }, plus procedure when the approved row was a
step — carrying the next step, or a terminal COMPLETED, FAILED or
CANCELLED. A failed step stops the procedure rather than skipping it. 403 when
the caller's role does not hold the action's own key, 410 when the proposal has
expired or the action is no longer in the catalogue, 404 when it was offered to
somebody else. A second press returns the first press's result rather than
running anything twice.
The DELETE requires nothing beyond the console key, deliberately: approving runs
something, declining runs nothing, and demanding a permission to decline would
strand a proposal in the panel of the very administrator who has decided against
it. It writes EXPIRED, only over a PROPOSED row, so it can never withdraw a
decision already taken — and when the proposal is a step, every other step of the
procedure still outstanding goes with it, so the next poll cannot resurrect the
card or report the abandoned procedure as a success. Returns
{ message, dismissed }; dismissed is
false when the row had already been handled, which is reported as success
because the caller's intent is satisfied either way.
History
console/session takes ?offset= and pages 30 at a time, returning
{ sessions, hasMore, offset, spend, spendTotalUsd }.
spend is sent on the first page only and is the per-surface summary of the
billed calls made outside the console — handbook questions, ask-about-a-
customer, articles written from tickets, provider tests. Those are filed
against a reused per-administrator ledger row in the same table, so their cost
reaches the budget; they are excluded from sessions because they are not
threads anybody had, and because a background call bumping their timestamp would
shove the real conversation the operator came back for off the top of the page.
console/session/{id} returns { session, turns }, up to 200 turns, each with
question, answer, grounded, sources, proposals, screen, costUsd and
createdAt. Proposals come back so a reopened conversation reads the way it did,
and the client renders them flat. Do not re-arm them: the proposal may have
expired, the operator may have withdrawn the action since, and the row may
already have been approved and run. console/pending is the only surface that
draws a live approval control.
Both reads are scoped to the owner in the query and there is no parameter that widens it — not even for a Super Admin. Every stored answer was produced under one administrator's permission set, so a transcript is a record of one person's authorised view, and re-serving it re-serves their authority with it. Another administrator's conversation is a 404, which is also the honest answer.
console/session/{id} DELETE removes the session row and empties its turns —
question, answer, sources and proposals are cleared — while each turn keeps its
cost and its timestamp. Deleting the rows outright would refund their spend
against the cap, and let an operator spend past their own ceiling by tidying up.
The customer record
Body { "userId": "…", "question": "…" }. The one route in the addon whose
subject comes from the request rather than the session. Gated on the same key as
the customer record it sits on, denylisted, escaped and audited — the whole
argument is on Ask the AI about this customer.
console, ask, handbook, article/from-ticket, gap/draft,
provider/test and knowledge/search all call a model. The first six check the
budget before they do and return 429 when a ceiling is reached; the seventh
checks nothing, which is the second of the two gaps below.
The ceiling is now computed by summing ai_support_turn and
ai_support_admin_turn, with no status allowlist — every row carrying a cost
counts, whatever happened to it afterwards. console writes its own turn, and
ask, handbook, article/from-ticket and provider/test file theirs against a
per-administrator, per-surface ledger row in the same table. So an operator's own
questions now spend the same allowance their customers' answers do, and the
Overview figure and the gate finally describe the same population.
Two gaps are left, and both are worth knowing before you build against them:
gap/draftstill writes no turn. It compensates with a ceiling of its own, computed for the request from the remaining daily allowance and re-checked between gaps, and it reportscostUsdandstoppedOnBudgetin the response — which is the only place that spend is ever visible. It reaches neither the cap nor the per-surfacespendsummary. Close the browser tab and the accounting goes with it.knowledge/searchchecks no budget at all and reranks by default. See the warning under Sources and articles.
Cost and budgets has the consequences.
Customer endpoints
Eight routes under /api/ai/support. All require a session; none declares a
permission, and every one is scoped to the caller. {id} is a ticket id except
where noted.
| Route | Input |
|---|---|
human |
{ ticketId, note? } |
feedback |
{ turnId, isHelpful, comment? } |
suggest |
?q= — the partly-typed subject line |
suggest/{id} |
{ outcome: "RESOLVED" | "FILED" }; {id} is the suggestion id |
operation/pending |
?ticketId= — optional |
operation/{id} |
{id} is the proposal id from the assistant's message |
Four of these have behaviour worth knowing before you build against them:
greetingcreates nothing. The widget deliberately does not open a conversation when it is clicked; doing so left an emptyPENDING"Live Chat" row in the desk queue for every customer who opened the bubble and closed it again. The greeting is templated, not generated.greetinganswers two questions, and the second one survives the first being off. The response is{ enabled, greeting, quickReplies, persona, presence }. When the assistant will not speak on the live channel — the addon off, live chat off, or COPILOT, where every word the customer reads is written by a person — or when the opening line itself is switched off withaiSupportGreetingEnabled,enabledisfalseand the first four fields are empty.presenceis resolved regardless. It isnullwhen the operator has configured no office hours, and otherwise{ open, notice, nextOpenAt, nextOpenLabel, timezone }. That is a fact about the desk, not about the assistant, and on an install with the opening line switched off it is the more useful of the two answers — so a widget that reads onlyenabledthrows away the one thing it could still tell the customer.suggestis safe on every keystroke. No model call — it is the direct-answer search plus an identity check. It ignores queries under 12 characters, is off unlessaiSupportDeflectEnabledis on, and is not offered to a customer typing in a non-English language, because serving an English article verbatim to someone writing in Spanish is a confusing answer rather than a fast one. Repeated calls inside 30 minutes are deduplicated, so one deflection is not counted six times.operation/{id}is the only place an action runs. The assistant cannot reach it: it may only propose, and the customer's own click executes under their own session.FILEDonsuggest/{id}is the more valuable of the two outcomes — it identifies an article that looks right to search and reads wrong to a person.
It always answers { operation, workflow }, and both are resolved
independently — a standalone offer and a running process are different questions
about different rows, and one arriving on top of the other used to declare the
other finished.
workflow is not only a step waiting to be confirmed. For ten minutes after
a process completes, the same envelope comes back with state: "COMPLETED",
every entry in steps marked done, and step: null. That is the receipt,
and step is the discriminator — a client that treats any non-null workflow as
outstanding work will draw a confirm button with no proposal behind it for ten
minutes after every success. Only COMPLETED is reported this way; a process
that failed or was cancelled already said so in the transcript, in a sentence
written for the specific reason.
It is a pure read on every path, including for a row that has aged past its TTL. Expiry is a write, and a GET that mutated on a page load would mean opening a ticket could retire an offer the customer was about to accept. Stale rows are simply not returned; the confirm route and the hourly sweep retire them.
Omitting ?ticketId= widens it from "what is outstanding on this conversation"
to "what is outstanding for this person, anywhere" — which is what the companion
rail asks, because a process whose second step is now go to the deposit page
takes the customer away from the transcript. It can only narrow the result: the
query is scoped on the session's own user id either way.
Permissions at a glance
Every admin route declares exactly one, and the counts add up to forty-eight.
| Key | Routes | Which |
|---|---|---|
access.ai.support |
9 | activate, and all eight console routes |
access.ai.support.provider |
3 | provider, provider/test, provider/activate |
access.ai.support.settings |
6 | settings GET and PUT, operation, workflow, guide, proactive/preview |
view.ai.support.analytics |
2 | overview, escalation |
view.ai.support.knowledge |
8 | knowledge, knowledge/search, article GET, answer-coverage, and the four gap reads |
create.ai.support.knowledge |
3 | article POST, article/from-ticket, gap/draft POST |
edit.ai.support.knowledge |
3 | article/{id} PUT, gap/{id} PUT, knowledge/reindex |
delete.ai.support.knowledge |
1 | article/{id} DELETE |
view / create / edit / delete.ai.support.agent |
5 | the five agent routes; create covers both POST and agent/default |
view.ai.support.handbook |
1 | handbook |
view.support.ticket |
2 | inbox, session/{id} |
edit.support.ticket |
4 | takeover, release, draft, workflow/{id}/cancel |
view.user |
1 | ask |
The first row is the one to read twice: one route switches the addon on and the
other eight are the entire admin assistant. access.ai.support is the weakest
key in the addon and it opens all of that, which is
safe only because everything the assistant can reach is filtered a second time
through the caller's other permissions — including inside console/action/{id},
which re-derives the approved action's own key from the live catalogue and
refuses with a 403 if the caller does not hold it.
Which screen each key opens, the Super-Admin-only settings, and two worked role grants are in Permissions.