Runbook — Fluxos de Conversa do Sagazchat

Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o módulo Fluxos de Conversa do Sagazchat via Playwright. Acompanha a doc user-facing em src/c

8 min de leitura Atualizado em 30 de ago. de 2026

Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o módulo Fluxos de Conversa do Sagazchat via Playwright. Acompanha a doc user-facing em src/content/automacao/fluxos-gestao.md, fluxos-blocos.md e fluxo-padrao.md. Foi escrito após auditoria completa em 2026-05-09 (sessão PETSHOP-EXEMPLO), com construção real de um fluxo de atendimento de petshop usando os blocos.

Status: trechos [validado] foram executados nesta sessão. Trechos [a validar] são inferidos da UI ou do mapeamento DOM e dependem de cenário específico. Trechos [não validado por decisão] são operações destrutivas que não foram executadas deliberadamente.


1. Pré-requisitos

Idênticos ao runbook do Bate Papo (bate-papo § 1). Em resumo: Playwright local 1.59.1, Chromium do Playwright já instalado em %USERPROFILE%\AppData\Local\ms-playwright\chromium-1217\, sessão persistente CDP em scripts/_out/cdp.txt, viewport 1440×900 (com setViewportSize — note que window.innerHeight pode ficar em 1000 mesmo após setar 900, ver § 5).

2. Navegação

  • URL da lista: https://app.sagazchat.com/flowbuilders
  • URL de um fluxo: https://app.sagazchat.com/flowbuilder/<id-numérico> (ex: /flowbuilder/533)
  • Sidebar: Criação › Fluxos de Conversa. Use page.locator('text=/^Fluxos de Conversa$/').first() ou navegue direto pela URL.
  • Cadastro de Agenda: fica no sidebar em Automação › Agendamentos. Precisa existir antes para ser selecionada no bloco Agenda.
  • Cadastro de Variável global: fica em Configurações › Variável global. Precisa existir antes para aparecer no combobox do bloco Variável global.
  • Cadastro de Remarketing: fica no módulo Remarketing. Precisa existir antes para usar os sub-tipos de inscrição/descadastro em remarketing. Detalhe operacional em remarketing.
  • Cadastro de Assistente IA: fica em Assistentes IA. Precisa existir antes para usar o sub-tipo Assistente IA dentro do bloco Ação.
  • Biblioteca de mídias: fica em Configurações → Bibliotecas (/library). Mídias cadastradas ali podem ser selecionadas como fonte Biblioteca no bloco Conteúdo, sem novo upload.

Princípio: não recarregar a página entre operações. Cada page.goto “atualiza” a tela visualmente — irritante e custoso. Trabalhe na página atual sempre que possível; só vá pra /flowbuilders quando precisar de fato listar/criar/excluir fluxos.

3. Mapa da UI por área

Coordenadas válidas para viewport 1440×900. Em viewport diferente, use getBoundingClientRect ao vivo.

3.1 Tela /flowbuilders (lista)

ElementoPosição típicaSeletor
Título “Fluxos”(335, 30)
Busca “Pesquisar…”(1080, 30)input[placeholder="Pesquisar..."]
Botão + Adicionar(~1290, 20)getByRole('button', { name: /^Adicionar$/ })
Botão Nova pastadireita do AdicionargetByRole('button', { name: /^Nova pasta$/ })
Botão Importardireita do Nova pastagetByRole('button', { name: /^Importar$/ })
Botão Raw Importdireita do ImportargetByRole('button', { name: /Raw Import/i })
Linha de pastay variatext=/^<NomeDaPasta>$/
Linha de fluxoy variatext=/^<NomeDoFluxo>$/
Kebab da linhax ≈ 1380, mesma y da linhabotão MUI sem aria; identificar pela posição

Pasta tem 2 ações no kebab: Editar nome / Apagar (validado pelo usuário). Fluxo tem ações no kebab: Editar / Excluir / outras (Duplicar, Exportar — [a validar]).

3.2 Modais da lista

3.2.1 Modal Criar fluxo (botão Adicionar) — 2 etapas [validado]

Etapa 1 — Selecionar canal: modal mostra “Selecione o canal do fluxo” com cartões clicáveis “WhatsApp” e “Instagram” (não há combobox, não há input). Click no cartão avança.

Etapa 2 — Nome + Atalhos: modal mostra “Adicionar fluxo” com:

  • Input name (texto)
  • Input shortcutItems[0].value com placeholder “Ex: enviar”
  • Botões: “Adicionar atalho”, “Cancelar”, “Adicionar” (verde — confirma)
await page.getByRole('button', { name: /^Adicionar$/ }).click();
await page.waitForTimeout(1500);
await page.locator('[role="dialog"]').getByText(/^WhatsApp$/, { exact: true }).first().click();
await page.waitForTimeout(1800);
await page.locator('[role="dialog"] input').first().fill('NOME_FLUXO');
await page.locator('[role="dialog"]').getByRole('button', { name: /Adicionar/i }).last().click();
await page.waitForTimeout(4000);
// Pode redirecionar pra /flowbuilder/<id>; se não, abrir manualmente pelo nome.

3.2.2 Modal Excluir fluxo [validado pelo cleanup]

Header “Deletar <nome>?” + texto “Tem certeza que deseja deletar este fluxo? Todas as integrações relacionados serão perdidos.” + botões Cancelar / Ok.

⚠️ Mesmo modal reaproveitado pelo Kanban (ver kanban § 5.5). Botão de confirmação é “Ok”, não “Excluir”.

3.2.3 Modal Nova pasta [validado abertura]

Modal simples com input de nome + botões Cancelar/Adicionar.

3.2.4 Modal Importar / Raw Import [validado abertura]

Importar: cola código exportado de outro fluxo. Raw Import: variante de importação raw (JSON cru). Confirmação efetiva [a validar].

3.3 Construtor /flowbuilder/<id>

ElementoPosiçãoSeletor
Voltar <(~290, 30)botão sem aria
Nome do fluxo(~420, 30)h1/h2
Botão Compartilhar fluxo(~1265, 35)getByRole('button', { name: /Compartilhar/i })
Botão Salvar (header)(~1385, 35)botão fora de qualquer dialog, r.y < 80
Tesoura ✂(~1115, 35)função [a validar]
Ícone ⊕(~1155, 35)função [a validar]
Paleta lateralx ∈ [298, 432]container scrollável com [draggable="true"]
Canvasx > 432.react-flow

3.4 Paleta de blocos [validado em 2026-05-09]

12 blocos visíveis (via scroll top + bottom):

OrdemBlocoCategoria (user-facing)Tipo
1ConteúdoMensagemMulti-item (8 cartões)
2MenuMensagemPergunta com opções
3AçãoMeta-blocoCombobox de 12 sub-tipos
4RandomizadorFluxoSaídas A/B com %
5SalvarDadosCaptura resposta em variável
6IntegraçãoIntegraçãoHTTP outbound
7CondiçãoFluxoBranching lógico
8Atraso inteligenteOperaçãoPausa programada
9DistribuidorFluxoSaídas rotativas
10Variável globalDadosSet/somar/subtrair
11OpenAIIntegraçãoChatGPT API
12AgendaOperaçãoAgendamento de evento

Plus Início (node fixo .react-flow__node-start, não está na paleta).

Divergência com fluxos-blocos.md user-facing (que lista 18 blocos): Etiqueta, Departamentos, Remarketing, Notificação, Conexão de fluxo, Manipulador, Controlador de chat NÃO são blocos drag separados — são sub-tipos do bloco Ação. Pixel e Cartão pix (também sub-tipos de Ação) não estão no user-facing. Assistente IA (sub-tipo de Ação) não está no user-facing. OpenAI (bloco drag) não está no user-facing.

3.5 Painéis dos blocos

3.5.1 Bloco Conteúdo [validado — todos os 8 tipos]

Heading: “Adicionar conteúdo ao fluxo”.

Painel mostra 8 cartões de tipo de mídia em 2 linhas (4×2). Todos os tipos (validados em 2026-05-09 via sagaz-fluxos-conteudo-tipos.mjs):

CartãoCampos do itemAceita arquivo (accept)
TextoEditor whatsapp-editor (contenteditable) com toolbar B / I / Tachado / Emoji / Variável {}
IntervaloSlider + input number “Tempo em segundos” (unidade fixa em segundos)
ImagemTextarea “Legenda” (opcional) + <input type="file">.png, .jpg, .jpeg
Áudio(sem legenda) + <input type="file">audio/ogg, audio/mp3, audio/opus
VídeoTextarea “Legenda” + <input type="file">video/mp4
ArquivoTextarea “Legenda” + <input type="file">.doc, .docx, .pdf, .txt, .xlsx, .xls, .csv, .zip, .rar, .json, .pptx
ContatoNome (placeholder “Exemplo da silva”) + Organização (opcional) (placeholder “Exemplo LTDA”) + Número com seletor de país (🇧🇷 +55)
Sticker(sem legenda) + <input type="file">.png, .jpg, .jpeg, .gif

Fonte Biblioteca (v4.24.1): para itens de mídia, o painel também permite selecionar uma mídia já cadastrada em Configurações → Bibliotecas. A seleção reutiliza o arquivo por referência; não faça novo upload. A descrição/categoria da mídia permanece a fonte de contexto para a IA.

Características importantes:

  • Empilhável: clicar em cartão adiciona novo item ao painel atual (sem fechar modal). Vários itens encadeados num único node Conteúdo. Não usar 1 node por mensagem.
  • Lixeira por item: cada item adicionado tem ícone trash em x ≈ 1056 (path SVG M6 19c0 1.1.9 2 2 2h8c1.1...). Click remove só esse item.
  • Painel é rolável: com muitos itens, lixeiras/cartões saem do viewport — usar scrollIntoView antes de clicar (ver § 5).
  • Modo edição vs criação: botão muda de “Adicionar” (criar novo node) pra “Salvar” (editar node existente).
  • Áudio e Sticker NÃO aceitam legenda; os outros 3 (Imagem/Vídeo/Arquivo) aceitam.
  • Upload via Playwright: usar page.locator('[role="dialog"]:visible input[type="file"]').setInputFiles('caminho/arquivo.ext'). Conferir o accept correto antes de upload.

Snippet — adicionar Imagem com legenda:

// Painel Conteúdo aberto
await clicarCartao('Imagem');
// Preencher legenda (último textarea[name="text"] do dialog)
const legendaPos = await page.evaluate(() => {
  const v = Array.from(document.querySelectorAll('[role="dialog"]')).filter(d => {
    const r = d.getBoundingClientRect();
    return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
  });
  const dlg = v[v.length - 1];
  const tas = Array.from(dlg.querySelectorAll('textarea[placeholder="Legenda"]'));
  if (!tas.length) return null;
  const t = tas[tas.length - 1];
  t.scrollIntoView({ block: 'center' });
  const r = t.getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(legendaPos.x, legendaPos.y);
await page.keyboard.type('Olha que fofo!', { delay: 12 });
// Upload do arquivo
await page.locator('[role="dialog"]:visible input[type="file"]').last().setInputFiles('C:/caminho/foto.jpg');
await page.waitForTimeout(1500);

Snippet — adicionar Contato:

await clicarCartao('Contato');
// Inputs: Nome + Organização (opcional). Número usa componente customizado (não input simples)
const nomePos = await page.evaluate(() => {
  const v = Array.from(document.querySelectorAll('[role="dialog"]')).filter(d => {
    const r = d.getBoundingClientRect();
    return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
  });
  const dlg = v[v.length - 1];
  const inps = Array.from(dlg.querySelectorAll('input[placeholder="Exemplo da silva"]'));
  if (!inps.length) return null;
  const i = inps[inps.length - 1];
  i.scrollIntoView({ block: 'center' });
  const r = i.getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(nomePos.x, nomePos.y);
await page.keyboard.type('Fulano Exemplo', { delay: 12 });
// Idem pra "Exemplo LTDA" (organização) — placeholder
// Pra Número: localizar input próximo ao seletor 🇧🇷 e digitar dígitos (formato +55 já default)

3.5.2 Bloco Menu [validado]

Heading: “Adicionar menu ao fluxo”.

Campos:

  • Radio “Tipo”: Número / Emoticon / Lista (default Número)
  • “Mensagem de explicação do menu” — textarea[name="whatsapp-editor"]
  • Botão “Adicionar resposta” — cada click adiciona slot de resposta (input text)
  • Combobox “Qual tipo de tempo”: Minutos / Horas / Dias (timeout) — vem default em “Minutos” (≠ Salvar que vem vazio)
  • Botões: Cancelar / Salvar

Cada resposta vira uma saída do node com handle source data-handleid="aN" (a1, a2, a3…).

⚠️ Editar Menu existente preserva handles e adiciona novos [validado 2026-05-10]: ao adicionar uma 5ª resposta num Menu que já tinha 4, o sistema cria automaticamente data-handleid="a5" mantendo a1..a4 intactos. Edges existentes nas saídas anteriores não são quebradas.

3.5.3 Bloco Ação (meta-bloco) [validado]

Heading: “Editar ação” ou “Adicionar ação”.

Campos iniciais:

  • Botão “Remover todas as etiquetas”
  • Combobox “Adicionar uma ação” — placeholder Digite para buscar… (ex: adicionar etiqueta)
  • Texto “Selecione no dropdown para adicionar o bloco da ação.”
  • Botões: Cancelar / Salvar

Combobox lista 12 sub-tipos:

#Sub-tipoCampos resultantes
1Conexão de fluxoCombobox “Selecione o fluxo”
2Assistente IACombobox “Suporte IA” (Transferir para)
3Adicionar etiquetaCombobox “Selecione ou crie uma etiqueta” (Enter cria nova)
4Remover etiquetaCombobox “Selecione uma etiqueta”
5Inscrição em RemarketingCombobox “Selecione um remarketing”; salva sequenceId, sequenceName, subscriber: true
6Descadastrar do RemarketingMesmo combobox; salva subscriber: false
7Redirecionar para departamentoCombobox “Selecione o departamento”
8Controlador de chatCombobox “Escolha um estado” (Aguardando/Atendendo/Resolvido)
9Notificar membro da equipeNome (texto) + Número (+55 (13) 91234 4321) + Mensagem
10ManipuladorVar + op + tipo de valor + valor — campos aparecem progressivamente (ver detalhe abaixo)
11PixelCombobox “Tipo de tracking” (Facebook por default)
12Cartão pixNome + Tipo da chave (CPF default) + Chave pix

⚠️ Aviso descoberto: “Conexão de fluxo ou Assistente IA ativo: outras ações ficam desativadas.” — esses 2 são exclusivos.

Manipulador — campos progressivos [validado 2026-05-10]

Os campos do sub-tipo Manipulador aparecem em sequência conforme preenche:

  1. input[placeholder="Conta1"]Nome da variável (placeholder estranho mas aceita qualquer nome, ex: servico_escolhido)
  2. input[placeholder="Qual tipo de operação"] — combobox com 6 opções: Definir valor · Somar ao valor · Subtrair ao valor · Dividir ao valor · Multiplicar ao valor · Conta matemática
  3. input[placeholder="Qual tipo de valor"]só aparece após selecionar operação. Combobox com 2 opções: Texto / Número
  4. input[placeholder="Texto"] ou input[placeholder="Número"] — campo de valor, só aparece após selecionar tipo de valor. Atenção: distinguir do combobox anterior — pegar pelo getAttribute('role') !== 'combobox'.

⚠️ Não tente preencher em paralelo — se você pular pra preencher o valor antes de selecionar o tipo, o campo nem existe ainda.

3.5.4 Bloco Salvar [validado]

Heading: “Adicione Salvar”.

Campos:

  • “Tempo mínimo caso o cliente não responda:” — input name="timer" (number) + combobox “Qual tipo de tempo” (Minutos/Horas/Dias)
  • “Mensagem antes de aguardar a resposta:” (Opcional) — whatsapp-editor (contenteditable). É AQUI que vai a pergunta (ex: “qual é o nome do seu pet?”).
  • “Campo para salvar a informação no usuário:” — textarea (não input!) com placeholder “Nome do campo”
  • Switch “Aceitar mídias como resposta”
  • “Tentativas para respostas erradas:” (Opcional) — input number, placeholder “Número de tentativas”
  • “Mensagem para avisar que a resposta está errada:” (Opcional) — whatsapp-editor
  • Botões: Cancelar / Adicionar

⚠️ Pergunta sempre vai dentro do bloco que captura. Errado: Conteúdo “Qual seu nome?” → Salvar (campo). Certo: Salvar (Mensagem antes = pergunta + campo). Senão a variável fica órfã.

⚠️ “Nome do campo” é <textarea>, não <input> — usar seletor textarea[placeholder="Nome do campo"].

⚠️ Combobox “Qual tipo de tempo” é OBRIGATÓRIO (validado 2026-05-10 em ESTETICA-EXEMPLO). Sem selecionar Minutos/Horas/Dias, click em “Adicionar” é silenciosamente ignorado — dialog não fecha, sem mensagem de erro visível. Sempre selecionar antes de confirmar:

const cbPos = await page.evaluate(() => {
  const v = Array.from(document.querySelectorAll('[role="dialog"]'))
    .filter(d => { const r = d.getBoundingClientRect(); return r.width > 50 && getComputedStyle(d).visibility !== 'hidden'; });
  const dlg = v[v.length - 1];
  const inp = dlg.querySelector('input[placeholder="Qual tipo de tempo"]');
  inp.scrollIntoView({ block: 'center' });
  const r = inp.getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(cbPos.x, cbPos.y);
await page.waitForTimeout(800);
const opPos = await page.evaluate(() => {
  const m = Array.from(document.querySelectorAll('[role="option"], li.MuiAutocomplete-option')).find(o => /^Minutos$/i.test((o.innerText || '').trim()));
  const r = m.getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(opPos.x, opPos.y);

⚠️ whatsapp-editor NÃO existe no painel Conteúdo (item Texto) mas EXISTE no painel Salvar (Mensagem antes / Mensagem de erro). No Conteúdo/Texto o seletor é textarea[name="text"]. Os dois coexistem na plataforma.

3.5.5 Bloco Randomizador [validado]

Heading: “Adicionar um randomizador ao fluxo”.

Campos:

  • Slider 0% – 100% (divide saídas A/B)
  • Botões: Cancelar / Adicionar

3.5.6 Bloco Condição [validado]

Heading: “Adicionar condição ao fluxo”.

Campos:

  • Texto explicativo “Defina as condições e regra lógica para que o fluxo continue pela saída superior deste bloco:”
  • Combobox “Selecionar condição” — 8 opções:
    • Etiqueta · Dia da semana · Atendimento pendente · Atendimento aberto · Atendimento fechado · Nome · Número · Email
  • Radio “Regra corresponde a todas as condições (e)” / “Regra corresponde a qualquer condição (ou)“

3.5.7 Bloco Distribuidor [validado]

Heading: “Adicionar distribuidor ao fluxo”.

  • Botão “Adicionar saída” — cada click adiciona saída numerada
  • Botões: Cancelar / Salvar

Distribui leads em rotação entre as saídas.

3.5.8 Bloco Variável global [validado]

Heading: “Adicione variável global”.

Campos:

  • Combobox “Variável que será utilizada” (lista variáveis globais existentes)
  • Combobox “Escolha qual operação” — 3 opções: Definir valor (=) / Somar ao valor (+) / Subtrair ao valor (-)
  • Input “Valor” (text)

⚠️ Pré-requisito real: a variável global precisa estar criada em Configurações antes de usar este bloco. Se ela não existir, criar primeiro no módulo de Configurações; depois voltar ao Fluxos de Conversa e selecionar a variável no combobox. Um payload manual com nome/id inventado pode até renderizar visualmente, mas não deve ser tratado como configuração funcional se não veio de uma variável global real da conta.

Nota: Manipulador (sub-tipo da Ação) tem 6 operações, não 3 como Variável global — diferença real entre os dois blocos. Ver § 3.5.3 nota sobre Manipulador.

3.5.9 Bloco Atraso inteligente [validado]

Heading: “Adicionar um intervalo inteligente”.

Campos:

  • Input number “Tempo” + combobox “Qual tipo de tempo” (Minutos/Horas/Dias)
  • Switch (alternativa: Data específica) → input “DD/MM/YYYY hh:mm”

Diferente do Intervalo do Conteúdo (que é só segundos).

3.5.10 Bloco Integração [validado]

Heading: “Adicionar requisição HTTP”.

Campos:

  • Combobox “Tipo de requisição”: POST / PUT / GET
  • Input “Url da requisição” (name="baseUrl", placeholder https://teste.exemplo/1234556/{id})
  • Tabs: Header da requisição / Corpo da requisição / Mapear resposta
  • Header padrão: { "Content-Type": "application/json", "Cache-Control": "no-cache" }
  • Botão “Testar requisição”

3.5.11 Bloco Agenda [validado]

Heading: “Criar evento na agenda”.

Campos extensos (~30 labels): combobox Agenda, dias a frente, textos do menu para escolher data/hora, dados do evento (título, mensagem, cor de fundo, cor do texto), aviso de compromisso. Painel é rolável.

⚠️ Pré-requisito real: precisa existir uma Agenda criada na conta em Automação para aparecer no combobox e ser selecionada no fluxo. Se não existir, criar a agenda primeiro pelo sidebar em Automação; depois voltar ao Fluxos de Conversa e selecionar essa agenda no bloco. Um payload manual com calendar: { id, name } pode renderizar visualmente o node, mas não deve ser tratado como configuração funcional se esse id não veio de uma agenda real da conta.

Cadastro observado em 2026-05-10:

  • UI: sidebar Automação › Agendamentos (/calendars) › botão Criar agenda.
  • Modal de criação: campo único input[name="name"] + botões Cancelar / Adicionar.
  • API interna usada pelo frontend: GET /calendars?searchParam=&pageNumber=1 lista agendas; POST /calendars com payload { "name": "Nome da agenda" } cria agenda.
  • Exemplo real criado na conta: LAB - Agenda Flowbuilder, id: 35, uuid: 9eab2ae4-7ae1-4fba-a052-ca7f303464ed.

Configuração da agenda observada em 2026-05-10:

  • A lista /calendars tem menu de três pontos com Editar / Excluir. Editar nesta lista só renomeia a agenda; não é a tela completa de disponibilidade.
  • Para configurar disponibilidade, abrir a agenda pela linha/lista ou acessar a rota interna /calendar/<uuid>/<nome-url-encoded>. Exemplo real: /calendar/9eab2ae4-7ae1-4fba-a052-ca7f303464ed/LAB%20-%20Agenda%20Flowbuilder.
  • Na tela da agenda, clicar no botão Configurar no topo direito. Isso abre o modal Configurar agenda. Cuidado: existe item Configurações no sidebar; não confundir com o botão Configurar da agenda.
  • O modal carrega GET /calendars/<uuid>/settings. Se ainda não existe configuração, a resposta vem null.
  • Campos do modal:
    • Quais dias da semana não é possível agendar? combobox multi-select. Precisa clicar/expandir. Opções observadas: Dom, Seg, Ter, Qua, Qui, Sex, Sab.
    • Dias em que não farei agendamentos neste calendário lista os dias indisponíveis selecionados. Botão textual Adicionar confirma/adiciona a seleção atual.
    • Horários: Inicia os agendamentos (07:00 placeholder), Entra em pausa (12:00), Volta da pausa (14:00), Fim dos agendamentos (17:00).
    • Intervalos: switch entre Intervalo fixo e Intervalo específico.
    • No modo Intervalo fixo aparecem número + combobox de tipo de tempo. O combobox precisa ser expandido; opções observadas: Minutos, Horas, Dias.
    • No modo Intervalo específico, clicar no switch para a direita. Abre a seção Horários disponíveis com botão textual Adicionar; cada clique adiciona um input de horário com placeholder 07:00. Usar quando os horários não seguem uma cadência fixa.
    • Duração dos eventos: número + combobox de tipo de tempo. Opções observadas: Minutos, Horas, Dias.
  • Botões finais: Sair sem salvar e Salvar. Ao automatizar, clicar no botão com texto exatamente Salvar, porque Sair sem salvar também contém a palavra “salvar”.
  • Payload interno observado ao salvar configuração básica:
{
  "specificDays": [],
  "specificRange": [],
  "times": {
    "startTime": "09:00",
    "startTimePause": "12:00",
    "endTimePause": "13:00",
    "endTime": "18:00"
  },
  "rangeMarked": {
    "rangeMarkedTime": 30,
    "rangeMarkedType": "Minutos"
  },
  "valueDefaultdaysWeek": [],
  "duration": {
    "number": "60",
    "type": "Minutos"
  }
}
  • API interna usada pelo frontend para salvar: PUT /calendars/<uuid>/settings.
  • Resposta real da agenda lab após salvar: calendarId: 35, rangeMarkedTime: 30, rangeMarkedType: "Minutos", startTime: "09:00", startTimePause: "12:00", endTimePause: "13:00", endTime: "18:00", intervalNumber: 60, intervalPeriod: "Minutos".
  • Para outra IA: sempre abrir/expandir comboboxes e switchs antes de declarar que a agenda foi mapeada. Parte da configuração só aparece após clicar no switch Intervalo específico ou nos botões textuais Adicionar.

⚠️ Nomenclatura cruzada confusa request→response:

  • rangeMarked.rangeMarkedTime (request) = “Intervalo fixo” do modal = intervalo entre demos (60min = 1 demo/hora).
  • duration.number (request) → vira intervalNumber (response) = “Duração dos eventos” do modal = duração de cada demo (30min).
  • Ou seja: o backend chama de intervalNumber o que o frontend chama de “duration” e vice-versa. Não confiar no nome do campo na response — alinhar pelo modal visual ou pelo payload do request.
  • valueDefaultdaysWeek: ['Dom', 'Sab'] (request) vira hiddenDays: [0, 6] (response). Mapeamento: Dom=0, Seg=1, Ter=2, Qua=3, Qui=4, Sex=5, Sab=6.

⚠️ Chamadas diretas via fetch() em page.evaluate precisam de Bearer token:

  • Cookies sozinhos retornam 401 ERR_SESSION_EXPIRED. Frontend usa Authorization: Bearer <jwt> explícito.
  • Token está em localStorage.token (string com aspas duplas — precisa JSON.parse).
  • Refresh token em localStorage.tokenRefresh.
  • Padrão pra usar:
const v = localStorage.getItem('token');
const token = v?.startsWith('"') ? JSON.parse(v) : v;
const res = await fetch(`https://backend.sagazchat.com/calendars/${uuid}/settings`, {
  method: 'PUT',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
  body: JSON.stringify(payload),
});
  • Vale pra qualquer endpoint backend.sagazchat.com chamado de dentro da página via fetch. Via UI nativa o frontend já injeta isso automaticamente.

3.5.12 Bloco OpenAI [validado]

Heading: “Adicionar ChatGPT API”.

Campos:

  • “Pergunta ou mensagem para o cliente” — input text, placeholder “Qual sua dúvida sobre nossos produtos?”
  • “Modelo do chatGPT” — combobox, opções: gpt-4.1, gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-5.1, gpt-5-mini, gpt-5-nano, gpt-5.2
  • “Chave de API (Token)” — input text (name="token")
  • “Prompt” — textarea (name="headerHttp"), placeholder “Coloque aqui tudo que IA precisa saber para informar ao cliente.”
  • Botões: Cancelar / Adicionar

3.5.13 Bloco Início [validado por regra operacional]

O Início é apenas onde começa o fluxo. Ele é o node fixo .react-flow__node-start, não fica na paleta e deve estar conectado ao primeiro bloco real da jornada. Não tratar o Início como lugar para configurar etiqueta, departamento, remarketing ou qualquer outro efeito; use blocos Ação dedicados depois do Início.

3.6 Guia geral: quando usar cada bloco

Esta seção é o mapa principal para outra IA saber usar o criador de fluxo como produto, independentemente do fluxo PETSHOP ou ESTETICA.

Necessidade no fluxoBloco corretoObservação operacional
Começar o fluxoInícioNode fixo; não vem da paleta. Serve para iniciar e conectar ao primeiro bloco real.
Enviar texto, imagem, áudio, vídeo, arquivo, contato, sticker ou pausa curtaConteúdoEmpilhar múltiplos itens no mesmo node. Intervalo aqui é em segundos.
Fazer escolha com opções numeradas/lista/emoticonMenuCada opção vira saída a1, a2, a3… Conectar todas as opções relevantes.
Capturar nome, telefone, dúvida, preferência ou qualquer resposta livreSalvarA pergunta vai dentro do próprio bloco Salvar, não em Conteúdo anterior.
Aplicar etiqueta, remover etiqueta, enviar para departamento, notificar equipe, manipular variável, conectar fluxo, remarketing, pixel, pix, IA assistenteAçãoÉ o meta-bloco de efeitos. Etiqueta/Departamento/Notificação não aparecem como blocos separados na paleta.
Criar bifurcação por regraCondiçãoUsar para etiqueta, dia da semana, atendimento pendente/aberto/fechado, nome, número ou email. Conectar saída verdadeira e fallback.
Dividir tráfego por percentualRandomizadorÚtil para teste A/B ou distribuição probabilística. Conectar as duas saídas.
Revezar atendimento entre pessoas/filasDistribuidorAdicionar uma saída por destino. Depois conectar cada saída para o departamento/pessoa correta.
Alterar variável global existenteVariável globalExige variável global criada antes em Configurações. Não confundir com Manipulador, que mexe em variável do contato/fluxo.
Esperar minutos/horas/dias ou data específicaAtraso inteligenteUsar para follow-up programado. Para pausa humana curta entre mensagens, usar Intervalo dentro de Conteúdo.
Chamar API externaIntegraçãoConfigurar método, URL, headers, body e mapeamento. Usar “Testar requisição” antes de confiar.
Criar agendamentoAgendaExige agenda criada antes em Automação. Painel é longo e deve ser preenchido com scroll interno.
Responder com IA via ChatGPT APIOpenAIExige token, modelo e prompt. Usar com prompt fechado e saída humana de fallback.

3.7 Guia geral: sub-tipos do bloco Ação

O bloco Ação é onde ficam vários recursos que parecem blocos independentes na documentação user-facing. No canvas, eles são sub-tipos dentro do mesmo painel.

Sub-tipo de AçãoUsar paraCuidados
Conexão de fluxoEncaminhar para outro fluxoÉ exclusivo: quando ativo, outras ações ficam desativadas.
Assistente IATransferir para suporte/assistente IAExige assistente criado antes em Assistentes IA. Também é exclusivo com outras ações.
Adicionar etiquetaMarcar interesse, etapa, serviço, status ou intençãoAntes de criar, verificar etiqueta existente. Autocomplete pode criar duplicata.
Remover etiquetaLimpar status antigo ou tirar usuário de uma etapaSó usar quando a remoção faz parte da regra de negócio.
Inscrição em RemarketingEntrar em campanha/lista de remarketingExige remarketing criado antes no módulo Remarketing. Campo: combobox “Selecione um remarketing”. Payload: { sequenceId, sequenceName, subscriber: true }.
Descadastrar do RemarketingRemover de campanha/listaExige remarketing criado antes no módulo Remarketing. Campo: combobox “Selecione um remarketing”. Payload: { sequenceId, sequenceName, subscriber: false }.
Redirecionar para departamentoMandar para Comercial, Financeiro, Suporte etc.Não é “Distribuidor”; envia para uma fila/departamento específico.
Controlador de chatMudar estado do atendimentoEstados vistos: Aguardando, Atendendo, Resolvido. Usar com cuidado porque afeta operação.
Notificar membro da equipeAvisar alguém por nome/número/mensagemBom para lead quente, agendamento solicitado, pagamento, exceção ou erro.
ManipuladorDefinir/somar/subtrair/dividir/multiplicar variável do contato/fluxoCampos aparecem progressivamente; não preencher fora de ordem.
PixelDisparar trackingNão priorizar agora. Tipo padrão observado: Facebook. Validar conta/evento antes de produção.
Cartão pixEnviar dados de pagamento PIXExige nome, tipo de chave e chave pix. Usar só em fluxo financeiro validado.

3.8 Checklist para declarar um bloco “mapeado”

Uma IA só deve considerar um bloco mapeado quando souber:

  1. Como abrir o painel do bloco.
  2. Quais campos obrigatórios aparecem.
  3. Quais campos aparecem só depois de uma escolha anterior.
  4. Qual botão confirma (Adicionar ou Salvar).
  5. Como o bloco cria saídas/handles no React Flow.
  6. Como validar no DOM ou no JSON salvo.
  7. Qual erro comum faz o modal ficar aberto sem feedback.
  8. Se a ação é destrutiva, operacional ou apenas visual.

Se algum item estiver ausente, marcar como [a validar] no runbook em vez de inventar.

4. Ações seguras vs disparadoras

AçãoDisparadora?Observação
Listar fluxos / pastasNão
Abrir modal Criar fluxoNãoCancelar fecha sem efeito
Criar fluxoSimCria registro real; cleanup necessário
Editar nome do fluxoSim
Excluir fluxoSim — destrutivaApaga fluxo + integrações relacionadas
Excluir pastaSim — destrutiva[a validar] se apaga fluxos dentro
Abrir construtorNão
Adicionar bloco (click paleta)NãoCria node não-persistido até salvar
Editar painel + CancelarNão
Editar painel + Salvar/AdicionarNãoPersiste local (não salva no servidor até Salvar do header)
Salvar fluxo (header)SimPersiste tudo no backend
Deletar item dentro do painel ConteúdoNãoLocal até salvar
Deletar node (Backspace)NãoLocal até salvar
Conectar handlesNãoLocal até salvar

5. Quirks descobertas em 2026-05-09

5.1 Viewport e elementos rolados

  • setViewportSize({ width: 1440, height: 900 }) mas window.innerHeight pode reportar 1000. Não confiar em altura fixa — sempre comparar com window.innerHeight ao testar visibilidade.
  • Painéis longos rolam dentro do dialog. Itens em y > viewportH não respondem a mouse.click(x, y) mesmo que getBoundingClientRect retorne aquela coordenada. Sempre element.scrollIntoView({ block: 'center', behavior: 'instant' }) antes, depois re-pegar getBoundingClientRect, depois clicar. Padrão validado:
await page.evaluate(() => {
  const last = document.querySelector('SELETOR_DO_ALVO');
  last.scrollIntoView({ block: 'center', behavior: 'instant' });
});
await page.waitForTimeout(700);
const pos = await page.evaluate(() => {
  const el = document.querySelector('SELETOR_DO_ALVO');
  const r = el.getBoundingClientRect();
  return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
await page.mouse.click(pos.x, pos.y);

5.2 Filtro hasText: /^OpenAI$/ falha no Playwright

page.locator('[draggable="true"]').filter({ hasText: /^OpenAI$/ }) retorna count 0 mesmo com “OpenAI” presente. Workaround: re-mapear via page.evaluate retornando (innerText || '').trim() direto e clicar por coordenada. Padrão:

const blocos = await page.evaluate(() =>
  Array.from(document.querySelectorAll('[draggable="true"]')).map(el => {
    const r = el.getBoundingClientRect();
    return { text: (el.innerText || '').trim(), x: r.x + r.width/2, y: r.y + r.height/2 };
  })
);
const oai = blocos.find(b => /openai/i.test(b.text));
await page.mouse.click(oai.x, oai.y);

5.3 evaluate(fn, arg1, arg2) com múltiplos args = erro

Playwright só aceita 1 arg. Sempre passar como objeto:

// Errado: await page.evaluate((a, b) => ..., 1, 2)
// Certo:
await page.evaluate(({ a, b }) => ..., { a: 1, b: 2 });

5.4 Botão “Adicionar” do dialog via getByRole falha às vezes

Em painéis pesados, [role="dialog"]:visible não bate corretamente. Usar coordenada via evaluate:

async function clicarBotaoNoDialog(nome) {
  const pos = await page.evaluate((n) => {
    const v = Array.from(document.querySelectorAll('[role="dialog"]'))
      .filter(d => { const r = d.getBoundingClientRect(); return r.width > 50 && getComputedStyle(d).visibility !== 'hidden'; });
    const dlg = v[v.length - 1];
    const btn = Array.from(dlg.querySelectorAll('button')).find(b => (b.innerText || '').trim() === n);
    if (!btn) return null;
    btn.scrollIntoView({ block: 'center', behavior: 'instant' });
    const r = btn.getBoundingClientRect();
    return { x: r.x + r.width/2, y: r.y + r.height/2 };
  }, nome);
  if (pos) { await page.mouse.click(pos.x, pos.y); await page.waitForTimeout(2500); }
}

5.5 Modal residual cancela sem querer

fecharModais() que aperta Cancelar em qualquer dialog visível pode cancelar painéis abertos legítimos. Não chamar fecharModais() entre clicarBotaoNoDialog('Adicionar') e Salvar fluxo — o painel já fechou naturalmente após Adicionar, qualquer Cancelar adicional vai fechar o dialog ERRADO.

5.6 Lixeira de item Conteúdo

Cada item adicionado dentro do bloco Conteúdo tem ícone SVG de delete em x ≈ 1056, path começando com M6 19c0 1.1.9 2 2 2h8c1.1.... Filtrar:

const lixeiras = Array.from(dlg.querySelectorAll('svg')).filter(svg => {
  const r = svg.getBoundingClientRect();
  if (r.x < 1030 || r.x > 1080) return false;
  const path = svg.querySelector('path');
  return path && /^M6 19c0/.test(path.getAttribute('d') || '');
});

5.7 Pergunta dentro do bloco que captura

Pra capturar resposta livre do cliente: pergunta vai em whatsapp-editor do bloco Salvar (campo “Mensagem antes de aguardar a resposta”) + nome do campo em textarea[placeholder="Nome do campo"]. NUNCA colocar a pergunta num Conteúdo separado anterior — a variável captura n mensagens depois mas não associa à pergunta original.

5.8 Conteúdo aceita múltiplos itens — não use 1 node por mensagem

Errado: 5 nodes Conteúdo (3 Texto + 2 Intervalo) encadeados
Certo: 1 node Conteúdo com 5 itens dentro

Para sequência humana de mensagens: empilhar Texto + Intervalo + Texto + Intervalo + Texto no mesmo bloco.

5.9 1s = robótico

Intervalos no Conteúdo são em segundos. Usar 3–5s entre mensagens para imitar tempo de digitação humano. 1s parece bot.

5.10 React Flow handles e conexão drag

  • Orientação real (validada 2026-05-10 em fluxo ESTETICA-EXEMPLO): o construtor do Sagazchat usa handles laterais, não verticais.
    • Source: .react-flow__handle-right.source no node origem (inclusive .react-flow__node-start)
    • Target: .react-flow__handle-left.target no node destino (nodes singleBlock)
    • O .react-flow__node-start tem o handle right (source). Não tem left.
  • ⚠️ Runbook anterior falava em bottom/topfalso para esta versão. Apêndice PETSHOP foi montado com a layout antiga; para novos fluxos, usar right/left.
  • Drag funciona com mouse.movemouse.downmouse.move em steps → mouse.up. Não precisa pointer eventsmouse.* funciona. Adicionar 1 step intermediário em arco (srcX+30, srcY+10) ajuda o React Flow a registrar como drag intencional.

5.10a Criar bloco pela paleta é em DUAS FASES — node temporário + persistência

[validado 2026-05-10 em VENDA-SAGAZCHAT] Click (ou drag) em bloco da paleta:

  1. Fase 1 — criação temporária: o node aparece no canvas IMEDIATAMENTE com data-id próprio + painel de configuração abre (modal “Adicione X” ou “Adicionar X ao fluxo”).
  2. Fase 2 — persistência: só ao clicar o botão de confirmação do painel (“Adicionar” pra Conteúdo/Salvar/Ação, “Salvar” pra Menu) o node fica gravado.
  3. Cancelar OU ESC OU click em “Sair sem salvar” desfaz o node — ele some do canvas. Isso explica casos em que o painel “Adicione X” aparece num run, fecha por timeout/erro, e o node fica órfão visível por uns segundos antes do React Flow re-renderizar e o eliminar.

⚠️ Implicação operacional: ao automatizar, NUNCA assumir que clicar na paleta criou node final. Sempre:

  • Conferir [role="dialog"] aberto após click — se sim, painel temporário ativo.
  • Preencher TODOS os campos obrigatórios antes de confirmar.
  • Após confirmar, conferir que [role="dialog"] fechou E que document.querySelectorAll('.react-flow__node:not(.react-flow__node-start)') cresceu em 1.
  • Se Cancelar foi acionado em qualquer ponto, o node não existe e abrir painel via dblclick em “último node” vai cair no node anterior — bug latente que confunde scripts.

Validação obrigatória do node criado: comparar lista data-id antes e depois. Se o set não cresceu, ABORTAR — o click na paleta foi absorvido por outro elemento ou o painel foi cancelado.

5.10a-bis Bloco Salvar exige timer > 0 + tipo de tempo selecionado

[validado 2026-05-10 em VENDA-SAGAZCHAT] No painel “Adicione Salvar”:

  • Campo input[name="timer"] (primeiro input[type="number"]) começa em 0. Se ficar 0, o botão Adicionar não persiste o node (sem mensagem de erro visível — falha silenciosa).
  • Combobox input[placeholder="Qual tipo de tempo"] é obrigatório (Minutos/Horas/Dias).
  • O segundo input[type="number"] (placeholder “Número de tentativas”) é opcional.
  • Whatsapp-editor (Mensagem antes) e textarea (Nome do campo) são opcionais funcionalmente mas obrigatórios pra ser útil.

⚠️ Cuidado com numberInputs.last().fill(...) — pega o “Número de tentativas”, não o timer principal. Usar input[name="timer"] explicitamente ou nth(0).

5.10a-quater Bloco Ação às vezes não abre painel automaticamente

[validado 2026-05-10 em VENDA-SAGAZCHAT] Diferente de Conteúdo/Salvar/Menu que abrem painel imediatamente após click na paleta, o bloco Ação pode aparecer no canvas (node temporário criado, data-id novo) sem painel aberto. Causa não identificada — pode ser timing ou comportamento esperado.

Workaround: após click na paleta + waitTimeout(~2200ms), verificar se [role="dialog"] está aberto. Se não estiver, fazer page.locator([data-id=”${novoId}“]).dblclick() pra abrir o painel manualmente. Pelos testes, o dblclick funciona consistentemente.

Note que esse node “criado mas sem painel” persiste no canvas até ser configurado ou removido — não é um node temporário que some sozinho (diferente do caso da § 5.10a quando o painel abre e usuário cancela). É como se a Ação tivesse pulado direto pra Fase 2 (persistido) sem passar pela configuração.

5.10a-ter Bloco Menu: confirmação é “Salvar”, input de opção é “Digite opção”

[validado 2026-05-10] No painel “Adicionar menu ao fluxo”:

  • Botão de confirmação é “Salvar”, não “Adicionar”.
  • Tipo de menu (radio): default “number” (1️⃣ 2️⃣ 3️⃣) — outras opções: emoticon, list.
  • Adicionar opções: botão “Adicionar resposta” dentro do painel. Cada click cria um input[placeholder="Digite opção"].
  • Timer inicial (campo “Tempo mínimo caso o cliente não responda”) é input[type="number"] nth(0) — começa em 0. Setar > 0.
  • O botão “Salvar” do Menu pode ficar fora da viewport (y > 900 em viewport 1440×900). O scrollIntoViewIfNeeded do Playwright às vezes falha (element “not visible”). Workaround validado: capturar a posição via getBoundingClientRect() em page.evaluate (que internamente chama scrollIntoView({ block: 'center' })) e clicar a coordenada via page.mouse.click(x, y) diretamente.

5.10b Deletar edges é frágil — não deletar sem autorização

[validado 2026-05-10] Operações de deletar edge tentadas e seus problemas:

  1. Click no midpoint da edge + Backspace — seleciona o node adjacente em vez da edge. Backspace deleta o NODE inteiro com todas suas configurações + as edges associadas. Causou perda de Manipulador 4 (config completa) numa sessão.
  2. Click no <path> SVG da edge (getPointAtLength + getScreenCTM) — funciona mas pode pegar edges sobrepostas no mesmo pixel. Causou perda de edge inesperada Menu(a4)→Manip4 numa sessão.
  3. React fiber walk pra achar store — Zustand não está exposto via memoizedState.next chain do React Flow do Sagazchat. Walk completo da fiber tree (depth 20) não encontrou getState() com edges/nodes. Não-acessível por fora.
  4. data-source/data-target/id das edges no DOM — não existem. Cada .react-flow__edge tem só class (sem ID). Identificação só por endpoints do path SVG, que é frágil.

Regra operacional: em fluxo crítico, não deletar node nem edge sem autorização explícita do usuário. Se a intenção for só organizar ou continuar o fluxo, adicionar/reconectar/reposicionar é preferível. Se algum dia for realmente necessário remover uma conexão, fazer backup do JSON do fluxo antes e editar a lista connections pela API interna do frontend (ver § 6.15), nunca por click cego no canvas.

5.10c Drag de edge falha em zoom muito out

[validado 2026-05-10] Em zoom out muito agressivo (Ctrl+wheel 14x ou mais), nodes ficam visíveis mas mouse.move/down/up no handle não dispara conexão. Edge counter não muda. Em zoom normal, nodes distantes >2000px ficam fora da viewport e drag também falha.

Sweet spot: zoom out ~6-8 níveis (Ctrl+wheel positivo) + pan canvas via drag em .react-flow__pane se necessário. Validar inView (handles em x:[50,1400] y:[50,850]) antes de cada drag.

5.10d Pan canvas funciona via drag em pane vazio

[validado 2026-05-10] Click+drag em coordenada DENTRO de .react-flow__pane mas FORA de qualquer node move a viewport (pan). Distância pequena suficiente: 300-400px por chamada.

const empty = await page.evaluate(() => {
  const pane = document.querySelector('.react-flow__pane');
  const r = pane.getBoundingClientRect();
  return { x: r.x + r.width * 0.5, y: r.y + r.height * 0.6 };
});
await page.mouse.move(empty.x, empty.y);
await page.mouse.down();
await page.mouse.move(empty.x + 400, empty.y, { steps: 25 });
await page.mouse.up();

5.10e Etiquetas — autocomplete engana

[validado 2026-05-10] Quando você digita uma string no combobox de etiqueta (ex: int-limpeza), o autocomplete mostra a string como opção válida MESMO QUE a etiqueta não exista na conta. Click nela ou Enter cria etiqueta nova.

Padrão de detecção etiqueta existe vs criar é não confiável. Pra distinguir: listar opções do combobox SEM digitar (mostra todas existentes na conta), depois match exato. Se a etiqueta visada não está na lista pré-filtragem, é nova.

Convenção de etiquetas:

  • Nome direto do serviço (Limpeza de pele, Massagem, Depilação) — não usar prefixos int-*
  • Sem números prefixo (2-qualificado é errado, Lead interessado é certo)
  • Financeiro NÃO precisa de etiqueta no fluxo
  • Pra renomear/deletar etiqueta global: Configurações > submenu de Etiquetas (não dá pra fazer pelo bloco de fluxo)

5.11 Header do node tem botões de DUPLICAR e LIXEIRA — economia gigante

⚠️ Validado 2026-05-10 em ESTETICA-EXEMPLO. No header colorido de cada node (faixa do topo, ~40px de altura) existem 2 botões <button> com SVG, sempre visíveis (não precisam de hover/select):

Posição relativaFunçãoSVG path inicial
relX ≈ +111 do canto esquerdo do node, relY ≈ 10DuplicarM16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 ...
relX ≈ +135, relY ≈ 9Lixeira (deletar node)M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6zM19 4...

Quando usar duplicar em vez de criar do zero: quando precisa de N nodes com configuração quase idêntica (ex: 4 Manipuladores variando só o valor). Click em duplicar → novo node aparece próximo ao original com toda a configuração interna copiada → dblclick pra editar só o que muda.

// Click no botão duplicar de um node selecionado
async function duplicarNode(dataId) {
  const pos = await page.evaluate((id) => {
    const n = document.querySelector(`[data-id="${id}"]`);
    const r = n.getBoundingClientRect();
    const buttons = Array.from(n.querySelectorAll('button')).filter(b => {
      const br = b.getBoundingClientRect();
      return br.y - r.y < 40; // só header
    });
    // Primeiro button é duplicar, segundo é lixeira (esquerda → direita)
    const dupBtn = buttons[0];
    const br = dupBtn.getBoundingClientRect();
    return { x: br.x + br.width/2, y: br.y + br.height/2 };
  }, dataId);
  await page.mouse.click(pos.x, pos.y);
  await page.waitForTimeout(1500);
}

⚠️ Erro caro: na primeira passada de ESTETICA-EXEMPLO, criei 4 Manipuladores do zero (~25s cada via paleta + combobox + 4 campos), quando 1 + 3 duplicações teria sido ~1/3 do tempo. Sempre considerar duplicação quando há repetição estrutural.

5.12 Validação ao salvar

Painel Salvar (e provavelmente outros) só fecha ao clicar Adicionar/Salvar se campos obrigatórios estão preenchidos. Se não, o click é silenciosamente ignorado. Pra debug: tirar screenshot ANTES e DEPOIS do click pra ver se modal fechou.

6. Operações comuns (snippets)

Todos assumem connect() do scripts/_lib/connect.mjs.

6.1 Listar fluxos da raiz [validado]

await page.goto('https://app.sagazchat.com/flowbuilders', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
const fluxos = await page.evaluate(() => {
  const cells = Array.from(document.querySelectorAll('p, span'));
  return cells
    .filter(el => {
      const r = el.getBoundingClientRect();
      return r.x > 320 && r.x < 700 && r.y > 100 && r.y < 800;
    })
    .map(el => (el.innerText || '').trim())
    .filter(t => t && t.length < 60);
});

6.2 Criar fluxo [validado]

await page.getByRole('button', { name: /^Adicionar$/ }).click();
await page.waitForTimeout(1500);
// Etapa 1: canal
await page.locator('[role="dialog"]').getByText(/^WhatsApp$/, { exact: true }).first().click();
await page.waitForTimeout(1800);
// Etapa 2: nome
await page.locator('[role="dialog"] input').first().fill('PETSHOP-EXEMPLO');
await page.locator('[role="dialog"]').getByRole('button', { name: /Adicionar/i }).last().click();
await page.waitForTimeout(4000);
// Pode redirecionar pra /flowbuilder/<id>; se não, abrir manualmente:
if (!page.url().includes('/flowbuilder/')) {
  await page.locator(`text=/^PETSHOP-EXEMPLO$/`).first().click();
  await page.waitForTimeout(3500);
}

6.3 Excluir fluxo (DESTRUTIVO) [validado pelo cleanup]

const row = page.locator(`text=/^${NOME}$/`).first();
const rb = await row.boundingBox();
const kebab = await page.evaluate((y) => {
  return Array.from(document.querySelectorAll('button, [role="button"]'))
    .map(el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })
    .filter(it => it.y > y - 10 && it.y < y + 50 && it.x > 1100)
    .sort((a, b) => b.x - a.x)[0];
}, rb.y);
await page.mouse.click(kebab.x + kebab.w/2, kebab.y + kebab.h/2);
await page.waitForTimeout(800);
await page.locator('[role="menuitem"], .MuiMenuItem-root').filter({ hasText: /^Excluir$/i }).click();
await page.waitForTimeout(1500);
// Modal "Deletar X?" — botão Ok
await page.locator('[role="dialog"]').getByRole('button', { name: /^Ok$/ }).click({ force: true });
await page.waitForTimeout(2500);

6.4 Listar paleta com scroll [validado]

async function scrollPaleta(dir) {
  await page.evaluate((d) => {
    const x = document.querySelectorAll('[draggable="true"]');
    if (!x.length) return;
    let p = x[0].parentElement;
    for (let i = 0; i < 6; i++) {
      if (!p) return;
      if (p.scrollHeight > p.clientHeight) { p.scrollTop = d === 'top' ? 0 : p.scrollHeight; return; }
      p = p.parentElement;
    }
  }, dir);
  await page.waitForTimeout(500);
}

async function paletaCompleta() {
  const set = new Set();
  for (const dir of ['top', 'bottom']) {
    await scrollPaleta(dir);
    const blocos = await page.evaluate(() =>
      Array.from(document.querySelectorAll('[draggable="true"]'))
        .map(el => (el.innerText || '').trim()).filter(Boolean)
    );
    blocos.forEach(b => set.add(b));
  }
  return Array.from(set);
}

6.5 Adicionar bloco da paleta + abrir painel [validado]

async function clicarBlocoPaleta(nome) {
  for (const dir of ['top', 'bottom']) {
    await scrollPaleta(dir);
    const blocos = await page.evaluate(() =>
      Array.from(document.querySelectorAll('[draggable="true"]')).map(el => {
        const r = el.getBoundingClientRect();
        return { text: (el.innerText || '').trim(), x: r.x + r.width/2, y: r.y + r.height/2 };
      })
    );
    const hit = blocos.find(b => new RegExp(`^${nome}$`).test(b.text));
    if (hit) { await page.mouse.click(hit.x, hit.y); await page.waitForTimeout(1500); return true; }
  }
  return false;
}

async function abrirPainelDoUltimoNode() {
  const dlgAberto = await page.evaluate(() =>
    Array.from(document.querySelectorAll('[role="dialog"]')).some(d => {
      const r = d.getBoundingClientRect();
      return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
    })
  );
  if (dlgAberto) return true;
  const lastId = await page.evaluate(() => {
    const arr = Array.from(document.querySelectorAll('.react-flow__node:not(.react-flow__node-start)'));
    return arr.length ? arr[arr.length - 1].getAttribute('data-id') : null;
  });
  if (!lastId) return false;
  await page.locator(`[data-id="${lastId}"]`).dblclick();
  await page.waitForTimeout(1500);
  return true;
}

6.6 Adicionar bloco Conteúdo com múltiplos itens [validado]

await clicarBlocoPaleta('Conteúdo');
await abrirPainelDoUltimoNode();

// Click cartão Texto e digitar
await page.locator('[role="dialog"]:visible').getByText(/^Texto$/, { exact: true }).first().click();
await page.waitForTimeout(1500);
const editor = page.locator('[role="dialog"]:visible textarea[name="whatsapp-editor"], [role="dialog"]:visible [contenteditable="true"]').first();
await editor.click();
await page.keyboard.type('Olá! 🐶 Bem-vindo!', { delay: 12 });

// Adicionar Intervalo de 3 segundos
await clicarCartaoConteudo('Intervalo'); // helper que re-mapeia ao vivo
const inp = page.locator('[role="dialog"]:visible input[type="number"]').last();
await inp.click();
await page.keyboard.press('Control+A');
await page.keyboard.type('3');

// Adicionar segundo Texto
await clicarCartaoConteudo('Texto');
const ed2 = page.locator('[role="dialog"]:visible textarea[name="whatsapp-editor"], [role="dialog"]:visible [contenteditable="true"]').last();
await ed2.click();
await page.keyboard.type('Como podemos ajudar?', { delay: 12 });

// Confirmar
await clicarBotaoNoDialog('Adicionar');

6.7 Adicionar bloco Salvar com pergunta + variável [validado]

await clicarBlocoPaleta('Salvar');
await abrirPainelDoUltimoNode();

// Tempo (5 minutos)
const inp = page.locator('[role="dialog"]:visible input[name="timer"], [role="dialog"]:visible input[type="number"]').first();
await inp.click();
await page.keyboard.press('Control+A');
await page.keyboard.type('5');

// Mensagem antes (a pergunta)
const editor = page.locator('[role="dialog"]:visible [contenteditable="true"], [role="dialog"]:visible textarea[name="whatsapp-editor"]').first();
await editor.click();
await page.keyboard.type('Qual é o nome do seu pet?', { delay: 10 });

// Campo (textarea, NÃO input)
const campo = page.locator('[role="dialog"]:visible textarea[placeholder="Nome do campo"]').first();
await campo.click();
await page.keyboard.type('nome_pet', { delay: 20 });

await clicarBotaoNoDialog('Adicionar');

6.8 Adicionar bloco Menu com N opções [validado]

await clicarBlocoPaleta('Menu');
await abrirPainelDoUltimoNode();

// Mensagem
const editor = page.locator('[role="dialog"]:visible [contenteditable="true"], [role="dialog"]:visible textarea[name="whatsapp-editor"]').first();
await editor.click();
await page.keyboard.type('Escolha uma opção:', { delay: 12 });

// Adicionar N respostas
const opcoes = ['Banho e Tosa', 'Consulta veterinária', 'Falar com atendente'];
for (const op of opcoes) {
  // Click "Adicionar resposta" via coord (fica fora do viewport conforme adiciona)
  const btnPos = await page.evaluate(() => {
    const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
      const r = d.getBoundingClientRect();
      return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
    });
    const btn = Array.from(dlg.querySelectorAll('button')).find(b => /Adicionar resposta/i.test(b.innerText || ''));
    if (!btn) return null;
    btn.scrollIntoView({ block: 'center', behavior: 'instant' });
    const r = btn.getBoundingClientRect();
    return { x: r.x + r.width/2, y: r.y + r.height/2 };
  });
  await page.mouse.click(btnPos.x, btnPos.y);
  await page.waitForTimeout(800);

  // Pegar último input vazio adicionado
  const inpPos = await page.evaluate(() => {
    const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
      const r = d.getBoundingClientRect();
      return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
    });
    const inps = Array.from(dlg.querySelectorAll('input[type="text"], input:not([type])')).filter(i => i.name !== 'whatsapp-editor');
    const vazios = inps.filter(i => !i.value);
    if (!vazios.length) return null;
    const e = vazios[vazios.length - 1];
    e.scrollIntoView({ block: 'center' });
    const r = e.getBoundingClientRect();
    return { x: r.x + r.width/2, y: r.y + r.height/2 };
  });
  await page.mouse.click(inpPos.x, inpPos.y);
  await page.keyboard.type(op, { delay: 12 });
}

await clicarBotaoNoDialog('Salvar');

6.9 Adicionar bloco Ação com sub-tipo [validado]

await clicarBlocoPaleta('Ação');
await abrirPainelDoUltimoNode();

// Combobox de tipo
const cbPos = await page.evaluate(() => {
  const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
    const r = d.getBoundingClientRect();
    return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
  });
  const c = dlg.querySelector('[role="combobox"]');
  const r = c.getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(cbPos.x, cbPos.y);
await page.waitForTimeout(800);

// Selecionar (ex: Adicionar etiqueta)
await page.locator('[role="option"]').filter({ hasText: /^Adicionar etiqueta$/ }).first().click();
await page.waitForTimeout(1200);

// Preencher subcampo (etiqueta)
const etiq = page.locator('[role="dialog"]:visible input[placeholder*="etiqueta" i]').first();
await etiq.click();
await page.keyboard.type('Cliente Petshop', { delay: 30 });
await page.keyboard.press('Enter'); // cria nova etiqueta

await clicarBotaoNoDialog('Salvar');

6.10 Conectar handles de 2 nodes [validado]

async function conectar(srcId, tgtId) {
  const handles = await page.evaluate(({ sid, tid }) => {
    const src = document.querySelector(`[data-id="${sid}"]`);
    const tgt = document.querySelector(`[data-id="${tid}"]`);
    // Handles laterais — right (source) → left (target)
    const sH = src.querySelector('.react-flow__handle-right.source') || src.querySelector('.react-flow__handle-right');
    const tH = tgt.querySelector('.react-flow__handle-left.target') || tgt.querySelector('.react-flow__handle-left');
    if (!sH || !tH) return null;
    src.scrollIntoView({ block: 'center', behavior: 'instant' });
    const sR = sH.getBoundingClientRect();
    const tR = tH.getBoundingClientRect();
    return {
      srcX: sR.x + sR.width/2, srcY: sR.y + sR.height/2,
      tgtX: tR.x + tR.width/2, tgtY: tR.y + tR.height/2,
    };
  }, { sid: srcId, tid: tgtId });
  await page.mouse.move(handles.srcX, handles.srcY);
  await page.waitForTimeout(250);
  await page.mouse.down();
  await page.waitForTimeout(150);
  await page.mouse.move(handles.srcX + 30, handles.srcY + 10, { steps: 6 }); // arco intermediário
  await page.mouse.move(handles.tgtX, handles.tgtY, { steps: 25 });
  await page.waitForTimeout(400);
  await page.mouse.up();
  await page.waitForTimeout(1200);
}

6.11 Remover item de dentro do bloco Conteúdo [validado]

// Painel Conteúdo aberto. Remove o ÚLTIMO item.
const lixeiraInfo = await page.evaluate(() => {
  const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
    const r = d.getBoundingClientRect();
    return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
  });
  const lixeiras = Array.from(dlg.querySelectorAll('svg')).filter(svg => {
    const r = svg.getBoundingClientRect();
    if (r.x < 1030 || r.x > 1080) return false;
    const path = svg.querySelector('path');
    return path && /^M6 19c0/.test(path.getAttribute('d') || '');
  });
  if (!lixeiras.length) return null;
  const last = lixeiras[lixeiras.length - 1];
  last.scrollIntoView({ block: 'center', behavior: 'instant' });
  return { count: lixeiras.length };
});
await page.waitForTimeout(700);
const novaPos = await page.evaluate(() => {
  const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
    const r = d.getBoundingClientRect();
    return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
  });
  const lixeiras = Array.from(dlg.querySelectorAll('svg')).filter(svg => {
    const r = svg.getBoundingClientRect();
    if (r.x < 1030 || r.x > 1080) return false;
    const path = svg.querySelector('path');
    return path && /^M6 19c0/.test(path.getAttribute('d') || '');
  });
  const last = lixeiras[lixeiras.length - 1];
  const r = last.getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(novaPos.x, novaPos.y);
await page.waitForTimeout(1500);

6.12 Deletar node do canvas [validado]

await page.locator(`[data-id="${nodeId}"]`).click({ force: true });
await page.waitForTimeout(400);
await page.keyboard.press('Backspace'); // ou Delete
await page.waitForTimeout(800);

6.13 Duplicar node + editar valor [validado 2026-05-10]

Para criar N variantes de um mesmo bloco (ex: 4 Manipuladores variando só o valor):

// 1. Criar e configurar o "modelo" (N=1)
await criarManipulador('servico_escolhido', 'Limpeza de pele');

// 2. Para cada variante adicional: duplicar + editar
const modelos = [
  { handle: 'a2', valor: 'Massagem relaxante' },
  { handle: 'a3', valor: 'Depilação' },
  { handle: 'a4', valor: 'Falar com atendente' },
];
for (const m of modelos) {
  const origemId = await page.evaluate(() => {
    const arr = Array.from(document.querySelectorAll('.react-flow__node-action'));
    return arr[arr.length - 1].getAttribute('data-id');
  });
  await duplicarNode(origemId); // ver § 5.11
  // Novo node criado próximo. Localizar o novo (último), abrir, trocar só o input "Texto" do valor
  // ... abrir painel + editar valor + Salvar
  // ... conectar Menu(handle) → novo node
}

6.14 Salvar fluxo (header) [validado]

const headerBtn = await page.evaluate(() => {
  const btns = Array.from(document.querySelectorAll('button'))
    .filter(b => (b.innerText || '').trim() === 'Salvar')
    .filter(b => { const r = b.getBoundingClientRect(); return r.width > 0 && r.y < 80; });
  if (!btns.length) return null;
  const r = btns[0].getBoundingClientRect();
  return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(headerBtn.x, headerBtn.y);
await page.waitForTimeout(3000);

6.15 Operar o JSON do fluxo pela API interna usada pelo frontend [validado 2026-05-10]

Isto não é uma API pública/oficial documentada da Sagazchat. É a API interna que o próprio frontend usa, descoberta por inspeção da aplicação, window.RUNTIME_CONFIG e chamadas de rede do flowbuilder. Tratar como contrato interno: funciona para operação assistida, mas pode mudar sem aviso.

Quando usar:

  • Auditar estado real do fluxo sem depender do DOM.
  • Fazer backup antes de qualquer alteração.
  • Corrigir conexões quando source, target e sourceHandle já são conhecidos.
  • Reposicionar blocos no canvas em lote.
  • Validar se o fluxo salvo tem as conexões esperadas.

Quando não usar:

  • Para fingir que existe API pública estável.
  • Para deletar nodes/conexões sem autorização explícita.
  • Para editar campos desconhecidos sem comparar com um payload salvo pelo frontend.

Base e autenticação vêm da sessão logada no browser:

const baseUrl = await page.evaluate(() => window.RUNTIME_CONFIG.VITE_APP_BACKEND_URL);
const token = await page.evaluate(() => JSON.parse(localStorage.getItem('token')));

Endpoints observados:

GET  ${baseUrl}/flowbuilder/flow/<id>
POST ${baseUrl}/flowbuilder/flow
GET  ${baseUrl}/tags/all
GET  ${baseUrl}/flowbuilder/<id>/menuctr/<menuNodeId>

Formato essencial do fluxo:

{
  idFlow: String(flowId),
  nodes: flow.flow.nodes,
  connections: flow.flow.connections
}

Cada node segue o padrão:

{
  id: 'uuid-ou-id-do-react-flow',
  type: 'singleBlock',
  position: { x: 1040, y: 90 },
  data: { type: 'menu', ... }
}

Cada conexão segue o padrão:

{
  id: `reactflow__edge-${source}${sourceHandle ?? ''}-${target}`,
  source,
  target,
  sourceHandle: 'a1', // null/undefined quando for saída única
  targetHandle: null,
  animated: true,
  style: { stroke: '#555', strokeWidth: 2 }
}

Protocolo seguro:

  1. Fazer dump antes de alterar: scripts/_out/flow-<id>-before-<slug>.json.
  2. Alterar nodes/connections em memória, preservando todos os campos não tocados.
  3. Enviar POST /flowbuilder/flow com { idFlow, nodes, connections }.
  4. Fazer novo dump com GET /flowbuilder/flow/<id>.
  5. Rodar validador determinístico do fluxo.
  6. Tirar screenshot do canvas para validar o visual.

Exemplo mínimo de dump:

const data = await page.evaluate(async () => {
  const response = await fetch(`${window.RUNTIME_CONFIG.VITE_APP_BACKEND_URL}/flowbuilder/flow/538`, {
    headers: {
      Authorization: `Bearer ${JSON.parse(localStorage.getItem('token'))}`,
    },
  });
  return await response.json();
});

6.16 Organizar visual do canvas por coordenadas [validado 2026-05-10]

Para fluxos grandes, organizar por coordenadas no JSON é mais confiável que arrastar bloco por bloco. O objetivo é que a leitura no canvas siga a mesma ordem mental do atendimento.

Padrão recomendado:

coluna 1: Início
coluna 2: Conteúdo de boas-vindas
coluna 3: Salvar/capturar dado
coluna 4: Menu principal
coluna 5: Ações/qualificadores por opção
coluna 6: Conteúdo de qualificação
coluna 7: Menu de qualificação
coluna 8: Tag/ação de lead quente
coluna 9: Distribuidor
coluna 10: Atendentes/departamentos

Reservar margem à esquerda quando a paleta lateral estiver aberta. Em screenshots com viewport 1440×900, x: 250 deixa o node inicial parcialmente escondido pela paleta; preferir iniciar o primeiro bloco útil em x >= 500 ou fechar/recolher a paleta antes da captura.

Para fluxo de clínica estética, a hierarquia visual validada foi:

Início
  -> Boas-vindas
  -> Salvar nome
  -> Menu principal
      -> Limpeza de pele / Massagem / Depilação
          -> Qualificação
          -> Menu: agendar agora ou tirar dúvidas
              -> Cliente quente
              -> Distribuidor
                  -> Comercial - Atendente 1
                  -> Comercial - Atendente 2
      -> Falar com atendente
          -> Distribuidor
      -> Falar com financeiro
          -> Financeiro/RH

6.17 Criar fluxo-laboratório com todos os blocos não-IA [validado 2026-05-10]

Foi criado um fluxo real para testar o criador de fluxo como ferramenta, sem depender do fluxo de estética:

Nome: LAB-BLOCOS-SEM-IA-202605101624
URL:  https://app.sagazchat.com/flowbuilder/539
Dump: scripts/_out/flow-539-lab-blocos-sem-ia.json
Screenshot: scripts/_out/fluxos/539-lab-blocos-sem-ia.png
Script: scripts/sagaz-lab-blocos-sem-ia-criar.mjs

O script cria o fluxo pela UI e depois salva o canvas pela API interna do frontend. Também aceita regravar um fluxo existente:

node scripts/sagaz-lab-blocos-sem-ia-criar.mjs
node scripts/sagaz-lab-blocos-sem-ia-criar.mjs 539

Para o bloco Agenda, o script agora exige uma agenda real já cadastrada. Ele tenta usar LAB_CALENDAR_ID, depois uma agenda com nome LAB_CALENDAR_NAME (default LAB - Agenda Flowbuilder), depois a primeira agenda existente. Se não houver agenda, o script falha em vez de montar node visual falso.

Para os sub-tipos de Remarketing, o script prefere um remarketing real chamado LAB - Remarketing Flowbuilder (LAB_REMARKETING_NAME). Se não encontrar, usa o primeiro remarketing existente; se a conta não tiver remarketing, usa fallback visual. Em 2026-05-11 o fluxo 539 foi atualizado para usar o remarketing real LAB - Remarketing Flowbuilder (id: 150, uuid: b96abd79-cdba-4051-86ef-d49f7c7174ef) nos dois sub-tipos: inscrição (subscriber: true) e descadastro (subscriber: false). O modo de criar/configurar remarketing está no runbook remarketing.

Blocos principais presentes no fluxo-laboratório:

start
singleBlock       -> Conteúdo
stopFlow          -> Salvar
menu
action
condition
randomizer
smartRange        -> Atraso inteligente
globalVariables   -> Variável global
distributor
httpRequest       -> Integração
calendar          -> Agenda

Blocos/recursos de IA excluídos:

openai
transfertoAI

Sub-tipos não-IA do bloco Ação exercitados:

taggy com isAdd=true     -> Adicionar etiqueta
taggy com isAdd=false    -> Remover etiqueta
remarketing subscriber=true
remarketing subscriber=false
queueDirect              -> Redirecionar para departamento
ticketManager            -> Controlador de chat
notification             -> Notificar membro da equipe
mathematicalOperation    -> Manipulador
pixel
cardPix
flowDirect               -> Conexão de fluxo

⚠️ Payload do Agenda: o frontend renderiza data.calendar.name. Usar calendarName quebra a tela com erro Cannot read properties of undefined (reading 'name') e mostra fallback tipo “Voltamos já”.

⚠️ Agenda funcional vs visual: para o bloco Agenda funcionar de ponta a ponta, a agenda precisa ser criada antes no módulo de agenda/calendário da conta, configurada no botão Configurar da tela da agenda e selecionada pelo combobox do bloco. O fluxo-laboratório usa o node calendar para cobrir o bloco no canvas; se calendar.id não veio de uma agenda real, considerar esse node demonstração visual, não teste funcional de agendamento. Em 2026-05-10 o fluxo 539 foi atualizado para usar a agenda real LAB - Agenda Flowbuilder (id: 35) e essa agenda foi configurada com atendimento 09:00-18:00, pausa 12:00-13:00, intervalo fixo de 30 Minutos e duração de evento de 60 Minutos.

⚠️ Variável global funcional vs visual: para o bloco Variável global funcionar de ponta a ponta, a variável precisa ser criada antes em Configurações e selecionada no combobox do bloco. Se o fluxo-laboratório usar um identificador manual para cobrir o node globalVariables, considerar esse node demonstração visual, não teste funcional de variável global.

Formato mínimo que renderiza:

{
  type: 'calendar',
  data: {
    calendar: { id: 123, name: 'Agenda real criada na conta' },
    days: 7,
    titleDate: 'Escolha uma data',
    messageDate: '...',
    buttonDate: 'Ver datas',
    titleHour: 'Escolha um horário',
    messageHour: '...',
    buttonHour: 'Ver horários',
    title: 'LAB: Evento de teste',
    message: '...',
    backgroundColor: '#A4CCCC',
    textColor: '#13111C',
    notice: '...'
  }
}

Checklist pós-criação usado no script:

nodes: 15
connections: 16
hasAi: false
nodeTypes inclui todos os blocos principais não-IA
actionTypes não inclui transfertoAI
screenshot abre canvas, não tela de fallback

7. Detecção de sucesso

  • Após criar fluxo: redirect pra /flowbuilder/<id>. Se não, fluxo aparece na lista /flowbuilders.
  • Após criar coluna/node: counter de .react-flow__node cresce. Conferir data-id do último node não-start.
  • Após Adicionar/Salvar do dialog: dialog fecha (não há mais [role="dialog"]:visible). Se ficar aberto, validação interna falhou (ver § 5.11).
  • Após conectar handles: .react-flow__edge aumenta em 1.
  • Após Salvar fluxo (header): toast MUI ou re-render dos nodes confirma persistência.
  • Após operação pela API interna: GET /flowbuilder/flow/<id> retorna os novos nodes/connections, o validador zera pendências obrigatórias e o screenshot do canvas mostra a ordem visual esperada.

8. Recuperação de erros

  • browserType.connectOverCDP: ECONNREFUSED: sessão CDP morta. Re-rodar scripts/sagaz-session.mjs em background.
  • Modal não fecha após Adicionar/Salvar: campo obrigatório vazio (provável). Tirar screenshot + capturar dlg.innerText pra diagnosticar.
  • Click em paleta sem efeito: clicarBlocoPaleta precisa scroll antes — usar helper paletaMap() + scrollPaleta('top'/'bottom').
  • getByText('OpenAI') retorna 0: usar workaround do § 5.2.
  • Painel longo “perdido”: viewport rolável — usar scrollIntoView (§ 5.1) antes de qualquer click em elemento abaixo do fold.
  • Conexão de handles não cria edge: confirmar que ambos handles existem (src.querySelector('.react-flow__handle-bottom') e tgt.querySelector('.react-flow__handle-top')). Mover mouse em steps ({ steps: 25 }) e dar waitForTimeout(300) antes do up.

9. Cleanup

Toda sessão de auditoria que cria fluxos deve fechar com cleanup:

// 1. Voltar pra /flowbuilders
await page.goto('https://app.sagazchat.com/flowbuilders', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
// 2. Excluir cada fluxo TESTE-IA-* via § 6.3
// 3. Conferir que sumiram da lista
console.log('ainda existe?', await page.locator(`text=/^TESTE-IA-/`).count());

⚠️ Nunca deletar o fluxo PETSHOP-EXEMPLO sem autorização — ele foi mantido como referência operacional para futuras sessões.

10. Como iniciar nova auditoria

  1. Garantir sessão CDP viva (scripts/sagaz-session.mjs em background; checar scripts/_out/cdp.txt).
  2. Conectar com connect() do _lib/connect.mjs.
  3. Se for explorar: navegar pra /flowbuilders, listar fluxos com § 6.1.
  4. Se for criar fluxo de teste: use prefixo TESTE-IA-<random> (ver § 6.2). Sempre cleanup ao final.
  5. Se for continuar PETSHOP-EXEMPLO: ele já existe em /flowbuilder/533 (id pode mudar em re-runs). Localizar via text=/^PETSHOP-EXEMPLO$/ na lista.
  6. Não recarregar a página entre operações — irrita o usuário e perde estado. Trabalhar na URL atual.

11. Estado do ESTETICA-EXEMPLO em 2026-05-10

Fluxo real em https://app.sagazchat.com/flowbuilder/538, montado para clínica de estética.

Estado lógico após correção pela API interna:

15 nodes
18 connections
directServiceToDistributor: 0
missingRequired: 0

Conexões obrigatórias validadas:

Início -> Boas-vindas
Boas-vindas -> Salvar nome
Salvar nome -> Menu principal
Menu a1 Limpeza de pele -> Ação servico_escolhido=Limpeza de pele
Menu a2 Massagem relaxante -> Ação servico_escolhido=Massagem relaxante
Menu a3 Depilação -> Ação servico_escolhido=Depilação
Ações de serviço -> Conteúdo de qualificação
Conteúdo de qualificação -> Menu de qualificação
Menu qualificação a1 Quero agendar agora -> Ação Cliente quente
Ação Cliente quente -> Distribuidor
Menu qualificação a2 Quero tirar dúvidas primeiro -> Distribuidor
Menu a4 Falar com atendente -> Ação atendente
Ação atendente -> Distribuidor
Menu a5 Falar com financeiro -> Financeiro/RH
Distribuidor -> Comercial - Atendente 1
Distribuidor -> Comercial - Atendente 2

Pontos ainda não fechados como produto final:

  • A opção Quero agendar agora ainda marca/tagueia como cliente quente e envia ao Distribuidor. Não há bloco Agenda configurado nessa trilha.
  • Saídas de timeout/não resposta do bloco Salvar não foram conectadas.
  • O ajuste visual foi feito por coordenadas e validado por screenshot; se a paleta lateral estiver aberta, revisar margem esquerda antes de apresentar.

11.1 Exemplo aplicado: recursos usados no fluxo de estética

Esta subseção não é uma instrução para “continuar o ESTETICA”; é um exemplo concreto de como auditar se um fluxo usa etiquetas, variáveis, departamentos, notificações, agenda e distribuição. Use como referência de leitura, não como roteiro obrigatório.

Já configurado no dump atual:

  • Etiquetas de serviço: Limpeza de pele, Massagem, Depilação.
  • Etiqueta geral: Lead interessado.
  • Etiqueta de intenção forte: Cliente quente.
  • Variável: servico_escolhido.
  • Filas/departamentos: Comercial - Atendente 1, Comercial - Atendente 2, Financeiro/RH.
  • Distribuidor para alternar atendimento comercial entre Atendente 1 e Atendente 2.

Recursos que este exemplo ainda não cobre:

  • Bloco Agenda na trilha Quero agendar agora.
  • Bloco/sub-ação de Notificação para avisar comercial quando o lead estiver quente.
  • Etiqueta Dúvida antes de agendar para quem escolhe tirar dúvidas.
  • Etiqueta Financeiro para a opção Falar com financeiro.
  • Etiqueta Agendamento solicitado depois da agenda ou antes do repasse ao comercial.
  • Rota de timeout/não resposta do bloco Salvar para lembrete curto + retorno ao menu.

Desenho recomendado:

Escolheu serviço
  -> etiqueta do serviço
  -> Lead interessado
  -> Qualificação

Quero agendar agora
  -> Cliente quente
  -> Agendamento solicitado
  -> Notificar comercial
  -> Agenda
  -> Distribuidor

Quero tirar dúvidas primeiro
  -> Dúvida antes de agendar
  -> Distribuidor

Falar com financeiro
  -> Financeiro
  -> Financeiro/RH

Cuidado: antes de criar novas etiquetas via combobox, listar etiquetas existentes (/tags/all ou combobox sem filtro) e reutilizar por nome exato quando já existir. O autocomplete pode criar etiqueta nova sem deixar isso claro.

Apêndice — Estado do PETSHOP-EXEMPLO em 2026-05-09

5 nodes conectados em sequência:

Início (default)

Conteúdo (1 bloco com 5 itens):
  • Texto: "Olá! 🐶 Bem-vindo ao Pet Shop Pata Feliz!"
  • Intervalo: 3 segundos
  • Texto: "Aqui cuidamos do seu melhor amigo com muito carinho 🐾✨"
  • Intervalo: 4 segundos

Salvar:
  • Tempo mínimo: 5 minutos
  • Mensagem antes: "Antes de começarmos, qual é o nome do seu pet?"
  • Campo: nome_pet

Conteúdo: "Que nome lindo! 🐾 Em que posso ajudar você e seu pet hoje?"

Menu (3 opções):
  • Banho e Tosa
  • Consulta veterinária
  • Falar com atendente

Saídas do Menu ainda não conectadas a destinos (próxima iteração: cada opção → bloco Ação com Adicionar etiqueta + Redirecionar para departamento).

Posições no canvas após criação automática (visualmente desalinhadas — ordem de criação ≠ ordem lógica): Início(479) → Saudação(747) → Resposta(1013) → Salvar(1279) → Menu(1567). As linhas cruzam Resposta↔Salvar; reordenar manualmente é cosmético.

Apêndice — Scripts de referência criados nesta sessão

ScriptPropósito
sagaz-fluxos-list.mjsMapa básico da tela /flowbuilders
sagaz-fluxos-explore.mjsSidebar e navegação inicial
sagaz-fluxos-criar.mjsCriar fluxo (modal 1 etapa — desatualizado, ver criar2)
sagaz-fluxos-criar2.mjsCriar fluxo modal 2 etapas (canal + nome)
sagaz-fluxos-investigar-acao.mjsLista paleta + 12 sub-tipos do Ação
sagaz-fluxos-aprender.mjsAbre painel de cada bloco e mapeia campos
sagaz-fluxos-petshop-conteudo.mjsCria PETSHOP com 5 nodes Conteúdo (lição: usar 1 bloco com itens)
sagaz-fluxos-petshop-iter1b.mjsConsolida em 1 Conteúdo com itens empilhados
sagaz-fluxos-petshop-fix.mjs / fix2.mjsRemove item Texto + adiciona Salvar correto
sagaz-fluxos-petshop-menu.mjsAdiciona Menu com 3 respostas
sagaz-fluxos-petshop-conectar.mjsConecta handles dos 5 nodes
sagaz-fluxos-estado.mjsInspeção rápida do estado do canvas
sagaz-fluxos-inspecionar-item-delete.mjsMapa DOM dos botões/SVGs do painel Conteúdo
sagaz-estetica-map-completo.mjsMapeia nodes, handles, labels e edges do fluxo de estética pelo DOM
sagaz-estetica-inspect-app.mjsInspeciona runtime config, localStorage e recursos carregados do app
sagaz-estetica-fetch-chunks.mjsBaixa chunks relevantes do frontend para investigar endpoints internos
sagaz-estetica-api-dump.mjsFaz backup/dump do fluxo 538 via API interna do frontend
sagaz-estetica-api-corrigir-qualificacao.mjsCorrige conexões da qualificação do ESTETICA-EXEMPLO com backup prévio
sagaz-estetica-api-organizar-visual.mjsReposiciona nodes do ESTETICA-EXEMPLO no canvas por coordenadas
sagaz-estetica-validar-fluxo.mjsValida conexões obrigatórias do fluxo de estética e aponta pendências
sagaz-estetica-screenshot.mjsGera screenshot do canvas em scripts/_out/fluxos/