Appearance
Jornada do usuario ponta-a-ponta
Este guia narra o caminho completo de uma mensagem atravessando todos os serviços — do primeiro contato do usuário até a resolução do atendimento. Onde os outros guias de fluxo dissecam um salto (inbound, outbound, sessão, atendimento humano, agentes de IA), aqui costuramos tudo numa linha do tempo única: quando cada serviço atua, quando a sessão muda de modo (flow → agent → human) e quando um ticket abre ou fecha.
Para o detalhe de cada etapa, cruze com os guias específicos: Inbound completo, Outbound completo, Ciclo de vida da sessão, Atendimento humano e Comunicação e ciclo de vida dos agentes de IA.
Visão geral — os serviços que a mensagem atravessa
O sistema é orientado a eventos: os serviços internos nunca se chamam por HTTP, só por Redis Streams. HTTP e Socket.io existem apenas nas bordas (gateways ↔ Meta/IG/browser). A mensagem de um usuário passa, na ordem, por:
usuário (WhatsApp / Instagram / WebChat)
│ webhook HTTP (WA/IG) ou Socket.io (WebChat)
▼
gateway do canal flow-ai-meta-api | flow-ai-ig-api | flow-ai-webchat-gateway
│ stream:incoming
▼
flow-ai-orchestrator ← persiste, resolve/atualiza a SessionState, roteia por session.mode
│ stream:flow (flow) | stream:agent (agent) | stream:helpdesk (human)
├──────────────────────┬───────────────────────┐
▼ ▼ ▼
flow-ai-engine flow-ai-agent flow-ai-core (helpdesk)
executa o flow loop LLM + tools entrega ao agente humano via Socket.io
│ │ │
└──────────────────────┴────────────────────────┘
│ stream:outgoing-meta | -chat | -ig
▼
gateway do canal ← envia a resposta ao usuárioO campo session.mode ("flow" | "human" | "agent") é a única fonte de verdade para o roteamento no orchestrator. Toda troca de modo é persistida no Redis antes de qualquer publicação em stream — assim o próximo consumer nunca lê uma sessão desatualizada.
Os fluxos abaixo usam o canal WhatsApp como referência (o contrato inbound é WhatsApp-shaped em todos os canais). Para as diferenças de Instagram e WebChat, veja Inbound completo.
Quem faz o que em cada etapa
Cada serviço tem uma responsabilidade estreita. A tabela abaixo é o mapa que os dois cenários instanciam:
| Serviço | Consome | Produz | Papel na jornada |
|---|---|---|---|
| flow-ai-meta-api (gateway, porta 4444) | webhook HTTP da Meta; stream:outgoing-meta (grupo send) | stream:incoming, stream:status | Valida webhook (verify token + HMAC-SHA256), normaliza inbound e publica; consome outbound e envia à Meta Cloud API |
| flow-ai-orchestrator (sem HTTP) | stream:incoming; stream:outgoing-* (grupo persist); stream:status (grupo status) | stream:flow / stream:agent / stream:helpdesk | Persiste Contact/Chat/Message, cria/atualiza SessionState, roteia por session.mode; persiste outbound e aplica status |
| flow-ai-engine (sem HTTP) | stream:flow (grupo engine) | stream:outgoing-*, stream:helpdesk, stream:agent | Executa a máquina de estados do flow; entra em HumanAttendanceBlock (→ human) e AgentBlock (→ agent); retoma o flow no humanSessionEnded/agentSessionEnded |
| flow-ai-agent (sem HTTP) | stream:agent (grupo agent, consumer único serial) | stream:outgoing-meta/-chat, stream:agent (self-loop), stream:helpdesk, stream:flow, telemetria | Roda o loop LLM de tool-calling; transfere entre agentes; abre/fecha o ticket de IA; encerra devolvendo ao flow |
| flow-ai-core (porta 3333) | stream:helpdesk, stream:status (grupos core-*), stream:outgoing-* | stream:flow (humanSessionEnded/agentSessionEnded), stream:agent (manualHandoff), Socket.io ao desk | Cria/atribui/fecha tickets humanos, resolve fila, entrega ao agente humano em tempo real; única fronteira de cripto e escritora de caches |
Detalhe da fronteira de saída (persist + send + status em
stream:status) em Outbound completo. Detalhe de criação/atualização de sessão em Ciclo de vida da sessão.
Cenário A — WhatsApp: flow determinístico → handoff humano → resume
Um contato novo escreve pela primeira vez, percorre um flow, é encaminhado a um atendente humano e, ao fim do atendimento, volta ao flow de onde parou.
A.1 Primeiro contato e criação de sessão
- O usuário envia uma mensagem. A Meta faz
POST /webhookno flow-ai-meta-api. - O gateway extrai
phoneNumberId, buscawa:webhook:{phoneNumberId}no Redis, valida o HMAC-SHA256 do corpo, normaliza cada mensagem paraWhatsAppIncomingMessagee publica umIncomingStreamEventemstream:incoming(sessionId = wa-{phoneNumberId}-{from}). - O flow-ai-orchestrator consome, deduplica por
whatsappMessageId, resolve o Contact/Chat no Postgres. Como não há sessão no Redis, resolve o roteamento inicial via cachewa:router:{phoneNumberId}({ routerId, initialFlowId }) e cria aSessionStatecommode: "flow", gravando emsession:{sessionId}. - Como
mode === "flow", publica umFlowStreamUserInputEventemstream:flowe insere osessionIdno índicesessions:active:{routerId}.
A.2 Execução do flow no engine
- O flow-ai-engine consome
stream:flow, carregaSessionStatedo Redis, oFlowDefinitiondo cache agregadoflowse as variáveis (vars:router:{routerId},vars:flow:{flowId}). - Executa
walkBlocks(): avaliainputActions→content→outputConditions. Para cada mensagem gerada, publica no stream de saída resolvido porresolveContentChannel(sessionId)— aquistream:outgoing-meta. Regrava aSessionStatecom ocurrentBlockIddo bloco que aguarda input. - Fan-out de saída: o orchestrator (grupo
persist) grava aMessagecomstatus: "pending"e o meta-api (gruposend) resolvewa:credentials:{phoneNumberId}, envia à Meta e publica o resultado (sent/send_failed) emstream:status. O usuário recebe a resposta do bot.
A.3 Handoff para atendimento humano
- Em algum ponto o
walkBlocks()entra numHumanAttendanceBlock. O engine mudasession.mode = "human", estende o TTL paramax(janela Meta restante, 1h)e publica umHelpdeskHandoffEventemstream:helpdesk. - O flow-ai-core consome o handoff, resolve a fila (
event.presetQueueId ?? resolveQueueviaQueueDistributionRuleporrouterId), cria o Ticket e gravasession.helpdesk = { ticketId, queueId }.
Ticket humano (abertura):
kind: "human",openedByKind: "flow",openReason: "human_handoff". SempresetUserId, nascestatus: "waiting"e os agentes da fila são notificados viahelpdesk:waiting; a atribuição vem por pickup ou auto-assign (least_loaded). CompresetUserId, já nasceassigned.
A.4 Conversa durante o atendimento
- Enquanto
mode === "human", cada mensagem do contato faz o orchestrator publicar umHelpdeskMessageEventemstream:helpdesk(não emstream:flow). O core busca o ticket e emiteticket:messagevia Socket.io ao agente atribuído. Cada mensagem renova o TTL da sessão no Redis.
A.5 Fechamento e resume ao flow
- O agente fecha o ticket (
agent:closevia Socket.io ouPOST /tickets/:id/close). O core fecha comcloseKind: "resolved"e publica umhumanSessionEndedemstream:flow. - O flow-ai-engine consome, executa
runFlowFromHumanSessionEnded():session.mode = "flow", restaura oexpiryTimeoutpara osessionExpiryMsdo flow, deletasession.helpdesk, roda osoutputActions/outputConditionsdoHumanAttendanceBlocke continua owalkBlocks(). O contato volta a conversar com o bot de onde parou.
Se a janela de 24h da Meta expirar antes do fechamento (erro
131026emsend_failed), o próprio core fecha o ticket comcloseKind: "meta_window_expired"e publica o mesmohumanSessionEnded— ver Atendimento humano.
Diagrama de sequência — Cenário A
Cada seta anota [stream] e o {cache} consultado/gravado naquele salto.
usuário meta-api orchestrator engine core (helpdesk) agente humano
│ webhook │ │ │ │ │
│──────────────>│ │ │ │ │
│ {wa:webhook:{phoneNumberId}} valida HMAC │ │ │
│ │ [stream:incoming] │ │ │
│ │────────────────>│ │ │ │
│ │ {wa:router:{phoneNumberId}} → cria SessionState mode:flow {session:{id}} │
│ │ │ [stream:flow] userInput │ │
│ │ │───────────────>│ │ │
│ │ │ {session:{id}} + {flows} + {vars:*} → walkBlocks │
│ │ [stream:outgoing-meta] persist(orch)+send(meta-api) │ │
│ │<────────────────────────────────┤ {wa:credentials:{phoneNumberId}} │
│<──────────────│ resposta do bot │ │ │
│ │ │ │ │ │
│ (walkBlocks entra em HumanAttendanceBlock) mode=human {session:{id}}│ │
│ │ │ │ [stream:helpdesk] handoff │
│ │ │ │────────────────>│ │
│ │ │ resolveQueue(routerId) → cria Ticket(human, waiting) │
│ │ │ grava session.helpdesk {session:{id}} │
│ │ │ │ │ Socket.io ticket:* │
│ │ │ │ │─────────────────────>│
│ contato manda msg │ │ │ │
│──────────────>│ [stream:incoming] │ │ │
│ │────────────────>│ mode=human → [stream:helpdesk] message │
│ │ │───────────────────────────────>│ Socket.io message │
│ │ │ │ │─────────────────────>│
│ │ │ │ │ agente fecha ticket │
│ │ │ │ │<─────────────────────│
│ │ │ Ticket → closed (resolved) │ │
│ │ │ │ [stream:flow] humanSessionEnded │
│ │ │ │<────────────────┤ │
│ runFlowFromHumanSessionEnded → mode=flow, delete helpdesk, walkBlocks continua │
│ │<─── [stream:outgoing-meta] retoma o flow ──────────┤ │
│<──────────────│ │ │ │ │Cenário B — Instagram/WhatsApp: agente de IA, transferência a especialista, retorno ao flow
O contato entra num AgentBlock, conversa com um agente de IA orquestrador, é transferido a um especialista, e ao encerrar o controle retorna ao flow determinístico. Diferente do Cenário A, o loop entre agentes acontece dentro do flow-ai-agent — sem passar pelo orchestrator.
B.1 Entrada no AgentBlock
- Sessão em
mode: "flow", o flow-ai-engine rodawalkBlocks()e entra numAgentBlock. Ele mudasession.mode = "agent", semeia umagentStateesqueleto (activeAgentId,agentChain: [{ agentId, returnToParent: false }],configSnapshotcom strings vazias,turnCount: 0) e retorna oagentEventnoRunResult. - O handler
flow.tsgrava a sessão (setSession) antes de publicar oAgentStreamEventemstream:agent— assim o agente sempre encontraagentStateno Redis.
A partir daqui, enquanto
mode === "agent", o orchestrator roteia toda mensagem do contato direto parastream:agent(nãostream:flow) e remove osessionIddo índicesessions:active.
B.2 Executor do agente e ticket de IA
- O flow-ai-agent (consumer único e serial de
stream:agent) consome oAgentStreamEvent, carrega a sessão, e resolve a config do agente via cacheagent:{agentId}(AiAgentCacheConfig) e a credencial LLM viallm:credential:{credentialId}. Preenche oconfigSnapshotreal (systemPrompt, model, guardrails, tools). - Antes do loop, se
agentState.ticketIdestiver ausente, cria o ticket de IA e emiteHelpdeskAITicketEvent(action: "created") emstream:helpdesk.
Ticket de IA (abertura):
kind: "ai",openedByKind: "ai_agent",openReason: "ai_session",status: "assigned". É único por sessão e sobrevive às transferências entre agentes —transferToAgentnão abre nem fecha ticket.
- O executor roda o loop de tool-calling (Responses API stateful,
previous_response_id). Tools de resposta (sendTextMessage,sendButtonMessage, ...) publicam emstream:outgoing-meta/-chat. Cada iteração emite telemetria emstream:agent-turnsestream:llm-usage.
B.3 Transferência ao especialista (self-loop no flow-ai-agent)
- O orquestrador chama a tool
transferToAgent(agentId, handoffContext, lastUserInput, returnToOrchestrator: true). O executor valida (profundidade ≤10, agente existe no cache, sem ciclo), empilha{ agentId, returnToParent: true }noagentChain, zera opreviousResponseIde publica um novoAgentStreamEventemstream:agent— que ele mesmo consome no próximo turno. O orchestrator não participa dessa transição.
B.4 Especialista encerra → volta ao orquestrador
- O especialista resolve e chama
endAgentSession(completionContext). Como o entry atual temreturnToParent: trueeagentChain.length > 1, o executor fazpop()da cadeia, voltaactiveAgentIdao orquestrador e republica umAgentStreamEventemstream:agent(comhandoffContext = completionContext). Não publicaagentSessionEnded— o controle não volta ao flow ainda. O ticket de IA continua aberto.
B.5 Orquestrador encerra → retorno ao flow
- O orquestrador (agora agente raiz,
agentChain.length === 1,returnToParent: false) chamaendAgentSession(reason). Agora sim: gravasession.variables.agentEndReason = reason, fazsession.mode = "flow", deletaagentState, fecha o ticket de IA (closeKind: "resolved"), emiteSummaryRequestedEventemstream:summary+HelpdeskAITicketEvent(action: "closed") emstream:helpdesk, e publica umAgentSessionEndedEventemstream:flow. - O flow-ai-engine consome, executa
runFlowFromAgentSessionEnded():session.mode = "flow", restaura oexpiryTimeoutdo flow, avalia asoutputConditionsdoAgentBlock(usandoagentEndReasondassession.variables) e continua owalkBlocks(). O contato volta ao flow determinístico.
Além do
endAgentSession, o agente pode transferir direto a um humano viatransferToHuman(tool) ou o desk pode forçar viaAgentManualHandoffEventemstream:agent. Ambos reusamperformHumanHandoff: fecham o ticket de IA, publicamHelpdeskHandoffEventemstream:helpdeske mudamsession.mode = "human"— daí em diante segue o Cenário A. Ver Agentes de IA.
Diagrama de sequência — Cenário B
usuário orchestrator engine flow-ai-agent core
│ │ │ │ │
│ (sessão em mode:flow → walkBlocks entra em AgentBlock) │ │
│ │ │ mode=agent + agentState semeado {session:{id}} │
│ │ │ [stream:agent] AgentStreamEvent (após setSession) │
│ │ │────────────────────────>│ │
│ │ {agent:{agentId}} + {llm:credential:{id}} → preenche configSnapshot │
│ │ │ cria Ticket(ai, assigned) [stream:helpdesk] │
│ │ │ │───────────────────────>│
│ │ │ loop LLM → [stream:outgoing-meta] resposta │
│<──────────────────────────────────────────────────────────────┤ (persist+send) │
│ contato responde │ │ │
│──> [stream:incoming] → orchestrator │ mode=agent → [stream:agent] {session:{id}} │
│ │────────────────────────────────────────────>│ │
│ │ orquestrador chama transferToAgent(returnToOrchestrator:true) │
│ │ push agentChain, [stream:agent] SELF-LOOP ──┐ (mesmo serviço) │
│ │ │ └>│ (especialista roda) │
│ │ │ especialista chama endAgentSession(returnToParent)│
│ │ │ pop agentChain → [stream:agent] SELF-LOOP ──┐ │
│ │ │ (volta ao orquestrador)└>│ │
│ │ │ orquestrador (raiz) chama endAgentSession │
│ │ │ mode=flow, delete agentState, fecha Ticket(ai) │
│ │ │ │ [stream:helpdesk] closed │
│ │ │ │───────────────────────>│
│ │ │ [stream:flow] agentSessionEnded │
│ │ │<────────────────────────┤ │
│ runFlowFromAgentSessionEnded → avalia outputConditions(agentEndReason) → walkBlocks │
│<── [stream:outgoing-meta] retoma o flow ─────────────────────────────────────────────── │Nota (KNOWN DRIFT — canal de saída do agente): o
flow-ai-agentescolhe o stream de saída por prefixo dosessionId:wc-*→stream:outgoing-chat, caso contráriostream:outgoing-meta. Não há branch paraig-*— um agente respondendo numa sessão Instagram publica emstream:outgoing-meta(canal errado). WhatsApp e WebChat funcionam corretamente. Ver Agentes de IA.
Ticket: quando abre e quando fecha
Os dois cenários abrem tickets diferentes. Esta é a linha do tempo consolidada de cada kind:
| Momento | Cenário A (humano) | Cenário B (IA) |
|---|---|---|
| Quem abre | flow-ai-core, ao consumir o handoff | flow-ai-agent, antes do loop LLM |
kind | human | ai |
openedByKind | flow | ai_agent |
openReason | human_handoff | ai_session |
status inicial | waiting (ou assigned com presetUserId) | assigned |
| Durante | mensagens via ticket:message (Socket.io) | mensagens via loop LLM; sobrevive a transferToAgent |
| Quem fecha | agente humano (ou sistema) | agente raiz em endAgentSession (ou performHumanHandoff) |
closeKind típico | resolved (ou meta_window_expired, abandoned) | resolved (ou system no handoff a humano) |
| Encadeamento | previousTicketId liga tickets sucessivos do mesmo chat | idem — mantido entre resets/expirações |
transferToAgent(B.3) e o retorno-ao-pai (B.4) não abrem nem fecham ticket algum — o ticket de IA é único e persiste por toda a cadeia de agentes. Detalhes dos enums em Atendimento humano.
Transições de modo — mapa consolidado
Toda a jornada é regida por session.mode. As transições que os dois cenários exercitam:
| De → Para | Gatilho | Quem executa | Stream do salto |
|---|---|---|---|
(inexistente) → flow | primeira mensagem do usuário | orchestrator | cria session:{id} |
flow → human | walkBlocks entra em HumanAttendanceBlock | engine | stream:helpdesk (handoff) |
human → flow | agente fecha ticket (ou janela Meta expira) | core → engine | stream:flow (humanSessionEnded) |
flow → agent | walkBlocks entra em AgentBlock | engine | stream:agent |
agent → agent | transferToAgent / retorno ao pai | flow-ai-agent (self-loop) | stream:agent |
agent → flow | agente raiz chama endAgentSession | flow-ai-agent → engine | stream:flow (agentSessionEnded) |
agent → human | transferToHuman ou handoff manual | flow-ai-agent → core | stream:helpdesk (handoff) |
flow → human (frozen) | erro de runtime irrecuperável | engine | stream:helpdesk + sessions:frozen |
O diagrama de estados completo (incluindo frozen e expiração por TTL) está em Ciclo de vida da sessão.
Por que a sessão nunca lê estado desatualizado
Um invariante atravessa os dois cenários: a SessionState é persistida no Redis antes de qualquer publicação em stream que dependa dela.
- No engine,
flow.tsfazsetSession()e só então publica oagentEventemstream:agent— o flow-ai-agent sempre encontraagentState(B.1/B.2). - No engine, o handoff (
flow → human) gravasession.mode = "human"antes dopublishHandoff(A.3). - No flow-ai-agent,
transferToAgent/endAgentSessionfazemsetSession()antes doxadd(B.3–B.5). - Como o flow-ai-agent é um consumer único e serial de
stream:agent, os self-loops de transferência e o handoff manual são serializados por sessão — sem corrida sobre oagentState.
O custo é que Redis vira ponto de falha crítico e depurar exige inspecionar streams, pending lists e session:{sessionId} — não apenas logs HTTP. Ver Redis consumers e a política de ACK at-least-once em Inbound completo.
Arquivos relevantes
| Arquivo | Papel na jornada |
|---|---|
services/flow-ai-meta-api/src/http/routes/webhook.ts | Gateway WhatsApp: valida HMAC, normaliza inbound, publica em stream:incoming |
services/flow-ai-meta-api/src/consumers/outgoing.ts | Consome stream:outgoing-meta (grupo send), envia à Meta, publica status |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Cria/atualiza SessionState, roteia por session.mode para flow/agent/helpdesk |
services/flow-ai-orchestrator/src/consumers/persist.ts | Persiste outbound (Message pending) no fan-out de saída |
services/flow-ai-engine/src/handlers/flow.ts | Handler de stream:flow; setSession → xadd(AGENT); dispatch de humanSessionEnded/agentSessionEnded |
services/flow-ai-engine/src/runtime/execute.ts | walkBlocks, transições flow→human/flow→agent, runFlowFromHumanSessionEnded, runFlowFromAgentSessionEnded |
services/flow-ai-agent/src/executor/index.ts | Loop de tool-calling, criação do ticket de IA, handleManualHandoff |
services/flow-ai-agent/src/tools/actions.ts | transferToAgent, endAgentSession (retorno ao pai vs. raiz), performHumanHandoff |
services/flow-ai-core/src/helpdesk/consumer.ts | Consome stream:helpdesk, cria ticket humano, resolve fila |
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.ts | Publica humanSessionEnded ao fechar ticket humano |
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.ts | Publica agentSessionEnded no fechamento manual do ticket de IA pelo desk |
packages/flow-ai-types/src/session.ts | SessionState, SessionMode, AgentState, AgentChainEntry |
packages/flow-ai-types/src/stream-events.ts | IncomingStreamEvent, FlowStreamEvent, HelpdeskHandoffEvent, OutgoingStreamEvent |
packages/flow-ai-types/src/ai-agents.ts | AgentStreamEvent, AgentSessionEndedEvent, AgentManualHandoffEvent, HelpdeskAITicketEvent |
packages/flow-ai-redis/src/constants.ts | STREAMS / GROUPS — mapa canônico de streams e consumer groups |
packages/flow-ai-database/prisma/schema.prisma | Modelo Ticket e enums TicketStatus/TicketOpenedByKind/TicketOpenReason/TicketCloseKind |