Skip to content

Chat IA — API REST (Lumia)

Esta página documenta a API REST do motor de chat IA da plataforma HorusBI (Lumia), disponível para integrações server-to-server. Com ela, você pode enviar mensagens em nome de um usuário e receber a resposta via streaming (Server-Sent Events) — incluindo os tool results estruturados (tabelas, gráficos, buscas) — ou no modo JSON buffered para integrações mais simples.

Autenticação e identidade

Todos os endpoints de chat ficam sob /v1/tenant e usam o Tenant Bearer Token (sys_tenant_api_token):

http
Authorization: Bearer <sys_tenant_api_token>

O token identifica o tenant. O userId vem no body (ou query) e identifica o usuário em nome de quem a ação é executada. Antes de qualquer operação, o backend valida que o userId pertence ao tenant (tabela sys_users_tenants, excluido=false, ativo=true). Usuário fora do tenant → 403.

As permissões de IA, apps e tabelas são herdadas automaticamente do usuário: o motor verifica use_ai antes de abrir o stream (403 se ausente) e aplica restrições de acesso por tabela/app dentro das ferramentas.

Modelo de confiança: o tenant token autoriza atuar em nome de qualquer usuário ativo do tenant. Esse modelo é adequado para integração server-to-server, onde o integrador gerencia com segurança o mapeamento email → userId.

Fluxo completo

1. GET  /v1/tenant/users?login=<email>          → resolve email para userId
2. POST /v1/tenant/ai/threads  (opcional)       → cria thread antecipadamente
3. POST /v1/tenant/ai/chat                      → envia mensagem (SSE ou JSON)
4. GET  /v1/tenant/ai/threads/:threadId/messages → consulta histórico

Se você omitir o threadId no passo 3, uma nova thread é criada automaticamente e seu handle chega no primeiro evento SSE (event: thread).


Referência dos endpoints

GET /v1/tenant/users

Resolve email → userId. Útil para obter o userId a partir do login do usuário antes de chamar o chat.

Query params:

ParâmetroTipoDescrição
loginstringE-mail do usuário (filtro exato)
idintegerID do usuário (alternativa)

Sem filtro, retorna todos os usuários do tenant (comportamento original, retrocompatível).

Resposta (200):

json
{
  "status": "success",
  "data": [
    { "id": 123, "nome": "João Silva", "login": "joao@empresa.com" }
  ]
}

POST /v1/tenant/ai/chat

Envia uma mensagem ao motor de IA em nome de um usuário. Retorna a resposta via SSE (padrão) ou JSON buffered.

Negociação de modo:

CondiçãoModo
Accept: text/event-stream ou ?stream=true (padrão)SSE (streaming)
Accept: application/json ou ?stream=falseJSON buffered

Body (JSON):

CampoTipoObrigatórioDescrição
userIdintegersimID do usuário no tenant
messagestringsimPergunta ou comando do usuário
threadIdstringnãoHandle da thread (bi_<uuid>); omitido = cria nova
modelKey'fast' | 'reasoning'nãoModelo preferido; sem efeito se o tenant tiver config própria de IA

Exemplo de body:

json
{
  "userId": 123,
  "message": "Quanto vendi em maio?",
  "threadId": "bi_a1b2c3d4-...",
  "modelKey": "fast"
}

Resposta SSE: veja a seção Contrato SSE abaixo.

Resposta JSON buffered (200):

json
{
  "status": "success",
  "data": {
    "threadId": "bi_a1b2c3d4-...",
    "text": "Em maio você vendeu R$ 214.500,50...",
    "toolResults": [
      {
        "toolName": "execute_query",
        "result": { "type": "query", "title": "Vendas maio", "columns": ["Mês", "Total"], "rows": [["Maio", 214500.50]], "rowCount": 1 }
      }
    ],
    "usage": { "inputTokens": 312, "outputTokens": 87, "totalTokens": 399 }
  }
}

GET /v1/tenant/ai/threads

Lista as threads de conversa do usuário. Por padrão retorna apenas threads de origem api (threads criadas por esta API), isolando-as das conversas do frontend.

Query params:

ParâmetroTipoObrigatórioDescrição
userIdintegersimID do usuário
originstringnãoFiltro de origem (padrão: api)

Resposta (200):

json
{
  "status": "success",
  "data": {
    "threads": [
      {
        "threadId": "bi_a1b2c3d4-...",
        "lastMessageAt": "2026-06-19T12:00:00Z",
        "messageCount": 8
      }
    ]
  }
}

POST /v1/tenant/ai/threads

Cria uma thread vazia com origem api e retorna o handle. Use quando precisar do threadId antes de enviar a primeira mensagem.

Body:

json
{ "userId": 123 }

Resposta (200):

json
{
  "status": "success",
  "data": { "threadId": "bi_a1b2c3d4-..." }
}

GET /v1/tenant/ai/threads/:threadId/messages

Retorna o histórico completo da thread. Thread de outro usuário ou tenant → 404.

Path params: :threadId — handle string (bi_<uuid>)

Query params:

ParâmetroTipoObrigatórioDescrição
userIdintegersimID do usuário dono da thread

Resposta (200):

json
{
  "status": "success",
  "data": {
    "messages": [
      {
        "role": "user",
        "message": "Quanto vendi em maio?",
        "enviado_em": "2026-06-19T12:00:00Z",
        "contexto": null
      },
      {
        "role": "assistant",
        "message": "Em maio você vendeu R$ 214.500,50...",
        "enviado_em": "2026-06-19T12:00:05Z",
        "contexto": { "toolCalls": [], "toolResults": [] }
      }
    ]
  }
}

POST /v1/tenant/ai/threads/:threadId/cancel

Sinaliza o cancelamento de um stream em andamento. Fallback explícito: o modo primário de cancelamento é fechar a conexão SSE. Este endpoint é best-effort e não garante interrupção imediata do passo atual do modelo.

Resposta (200):

json
{ "status": "success", "data": { "cancelled": true } }

Contrato SSE

Headers de resposta

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

Formato dos frames

Cada evento usa o formato padrão SSE:

event: <nome>\n
data: <json numa única linha>\n
\n

Keepalive: o servidor emite frames de comentário (: ping) periodicamente (~20s) para manter a conexão ativa. Ignore-os no parser.

Tabela de eventos

EventoDados (data)Descrição
thread{ "threadId": "bi_..." }Emitido no início; informa o handle da thread (nova ou existente)
text-delta{ "text": "...", "messageId": "..." }Fragmento incremental do texto da resposta
reasoning-delta{ "text": "..." }Fragmento do raciocínio interno (modelos com reasoning)
tool-call{ "toolName": "execute_query", "toolCallId": "...", "description": "..." }O modelo está invocando uma ferramenta
tool-result{ "toolName": "...", "toolCallId": "...", "result": { /* ToolResultData */ } }Resultado estruturado da ferramenta
done{ "messageId": "...", "threadId": "bi_...", "usage": { "inputTokens": N, "outputTokens": N, "totalTokens": N } }Stream encerrado com sucesso
error{ "error": "mensagem" }Falha terminal; stream é encerrado após este evento

usage no done (best-effort): os tokens são sempre contabilizados e persistidos internamente. O campo usage no evento done é exposto quando disponível; pode estar ausente em casos excepcionais.

error é terminal: O evento error encerra o stream imediatamente. Se a falha ocorrer após eventos parciais (text-delta, tool-result), o stream termina com error — neste caso, não haverá evento done. O cliente deve tratar tanto done quanto error como sinais de fim de stream e não esperar done após um error.


Shapes de ToolResultData

O campo result dentro do evento tool-result segue a união abaixo, discriminada pelo campo type.

type: 'query'

Resultado de uma consulta analítica.

CampoTipoObrigatórioDescrição
type'query'simDiscriminador
titlestringsimTítulo da consulta
columnsstring[]simNomes das colunas
rowsany[][]simLinhas de dados
rowCountintegersimTotal de linhas
summarystringnãoResumo textual gerado pela IA
permalinkstringnãoURL permanente para o relatório no DataViz
chartUrlstringnãoURL de imagem do gráfico
chartOptionsobjectnãoOpções de configuração do gráfico
filtersAppliedobjectnãoFiltros que foram aplicados na consulta

Exemplo:

json
{
  "type": "query",
  "title": "Vendas por mês",
  "columns": ["Mês", "Total (R$)"],
  "rows": [
    ["Janeiro", 198320.00],
    ["Fevereiro", 214500.50],
    ["Março", 187450.75]
  ],
  "rowCount": 3,
  "summary": "As vendas cresceram 8% de janeiro a fevereiro.",
  "permalink": "https://empresa.horusbi.com.br/app/123/dashboard/456?filters=...",
  "chartUrl": "https://api.horusbi.com.br/v1/exports/chart/abc123.png"
}

type: 'comparison'

Resultado de uma comparação entre dois períodos. Inclui todos os campos de query mais:

CampoTipoDescrição
secondaryRowsany[][]Linhas do período de comparação
comparisonLabelsstring[]Rótulos dos dois períodos (ex.: ["Maio/25", "Maio/26"])
variationColumnsstring[]Colunas que contêm a variação percentual

Exemplo:

json
{
  "type": "comparison",
  "title": "Vendas: Maio/25 vs Maio/26",
  "columns": ["Vendedor", "Maio/25", "Maio/26", "Variação"],
  "rows": [["Ana Lima", 45000, 52000, "+15,6%"]],
  "rowCount": 1,
  "secondaryRows": [["Ana Lima", 45000]],
  "comparisonLabels": ["Maio/25", "Maio/26"],
  "variationColumns": ["Variação"]
}

type: 'forecast'

Resultado de uma previsão. Inclui todos os campos de query mais:

CampoTipoDescrição
forecastDataobjectDados projetados e intervalos de confiança

Exemplo:

json
{
  "type": "forecast",
  "title": "Previsão de Vendas — Julho/26",
  "columns": ["Mês", "Previsto (R$)"],
  "rows": [["Julho/26", 230000]],
  "rowCount": 1,
  "forecastData": {
    "periods": ["Julho/26", "Agosto/26"],
    "values": [230000, 241500],
    "confidenceLow": [210000, 218000],
    "confidenceHigh": [250000, 265000]
  }
}

Resultado de uma busca de registros em um app.

CampoTipoObrigatórioDescrição
type'search'simDiscriminador
appNamestringsimNome do app pesquisado
columnLabelstringsimColuna onde a busca foi feita
searchTextstringsimTexto buscado
resultsstring[]simValores encontrados
resultCountintegersimQuantidade de resultados

Exemplo:

json
{
  "type": "search",
  "appName": "CRM",
  "columnLabel": "Nome do Cliente",
  "searchText": "Acme",
  "results": ["Acme Corp", "Acme Industrial", "Acme Logística"],
  "resultCount": 3
}

type: 'metadata'

Informações estruturais de um app (campos disponíveis, etc.).

CampoTipoObrigatórioDescrição
type'metadata'simDiscriminador
appNamestringsimNome do app

Exemplo:

json
{
  "type": "metadata",
  "appName": "Dashboard Vendas"
}

{ error: string }

Falha ao executar a ferramenta (sem campo type). O motor continua e pode gerar mais eventos após isso.

Exemplo:

json
{ "error": "Tabela 'vendas_detalhe' não acessível para este usuário." }

Exemplos de integração

curl — SSE

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

Node.js 18+ — fetch com parser SSE

js
const res = await fetch("https://api.horusbi.com.br/v1/tenant/ai/chat", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TENANT_TOKEN}`,
    Accept: "text/event-stream",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ userId: 123, message: "Quanto vendi em maio?" }),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let answer = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop() ?? "";
  for (const f of frames) {
    if (f.startsWith(":")) continue; // keepalive
    const event = f.match(/^event: (.+)$/m)?.[1];
    const data = JSON.parse(f.match(/^data: (.+)$/m)?.[1] ?? "{}");
    if (event === "thread")      console.log("threadId:", data.threadId);
    else if (event === "text-delta")  answer += data.text;
    else if (event === "tool-result") renderToolResult(data.result);
    else if (event === "done")
      console.log("Resposta completa. Tokens:", data.usage?.totalTokens);
  }
}
console.log("Resposta:", answer);

// Renderização de tool-result
function renderToolResult(r) {
  if (!r) return;
  if (r.type === "query" || r.type === "comparison" || r.type === "forecast") {
    console.log(`[${r.type}] ${r.title}`);
    console.table(r.rows.map((row) =>
      Object.fromEntries(r.columns.map((col, i) => [col, row[i]]))
    ));
    if (r.permalink) console.log("Relatório:", r.permalink);
    if (r.chartUrl)  console.log("Gráfico:", r.chartUrl);
  } else if (r.type === "search") {
    console.log(`[search] ${r.appName}${r.columnLabel}:`, r.results.join(", "));
  } else if (r.error) {
    console.warn("[tool-error]", r.error);
  }
}

Node.js — modo JSON buffered

Para casos onde streaming não é necessário, use ?stream=false ou Accept: application/json:

js
const res = await fetch("https://api.horusbi.com.br/v1/tenant/ai/chat?stream=false", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TENANT_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ userId: 123, message: "Qual o total de vendas de maio?" }),
});

const { status, data } = await res.json();
if (status === "success") {
  console.log("Resposta:", data.text);
  console.log("Thread:", data.threadId);
  console.log("Tokens:", data.usage?.totalTokens);
  for (const tr of data.toolResults ?? []) {
    renderToolResult(tr.result);
  }
}

Python — httpx com streaming SSE

python
import json
import httpx

TENANT_TOKEN = "seu_token_aqui"

with httpx.stream(
    "POST",
    "https://api.horusbi.com.br/v1/tenant/ai/chat",
    headers={
        "Authorization": f"Bearer {TENANT_TOKEN}",
        "Accept": "text/event-stream",
        "Content-Type": "application/json",
    },
    json={"userId": 123, "message": "Quanto vendi em maio?"},
    timeout=None,
) as r:
    event = None
    answer = ""
    for line in r.iter_lines():
        if not line:           # fim de um frame SSE
            event = None
            continue
        if line.startswith(":"):           # keepalive
            continue
        if line.startswith("event: "):
            event = line[7:]
        elif line.startswith("data: "):
            data = json.loads(line[6:])
            if event == "thread":
                print("threadId:", data["threadId"])
            elif event == "text-delta":
                answer += data["text"]
            elif event == "tool-result":
                r_data = data["result"]
                if r_data.get("type") == "query":
                    print(f"[query] {r_data['title']}: {r_data['rows']}")
                elif r_data.get("type") == "search":
                    print(f"[search] {r_data['appName']}: {r_data['results']}")
            elif event == "done":
                print("threadId:", data["threadId"],
                      "tokens:", data.get("usage", {}).get("totalTokens"))

print("Resposta:", answer)

Fluxo completo: email → chat → histórico

js
const BASE = "https://api.horusbi.com.br/v1/tenant";
const headers = { Authorization: `Bearer ${TENANT_TOKEN}` };

// 1. Resolver email → userId
const usersRes = await fetch(`${BASE}/users?login=joao@empresa.com`, { headers });
const { data: users } = await usersRes.json();
const userId = users[0]?.id;
if (!userId) throw new Error("Usuário não encontrado");

// 2. Enviar mensagem (nova thread criada automaticamente)
let threadId;
const chatRes = await fetch(`${BASE}/ai/chat?stream=false`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ userId, message: "Qual o produto mais vendido?" }),
});
const { data: chat } = await chatRes.json();
threadId = chat.threadId;
console.log("Resposta:", chat.text);

// 3. Continuar na mesma thread
const followRes = await fetch(`${BASE}/ai/chat?stream=false`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ userId, threadId, message: "E em fevereiro?" }),
});
const { data: follow } = await followRes.json();
console.log("Follow-up:", follow.text);

// 4. Consultar histórico
const histRes = await fetch(
  `${BASE}/ai/threads/${threadId}/messages?userId=${userId}`,
  { headers }
);
const { data: hist } = await histRes.json();
console.log("Mensagens na thread:", hist.messages.length);

Erros

CódigoSituação
400Validação do body falhou (campo obrigatório ausente, tipo incorreto)
403userId não pertence ao tenant, ou usuário sem permissão use_ai
404Thread não encontrada (ou pertence a outro usuário/tenant)
500Erro interno; em SSE o evento error é emitido antes do encerramento

No modo SSE, erros após o início do stream chegam como evento error:

event: error
data: {"error":"Usuário sem permissão de IA (use_ai)."}

No modo JSON buffered, erros retornam como:

json
{ "status": "error", "message": "Usuário sem permissão de IA (use_ai)." }