Buscar K
Aparência
Aparência
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.
Todos os endpoints de chat ficam sob /v1/tenant e usam o Tenant Bearer Token (sys_tenant_api_token):
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.
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óricoSe você omitir o threadId no passo 3, uma nova thread é criada automaticamente e seu handle chega no primeiro evento SSE (event: thread).
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âmetro | Tipo | Descrição |
|---|---|---|
login | string | E-mail do usuário (filtro exato) |
id | integer | ID do usuário (alternativa) |
Sem filtro, retorna todos os usuários do tenant (comportamento original, retrocompatível).
Resposta (200):
{
"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ção | Modo |
|---|---|
Accept: text/event-stream ou ?stream=true (padrão) | SSE (streaming) |
Accept: application/json ou ?stream=false | JSON buffered |
Body (JSON):
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
userId | integer | sim | ID do usuário no tenant |
message | string | sim | Pergunta ou comando do usuário |
threadId | string | não | Handle da thread (bi_<uuid>); omitido = cria nova |
modelKey | 'fast' | 'reasoning' | não | Modelo preferido; sem efeito se o tenant tiver config própria de IA |
Exemplo de body:
{
"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):
{
"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âmetro | Tipo | Obrigatório | Descrição |
|---|---|---|---|
userId | integer | sim | ID do usuário |
origin | string | não | Filtro de origem (padrão: api) |
Resposta (200):
{
"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:
{ "userId": 123 }Resposta (200):
{
"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âmetro | Tipo | Obrigatório | Descrição |
|---|---|---|---|
userId | integer | sim | ID do usuário dono da thread |
Resposta (200):
{
"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):
{ "status": "success", "data": { "cancelled": true } }Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: noCada evento usa o formato padrão SSE:
event: <nome>\n
data: <json numa única linha>\n
\nKeepalive: o servidor emite frames de comentário (: ping) periodicamente (~20s) para manter a conexão ativa. Ignore-os no parser.
| Evento | Dados (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 |
usagenodone(best-effort): os tokens são sempre contabilizados e persistidos internamente. O campousageno eventodoneé exposto quando disponível; pode estar ausente em casos excepcionais.
erroré terminal: O eventoerrorencerra o stream imediatamente. Se a falha ocorrer após eventos parciais (text-delta,tool-result), o stream termina comerror— neste caso, não haverá eventodone. O cliente deve tratar tantodonequantoerrorcomo sinais de fim de stream e não esperardoneapós umerror.
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.
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
type | 'query' | sim | Discriminador |
title | string | sim | Título da consulta |
columns | string[] | sim | Nomes das colunas |
rows | any[][] | sim | Linhas de dados |
rowCount | integer | sim | Total de linhas |
summary | string | não | Resumo textual gerado pela IA |
permalink | string | não | URL permanente para o relatório no DataViz |
chartUrl | string | não | URL de imagem do gráfico |
chartOptions | object | não | Opções de configuração do gráfico |
filtersApplied | object | não | Filtros que foram aplicados na consulta |
Exemplo:
{
"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:
| Campo | Tipo | Descrição |
|---|---|---|
secondaryRows | any[][] | Linhas do período de comparação |
comparisonLabels | string[] | Rótulos dos dois períodos (ex.: ["Maio/25", "Maio/26"]) |
variationColumns | string[] | Colunas que contêm a variação percentual |
Exemplo:
{
"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:
| Campo | Tipo | Descrição |
|---|---|---|
forecastData | object | Dados projetados e intervalos de confiança |
Exemplo:
{
"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]
}
}type: 'search' Resultado de uma busca de registros em um app.
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
type | 'search' | sim | Discriminador |
appName | string | sim | Nome do app pesquisado |
columnLabel | string | sim | Coluna onde a busca foi feita |
searchText | string | sim | Texto buscado |
results | string[] | sim | Valores encontrados |
resultCount | integer | sim | Quantidade de resultados |
Exemplo:
{
"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.).
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
type | 'metadata' | sim | Discriminador |
appName | string | sim | Nome do app |
Exemplo:
{
"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:
{ "error": "Tabela 'vendas_detalhe' não acessível para este usuário." }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?"}'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);
}
}Para casos onde streaming não é necessário, use ?stream=false ou Accept: application/json:
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);
}
}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)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);| Código | Situação |
|---|---|
400 | Validação do body falhou (campo obrigatório ausente, tipo incorreto) |
403 | userId não pertence ao tenant, ou usuário sem permissão use_ai |
404 | Thread não encontrada (ou pertence a outro usuário/tenant) |
500 | Erro 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:
{ "status": "error", "message": "Usuário sem permissão de IA (use_ai)." }