AI and human routing
Two fields on InboxThread decide who answers a conversation:
| Field | Meaning |
|---|---|
handler |
ai | human — who is currently answering. This is what the auto-reply paths read to decide whether to generate |
takenOver |
A human stepped in over an agent. Distinct from the thread merely being assigned |
takenOverAt |
When. This is the number support reports on |
The three are not redundant. A thread a human owned from the first message was never taken over, so
takenOver records that an escalation happened; handler records who is answering now.
Where a conversation starts
InboxAccountConfig.defaultHandler is stamped onto a thread at creation and only at creation. On
every subsequent inbound the thread already has a handler and the default is ignored — which is what
makes it safe against takeover. The default decides where a conversation starts; takeover owns it
thereafter.
Set it through the account settings route:
curl -X PATCH "$INBOX_URL/v1/inbox/accounts/$CONNECTION_ID/settings" \
-H "Authorization: Bearer $SESSION" \
-H "X-Axis-Tenant: ws_$GROUP_ID" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"defaultHandler":"ai","agentSlug":"support-agent"}'
null clears a field; an omitted key leaves it unchanged. When the caller is a user session, a change
to agentSlug is gated by that admin's Accounts user-groups — Inbox asks intelligence whether the slug
is actually visible to them, scoped to the connection's own app (Inbox is shared across apps, so the
app is derived per-connection, never pinned). A machine key has no user and the check is skipped.
Per-thread routing
curl -X PATCH "$INBOX_URL/v1/inbox/threads/$THREAD_ID/routing" \
-H "Authorization: Bearer $SESSION" -H "X-Axis-Tenant: ws_$GROUP_ID" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{"priority":"high","handler":"human"}'
priority accepts low | normal | high | urgent, handler accepts ai | human; anything else is a
validation_failed (422). null clears the field, an omitted key leaves it alone — so
{"priority":"urgent"} does not touch the handler.
Takeover
curl -X POST "$INBOX_URL/v1/inbox/threads/$THREAD_ID/takeover" \
-H "Authorization: Bearer $SESSION" -H "X-Axis-Tenant: ws_$GROUP_ID" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{"takeOver":true}'
Taking over sets handler: 'human', takenOver: true, takenOverAt: now(). {"takeOver":false}
hands it back: handler: 'ai', takenOver: false, takenOverAt: null. The timestamp is cleared
rather than kept, because a stale one would misreport the next takeover's response time.
The load-bearing consequence is the silence. Before takeover existed, "taking over" only changed what the UI displayed: the agent kept answering every inbound underneath the human, so the customer received two replies per message from what they believed was one person.
Note that ingest re-reads the thread after storing an entry precisely so a takeover landing mid-ingest is observed — see How a message arrives.
The AI bridge
modules/ai-inbound is the path that lets a real WhatsApp or Meta conversation be answered by an
intelligence agent. It fires on inbound for a non-ai connection whose InboxAccountConfig.agentSlug
binds an agent and whose thread is AI-handled. It runs the agent to generate text, then delivers through
that connection's own adapter via InboxService.reply.
It is awaited during ingest because its return value gates maybeAutoReply, the dedicated ai-network
path — a thread must never receive two AI answers. Threads on an ai-network connection have no
agentSlug on config (their agent lives in the connection's credentials), so the bridge returns false
for them and maybeAutoReply handles them as before.
Thread analysis
Summary and sentiment for one thread, produced by the intelligence conversation-analysis agent in a single call.
| Method | Path | Effect |
|---|---|---|
| GET | /v1/inbox/threads/:threadId/analysis |
Returns the persisted analysis, computing it on first access |
| POST | /v1/inbox/threads/:threadId/analysis |
Forces a re-run. Body { refresh?: boolean } |
| PATCH | /v1/inbox/threads/:threadId/analysis |
Persists an operator's hand-edited summary. Sentiment untouched |
ThreadAnalysisDto is { threadId, summary, sentimentTone, sentimentExplanation, intent?, generatedAt }
with sentimentTone one of positive | neutral | negative | mixed.
Results persist in two places: the summary onto the thread (summary + summaryGeneratedAt, the
freshness watermark that decides whether a GET re-runs), and the sentiment onto the thread's open
ticket. The summary lives on the thread because a thread is the stable unit of conversation while its
open ticket comes and goes. Both writes need an Idempotency-Key.
Auto-response
Workspace-level auto-reply settings, replacing axis-api's /autoresponse/{workspace}:
curl "$INBOX_URL/v1/auto-response" \
-H "Authorization: Bearer $SESSION" -H "X-Axis-Tenant: ws_$GROUP_ID"
PUT /v1/auto-response takes a partial patch of:
| Field | Notes |
|---|---|
sendConfirmationMessage |
Master enable for the confirmation/absence reply. Defaults true |
outOfOffice |
When true, absenceMessage is sent instead of confirmationMessage |
confirmationMessage / absenceMessage |
The bodies |
autoResponseTime |
Minutes to wait before the delayed confirmation fires. 0 = immediate only |
welcomeMessageEnabled |
First-contact greeting, independent of the confirmation flow |
welcomeMessageType |
text | image |
welcomeMessageText / welcomeMessageCaption / welcomeMessageMediaUrl / welcomeMessageMediaPath |
Three kinds fire: confirmation, absence, welcome. The welcome fires only when the contact has no
prior send on record, and it falls through — the confirmation may still fire on the same message,
matching axis-api, which sent both independently.
InboxAutoResponseSend is the durable dedupe ledger: one row per (tenantId, contactId) recording
lastSentAt and which kind last fired, enforcing once-per-contact-per-24h across restarts. A failed
send is deliberately not recorded, so a later inbound retries rather than being deduped out. An
expired messaging window is logged as expected, not as a fault.
Auto-response needs a resolved contact — without one there is nothing to dedupe or template against, and the handler returns.