Skip to content

Streaming do chat ​

Tipo
Como fazer
Para
Desenvolvedor

POST /tenant/ai/chat manda uma pergunta ao Chat com Dados em nome de um usuário e devolve a resposta em streaming (Server-Sent Events): o texto chega aos pedaços, e cada consulta que o assistente faz chega com os números crus, a unidade e a data do dado. Os parâmetros de cada rota estão no grupo Chat com Dados do menu.

Antes de começar ​

  • O Tenant Token do Tenant.
  • O userId de um usuário ativo no Tenant e com permissão de IA. O assistente responde com o acesso desse usuário: só vê as Aplicações e tabelas que ele vê, com as mesmas restrições de linha e coluna.

Para achar o userId a partir do e-mail:

bash
curl "https://api.horusbi.com.br/v1/tenant/users?email=ana.souza@lojaexemplo.com.br" \
  -H "Authorization: Bearer $TENANT_TOKEN"

O id de data[0] é o userId. Lista vazia quer dizer que o e-mail não está no Tenant.

Mandar a pergunta ​

bash
curl -N -X POST "https://api.horusbi.com.br/v1/tenant/ai/chat" \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userId": 123, "message": "Quanto vendi em setembro?"}'

Sem threadId, a pergunta abre uma conversa nova. O id dela chega no primeiro evento, thread. Mande esse id como threadId nas próximas perguntas para o assistente lembrar o que já foi dito.

modelKey escolhe o modelo: fast (Rápido) ou reasoning (Aprofundado). O custo de cada um está em Créditos de IA.

SSE ou JSON ​

A resposta vem em SSE por padrão. Para receber tudo de uma vez em JSON, mande ?stream=false ou o header Accept: application/json (exatamente esse valor). Com ?stream=true, vem SSE mesmo com Accept: application/json.

No JSON, data traz threadId, text (a resposta inteira), toolResults (uma entrada por consulta, com toolName e result) e usage. É mais simples de ler, mas o usuário espera a resposta inteira sem ver nada.

Ler os eventos ​

Cada evento tem duas linhas, event: e data: (um JSON numa linha só), e termina com uma linha em branco:

text
event: thread
data: {"threadId":"bi_3f6a1c2e-8b4d-4e7a-9c1f-2d5e8a7b4c10"}

event: text-delta
data: {"text":"Em setembro você vendeu ","messageId":"msg_01"}

: ping

A cada 20 s sem evento, o servidor manda a linha de comentário : ping para a conexão não cair em proxy. Ignore linhas que começam com :.

EventodataQuando
threadthreadIdPrimeiro evento, com a conversa nova ou a que você mandou
text-deltatext, messageIdUm pedaço da resposta. Junte todos na ordem
reasoning-deltatextUm pedaço do raciocínio do modelo, quando ele mostra. Não faz parte da resposta
tool-calltoolName, toolCallId, descriptionO assistente começou uma consulta. description diz o que ele está fazendo, bom para mostrar como andamento
tool-resulttoolName, toolCallId, resultResultado da consulta de mesmo toolCallId
donethreadId, usageFim da resposta. usage traz inputTokens, outputTokens e totalTokens
errorerror e, às vezes, codeFalha. Veja Erros

Usar o resultado das consultas ​

O assistente consulta os dados com as mesmas quatro ferramentas do MCP do Lumo: search_catalog, get_app, search_values e query. O result de cada uma tem duas partes: text, o que o modelo leu, e data, o resultado estruturado, que é o que a sua integração deve usar. Quando a consulta falha, data traz error e, se a recusa tiver um, code.

Em query, data tem os números crus, sem formatação de moeda ou percentual, e cada coluna diz a unidade:

json
{
  "text": "| Valor da venda (R$) |\n| --- |\n| 214500.5 |",
  "data": {
    "appId": 7,
    "title": "Vendas",
    "columns": [{ "key": "1_SUM", "label": "Valor da venda", "type": "number", "unit": "BRL" }],
    "rows": [[214500.5]],
    "rowCount": 1,
    "totalRows": 1,
    "truncated": false,
    "dataAsOf": "2026-10-31T03:00:00-03:00",
    "filtersApplied": ["Data entre 01/09/2026 e 30/09/2026"],
    "permalink": "https://lojaexemplo.horusbi.com.br/..."
  }
}

dataAsOf é a carga mais antiga entre as tabelas consultadas, e permalink abre o mesmo resultado no app. Os argumentos e o resultado de cada ferramenta estão em Ferramentas do MCP.

Exemplo em Node.js ​

Node.js 18 ou mais novo, sem biblioteca:

js
const res = await fetch("https://api.horusbi.com.br/v1/tenant/ai/chat", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TENANT_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ userId: 123, message: "Quanto vendi em setembro?" }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let resposta = "";
let falhou = false;

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const blocos = buffer.split("\n\n");
  buffer = blocos.pop() ?? "";
  for (const bloco of blocos) {
    if (bloco.startsWith(":")) continue; // ping
    const evento = bloco.match(/^event: (.+)$/m)?.[1];
    const data = JSON.parse(bloco.match(/^data: (.+)$/m)?.[1] ?? "{}");
    if (evento === "thread") console.log("conversa:", data.threadId);
    else if (evento === "text-delta" && !falhou) resposta += data.text;
    else if (evento === "tool-result" && data.toolName === "query" && !data.result?.data?.error) {
      const q = data.result.data;
      console.log(`[${q.title}] dados até ${q.dataAsOf ?? "sem carga"}`, q.rows);
    } else if (evento === "error" && !falhou) {
      falhou = true;
      console.error("falha:", data.code ?? "", data.error);
    } else if (evento === "done") console.log("tokens:", data.usage?.totalTokens);
  }
}
if (!falhou) console.log(resposta);

Exemplo em Python ​

Com httpx:

python
import json
import os
import httpx

with httpx.stream(
    "POST",
    "https://api.horusbi.com.br/v1/tenant/ai/chat",
    headers={"Authorization": f"Bearer {os.environ['TENANT_TOKEN']}"},
    json={"userId": 123, "message": "Quanto vendi em setembro?"},
    timeout=None,
) as r:
    r.raise_for_status()
    evento, resposta, falhou = None, "", False
    for linha in r.iter_lines():
        if not linha or linha.startswith(":"):
            continue
        if linha.startswith("event: "):
            evento = linha[7:]
        elif linha.startswith("data: "):
            data = json.loads(linha[6:])
            if evento == "thread":
                print("conversa:", data["threadId"])
            elif evento == "text-delta" and not falhou:
                resposta += data["text"]
            elif evento == "error" and not falhou:
                falhou = True
                print("falha:", data.get("code"), data["error"])
            elif evento == "done":
                print("tokens:", data.get("usage", {}).get("totalTokens"))

if not falhou:
    print(resposta)

Erros ​

No SSE, só o corpo inválido responde com status de erro (400, antes de o stream abrir). Todo o resto chega como evento error dentro de uma resposta HTTP 200: usuário fora do Tenant, sem permissão de IA, threadId de outro usuário, falha do provedor de IA. No modo JSON, essas falhas voltam como 403, 404 ou 500, com code (veja a operação Enviar mensagem ao assistente no menu).

Um error pode vir seguido de outro error ou de um done. Trate o primeiro error como falha da resposta, descarte o texto parcial se quiser, e leia até a conexão fechar.

code no eventoCausaO que fazer
AI_TOKEN_LIMITO Tenant passou do limite mensal de IAAvise o usuário. resolution e actions dizem o caminho para liberar
AI_PROVIDER_RATE_LIMITO provedor de IA recusou por excesso de pedidosTente de novo em alguns segundos
AI_PROVIDER_NO_OUTPUTO modelo não gerou respostaTente de novo
AI_PROVIDER_UNAVAILABLE, AI_PROVIDER_QUOTAProvedor de IA indisponívelTente mais tarde
sem codeUsuário inválido, conversa não encontrada ou erro internoLeia error

Para não esbarrar no limite no meio da conversa, consulte GET /tenant/ai/limit?userId= antes de mandar a pergunta. Com state: "block", novas mensagens são recusadas. No modo JSON isso é o único jeito de saber do limite: a pergunta que esbarra nele volta 500, sem o AI_TOKEN_LIMIT.

Cancelar uma resposta ​

Feche a conexão. O servidor para de mandar eventos, mas o assistente termina a resposta mesmo assim: ela fica no histórico da conversa, e o consumo de IA conta inteiro. A rota POST /tenant/ai/threads/{threadId}/cancel não interrompe nada e existe só por compatibilidade.

Relacionado ​