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.

Related