Design — “Set Conversation Mode” workflow action (Autopilot ↔ Assist)
Date: 2026-06-28 Status: Approved design, pending implementation planGoal
A new agentic-workflow action node that flips a single session between Autopilot (AI auto-sends public replies) and Assist (AI posts an internal draft/note for a human, resolve + handoff tools disabled), or back to the org/channel default. This lets a workflow promote a session to autonomy (e.g. “low-risk intent detected → Autopilot”) or pull it back to human review (e.g. “refund > $500 mentioned → Assist”) mid-conversation.Background — what “Autopilot vs Assist” is, and why this is net-new
“Autopilot vs Assist” is the public-reply-vs-internal-draft lever. It is distinct from the AI-active-vs-handed-off lever (assignee_id +
ai_closure_type), which already has workflow actions (assign-ticket-to-ai,
handoff). This design is strictly about the former.
Today the mode lives in chat_sessions.is_assist_mode (Generated<boolean>,
backend/src/db/opencx.ts:1519). It is snapshotted onto each session at
creation from the channel’s assist_mode_enabled setting (+ topic overrides),
and there is no code path that writes it after creation. So flipping a live
session’s mode is genuinely new behavior.
The read-site inconsistency (the design driver)
is_assist_mode is read inconsistently across channels:
- Already session-authoritative (read
session.is_assist_modedirectly):- Zendesk v2 —
backend/src/zendesk-integration-v2/zendesk.service.ts:2040,2309,2479,2772 - Salesforce —
backend/src/salesforce-case-integration/salesforce-case.service.ts:424,507 - Freshdesk —
backend/src/freshdesk-integration/freshdesk-ai-fields.service.ts:478 - HubSpot — via
backend/src/chat/chat.service.ts:1251
- Zendesk v2 —
- Recompute from channel settings, ignoring the session flag:
- Native web/widget + core agent run —
backend/src/chat/chat.service.ts:1248-1252(the session flag is gated behind a HubSpot-only branch) - Intercom reply decision —
backend/src/intercom-integration/intercom.service.ts:1668
- Native web/widget + core agent run —
is_assist_mode” would therefore silently no-op on the native
web path and Intercom. The chosen mechanism (below) fixes that without changing
behavior for any session the action never touches.
Chosen approach — nullable override column (Option B)
Add a new nullable column:null→ no override; resolution falls back to exactly today’s logic (channel recompute on web/Intercom, snapshot on integrations). Every existing session isnull, so zero behavior change for anything the action doesn’t touch — satisfies the “no casual change to existing behavior” rule.true→ force Assist for this session, authoritative on every channel.false→ force Autopilot for this session, authoritative on every channel.
Read sites to thread (override ?? existing)
backend/src/chat/chat.service.ts:1248-1252— primary; covers native web + core agent run + HubSpot. Becomes:session.is_assist_mode_override ?? (isHubspot ? session.is_assist_mode : channelAssistModeEnabled).backend/src/intercom-integration/intercom.service.ts:1668— wrap the recomputedassistModeEnabled.- Zendesk v2 —
zendesk.service.ts:2040,2309,2479,2772(sites already readingsession.is_assist_mode): wrap asis_assist_mode_override ?? is_assist_mode. - Salesforce —
salesforce-case.service.ts:507(and:424). - Freshdesk —
freshdesk-ai-fields.service.ts:478.
backend/src/chat-session/chat-session.repo.ts:183 and the per-integration
selects at zendesk.service.ts:1066,2466, freshdesk-ai-fields.service.ts:259,
etc.). The implementation plan enumerates the exact selects.
Rejected alternatives:
A (reuse is_assist_mode) — smaller diff, no migration, but changes the web
path from live channel-recompute to session-sticky, a behavior change to
existing sessions.
C (scope to channels that already read the flag) — tiniest diff, but a
silently partial feature (no-op on web + Intercom).
The action
- Enum:
WorkflowActionEnum.SET_CONVERSATION_MODE(set-conversation-mode), added tobackend/src/workflow/enums/workflow-action.enum.tsunder the session/ticket-state group, registered inbackend/src/workflow/definitions/actions/index.ts. - File:
backend/src/workflow/definitions/actions/set-conversation-mode.action.ts, modeled ontag-ticket.action.ts(factorycreateActionDefinition, services lazilyawait imported insiderun, scoped byctx.organization.id). - Input
Field.Object:ticketNumber: Field.Number— refable, defaults to the trigger’s ticket.mode: Field.Select(['autopilot', 'assist', 'follow_default'])— refable.
- Output
Field.Object:{ success: boolean, previousMode, newMode }. - Run body:
- Resolve session:
ChatSessionRepo.getByStringifiedTicketNumberOrThrow({ orgId, ticketNumber });actionRunError.runtimeErrorif not found. - Map mode → override value:
autopilot → false,assist → true,follow_default → null. - Idempotent: if the current
is_assist_mode_overridealready equals the target, return{ success: true, ... }with no write and no timeline note. - Write via
ChatSessionRepo.update(session.id, { is_assist_mode_override }). - Emit one internal system timeline note (see below).
- Return
{ success: true, data: { success, previousMode, newMode } }.
- Resolve session:
- Actor stamp:
{ type: 'system', sub_type: 'workflow', workflow_uuid, workflow_serial_id, run_id }(perchange-ticket-assignee.action.ts:91). - UI: reuses the generic field-driven config form — no dashboard changes.
Run
pnpm gensdkso the new action’s metadata reaches the builder.
Timeline note (visibility)
A new internalchat_history system event, modeled on AI_RESUMED_BY_SYSTEM
(backend/src/chat-session/chat-session.service.ts:1780-1793):
- Copy: e.g. “Conversation switched to Assist mode by workflow «name»” / “…to Autopilot mode…” / “…reset to channel default…”.
- Internal-only: hidden from the customer widget and excluded from LLM input (assert both in tests, per the extensive-defensive-tests rule).
- Pushed on the normal session-update path so the inbox reflects the change live.
Schema change
- Migration
add_is_assist_mode_override_to_chat_sessions:ALTER TABLE chat_sessions ADD COLUMN is_assist_mode_override boolean NULL DEFAULT NULL;(transactional; single statement). Thenpnpm dev:prepareto regenerateopencx.ts. - DTO: add
is_assist_mode_override: z.boolean().nullable()tobackend/src/chat-session/dtos/chat-session.dto.ts:101, bound viasatisfies z.ZodType<Selectable<DB['chat_sessions']>>if the schema mirrors the row.
Testing (mandatory closing step)
Extensive:- Action spec (
set-conversation-mode.action.spec.ts): input/output validation; all three mode values → correct override (false/true/null); not-found →actionRunError; idempotent no-op (already-target → no write, no note); system-actor stamp; cross-org isolation (another org’s ticket rejected). - Chat-service test: a session with
is_assist_mode_override = truemakes the AI post an internal note (assist) on the next run even when the web channel default is autopilot;= falseposts a public message even when the channel default is assist;= nullfollows the channel default (no regression).
- Timeline note is internal: assert it’s hidden from the widget surface and excluded from LLM input.
- Override precedence: assert
override ?? existingat the native chokepoint — feed(override=true, channel=autopilot)and prove assist wins; feed(override=null, channel=assist)and prove the channel default still applies (no behavior change for untouched sessions).
Files touched (summary)
- New: migration,
set-conversation-mode.action.ts, action spec, chat-service spec. - Edit:
workflow-action.enum.ts,actions/index.ts,chat-session.dto.ts,chat-session.repo.ts(+ integration selects), the 5 read-site files above, thechat_historysystem-event enum/emitter,opencx.ts(generated). - Run:
pnpm dev:prepare,pnpm gensdk,pnpm tsgo, scoped tests.