Appearance
Ciclo de vida da SessionState
A sessão é o estado vivo de uma conversa ativa. Ela nasce quando o primeiro usuário manda uma mensagem, evolui a cada interação, transita entre modos e morre por inatividade ou encerramento explícito.
O que é uma sessão
SessionState é um objeto JSON armazenado no Redis com TTL. Cada sessão representa uma conversa ativa entre um contato e um canal — nunca há duas sessões ativas para o mesmo par (phoneNumberId, contactPhone).
typescript
type SessionState = {
// Identidade
mode: "flow" | "human" | "agent"
routerId: string
flowId?: string
contactId: string // ID no Postgres
chatId: string // ID no Postgres
// Temporalidade
startedAt: number
lastInteractionAt: number
metaWindowExpiresAt: number // lastInteractionAt + 24h
expiryTimeout: number // TTL de inatividade em ms
// Execução do flow
currentBlockId?: string // bloco aguardando input do usuário
currentBlockEnteredAt?: number // quando a sessão entrou no bloco atual
lastInput?: string | null // último texto do contato, preservado entre blocos
variables: Record<string, string | string[] | null>
// Contexto de sistema (preenchidos automaticamente)
source?: "whatsapp" | "webchat" | "instagram" // canal de origem
contactIdentity?: string // = sessionId (ex: "wa-123-5511...")
currentFlow?: { id: string; name: string } // flow onde a sessão está parada
currentBlock?: { id: string; name: string } // bloco onde a sessão está parada
previousBlock?: { id: string; name: string } // bloco visitado antes do currentBlock
sessionHistory?: Array<{ // histórico de paradas em InputBlocks
flow: { id: string; name: string }
block: { id: string; name: string }
}>
// Estados especializados
agentState?: AgentState // presente apenas quando mode="agent" (bloco de agente IA)
helpdesk?: { queueId?: string; ticketId?: string }
frozenByError?: SessionFrozenError
redirectHistory?: Array<{ flowId: string; blockId: string }>
// Canal WhatsApp
whatsApp: { contact: { name: string; phoneNumber: string } }
lastIncomingMessageId?: string
}A chave no Redis é session:{sessionId}, onde sessionId é derivado deterministicamente do canal:
- WhatsApp:
wa-{phoneNumberId}-{contactPhone} - WebChat:
wc-{channelId}-{userId} - Instagram:
ig-{igUserId}-{senderIgsid}
Criação
A sessão é criada pelo flow-ai-orchestrator quando uma mensagem chega e nenhuma sessão existe no Redis para aquele sessionId.
Fluxo de criação:
- Orchestrator recebe
IncomingStreamEventcomsessionId. - Tenta
getSession(sessionId)→ Redis retornanull. - Resolve
routerIdeinitialFlowIdvia cachewa:router:{phoneNumberId}. - Cria
SessionStatecom:
typescript
{
mode: "flow",
routerId,
flowId: initialFlowId,
contactId,
chatId,
startedAt: Date.now(),
lastInteractionAt: Date.now(),
metaWindowExpiresAt: Date.now() + META_WINDOW_MS, // + 24h
expiryTimeout: sessionExpiryMs, // do router (fallback DEFAULT_SESSION_TTL_MS = 30 min)
currentBlockId: undefined, // engine vai preencher no primeiro run
variables: {},
whatsApp: { contact: { name, phoneNumber } },
}- Serializa para JSON e grava no Redis via
setSession(sessionId, json, ttlMs)com SET PX.
Os campos source e contactIdentity são preenchidos na criação. currentFlow, currentBlock, previousBlock e sessionHistory são preenchidos pelo engine na primeira parada de InputBlock.
Por que a sessão não é criada no banco? A sessão muda a cada mensagem. Persistir no Postgres adicionaria latência de escrita em cada interação. O Redis fornece sub-milissegundo com TTL automático. O Postgres armazena o histórico duradouro (mensagens, tickets) — a sessão é estado volátil e efêmero.
Dois TTLs independentes
A sessão tem dois conceitos de expiração que coexistem:
| Campo | Valor | Controlado por |
|---|---|---|
expiryTimeout | TTL de inatividade (ms) | Renovado a cada mensagem via setSession(..., ttlMs) |
metaWindowExpiresAt | timestamp fixo (lastInteraction + 24h) | Atualizado pelo orchestrator a cada mensagem |
expiryTimeout (TTL de inatividade): Renovado a cada mensagem. Se o usuário ficar 30 minutos sem interagir, o Redis apaga a chave automaticamente. Na próxima mensagem, uma nova sessão é criada do zero.
| Modo da sessão | Valor do expiryTimeout |
|---|---|
"flow" | settings.sessionExpiryMs do flow (fallback 30 min) |
"agent" | AgentBlock.inputTimeout ?? settings.sessionExpiryMs ?? 30 min |
"human" | max(tempo restante da janela Meta, 60 minutos) |
"human" com frozenByError | max(tempo restante da janela Meta, 60 minutos) |
Em modo "human", o TTL é estendido porque a janela Meta define por quanto tempo ainda é possível contatar o usuário — perder a sessão antes disso significaria perder o contexto do atendimento humano em andamento.
Em modo "agent", o TTL vem do inputTimeout configurado no AgentBlock (ou do sessionExpiryMs do flow como fallback) — o engine também arma um timer de inatividade com esse valor para encerrar conversas de agente abandonadas.
metaWindowExpiresAt (janela Meta de 24h): A Meta permite enviar mensagens gratuítas para um usuário por 24h após o último contato. Após esse período, apenas templates são permitidos. O sistema rastreia essa expiração para tomar decisões (ex: fechar ticket automaticamente quando send_failed com código 131026).
Atualização durante execução
O flow-ai-engine atualiza a sessão após cada execução:
currentBlockId: sempre atualizado para o bloco em que o engine parou aguardando input.variables: atualizado por açõesSetContextVariableActionesaveInputAsdos blocos.lastInteractionAt+metaWindowExpiresAt: atualizados pelo orchestrator em cada mensagem recebida, antes do engine executar.mode,helpdesk,agentState,frozenByError,redirectHistory: atualizados pelo engine conforme transições.
O engine nunca persiste a sessão parcialmente — ele carrega o estado, executa toda a lógica, e grava o estado final de uma vez. Isso evita estados inconsistentes se o processo morrer no meio da execução (a entry ficará na pending list e será reprocessada com o estado original).
Índice de sessões ativas (sessions:active:{routerId}): a cada gravação de sessão, o orchestrator mantém um sorted set por router com o sessionId das sessões em mode: "flow" (score = lastInteractionAt). Sessões que entram em mode: "human" ou "agent" são removidas do índice via removeActiveSession; sessões em "flow" são reinseridas via upsertActiveSession. Isso permite ao monitoramento enumerar sessões ativas de um router sem varrer todas as chaves do Redis.
Transições de modo
flow → human (handoff para atendimento humano)
Ocorre quando o engine entra num bloco HumanAttendanceBlock durante walkBlocks():
typescript
// engine/runtime/execute.ts
session.mode = "human"
const remainingWindow = session.metaWindowExpiresAt - Date.now()
session.expiryTimeout = Math.max(remainingWindow, HUMAN_SESSION_MIN_TTL_MS) // mín 1h
await publishHandoff(ctx) // → stream:helpdeskO core (flow-ai-core) consome o handoff, resolve a fila de atendimento via regras de distribuição, cria o ticket e grava session.helpdesk = { ticketId, queueId } na sessão.
engine
→ session.mode = "human"
→ stream:helpdesk (HelpdeskHandoffEvent)
→ core cria Ticket
→ core grava session.helpdesk
→ core notifica desk via Socket.ioflow → agent (bloco de agente IA)
Ocorre quando o engine entra num AgentBlock durante walkBlocks(). O engine muda o modo, semeia um agentState esqueleto e devolve o agentEvent no RunResult — a publicação em stream:agent só acontece depois de setSession(), para o agente nunca ler uma sessão desatualizada:
typescript
// engine/runtime/execute.ts (walkBlocks)
session.mode = "agent"
session.expiryTimeout = agentBlock.inputTimeout ?? settings.sessionExpiryMs ?? DEFAULT_SESSION_TTL_MS
session.agentState = {
activeAgentId: agentBlock.agentId,
agentChain: [{ agentId: agentBlock.agentId, returnToParent: false }],
messages: [],
contactContextSnapshot: "",
configSnapshot: { agentId: agentBlock.agentId, systemPrompt: "", model: "", provider: "", guardrails: [], maxMessages: agentBlock.maxMessages },
turnCount: 0,
}
return { deleted: false, agentEvent } // handler publica em stream:agent APÓS setSession()O configSnapshot sai daqui como esqueleto (strings vazias) — quem preenche systemPrompt, model, provider e guardrails a partir do cache agent:{agentId} é o flow-ai-agent ao iniciar a execução. A partir daí, enquanto mode: "agent", o orchestrator roteia as mensagens do contato direto para stream:agent (em vez de stream:flow).
engine (AgentBlock)
→ session.mode = "agent" + agentState semeado
→ setSession()
→ stream:agent (AgentStreamEvent)
→ flow-ai-agent roda o loop de tool-calling do LLM
→ preenche configSnapshot + acumula messages/turnCountagent → flow (encerramento da sessão de agente)
Ocorre quando o agente encerra a conversa (tool endAgentSession), ou quando o monitoramento fecha o ticket de IA manualmente. O flow-ai-agent (ou o core, via publishAgentSessionEnded()) publica AgentSessionEndedEvent em stream:flow:
typescript
xadd(STREAMS.FLOW, {
payload: JSON.stringify({ kind: "agentSessionEnded", sessionId, ... })
})O engine consome e executa runFlowFromAgentSessionEnded():
typescript
session.mode = "flow"
session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs // TTL configurado no flow
delete session.agentState
// avalia outputConditions do AgentBlock e navega — continua em walkBlocks()Nota: assim como no
humanSessionEnded, o evento pode trazer umjumpTargetpara reposicionar a sessão num flow/bloco específico em vez de avaliar asoutputConditionsdoAgentBlock.
agent → human (handoff a partir do agente)
Um agente pode transferir o atendimento para um humano sem passar de volta pelo flow. A tool transferToHuman (chamada pelo LLM) e o handoff manual disparado pelo monitoramento — AgentManualHandoffEvent (kind: "manualHandoff") publicado em stream:agent — compartilham a mesma transição performHumanHandoff no flow-ai-agent:
typescript
// flow-ai-agent/src/tools/actions.ts (performHumanHandoff)
session.mode = "human"
delete session.agentState
// fecha o ticket de IA + publica HelpdeskHandoffEvent em stream:helpdesk
// (com presetQueueId/presetUserId quando informados)O contato entra então na fila de helpdesk com o contexto da sessão, seguindo o mesmo fluxo do handoff flow → human.
human → flow (encerramento do atendimento)
Ocorre quando o agente fecha ou encaminha o ticket no desk:
typescript
// core/helpdesk/publishHumanSessionEnded.ts
xadd(STREAMS.FLOW, {
payload: JSON.stringify({ kind: "humanSessionEnded", sessionId, ... })
})O engine consome FlowStreamHumanSessionEndedEvent e executa runFlowFromHumanSessionEnded():
typescript
session.mode = "flow"
session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs // TTL configurado no flow
delete session.helpdesk
ctx.currentInput = null
// roda outputActions + outputConditions do HumanAttendanceBlock
// continua em walkBlocks()Nota: o evento pode carregar um
jumpTarget(usado pelo "finalizar e direcionar" do monitoramento). Nesse caso o engine reposiciona a sessão no flow/bloco indicado viarunFlowFromSessionEndedWithJump()em vez de rodar asoutputConditionsdoHumanAttendanceBlock.
flow → frozen (erro de runtime)
Quando o engine encontra um erro irrecuperável (bloco inexistente, loop detectado, exception em executeScript, etc.):
typescript
// session.frozenByError é preenchido
{
message: "...",
blockId: "blk_abc",
phase: "outputActions", // onde o erro ocorreu
occurredAt: Date.now(),
}Além de gravar frozenByError na sessão, o engine:
- Persiste
SessionRuntimeErrorno Postgres (para histórico e diagnóstico). - Adiciona o
sessionIdao sorted setsessions:frozenno Redis (para monitoramento viaGET /sessions/frozen). - Publica uma mensagem de erro para o contato. A mensagem é configurável e interpolável por action (
errorMessage, suportando{{contact.name}},{{errorVariable}},{{session.*}}, etc.); sem valor, usa oDEFAULT_RUNTIME_ERROR_MESSAGE("Encontramos um problema técnico…"). Erros estruturais (loop, bloco inexistente, hop limit) sempre usam a mensagem default. - Transiciona
mode = "human"e publica handoff.
flow → exception (action blocking sem handoff)
As actions executeScript/httpCall/executeTool/callEndpoint aceitam errorHandoff: false. Nesse caso, ao falhar em modo blocking, o engine não congela a sessão: envia a errorMessage (interpolada) e redireciona para o defaultExceptionBlockId do flow via redirectSignal, seguindo a execução em mode = "flow" a partir do bloco de exceção (sem ticket de helpdesk, sem entrada em sessions:frozen). Um guard de re-entrância (redirectHopCount / MAX_REDIRECT_HOPS) protege contra o caso de a própria action de exceção falhar — ao estourar, degrada para freeze + handoff.
frozen → flow (desbloqueio pelo agente)
Quando o agente fecha o ticket de uma sessão frozen, o mesmo evento humanSessionEnded é publicado. O engine detecta frozenByError em runFlowFromHumanSessionEnded():
typescript
if (session.frozenByError) {
delete session.frozenByError
session.mode = "flow"
session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs // TTL configurado no flow
delete session.helpdesk
// navega para defaultExceptionBlockId (bloco de recuperação configurado no flow)
}O desbloqueio pode também ser feito manualmente via POST /sessions/:sessionId/unfreeze com action: "resume" (aplica overrides de flowId, currentBlockId, variables) ou action: "delete" (apaga a sessão para recomeçar do zero).
Diagrama de estados
┌─────────────────────────────┐
│ [INEXISTENTE] │
│ Redis não tem a chave │
└──────────────┬──────────────┘
│ primeira mensagem do usuário
│ (orchestrator cria com mode=flow)
▼
AgentBlock ┌─────────────────────────────┐
┌──────────────────► │ [FLOW] │ ──────► [EXPIRADA]
│ │ mode: "flow" │ TTL (chave some
│ agentSessionEnded│ TTL: sessionExpiryMs │ expirado do Redis)
│ (endAgentSession)│ (renovável) │
│ ┌──────────────── └──────────────┬──────────────┘
│ │ │
│ ▼ HumanAttendanceBlock
┌───────────────────────┐ ▼
│ [AGENT] │ ┌────────────────────────┐
│ mode: "agent" │ │ [HUMAN] │
│ agentState + loop LLM │──►│ mode: "human" │
│ no flow-ai-agent │ │ TTL: max(janela,1h) │
└────────────────────────┘ └──────────┬─────────────┘
transferToHuman / │ │
handoff manual agente fecha │ │ erro de runtime no engine
ticket │ ▼
│ ┌────────────────────────────┐
│ │ [FROZEN] │
│ │ mode: "human" │
│ │ frozenByError preenchido │
│ │ no sorted set sessions:frozen │
│ └────────────┬───────────────┘
│ │ agente fecha ticket
│ │ (desbloqueio)
│ ▼
└──────► [FLOW] em defaultExceptionBlockId
ou DELETE (restart manual)O nó
[EXPIRADA]representa qualquer modo cuja chavesession:{sessionId}expirou no Redis — não sómode: "flow". Uma sessão em"agent"ou"human"também some quando o TTL correspondente estoura sem renovação.
Encerramento
A sessão é removida do Redis em dois cenários:
- TTL expirado: o Redis apaga automaticamente a chave após
expiryTimeoutms sem renovação. Próxima mensagem do usuário cria nova sessão. - Bloco
endAttendance: o engine retorna{ deleted: true }e o handler chamadeleteSession(sessionId)explicitamente.
Não existe "encerramento gracioso" com evento publicado — a sessão simplesmente desaparece do Redis. O histórico permanece intacto no Postgres (Chat + Messages + Tickets).
Variáveis de sistema no builder
O engine expõe campos da sessão como tokens de interpolação {{session.*}} prontos para uso em textos, condições e configurações de blocos. Esses tokens não precisam ser definidos pelo builder — eles já existem automaticamente.
| Token | Valor | Disponível a partir de |
|---|---|---|
{{session.source}} | Canal de origem: whatsapp, webchat ou instagram | Criação da sessão |
{{session.contactIdentity}} | Identificador composto do contato (wa-... ou wc-...) | Criação da sessão |
{{session.currentFlow.id}} | ID do flow onde a sessão está parada | Primeira parada em InputBlock |
{{session.currentFlow.name}} | Nome do flow onde a sessão está parada | Primeira parada em InputBlock |
{{session.currentBlock.id}} | ID do bloco onde a sessão está parada | Primeira parada em InputBlock |
{{session.currentBlock.name}} | Título do bloco onde a sessão está parada | Primeira parada em InputBlock |
{{session.previousBlock.id}} | ID do bloco visitado antes do atual | Segunda parada em InputBlock (ausente na primeira) |
{{session.previousBlock.name}} | Título do bloco visitado antes do atual | Segunda parada em InputBlock (ausente na primeira) |
{{session.sessionHistory}} | JSON com o histórico de paradas (mais recente primeiro) | Primeira parada em InputBlock |
{{session.flowId}} | ID do flow em execução | Sempre |
{{session.routerId}} | ID do router da sessão | Sempre |
{{session.blockId}} | ID do bloco atual | Sempre |
{{session.mode}} | Modo atual da sessão (flow / human / agent) | Sempre |
{{session.chatId}} | ID do Chat no Postgres | Sempre |
sessionHistory: Array JSON ordenado do mais recente para o mais antigo. Cada entrada tem { flow: { id, name }, block: { id, name } }. Limite configurável via FlowSettings.sessionHistoryLimit (default: 20 entradas).
previousBlock: Atualizado toda vez que o engine entra em um bloco diferente do currentBlockId anterior — inclusive em blocos com título prefixado por #, já que não segue a convenção de exclusão do sessionHistory. Útil para fluxos de exceção/fallback que precisam saber de onde o usuário veio.
Convenção #: Blocos cujo title começa com # não são adicionados ao sessionHistory. Use esse prefixo em blocos utilitários ou de controle que não devem aparecer no histórico de navegação do usuário (ex: #verificar-plano, #rota-interna). O currentBlock ainda é atualizado normalmente para esses blocos.
Campos especializados
redirectHistory
Pilha usada pela action returnToFlow para saber de onde voltar após um redirectToBot. Cada entrada contém o flowId e blockId de origem, empilhada pelo redirectToBot e desempilhada (um pop) pelo returnToFlow. A pilha pode acumular mais de 5 entradas ao longo de várias execuções. O que limita a 5 é o redirectHopCount — um contador por execução (zerado a cada run do engine): ao atingir MAX_REDIRECT_HOPS = 5, o redirectToBot lança e a sessão congela, protegendo contra loops entre flows dentro de um mesmo run.
agentState
Presente apenas quando mode: "agent" — ou seja, enquanto o flow está pausado num AgentBlock e o flow-ai-agent conduz a conversa com o LLM. Campos:
activeAgentId— agente de IA em execução.agentChain: AgentChainEntry[]— pilha de retorno multi-agente; o último elemento é o agente atual, e umpopocorre ao voltar para o agente pai (transferToAgentcomreturnToOrchestrator).messages: AgentMessage[]— histórico de mensagens (mantido para logging/monitoramento).previousResponseId?— ID da última resposta da Responses API, encadeado comoprevious_response_idno turno seguinte.contactContextSnapshot— snapshot decontact.contextno início da sessão.configSnapshot: AgentConfig— snapshot imutável da config do agente (systemPrompt, model, provider, guardrails, maxMessages). O engine o semeia como esqueleto (strings vazias); o flow-ai-agent preenche a partir do cacheagent:{agentId}.turnCount— número de turnos completos (mensagem do contato + resposta do agente).ticketId?— ticket de IA criado para a sessão (único, mantido em transferências entre agentes).sentiment?/sentimentHistory?— sentimento atual e histórico, quando a análiseperRoundestá habilitada.
Isso permite que o agente de IA retome uma conversa de múltiplos turnos entre interações do usuário e sobreviva a transferências entre agentes especialistas.
variables
Dicionário livre de chave-string → valor. Preenchido por:
SetContextVariableAction(ação explícita do flow)saveInputAsnoInputConfigde um bloco (salva o que o usuário digitou)executeScriptque escreve emctx.variables
Essas variáveis são locais à sessão — não persistem no banco e expiram com a sessão.
Arquivos relevantes
| Arquivo | Papel |
|---|---|
packages/flow-ai-types/src/session.ts | Definição de SessionState, SessionFrozenError, AgentState, AgentConfig (o index.ts apenas reexporta) |
packages/flow-ai-types/src/ai-agents.ts | AgentStreamEvent, AgentSessionEndedEvent, AgentManualHandoffEvent, AgentStreamPayload |
packages/flow-ai-redis/src/session.ts | getSession, setSession, deleteSession |
packages/flow-ai-redis/src/frozen-sessions.ts | markSessionFrozen, unmarkSessionFrozen, listFrozenSessionIds (sorted set sessions:frozen) |
packages/flow-ai-redis/src/active-sessions.ts | upsertActiveSession, removeActiveSession, listActiveSessionIds, pruneStaleActiveSessions (sorted set sessions:active:{routerId}) |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Criação e retomada de sessão; roteamento por modo (stream:flow / stream:helpdesk / stream:agent) e manutenção do índice de ativos |
services/flow-ai-orchestrator/src/constants.ts | DEFAULT_SESSION_TTL_MS, META_WINDOW_MS |
services/flow-ai-engine/src/handlers/flow.ts | Carregamento, execução e persistência da sessão; publicação do agentEvent após setSession() |
services/flow-ai-engine/src/runtime/execute.ts | Transições de modo, runFlowFromAgentSessionEnded, runFlowFromHumanSessionEnded, freezeAndReturn, walkBlocks |
services/flow-ai-engine/src/constants.ts | HUMAN_SESSION_MIN_TTL_MS, DEFAULT_RUNTIME_ERROR_MESSAGE |
services/flow-ai-core/src/helpdesk/sessionState.ts | setCurrentHelpdeskTicket |
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.ts | Publicação de humanSessionEnded |
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.ts | Publicação de agentSessionEnded (fechamento manual do ticket de IA) |
services/flow-ai-core/src/http/routes/session.routes.ts | GET /frozen, POST /:sessionId/unfreeze, GET /:sessionId, PATCH /:sessionId/variables, POST /:sessionId/redirect |