Appearance
Catálogo de variáveis (Router e Flow)
Variáveis estáticas vinculadas ao Router ou ao Flow. Diferentemente das variáveis de sessão (session.variables), que são efêmeras e criadas em runtime pelo flow, as variáveis de catálogo são configuradas antecipadamente, sobrevivem entre sessões e ficam disponíveis para interpolação sem ocupar espaço na sessão de nenhum usuário.
O caso de uso típico é armazenar credenciais de API, URLs de ambientes ou flags de configuração que todos os flows de um Router (ou um Flow específico) precisam consultar, especialmente como secrets que não devem aparecer em texto plano no código do flow.
Dois escopos distintos
| Escopo | Modelo | Acesso na interpolação | Visibilidade |
|---|---|---|---|
| Router | RouterVariable | {{router.key}} | Todos os flows do Router |
| Flow | FlowVariable | {{flow.key}} | Apenas o Flow dono |
A distinção é proposital: credenciais compartilhadas entre vários flows (token de integração, URL de webhook) vão no Router; configurações específicas de um flow (prompt de sistema de um LLM, endpoint de uma API específica) vão no Flow.
Modelo de dados
prisma
model RouterVariable {
id String @id @default(cuid())
routerId String
router Router @relation(fields: [routerId], references: [id], onDelete: Cascade)
key String
value String? // plaintext — só preenchido se isSecret=false
valueEncrypted String? // AES-256-CBC — só preenchido se isSecret=true
isSecret Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([routerId, key])
@@map("router_variables")
}
model FlowVariable {
id String @id @default(cuid())
flowId String
flow Flow @relation(fields: [flowId], references: [id], onDelete: Cascade)
key String
value String?
valueEncrypted String?
isSecret Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([flowId, key])
@@map("flow_variables")
}Notas do schema:
@@unique([routerId, key])— a mesma chave não pode existir duas vezes no mesmo Router (ou Flow). Renomear é um DELETE + POST.onDelete: Cascade— ao deletar um Router ou Flow, todas as suas variáveis são deletadas automaticamente pelo banco.- Nunca ambos
valueevalueEncryptedsão preenchidos ao mesmo tempo — a coluna usada depende deisSecret.
Restrição de nomenclatura de chaves: o regex ^[a-zA-Z_][a-zA-Z0-9_]*$ é validado no backend. Chaves só podem conter letras, dígitos e underscore, e não podem começar com dígito. Isso garante que {{router.MY_KEY}} seja sempre um token válido na interpolação.
Fronteira de criptografia
O flow-ai-core é a única peça do sistema que conhece a chave de criptografia (CRYPTO_SECRET, 32 bytes). O fluxo é exatamente o mesmo dos outros secrets da plataforma:
Postgres (criptografado) ←→ flow-ai-core (decripta) → Redis (plaintext)
↑
flow-ai-engine lê aquiA criptografia usa AES-256-CBC com IV aleatório de 16 bytes. O resultado armazenado no banco tem o formato iv_hex:ciphertext_hex:
typescript
// Crypto.encrypt() — gera IV aleatório a cada chamada
const iv = randomBytes(16)
const ivHex = iv.toString("hex")
const cipher = createCipheriv("aes-256-cbc", Buffer.from(CRYPTO_SECRET), iv)
let encrypted = cipher.update(data, "utf8", "hex")
encrypted += cipher.final("hex")
return `${ivHex}:${encrypted}` // ex: "a1b2c3d4...:e5f6a7b8..."
// Crypto.decrypt() — extrai IV do prefixo
const [ivHex, encrypted] = encryptedData.split(":")
const decipher = createDecipheriv("aes-256-cbc", Buffer.from(CRYPTO_SECRET), Buffer.from(ivHex, "hex"))O Redis recebe o valor já decifrado. A API nunca devolve o valor de um secret — retorna null no campo value. Isso evita que credenciais apareçam em logs de requisição ou ferramentas de monitoramento de HTTP.
Caches Redis
Dois caches armazenam todas as variáveis de um escopo já decifradas, como um Record<string, string> serializado em JSON:
| Chave | Conteúdo |
|---|---|
vars:router:{routerId} | {"MY_API_KEY": "sk-...", "WEBHOOK_URL": "https://..."} |
vars:flow:{flowId} | {"SYSTEM_PROMPT": "Você é um assistente...", "MAX_TOKENS": "500"} |
Os caches não têm TTL — são atualizados sincronamente pelo flow-ai-core em qualquer operação de escrita (create, update, delete) de variável, sempre via syncRouterVarsCache / syncFlowVarsCache (que regeram o Record inteiro a partir do Postgres). Ao deletar uma variável, o cache é regerado sem ela.
Nota: ao deletar o Router ou Flow inteiro, o cache não é limpo — os helpers
deleteRouterVars/deleteFlowVarsexistem mas não são chamados no caminho de delete da entidade (ver "Casos de borda"). A chavevars:*fica órfã no Redis.
POST /routers/:id/variables (cria variável)
└─ Valida formato da key
├─ Se isSecret: Crypto.encrypt(value) → valueEncrypted
├─ Se não: salva em value direto
├─ Persiste RouterVariable no Postgres
└─ syncRouterVarsCache(routerId)
├─ Busca todas RouterVariable do router
├─ Para cada: decripta se isSecret, usa value se não
└─ setRouterVars(routerId, JSON.stringify(resolved))
→ grava vars:router:{routerId}O mesmo padrão vale para FlowVariable.
Carregamento no engine
O flow-ai-engine carrega as variáveis antes de construir o ExecutionContext, imediatamente após carregar a sessão e o flow ativo:
typescript
// services/flow-ai-engine/src/handlers/flow.ts
const rawRouterVars = await getRouterVars(session.routerId)
const routerVars: Record<string, string> = rawRouterVars ? JSON.parse(rawRouterVars) : {}
const rawFlowVars = session.flowId ? await getFlowVars(session.flowId) : null
const flowVars: Record<string, string> = rawFlowVars ? JSON.parse(rawFlowVars) : {}
const ctx: ExecutionContext = {
session, event, activeFlow, flowsCache, currentInput: null,
redirectHopCount: 0,
routerVars, // ← disponível em toda execução via ctx.routerVars
flowVars, // ← disponível em toda execução via ctx.flowVars
}O que acontece se o cache não existir? O parser retorna um objeto vazio {}. A execução não falha — tokens como {{router.MINHA_VAR}} simplesmente ficam sem valor (comportamento de token não resolvido na interpolação: emite console.info e substitui o token por string vazia no output, não pelo token literal). Isso ocorre quando o Router não tem variáveis cadastradas ou quando o cache ainda não foi populado (ex: variável criada mas flow-ai-core ainda não terminou de sincronizar).
As variáveis nunca são gravadas na sessão. São somente-leitura durante a execução e isoladas do session.variables do assinante — um flow não pode sobrescrever uma variável de Router ou de Flow.
Interpolação
O interpolate.ts — que vive no pacote compartilhado flow-ai-runtime (usado tanto pelo engine quanto pelo agent) — resolve tokens em qualquer string de configuração do flow antes de executar ações:
typescript
// packages/flow-ai-runtime/src/interpolate.ts
if (expr.startsWith("router.")) {
const key = expr.slice(7) // remove "router."
const value = ctx.routerVars[key]
return value !== undefined ? value : undefined // undefined se não existir
}
if (expr.startsWith("flow.")) {
const key = expr.slice(5) // remove "flow."
const value = ctx.flowVars[key]
return value !== undefined ? value : undefined // undefined se não existir
}Tokens disponíveis na interpolação — visão completa:
| Token | Fonte | Escopo |
|---|---|---|
{{router.key}} | ctx.routerVars[key] | Todos os flows do Router |
{{flow.key}} | ctx.flowVars[key] | Flow atual |
{{contact.name}} | session.whatsApp.contact.name (perfil do canal — alias legado; reflete a action setContactName quando o nome foi fixado) | Sessão atual |
{{contact.phoneNumber}} | session.whatsApp.contact.phoneNumber (perfil do canal — alias legado) | Sessão atual |
{{contact.whatsapp.name}} | session.whatsApp.contact.name (perfil do canal — forma explícita) | Sessão atual |
{{contact.whatsapp.phoneNumber}} | session.whatsApp.contact.phoneNumber (perfil do canal — forma explícita) | Sessão atual |
{{contact.<chave>}} | ctx.contactVars[chave] — variável de contato (Contact.contactVariables); aceita path aninhado ({{contact.customer.nome}}) | Contato (permanente) |
{{contact.variables.<chave>}} | idem acima, forma explícita (paridade com o desk) | Contato (permanente) |
{{input}} | ctx.currentInput (JSON stringify do InputObject completo) | Input do usuário neste turno |
{{input.type}} | ctx.currentInput.type — "text" | "image" | "audio" | "video" | "document" | "sticker" | "interactive" | "location" | "reaction" | "contacts" | "button" | Input do usuário neste turno |
{{input.content}} | ctx.currentInput.content — texto body ou ID do botão/lista | Input do usuário neste turno |
{{inputMessage}} | Texto para type=text, ID para type=interactive, "" para mídia | Input do usuário neste turno |
{{lastInput}} | session.lastInput — conteúdo do último input recebido do contato; persiste entre blocos até ser substituído pelo próximo input | Sessão atual |
{{input.media.id}} | ctx.currentInput.media.id | Input de mídia |
{{input.media.mimeType}} | ctx.currentInput.media.mimeType | Input de mídia |
{{input.interactive.type}} | ctx.currentInput.interactive.type | Input interativo |
{{input.interactive.id}} | ctx.currentInput.interactive.id | Input interativo |
{{input.interactive.title}} | ctx.currentInput.interactive.title | Input interativo |
{{system.now}} | new Date().toISOString() | Momento da execução |
{{session.flowId}} | session.flowId | Sessão atual |
{{session.routerId}} | session.routerId | Sessão atual |
{{session.blockId}} | session.currentBlockId | Sessão atual |
{{session.chatId}} | ctx.event.chatId | Sessão atual |
{{session.mode}} | session.mode ("flow" | "human" | "agent") | Sessão atual |
{{session.source}} | session.source ("whatsapp" | "webchat" | "instagram") | Sessão atual |
{{session.contactIdentity}} | Identificador composto do contato (= sessionId) | Sessão atual |
{{session.currentFlow.id}} | session.currentFlow.id | Sessão atual |
{{session.currentFlow.name}} | session.currentFlow.name | Sessão atual |
{{session.currentBlock.id}} | session.currentBlock.id | Sessão atual |
{{session.currentBlock.name}} | session.currentBlock.name | Sessão atual |
{{session.previousBlock.id}} | session.previousBlock.id (ausente na primeira parada) | Sessão atual |
{{session.previousBlock.name}} | session.previousBlock.name (ausente na primeira parada) | Sessão atual |
{{session.sessionHistory}} | JSON do histórico de paradas em InputBlocks | Sessão atual |
{{session.*}} | Acesso genérico a qualquer campo do SessionState via JSON path (ex: session.variables.myVar, session.whatsApp.contact.name) | Sessão atual |
{{varName}} | session.variables[varName] | Sessão atual |
{{varName.campo.sub}} | Navega JSON path dentro de session.variables[varName] | Sessão atual |
{{ticket}} / {{ticket.campo}} | Variável de contexto gravada pelo engine ao sair do atendimento humano: o ticket encerrado em JSON (closeKind, closedBy, queue, tags, datas, durações). Ver Atendimento humano | Sessão atual |
{{handoffDeflect.reason}} / {{handoffDeflect.detail}} | Por que o atendimento humano não começou (fila fora do horário, sem atendente, fila inviável) — ver Deflexão na entrada | Sessão atual |
{{ticket.exit.key}} / {{ticket.exit.survey}} | Saída do bloco de atendimento que casou com o motivo do encerramento, e a marcação de pesquisa dela. Presente só quando o bloco configurou saída para aquele motivo — ver Saídas por motivo | Sessão atual |
Prioridade de resolução: tokens com prefixo explícito (router., flow., contact., input, system., session.) são resolvidos primeiro. Tokens sem prefixo caem no dicionário session.variables. O padrão {{session.*}} resolve qualquer campo do SessionState via JSON path — útil para acessar campos que não têm atalho direto.
API REST
Router Variables
GET /routers/:id/variables → lista todas (value=null para secrets)
POST /routers/:id/variables → cria variável (body: key, value, isSecret)
PUT /routers/:id/variables/:varId → atualiza value e/ou isSecret (a key não muda)
DELETE /routers/:id/variables/:varId → remove variávelFlow Variables
GET /flows/:id/variables → lista todas (value=null para secrets)
POST /flows/:id/variables → cria variável (body: key, value, isSecret)
PUT /flows/:id/variables/:varId → atualiza value e/ou isSecret (a key não muda)
DELETE /flows/:id/variables/:varId → remove variávelResposta GET: campo value é null para variáveis com isSecret=true, independentemente do que estiver no banco. Isso é garantido no nível do service, não do serializer — o campo nunca entra na resposta.
Atualizar isSecret: o value é sempre obrigatório no body do PUT (o cliente reenvia o valor a cada atualização). Ao mudar de false → true, o service cifra o value recebido e grava em valueEncrypted, zerando value. Ao mudar de true → false, grava o value recebido como texto plano, zerando valueEncrypted. O service não decifra nem migra o valor antigo do banco — em ambas as direções ele usa o value do request. O cache é regerado em ambos os casos.
Interface do usuário
Router settings
A página de configurações do Router tem um card "Variáveis do Router" que usa o componente VariablesCard — uma tabela com CRUD inline. Mostra: chave, tipo (texto/senha), valor (mascarado com botão de reveal para secrets), e ações de editar/excluir. O exemplo de uso do token ({{router.MINHA_VAR}}) é exibido abaixo da tabela.
Flow builder
O builder tem um painel lateral "Variáveis" implementado pelo componente FlowVariablesPanel. O painel mostra três seções:
- Variáveis do Flow — CRUD completo das variáveis do flow atual (
{{flow.key}}) - Variáveis do Router — exibição somente-leitura das variáveis herdadas do Router pai (
{{router.key}}) - Variáveis de sistema — referência das variáveis built-in (
{{contact.name}},{{input}}, etc.) disponíveis para consulta
A seção de variáveis do Router no painel do builder é somente-leitura intencionalmente: modificar variáveis de Router a partir do contexto de um Flow seria confuso, pois afetaria todos os outros flows do Router.
Casos de borda
Cache miss por race condition: se uma variável for criada e imediatamente uma execução de flow for disparada, o engine pode ler o cache antes de syncRouterVarsCache terminar. O engine usa {} como fallback e a interpolação falha graciosamente (token substituído por string vazia no output). Na próxima execução, o cache já estará populado.
Variável renomeada: não existe endpoint de rename — é um DELETE + POST. O cache é atualizado nos dois passos. Durante a janela entre os dois, qualquer execução usando o nome antigo obterá string vazia no lugar do token.
Router ou Flow deletado: onDelete: Cascade no Prisma apaga todas as RouterVariable/FlowVariable no Postgres. Flows executando em memória no momento do delete usarão o routerVars/flowVars que já carregaram no início da execução — esse snapshot não é recarregado mid-execution.
Nota: o cache Redis
vars:router:{routerId}/vars:flow:{flowId}não é limpo quando o Router ou Flow é deletado. Os repositóriosrouter.repository.delete()/flow.repository.delete()chamam apenasprisma.*.delete(); os helpersdeleteRouterVars/deleteFlowVarsexistem emflow-ai-redismas não são invocados nesse caminho (deleteRouterVarsé importado emrouter-variables.routes.tsporém não é usado;deleteFlowVarsnão é importado em lugar nenhum). Como esses caches não têm TTL, a chave permanece órfã no Redis até ser sobrescrita ou removida manualmente. Isso é inofensivo (nenhuma sessão nova referencia o escopo deletado), mas convém saber ao inspecionar o Redis.
Arquivos relevantes
| Arquivo | Papel |
|---|---|
packages/flow-ai-database/prisma/schema.prisma | Modelos RouterVariable, FlowVariable |
packages/flow-ai-redis/src/cache.ts | getRouterVars, setRouterVars, deleteRouterVars, getFlowVars, setFlowVars, deleteFlowVars |
services/flow-ai-core/src/common/crypto.ts | Crypto.encrypt() / Crypto.decrypt() — AES-256-CBC |
services/flow-ai-core/src/http/routes/router-variables.routes.ts | CRUD + syncRouterVarsCache() |
services/flow-ai-core/src/http/routes/flow-variables.routes.ts | CRUD + syncFlowVarsCache() |
services/flow-ai-engine/src/handlers/flow.ts | Carregamento de routerVars/flowVars no ExecutionContext |
services/flow-ai-engine/src/runtime/execute.ts | Definição de ExecutionContext com routerVars/flowVars |
packages/flow-ai-runtime/src/interpolate.ts | Resolução de {{router.key}} e {{flow.key}} (primitiva compartilhada entre engine e agent) |
apps/flow-ai-core-ui/components/studio/VariablesCard.tsx | Componente de UI reutilizável para CRUD de variáveis |
apps/flow-ai-core-ui/components/studio/FlowVariablesPanel.tsx | Painel de variáveis no builder (flow + router + built-in) |
apps/flow-ai-core-ui/app/(studio)/router/[id]/(with-sidebar)/settings/page.tsx | Card de variáveis na página de settings do Router |