# Catcher Agents — contexto completo para agentes de IA > Catcher Agents é uma plataforma multi-tenant de agentes de IA que lembram (memória por usuário final), sabem (conhecimento próprio com citação, RAG) e agem (ferramentas REST criadas por IA). Você monta o agente no Console e o embute em qualquer produto por uma API REST com streaming SSE, com custo medido por execução. > Console: https://agents.catcher.one · API: https://agents-api.catcher.one · Skills: https://agents.catcher.one/skill/SKILL.md Este arquivo é o manual público inteiro, num só documento, para um agente carregar todo o contexto da API em uma única requisição. --- # Catcher Agents API — Manual Público > **Base URL:** `https://agents-api.catcher.one` > **Versão:** v1 > **Formato:** JSON (`Content-Type: application/json`), UTF-8 > **Console:** `https://agents.catcher.one` (onde você cria e configura os agentes) Esta é a documentação da API que você usa para **operar** e **embutir** agentes da Catcher Agents e para processar documentos privados com **Document AI**. O agente — modelo, prompt, ferramentas, base de conhecimento — é montado no Console; depois o seu app conversa com ele por esta API REST (com streaming por SSE). Document AI usa jobs assíncronos, schemas versionados, consenso e revisão humana. Todo erro retorna um envelope unificado com `error_code`, `message` e `trace_id`. ## Autenticação A API aceita três credenciais, cada uma para um caso. O campo `{id}` nas rotas é sempre o id do agente. | Credencial | Header | Prefixo | Uso | | --- | --- | --- | --- | | API key | `X-API-Key: ctc_…` | `ctc_` | Chamadas servidor-a-servidor autenticadas como a sua conta (Console) | | JWT | `Authorization: Bearer ` | — | Sessão de navegador do Console (curto prazo + refresh + CSRF) | | Runtime token | `X-Agent-Token: prt_…` | `prt_` | Um app externo dirige um agente (ou os agentes de um tenant) | - **API keys** (`ctc_`) são guardadas com hash (SHA-256) — a chave crua aparece **uma única vez**, na criação. Cada key aceita uma allowlist opcional de IPs de origem. Crie em Tokens de acesso. - **Runtime tokens** (`prt_`) são **escopados**. Um token **agent-scoped** dirige UM agente: o `{id}` na URL precisa ser o agente do token, senão `403 RUNTIME_TOKEN_AGENT_MISMATCH`. Um token **tenant-scoped** dirige **todos-e-somente** os agentes de um tenant (seu cliente final): outro tenant → `403 RUNTIME_TOKEN_TENANT_MISMATCH`. É o escopo de quem cria agentes dinamicamente por cliente — o vínculo sobrevive a agente criado ou removido depois, então o app não troca de credencial. A autenticação é por header, sem CSRF — ideais para backend de terceiros. - **JWT** é o caminho do Console (login → refresh). O header exato de CSRF é `X-CSRF-Token`, casando com o cookie `saasbase_csrf_token` em métodos não-seguros. ### GET Validar a credencial Não existe um endpoint `GET /v1/auth/me`. A identidade da conta vem no corpo da resposta de `login` / `refresh` (veja Tokens de acesso). Para checar rapidamente uma API key servidor-a-servidor, qualquer leitura autenticada serve, por exemplo: ```bash curl -sf https://agents-api.catcher.one/v1/agents \ -H "X-API-Key: ctc_SUA_KEY" ``` ### POST Criar conta (quick-register) `POST /v1/auth/quick-register` — onboarding **programático** (o caminho para um agente de IA provisionar a conta sozinho, sem abrir o Console). Público (sem credencial), protegido por rate-limit/honeypot. ```bash curl -X POST https://agents-api.catcher.one/v1/auth/quick-register \ -H "Content-Type: application/json" \ -d '{ "email": "voce@empresa.com" }' ``` Resposta `201 Created` — guarda **as duas** credenciais na hora (são mostradas uma única vez; também vão por email): ```json { "company_id": 42, "api_key": "ctc_…", "email": "voce@empresa.com", "password": "…", "base_url": "https://agents-api.catcher.one", "message": "account created — credentials sent to your email" } ``` - `api_key` (`ctc_`, role **owner**) autentica todas as chamadas de conta — criar agente, mintar runtime token, etc. - `password` é a senha do Console (`POST /v1/auth/login`). O email já sai **verificado** (receber as credenciais prova posse do endereço). - `409 EMAIL_EXISTS` se o email já tem conta — use `login` + `forgot-password` para recuperar. ## Início rápido Do zero ao primeiro turno, embutindo o agente no seu app, em três passos. ### GET 1. Crie o agente e o token no Console Crie a conta em `https://agents-app.catcher.one/register` — ou programaticamente via `POST /v1/auth/quick-register` (veja Autenticação) — monte um agente (nome, modelo, prompt, ferramentas, conhecimento) e copie o `AGENT_ID` da URL da página do agente. Na aba de tokens de runtime do agente, gere um `prt_` (owner-only — mostrado uma vez). ### POST 2. Abra uma sessão Cada sessão é isolada por usuário final via `end_user_external_id` — é esse id que separa a memória de um cliente do outro. ```bash curl -X POST https://agents-api.catcher.one/v1/agent-runtime/AGENT_ID/sessions \ -H "X-Agent-Token: prt_SEU_RUNTIME_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "end_user_external_id": "cliente-42", "title": "Atendimento" }' ``` Resposta `201 Created`: ```json { "id": "sess_a1b2c3", "agent_id": "AGENT_ID", "title": "Atendimento", "created_at": "2026-07-02T01:00:00Z" } ``` ### POST 3. Mande a mensagem ```bash curl -X POST https://agents-api.catcher.one/v1/agent-runtime/AGENT_ID/sessions/sess_a1b2c3/messages \ -H "X-Agent-Token: prt_SEU_RUNTIME_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "content": "Qual foi o último pedido do cliente?" }' ``` A resposta traz o turno completo (memória + conhecimento + ferramentas aplicados) num envelope — o texto do agente está em `assistant_message.content`: ```json { "run_id": "9f2c…", "user_message": { "id": "…", "role": "user", "content": "Qual foi o último pedido do cliente?", "…": "…" }, "assistant_message": { "id": "…", "role": "assistant", "content": "O último pedido foi #4821…", "run_id": "9f2c…", "…": "…" }, "usage": { "input_tokens": 812, "output_tokens": 143, "total_tokens": 955 }, "provider": "engine", "model": "gpt-5.4-mini", "tool_calls": [ { "name": "brain_search", "is_error": false, "…": "…" } ] } ``` Pronto — o agente está embutido. Para uma UX ao vivo (token a token), use a variante de streaming (Streaming SSE). ## Runtime — embutir um agente O grupo `/v1/agent-runtime/{id}` é a superfície de embed. Autenticação **só por `X-Agent-Token: prt_…`**; o token é preso a um agente, e as rotas de sessão têm guarda de posse (proteção BOLA) — uma sessão de outro agente retorna `404 SESSION_NOT_FOUND`. O grupo roda num timeout estendido (5 min) porque um turno agêntico pode fazer várias chamadas de LLM. São os **mesmos handlers** do chat do Console, então os shapes são idênticos. ### POST Criar sessão `POST /v1/agent-runtime/{id}/sessions` — corpo `{ end_user_external_id?, title? }` (ambos opcionais). Registrar o `end_user_external_id` cria/atualiza o usuário final do agente, o que mantém a memória separada por pessoa. Resposta `201`: `{ id, agent_id, title, created_at }`. ### GET Listar mensagens `GET /v1/agent-runtime/{id}/sessions/{sid}/messages` — resposta `200`: `{ messages: [messageResponse], total }`. Cada `messageResponse`: ```json { "id": "…", "session_id": "sess_a1b2c3", "role": "user | assistant | tool | system", "content": "…", "thinking": "…", "tool_calls": [ { "name": "brain_search", "is_error": false, "output": "…", "duration_ms": 142 } ], "run_id": "9f2c…", "created_at": "2026-07-02T01:00:00Z" } ``` `thinking`, `tool_calls` e `run_id` só aparecem quando presentes. No `tool_calls`, o `output` público é truncado em 4096 caracteres (`output_truncated` e `output_chars` sinalizam). ### POST Enviar mensagem `POST /v1/agent-runtime/{id}/sessions/{sid}/messages` — corpo `{ content }` (obrigatório, não-vazio). Roda o turno completo e retorna o envelope `{ run_id, user_message, assistant_message, usage, provider, model, tool_calls }` mostrado no Início rápido. Quando alguma ferramenta anexada ao agente **não pôde ser montada** no turno, o envelope traz também `tools_unavailable: ["nome", …]` (o campo é omitido quando está vazio) — e o evento SSE `complete` carrega o mesmo. Causas típicas: a ferramenta foi deletada/renomeada, ela é escopada a outro tenant (um agente só enxerga as do próprio tenant + as company-global), ou é uma ferramenta de catálogo cuja integração não está configurada. Trate a presença desse campo como erro de configuração: sem a ferramenta o modelo tende a improvisar a chamada como texto em vez de executá-la. O `cost_usd` do turno **não** vem nesse corpo — ele fica persistido no run e é lido em `GET /v1/agents/{id}/runs/{runId}` (o evento `done` do streaming também carrega o custo agregado). ### POST Enviar mensagem (streaming) `POST /v1/agent-runtime/{id}/sessions/{sid}/messages/stream` — o mesmo turno, em SSE (Streaming SSE). ## Streaming (SSE) O endpoint `.../messages/stream` responde com `Content-Type: text/event-stream` (`Cache-Control: no-cache`, `X-Accel-Buffering: no`), status `200` imediato. O formato de cada evento é: ``` event: data: ``` O agente responde token a token; os chips de ferramenta acendem ao vivo; o resumo de raciocínio aparece antes da resposta. Há duas famílias de evento. ### POST Consumir um stream ```bash curl -N -X POST \ https://agents-api.catcher.one/v1/agent-runtime/AGENT_ID/sessions/sess_a1b2c3/messages/stream \ -H "X-Agent-Token: prt_SEU_RUNTIME_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "content": "Resuma os 3 últimos pedidos." }' ``` Sequência real de eventos num turno com ferramentas: ``` event: start data: {"run_id":"9f2c…","user_message":{"id":"…","role":"user","content":"Resuma os 3 últimos pedidos.","…":"…"}} event: tool_call_start data: {"type":"tool_call_start","tool_call":{"index":0,"id":"tc_1","name":"brain_search","arguments":"{\"q\":\"últimos pedidos\"}"}} event: tool_call_result data: {"type":"tool_call_result","tool_result":{"id":"tc_1","name":"brain_search","is_error":false,"output":"…","duration_ms":142}} event: text_delta data: {"type":"text_delta","text":"Os três últimos"} event: text_delta data: {"type":"text_delta","text":" pedidos foram…"} event: round_usage data: {"type":"round_usage","round_usage":{"usage":{"input_tokens":812,"output_tokens":143,"total_tokens":955}}} event: done data: {"type":"done","rounds":2,"stop_reason":"end_turn","cost":{"total_cost_usd":0.0144},"usage":{"input_tokens":812,"output_tokens":143,"total_tokens":955}} event: complete data: {"run_id":"9f2c…","assistant_message":{"id":"…","role":"assistant","content":"Os três últimos pedidos foram…","run_id":"9f2c…","…":"…"},"usage":{"input_tokens":812,"output_tokens":143,"total_tokens":955},"tool_calls":[…]} ``` ### GET Catálogo de eventos | Evento | Origem | `data` | | --- | --- | --- | | `start` | API (sempre 1º) | `{ run_id, user_message }` | | `thinking` | engine / single-shot | `{ type: "thinking", text }` — resumo de raciocínio | | `text_delta` | engine / single-shot | `{ type: "text_delta", text }` — fragmento da resposta | | `tool_call_start` | engine | `{ type, tool_call: { index, id, name, arguments } }` | | `tool_call_delta` | engine | `{ type, tool_call: { index, id, arguments } }` — fragmento dos argumentos | | `tool_call_result` | engine | `{ type, tool_result: { id, name, is_error, output, duration_ms } }` | | `round_usage` | engine | `{ type, round_usage: { usage: {…} } }` | | `done` | engine (verbatim) | `{ type, rounds, stop_reason, cost: {…}, usage: {…}, rounds_usage: […] }` | | — | — | `stop_reason` terminal: `completed` (resposta final), `max_rounds` (bateu o teto de rounds), `timeout` (esgotou o wall clock do turno), `error` (falha real), `user_aborted` (parado via `POST …/stop`). Os três primeiros significam que o trabalho feito até ali é real — reagir com retry/aprofundar, não escalar. | | `complete` | API (sempre último no sucesso) | `{ run_id, assistant_message, usage, tool_calls }` | | `error` | API / engine | `{ error }` (ou `{ type: "error", error }`) | O nome do evento de texto é **`text_delta`** (não "token"), e o de raciocínio é **`thinking`**. No fim de um turno com sucesso o cliente vê **dois** eventos terminais — o `done` do engine (repassado verbatim) e o `complete` da API — e pode usar qualquer um dos dois como sinal de fim. Um turno não-agêntico (single-shot, sem ferramentas) emite `start`, então `thinking` (se houver), `text_delta` com a resposta inteira, e `complete`. ## Agentes O grupo `/v1/agents` cobre o ciclo de vida do agente. As leituras aceitam JWT/API key; criar/editar/deletar exigem papel **owner** ou **admin** (o `/v1/agents` CRUD é servido pela superfície que atende Console e CRM). O shape canônico do agente (`agentResponse`) é retornado por get, create, clone, update e restore. ### GET Detalhe do agente `GET /v1/agents/{id}` — resposta `200`: ```json { "id": "…", "company_id": 30, "tenant_id": 0, "name": "Atendimento", "description": "…", "system_prompt": "…", "model": "gpt-5.4-mini", "output_format": "markdown", "tools": ["brain_search", "web_search"], "skills": ["tdd"], "active": true, "rag_auto": true, "rag_top_k": 5, "rag_qa": true, "conversation_recall": false, "legacy_mode": false, "brain_mode": true, "max_tokens": 0, "temperature": null, "reasoning_effort": "", "max_rounds_per_run": 20, "run_class": "", "monthly_budget_usd": 0, "created_at": "2026-06-16T…Z", "updated_at": "2026-07-01T…Z" } ``` `temperature` é anulável (`null` = default do provedor). `brain_mode` é derivado (`!legacy_mode`) e é alias deprecado — use `legacy_mode`. ### GET Listar agentes `GET /v1/agents` — aceita `?tenant_id=` (id interno) **ou** `?tenant_external_id=` (o seu identificador do tenant, para não precisar traduzir via `/v1/tenants`); resposta `200` `{ agents: [agentResponse], total }`. Um `tenant_external_id` desconhecido devolve `404 TENANT_NOT_FOUND` — de propósito, e não uma lista vazia: "esse tenant não existe" e "esse tenant não tem agentes" são respostas diferentes, e só a primeira significa que o seu mapeamento está errado. ### POST Criar agente `POST /v1/agents` (owner/admin) — corpo `agentRequest`: | Campo | Tipo | Default / regra | | --- | --- | --- | | `name` | string | **obrigatório** | | `tenant_id` | uint | `0` = tenant padrão do projeto | | `tenant_external_id` | string | resolve-ou-cria o tenant pelo id externo (clínica = tenant) | | `description` | string | — | | `system_prompt` | string | — | | `model` | string | default `gpt-5.4-mini` | | `output_format` | string | default `markdown` | | `tools` | string[] | — | | `skills` | string[] | — | | `rag_auto` | bool? | `null` → `true` | | `rag_top_k` | int? | `null` → 5; clamp 1..20 | | `rag_qa` | bool? | `null` → `true` (qa_first) | | `conversation_recall` | bool? | `null` → `false`. Quando `true`, o agente recupera semanticamente trechos das conversas ANTERIORES do MESMO end-user (via `brain_search`), escopado por `end_user_external_id` — sessões abertas sem end-user não recuperam nada. | | `max_tokens` | int? | `null`/0 → default do provedor; clamp 0..64000 | | `temperature` | float? | `null` → default; clamp 0..2 | | `reasoning_effort` | string? | `low\|medium\|high\|xhigh\|max`; valor fora do conjunto → `400 INVALID_REASONING_EFFORT` (nunca cai no default em silêncio) | | `max_rounds_per_run` | int? | `null`/0 → 20; clamp 1..50 (1..200 se `run_class="coder"`) | | `run_class` | string? | `null`/`""`/desconhecido → padrão (teto 50 rounds / 600s de wall clock). `"coder"` → teto 200 rounds / 1800s. Ver **Classes de run** abaixo. | | `monthly_budget_usd` | float? | `null`/≤0 → sem teto por agente | | `legacy_mode` | bool? | `true` = caminho legado deprecado (brain é o default) | Resposta `201`: `agentResponse`. Erros: `AGENT_NAME_REQUIRED`, `TENANT_NOT_FOUND`. ### PATCH Atualizar agente `PATCH /v1/agents/{id}` (owner/admin) — update esparso: só os campos presentes no corpo são aplicados. `temperature: null` e `reasoning_effort: null` limpam explicitamente para o default do provedor. **Campos-array são replace total do campo, nunca merge**: `{"tools": ["nova"]}` substitui o catálogo inteiro por `["nova"]` — para ADICIONAR uma entrada, envie a união do que já estava + a nova (leia antes com `GET /v1/agents/{id}`). Resposta `200`: `agentResponse`. ### POST Clonar agente `POST /v1/agents/{id}/clone` (owner/admin) — sem corpo; cria um agente novo `" (cópia)"` com a mesma config. Resposta `201`: `agentResponse`. ### DELETE Excluir agente `DELETE /v1/agents/{id}` (owner/admin) — resposta `204`. ### POST Avaliar (eval) `POST /v1/agents/{id}/eval` — roda o prompt montado do agente para um `input` contra **1..N modelos**, de forma **efêmera** (nada persiste: sem mensagem, sem run). É a base do replay (1 modelo) e da comparação A/B (N modelos, executados em paralelo, teto interno por chamada): resposta, uso, custo e latência lado a lado. ```bash curl -X POST https://agents-api.catcher.one/v1/agents/AGENT_ID/eval \ -H "X-API-Key: ctc_SUA_KEY" -H "Content-Type: application/json" \ -d '{ "input": "Quero cancelar meu pedido", "models": ["gpt-5.4-mini", "gpt-5.4"] }' ``` Resposta `200` `{ results: [ { model, content, thinking?, usage, cost_usd, duration_ms, error? } ] }`. ### GET Versões `GET /v1/agents/{id}/versions` — histórico de snapshots da config do agente: `200 { versions: [ { id, created_at, snapshot } ], total }` (o `snapshot` é a config completa decodificada). ### POST Restaurar versão `POST /v1/agents/{id}/versions/{vid}/restore` — sem corpo; volta a config do agente ao snapshot `vid`. Resposta `200`: `agentResponse`. Skills e ferramentas têm o mesmo par de rotas (`/v1/skills/{id}/versions…`, `/v1/tools/{id}/versions…`). ### GET Estatísticas `GET /v1/agents/stats` — `{ stats: { "": { sessions, runs } } }`. ### GET Classes de run (`run_class`) Um agente executa sob um **perfil de limites**, escolhido pelo campo `run_class`: | `run_class` | Teto de `max_rounds_per_run` | Wall clock por turno | Janela do `stream_token` | | --- | --- | --- | --- | | `""` (default) | `50` | `600s` (10 min) | 30 min | | `"coder"` | `200` | `1800s` (30 min) | 60 min | Escrever software é a carga mais *tool-heavy* e mais longa que a plataforma atende — uma run real encadeia dezenas de ciclos ler → editar → compilar → ler erro → corrigir, e pode passar de dez minutos. A classe `coder` existe para esse caso e é **opt-in por agente**, então nada muda para os demais. Três regras que evitam surpresa: 1. **A classe move o TETO, não o DEFAULT.** Um agente `coder` sem `max_rounds_per_run` continua rodando com os `20` rounds padrão — profundidade ainda se pede explicitamente. 2. **Valor desconhecido cai no padrão** (*fail closed*). `"CODER "` é normalizado (trim + caixa); `"turbo"` vira `""`. Um agente criado antes do campo existir mantém exatamente os limites de hoje. 3. **O override por dispatch é clampado contra a classe do AGENTE.** Mandar `max_rounds_per_run: 120` no corpo do dispatch de um agente padrão resulta em `50` — um dispatch reajusta profundidade dentro do que o agente recebeu, nunca compra um teto maior. O corpo do dispatch também aceita, por run (sem reprovisionar o agente): `model` (qualquer id que o runtime roteia — ex.: `k3`, `MiniMax-M3`; ausente ou vazio = o modelo do agente) e `reasoning_effort` (`low|medium|high|xhigh|max`; valor fora do conjunto → `400 INVALID_REASONING_EFFORT`, nunca fallback silencioso). `GET /v1/projects/{projectId}/agents/{agentId}/limits` publica a tabela acima em `run_classes`, para você ler os tetos em vez de descobri-los como um corte no meio da run. ## Sessões e mensagens O chat pelo Console (autenticado por JWT/API key) usa os mesmos handlers do runtime, com endpoints extras de gestão de sessão. ### POST Criar sessão `POST /v1/agents/{id}/sessions` — corpo `{ title?, end_user_external_id? }`, resposta `201`: `{ id, agent_id, title, created_at }`. ### GET Listar sessões `GET /v1/agents/{id}/sessions` — `{ sessions: [sessionResponse], total }`. ### POST Enviar mensagem `POST /v1/agents/{id}/sessions/{sid}/messages` — corpo `{ content }`, mesmo envelope de resposta do runtime (`run_id`, `user_message`, `assistant_message`, `usage`, `provider`, `model`, `tool_calls`). ### POST Enviar mensagem (streaming) `POST /v1/agents/{id}/sessions/{sid}/messages/stream` — o mesmo turno por SSE, com o catálogo de eventos da seção Streaming (SSE). É a variante JWT/API key do `.../agent-runtime/.../messages/stream` (que usa `prt_`). ### GET Detalhe do run `GET /v1/agents/{id}/runs/{runId}` — o registro priced do turno, com custo: ```json { "id": "9f2c…", "agent_id": "…", "session_id": "sess_a1b2c3", "model": "gpt-5.4-mini", "status": "completed", "input": "…", "output": "…", "usage": { "input_tokens": 812, "output_tokens": 143, "total_tokens": 955 }, "cost_usd": 0.0144, "rounds": 2, "duration_ms": 26600, "error": "", "started_at": "2026-07-02T01:00:00Z" } ``` ### GET Traço do turno (inspector) Três leituras detalham **como** o turno aconteceu — a base de um painel de depuração: | Rota | Conteúdo | | --- | --- | | `GET /v1/agents/{id}/runs/{runId}/usage` | O uso/custo do run (o painel de traço puxa daqui) | | `GET /v1/agents/{id}/runs/{runId}/tool-calls` | As chamadas de ferramenta do run, em ordem (args, resultado, erro) | | `GET /v1/agents/{id}/transcripts/{transcriptId}/llm-prompts` | Os prompts enviados ao LLM naquele transcript (system + rounds) | ### GET Exportar conversa `GET /v1/agents/{id}/sessions/{sid}/export` — retorna a transcrição como um arquivo **Markdown** para download (`Content-Disposition: attachment`), não JSON. ### GET Buscar mensagens `GET /v1/agents/{id}/sessions/search?q=…` — full-text search nas mensagens do agente (todas as sessões). Resposta `200` com os matches e a sessão de cada um. ### GET Usuários finais `GET /v1/agents/{id}/end-users` — os end-users que o seu app atende através do agente (quem aparece como `end_user_external_id` nas sessões): ```json { "end_users": [ { "id": "…", "external_id": "cliente-42", "display_name": "…", "sessions": 7, "last_seen": "2026-07-20T…Z" } ], "total": 1 } ``` ## Conhecimento O RAG por agente: suba documentos e a busca híbrida entra automaticamente no turno. Endpoints sob `/v1/agents/{id}` (JWT/API key). ### POST Adicionar texto `POST /v1/agents/{id}/knowledge` — corpo `{ title?, content }`. O campo é **`content`** (não `text`). Resposta `201`: `{ document_id, source }`. Erro: `KNOWLEDGE_CONTENT_REQUIRED`. Enviar de novo o **mesmo `title`** é **upsert**: substitui o documento no lugar, não cria um segundo. É o que permite re-semear um corpus inteiro sem duplicar trechos — veja *Manter um corpus curado* abaixo. ### GET Listar o corpus curado `GET /v1/agents/{id}/knowledge` — o que já está semeado no cérebro do agente: ```json { "documents": [ { "document_id": "knowledge/spec-da-linguagem", "title": "Spec da linguagem", "origin": "text", "updated_at": "2026-07-23T14:02:11Z" }, { "document_id": "knowledge/manual.pdf", "title": "manual.pdf", "origin": "file", "source_id": "src-9f2c" } ], "total": 2 } ``` `origin` diz de onde o documento veio e, com isso, como removê-lo: | `origin` | Veio de | Como remover | | --- | --- | --- | | `text` | `POST .../knowledge` | `DELETE .../knowledge/{docID}` | | `file` | `POST .../sources` | `DELETE .../sources/{source_id}` — traz `source_id` | `GET .../sources` **não** lista os documentos de texto: ele lista só os arquivos ingeridos. Para o corpus curado, a listagem é esta rota. ### DELETE Remover um documento `DELETE /v1/agents/{id}/knowledge/{docID}` → `204`. `{docID}` é o slug — o **último segmento** do `document_id`. Mandar o `document_id` inteiro URL-encodado (`knowledge%2Fspec`) responde `404`: a rota casa um único segmento de path. Em shell: `${document_id##*/}`. Um documento com `origin=file` responde `409 KNOWLEDGE_DOCUMENT_FILE_BACKED`: ele pertence a uma fonte, e apagar só a página deixaria a fonte e seus trechos órfãos — apague a fonte. Um id que resolva para fora do namespace `knowledge/` responde `400 KNOWLEDGE_DOCUMENT_INVALID`. ### DELETE Esvaziar o corpus `DELETE /v1/agents/{id}/knowledge` → `200 { deleted, kept_file_backed }`. Apaga os documentos de texto e **mantém** os que vieram de arquivos (removidos pela fonte), reportando os dois números. Idempotente: num corpus já vazio responde `{ "deleted": 0 }`. Requer papel **owner** ou **admin** — esvaziar um corpus inteiro é manutenção, não operação de agente. ### Manter um corpus curado Um corpus grande (uma spec + dezenas de prompts + exemplos) precisa continuar **vivo**: quando o material muda, você re-semeia. O ciclo: ```bash BASE=https://agents-api.catcher.one/v1/agents/$AGENT_ID AUTH="X-API-Key: $CATCHER_API_KEY" # 1. o que já está lá (conte antes e depois) curl -s -H "$AUTH" "$BASE/knowledge" | jq '.total' # 2. semear/atualizar — mesmo title = substitui, nunca duplica curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"title":"Spec da linguagem","content":"..."}' "$BASE/knowledge" # 3a. re-semeadura incremental: apague só o que saiu do corpus curl -s -X DELETE -H "$AUTH" "$BASE/knowledge/spec-antiga" # 3b. ou re-semeadura do zero: esvazie e repita o passo 2 curl -s -X DELETE -H "$AUTH" "$BASE/knowledge" | jq ``` Como o passo 2 é upsert, **a re-semeadura normal não precisa do wipe** — só use `3b` quando os títulos mudaram de forma e você quer garantir que nada antigo sobrou. O que essas rotas escrevem é exatamente a superfície que o agente consulta no turno (a busca do próprio cérebro), então o que você lista aqui é o que ele recupera lá. ### POST Enviar arquivo `POST /v1/agents/{id}/sources` — `multipart/form-data`, campo **`file`** (máx 25 MiB). Converte, fatia e embeda de forma síncrona (pode passar de 30s; roda no timeout estendido). Resposta `202 Accepted`: `{ id, status, filename, mime?, chunks_count, enabled, error? }`. ### GET Listar fontes `GET /v1/agents/{id}/sources` — `{ sources: [sourceResponse], total }`. `sourceResponse`: `{ id, status, filename, path?, mime?, chunks_count, enabled, error? }`. ### GET Detalhe da fonte `GET /v1/agents/{id}/sources/{sid}` — a fonte (filename, path, `chunks_count`, status) **com os chunks indexados** — os trechos exatos que a busca retornaria. Resposta `404 SOURCE_NOT_FOUND` para um `sid` desconhecido. ### PATCH Pausar/retomar fonte `PATCH /v1/agents/{id}/sources/{sid}` — corpo `{ "enabled": false }`. Pausa a fonte na busca **sem deletar**: os chunks ficam, mas saem de toda consulta; `enabled: true` traz de volta. O botão reversível ao lado do destrutivo "remover". Resposta `200`: `sourceResponse`. ### POST Reindexar fonte `POST /v1/agents/{id}/sources/{sid}/reindex` — re-fatia e re-embeda a partir do arquivo original guardado, **mantendo o mesmo `source_id`** (sem delete + re-upload quando a política de chunking muda). Resposta `202` com a fonte em `processing`; é add-before-delete — a fonte nunca fica vazia no meio do caminho. ### DELETE Remover fonte `DELETE /v1/agents/{id}/sources/{sid}` → `204`. Remove o arquivo **e** os chunks dele da base de conhecimento. > **O `{sid}` é UM segmento.** Nas quatro rotas por-fonte (`GET`, `PATCH`, > `DELETE` e `.../reindex`), um id que não permaneceria um único segmento — > vazio, `..`, ou com o separador escapado (`a%2Fb`, `%2e%2e`, inclusive duplo > `%252f`) — recebe `400 SOURCE_ID_INVALID` **antes** de qualquer chamada sair. > Isso é distinto de `404 SOURCE_NOT_FOUND`: um significa "esse id não é > endereçável", o outro "essa fonte não existe". Mande o `id` que o > `GET .../sources` devolveu, sem escapar nada. ### GET Inspetor de chunks `GET /v1/agents/{id}/knowledge/chunks` — **todos** os trechos indexados na base (sem consulta). Read-only, sem LLM: é assim que um script de seed responde "o seed funcionou?". ```json { "total": 2, "chunks": [ { "content": "Escreva curto…", "source": "House style", "surface": "brain", "document_id": "knowledge/house-style" }, { "content": "EC50 do AVX-006 = 22,4 µM…", "source": "protocolo-thv.pdf", "surface": "brain", "document_id": "knowledge/protocolo-thv.pdf", "source_id": "src_9f2…" } ] } ``` `document_id` é o mesmo id que `POST /v1/agents/{id}/knowledge` devolveu, então dá para casar trecho ↔ documento e conferir o corpus contra o que você semeou. `source_id` aparece só em trecho vindo de **arquivo** — e aí são os chunks reais da fonte; um documento de **texto** volta como um trecho único (a página é a unidade que a busca devolve). `origin` separa o que **você semeou** do que o sistema **derivou**: `text` (prosa curada), `file` (arquivo ingerido — e aí são os chunks reais da fonte) ou `distilled` (um card da Refinery, destilado a partir do seu corpus). Um audit de "o que eu semeei?" é a listagem menos os `distilled`. `surface` diz **onde** o trecho está indexado: `brain` (a base de conhecimento atual do agente) ou `knowledge` (a base vetorial legada). A listagem é a **união** das duas, porque o agente recupera das duas — se você vê trechos em `knowledge`, eles estão ativos na recuperação e podem ser migrados com `POST /v1/agents/{id}/knowledge/backfill`. Se uma das superfícies estiver indisponível no momento, a resposta traz `surfaces_unavailable` junto com o que deu para ler — o inspetor nunca deixa de fora uma superfície em silêncio. O inspetor cobre o corpus de conhecimento **e os cards destilados dele** — o mesmo conjunto que a busca recupera. Memória do end-user, persona e páginas internas do agente nunca aparecem. Corpus vazio → `total: 0` com `chunks: []`. ### GET Buscar no conhecimento `GET /v1/agents/{id}/knowledge/search` — parâmetros: | Param | Default | Uso | | --- | --- | --- | | `q` | — | **obrigatório** — a consulta | | `k` | 5 | trechos **por superfície** (cap 20) — veja abaixo: não é o total | | `source_id` | — | escopa a busca a um arquivo | | `mode` | — | `qa_first` \| `qa_only` \| `chunks` (prioriza os cards de Q&A) | Resposta `200`: ```json { "query": "qual o EC50 do AVX-006", "k": 5, "source_id": "", "mode": "qa_first", "chunks": [ { "content": "EC50 do AVX-006 = 22,4 µM…", "source": "protocolo-thv.pdf", "score": 0.94 }, { "content": "- prefere unidades em µM", "source": "O que você lembra deste usuário", "score": 1, "pinned": true } ], "counts": { "brain": 4, "knowledge": 0, "memory": 1, "conversation": 0, "total": 5 } } ``` **`k` limita cada superfície, não o total.** O agente recupera de várias superfícies em paralelo e o resultado é a união delas, então `total` normalmente excede `k` (`k=1` pode devolver 5 trechos; `k=10`, 8). **Dimensione sua janela de contexto por `counts.total`, nunca por `k`.** | `counts` | de onde vem | limite | | --- | --- | --- | | `brain` | as páginas de conhecimento do agente | `k` páginas **+** os cards de Q&A destilados, que entram por cima quando `mode=qa_first` | | `knowledge` | a base vetorial legada (agentes em modo legado) | `k` | | `memory` | o bloco de memória do end-user | 0 ou 1 — **fixo** | | `conversation` | recall de conversas anteriores (opt-in) | 3 | | `total` | o que realmente veio na resposta | soma das acima | **O bloco de memória é fixado (`pinned`), não recuperado.** Ele acompanha **toda** busca — inclusive uma consulta sem nenhuma relação com o conteúdo — porque a memória curada do end-user é injetada inteira, sem busca semântica. Ele vem com `score: 1` e `"pinned": true`: esse `1` é **marcador de fixação, não um grau de similaridade**. Trechos ranqueados de verdade não têm o campo `pinned`. Se você ordena, filtra ou mostra `score` na sua UI, **cheque `pinned` primeiro** — sem isso a memória parece um match perfeito que nunca foi calculado. **Duas coisas sobre `counts.memory` que surpreendem se você não souber:** 1. **Este endpoint é a visão do operador, não de um end-user.** Ele não aceita identificador de end-user, então a memória que aparece é sempre a da partição do operador — nunca a de um cliente seu. Não existe forma de inspecionar a memória de um end-user específico por aqui; ela entra no turno de chat daquele end-user. 2. **`counts.memory` pode virar `0` de um dia para o outro sem nada ter mudado.** O bloco é montado com o perfil de estilo + os fatos de longo prazo + as observações **do dia** ainda não consolidadas. Um agente cuja memória seja só observações do dia fica com `memory: 0` depois da virada do dia (UTC), até a consolidação promover aquilo a fato de longo prazo. Se você comparar duas medições em torno da meia-noite UTC, é isso — não é regressão. ### GET Cards de Q&A `GET /v1/agents/{id}/qa` — a FAQ destilada pela Refinery: `{ cards: [ { id, question, answer, provenance: [...], confidence, status, enabled } ], total }`. ### DELETE Descartar um card de Q&A `DELETE /v1/agents/{id}/qa/{cardID}` → `204`. Remove UM card destilado (owner/admin). `cardID` é o slug, ou o `id` que o `GET .../qa` devolveu — os dois funcionam. **Por que você vai precisar disso.** A auto-evolução transforma perguntas mal respondidas em cards novos. Então uma **query de teste** contra `GET .../knowledge/search` pode virar um card dentro de um agente de produção — e passar a ranquear alto para aquela pergunta. É feature, não bug, mas a consequência merece um aviso: **sonde com um agente descartável**, não com o que atende cliente. Se um card sintético apareceu, este endpoint é a saída. O card volta na próxima destilação se o conhecimento de origem ainda sustentar a pergunta — descartar não é uma regra permanente, é uma remoção. | Resposta | Significa | | --- | --- | | `400 QA_CARD_INVALID` | o `cardID` está vazio ou não é um card — tente o `id` que o `GET .../qa` devolveu. Também cobre o separador escapado (`a%2Fb`, `%2e%2e`): o id é **um** segmento e não pode apontar para fora de `faq/` | | `404 page not found` (sem envelope) | você mandou um `/` literal dentro do `cardID`. O roteador não casa a rota, então essa resposta **não** tem `error_code`/`trace_id` — trate um 404 sem envelope como "URL malformada", não como "card inexistente" | | `400 QA_CARD_NOT_ADDRESSABLE` | o agente está em modo legado: tem cards, mas sem endereço individual. **Nenhum id vai funcionar** — não vale procurar outro | | `404 QA_CARD_NOT_FOUND` | esse card não existe (mais) neste agente | ### POST Destilar (Refinery) e evoluir `POST /v1/agents/{id}/knowledge/refine` — inicia um run de **destilação** (BYO key): a Refinery relê o conhecimento e produz/atualiza os cards de Q&A. É assíncrono — resposta com o `id` do run; acompanhe em `GET /v1/agents/{id}/knowledge/refine/{rid}` (status/resultado). `POST /v1/agents/{id}/knowledge/evolve` — força **um ciclo de auto-evolução** da base (reorganiza/depura o conhecimento destilado). ## Memória O agente **aprende com o uso** — separado do conhecimento que você semeia. Dois mecanismos trabalham juntos, e as rotas abaixo (todas sob `/v1/agents/{id}`, JWT/API key) operam os dois: 1. **Memória de conversa (verbatim)** — 100% das trocas guardadas e pesquisáveis, por agente. Com `conversation_recall: true` no agente, trechos de conversas anteriores **do mesmo end-user** voltam ao contexto do turno (escopado por `end_user_external_id`). 2. **Memória curada (L3 → L4)** — a cada turno o agente anota observações L3 (insights do dia); o ciclo **dream** revisa a pilha, remove duplicatas e promove o que presta a memórias L4 de longo prazo (fatos, preferências, sínteses — o perfil do end-user). ### GET Insights do dia (L3) `GET /v1/agents/{id}/daily` — as anotações episódicas do agente, mais novas primeiro: `{ entries: [ { id, day, type, content, promoted, session_id, created_at } ], total }`. `promoted: true` = já revisada pelo dream. ### DELETE Limpar insights `DELETE /v1/agents/{id}/daily` → `200 { deleted }` — esvazia os insights L3 do agente (a memória L4 **não** é tocada). `DELETE .../daily/{entryID}` apaga um insight só → `200 { deleted }`. ### GET Memórias de longo prazo (L4) `GET /v1/agents/{id}/memories` — as memórias curadas do agente: `{ memories: [...], total }` (vazio se a memória não estiver configurada). ### DELETE Esquecer uma memória `DELETE /v1/agents/{id}/memories/{docID}` → `200 { deleted }` — esquece uma memória L4 (soft-delete, escopado a conta+agente). ### GET Memória de conversa (verbatim) `GET /v1/agents/{id}/conversation-memory` — todas as trocas guardadas: `{ exchanges: [...], total }`. ### POST Sonhar (dream) `POST /v1/agents/{id}/dream` — roda o ciclo completo de 3 fases: **Light** (dedup dos insights L3) → **Deep** (curadoria dos sobreviventes para L4) → **REM** (consolidação da coleção L4 da conta). Resposta `200` com os contadores (`considered`, `duplicates`, `promoted`, …). Erro: `503 MEMORY_NOT_CONFIGURED`. `POST /v1/agents/{id}/memory/consolidate` — limpeza **one-shot**: reprocessa TODA a pilha L3 (inclusive já promovida) e cura para L4. Use uma vez para migrar um agente que acumulou insights velhos/repetidos. ### POST Sonhar com progresso (SSE) `POST /v1/agents/{id}/dream/stream?mode=consolidate|cycle` — a variante SSE do dream: emite um evento `progress` por fase (`start` → `progress`* → `done`/`error`), para uma UI acompanhar ao vivo. `mode=consolidate` (default) reprocessa a pilha inteira; `mode=cycle` processa só o L3 não promovido (a passada de rotina). Roda fora do timeout de 30s — um consolidate grande faz dezenas de chamadas de LLM e leva minutos. ## Ferramentas e skills ### GET Catálogo de ferramentas `GET /v1/agent-tools` — as ferramentas selecionáveis (embutidas ∪ custom): `{ tools: [ { name, label, description } ] }`. ### GET Catálogo de modelos e provedores `GET /v1/llm-catalog` (alias: `GET /v1/providers`) — o catálogo de modelos, agrupado por provedor. Use-o para popular a sua UI com provedores, modelos, janelas de contexto e efforts REAIS em vez de descobrir por tentativa: ```json { "providers": [ { "provider": "kimi", "label": "Kimi", "available": true, "models": [ { "id": "k3", "provider": "kimi", "label": "K3", "context_window": 1048576, "max_output_tokens": null, "input_cost_per_1m": 0, "output_cost_per_1m": 0, "supports_tools": true, "supports_vision": true, "supports_thinking": true, "thinking_efforts": ["low", "high", "max"], "default_thinking_effort": "high" } ] } ] } ``` Semântica dos campos novos (contrato: **desconhecido = `null`, nunca um chute**): - `available` — se o runtime tem credencial configurada para o provedor AGORA. `null` = indeterminado (não conseguimos consultar o engine no momento); nunca um `false` fabricado. Não é checagem de saldo — crédito esgotado ainda aparece como erro no turno. - `max_output_tokens` — teto de saída documentado pelo provedor; `null` quando o provedor não publica. - `supports_thinking` — o runtime expõe o raciocínio deste modelo separado do texto final (`thinking` nos eventos/respostas). - `thinking_efforts` + `default_thinking_effort` — os valores de `reasoning_effort` que efetivamente mudam o comportamento deste modelo, e o aplicado quando o agente não define nenhum. `null` = o modelo raciocina mas não há botão de effort plugado. Entre os provedores estão **Kimi** (`k3` com 1M de contexto, `k3-256k`, `kimi-for-coding`, `kimi-for-coding-highspeed` — todos thinking-only, efforts `low|high|max`) e **MiniMax** (`MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2` + variantes `-highspeed`). Em todos os provedores o `content` das respostas vem **saneado**: o raciocínio nunca chega inline no texto (nada de `` cru) — ele vem no campo/evento `thinking`. ### POST Criar ferramenta REST `POST /v1/tools` (owner/admin) — corpo `customToolRequest`: | Campo | Tipo | Regra | | --- | --- | --- | | `name` | string | **obrigatório**, regex `^http_[a-z0-9_]{1,120}$` | | `url` | string | **obrigatório**, validado contra SSRF | | `method` | string | GET/POST/… (upper-case) | | `display_name`, `description` | string | — | | `header_template`, `query_template` | objeto | mapa string→string; use `${secret:NOME}` para segredos do cofre | | `body_template` | string | — | | `input_schema` | JSON | schema dos argumentos | | `timeout_ms` | int | default 15000 | | `max_response_kb` | int | default 256 | | `enabled` | bool? | default `true` | | `tenant_external_id` | string? | escopa a ferramenta a UM cliente final; ausente = visível a toda a conta | Resposta `201`: `customToolResponse` (ecoa `tenant_id`). Leituras (`GET /v1/tools`, `/v1/tools/{id}`) são all-auth. Erros: `TOOL_NAME_INVALID`, `TOOL_URL_INVALID`, `SECRET_NOT_FOUND`. **Checklist: do zero à ferramenta funcionando no turno.** Criar a ferramenta NÃO basta — para o modelo enxergá-la e o segredo resolver, são quatro passos: 1. **Crie a ferramenta** (`POST /v1/tools`). O nome segue `^http_[a-z0-9_]{1,120}$`. 2. **Crie o segredo com um nome do alfabeto do resolvedor** (`PUT /v1/tool-secrets`): `NOME` casa `[A-Za-z0-9_-]{1,128}` (letras, dígitos, `_` e `-`). Fora disso o upsert devolve `400`. (Antes de 2026-07-25 o cofre aceitava qualquer nome e um nome fora do alfabeto fazia o placeholder `${secret:...}` viajar LITERAL na requisição: o endpoint respondia 401 como se a credencial estivesse errada, e nada indicava que o problema era a RESOLUÇÃO na origem, não o destino.) 3. **Anexe a ferramenta ao agente** (`PATCH /v1/agents/{id}` com `tools` = a união do que já estava + a nova). O modelo só vê o que está no `tools[]` do agente: criar a ferramenta NÃO a anexa, e um agente criado ANTES dela não a recebe automaticamente — sem o patch, ele nunca a terá no catálogo do turno. 4. **Teste antes de depender** (`POST /v1/tools/{id}/test` com args de exemplo): executa a chamada real (guardada contra SSRF) e mostra o que o endpoint recebeu. Um segredo que não resolve aparece aqui como 401 do destino — em segundos, não no meio de um run de minutos. **Escopo por tenant (opcional).** `POST /v1/tools` e `PUT /v1/tool-secrets` (`{ name, value, tenant_external_id? }`) aceitam o eixo de tenant. Sem ele o recurso é da conta inteira — o comportamento de sempre. Com ele valem duas garantias no runtime: 1. Um agente do tenant A monta **apenas** ferramentas do tenant A ou da conta inteira. A ferramenta de outro tenant é ignorada **mesmo se o `tools:[...]` do agente citar o nome** (o vínculo é por nome, então sem isso o nome seria a permissão). 2. Um `${secret:NOME}` dentro de uma ferramenta do tenant A resolve **apenas** segredos do tenant A ou da conta inteira. O segredo de outro tenant segue o mesmo caminho de um segredo inexistente (substituição vazia), então o valor nunca chega ao endpoint. O **nome continua único por conta** — tenant é eixo de visibilidade, não namespace (o modelo escolhe a ferramenta pelo nome dentro do turno, então dois nomes iguais no mesmo catálogo seriam ambíguos). Use o seu próprio prefixo para segregar. O **escopo é imutável**: um `PUT /v1/tool-secrets` que mudaria o tenant de um segredo existente devolve `409 SECRET_SCOPE_IMMUTABLE` — remova e recrie, para que uma rotação distraída nunca alargue silenciosamente um segredo de tenant para a conta toda. ### Gerenciar ferramentas | Rota | Ação | | --- | --- | | `GET /v1/tools` · `GET /v1/tools/{id}` | Listar / detalhar (all-auth) | | `PATCH /v1/tools/{id}` | Update esparso (owner/admin) | | `DELETE /v1/tools/{id}` | Remover (owner/admin) | | `POST /v1/tools/{id}/test` | Dry-run da ferramenta salva, com args de exemplo | | `POST /v1/tools/draft` | **Tool-builder com IA**: descreva a integração em texto, receba um rascunho de ferramenta | | `POST /v1/tools/test-draft` | Dry-run de um rascunho **sem salvar** (corpo = a ferramenta) | | `GET /v1/tools/{id}/versions` · `POST /v1/tools/{id}/versions/{vid}/restore` | Histórico de snapshots + restauração | Segredos do cofre (os `${secret:NOME}` dos templates): `GET /v1/tool-secrets` lista (`name` + `last4`, **nunca** o valor), `PUT /v1/tool-secrets` cria/atualiza (`{ name, value, tenant_external_id? }`), `DELETE /v1/tool-secrets/{name}` remove. **Regras do `${secret:NOME}`:** o `NOME` casa `[A-Za-z0-9_-]{1,128}` — fora disso o `PUT` devolve `400` (a escrita e a leitura usam o mesmo alfabeto; um nome que o resolvedor não enxerga falha na hora de salvar, nunca no meio de um dispatch). A resolução é **fail-closed**: segredo ausente, indecifrável ou de outro tenant resolve para string vazia — nunca o placeholder literal — então o endpoint recebe uma credencial vazia. Se o seu endpoint responde 401 no dispatch, verifique nesta ordem: (a) o `NOME` no template bate exatamente com o nome no cofre; (b) o segredo está no mesmo escopo de tenant da ferramenta (ou é da conta inteira); (c) o `last4` em `GET /v1/tool-secrets` é o valor que você espera. ### Cabeçalhos confiáveis que a plataforma envia à sua ferramenta Cada dispatch carrega o escopo do run em cabeçalhos que a engine assina do lado dela — eles vêm do run request, **nunca** dos argumentos que o modelo escreveu, então sua ferramenta pode confiar neles para autorizar e para deduplicar: | Header | Sempre? | Significado | | --- | --- | --- | | `X-Tenant-ID` | sim | conta (organização) dona do run | | `X-Project-ID` | sim | projeto do run | | `X-Agent-ID` | sim | agente que chamou a ferramenta | | `X-Session-ID` | sim | conversa — estável entre turnos | | `X-Stream-ID` | sim | ESTE run — muda a cada turno | | `X-Tool-Call-ID` | em run de agente | ESTA invocação da ferramenta, dentro do turno | | `X-Project-Tenant-ID` | quando há | cliente final do projeto (eixo de tenant) | | `X-Runtime-Token-ID` | quando há | token `prt_` que originou o run | | `X-End-User-ID` / `X-End-User-External-ID` | quando há | usuário final vinculado ao run | > `X-Tool-Call-ID` existe quando um **agente** chamou a ferramenta (o id vem da > invocação do provider). O dispatch one-shot de `POST /v1/tools/{id}/test` não tem > invocação de provider nenhuma, então esse header **não vem** no dry-run — trate-o como > presente-em-run, ausente-em-teste, nunca como obrigatório. **Para idempotência use `X-Tool-Call-ID`, não o corpo.** Ele identifica uma invocação única e é reenviado idêntico se o mesmo dispatch for repetido, enquanto `X-Session-ID` cobre a conversa inteira e `X-Stream-ID` o turno inteiro (vários chamados de ferramenta compartilham ambos). Qualquer id vindo no corpo é escrito pelo modelo e pode repetir. **`timeout_ms` é o prazo que vale.** O deadline da sua ferramenta é o `timeout_ms` que você configurou (default 15000), limitado apenas pelo tempo restante do run — 600s no perfil padrão, 1800s em `run_class:"coder"`. Não existe mais um teto de 60s do lado do cliente HTTP: uma ferramenta que declara 180000 recebe de fato os 180s, desde que o run ainda tenha essa folga. ### GET Skills `GET /v1/skills` — `{ skills: [ { id, name, description, body, scope, read_only, … } ], total }`. `POST /v1/skills` cria (`{ name, description?, body }`; `SKILL_NAME_REQUIRED`, `SKILL_BODY_REQUIRED`). Skills globais da plataforma são read-only (`SKILL_READONLY`). Uma skill é um pacote de instruções reutilizável (nome + descrição + corpo). No prompt, o agente vê um catálogo progressivo: só nome+descrição entram de cara; quando a tarefa casa com a descrição, ele chama a tool `load_skill(name)` e recebe o corpo inteiro sob demanda. ### Gerenciar skills | Rota | Ação | | --- | --- | | `PATCH /v1/skills/{id}` | Update esparso (`name`/`description`/`body`) | | `DELETE /v1/skills/{id}` | Remover → `204` | | `POST /v1/skills/{id}/preview` | Corpo `{ system_prompt? }` → `{ header, body, injected, assembled }`: o que entra no prompt de cara (`header`) e o que `load_skill` devolve (`body`). Determinístico, sem LLM | | `GET /v1/skills/{id}/versions` · `POST /v1/skills/{id}/versions/{vid}/restore` | Histórico + restauração | ### Skills de um agente (plug/unplug) | Rota | Ação | | --- | --- | | `GET /v1/agents/{id}/skills` | `{ bindings: [ { skill_id, name, description, scope, read_only, enabled, priority } ], total }` | | `PUT /v1/agents/{id}/skills` | Corpo `{ bindings: [ { skill_id, enabled?, priority? } ] }` — define **atomicamente** o conjunto plugado (plug/unplug/reordenar/toggle) | | `PATCH /v1/agents/{id}/skills/{skillId}` | Corpo `{ enabled?, priority? }` — liga/desliga ou reordena UMA skill plugada (`404 SKILL_NOT_ACTIVE` se não está plugada) | ## Document AI Document AI transforma PDF/JPEG/PNG/WebP privados em dados estruturados. Cada job fixa uma versão imutável de JSON Schema e de política de qualidade, processa o documento por página e expõe o resultado para polling e revisão. Confiança é metadado do modelo; aceitação é decisão de política — a API não promete precisão perfeita. Todas as rotas usam `/v1/document-ai` e exigem JWT ou API key `ctc_`. O tenant vem da empresa autenticada: - omita `tenant_external_id` para usar o tenant padrão; - envie o identificador opaco do tenant na query, no JSON ou no multipart para operar outro tenant da mesma empresa; - nunca envie `company_id` ou o id interno do tenant. Leituras básicas aceitam qualquer papel autenticado. Resultado, preview, revisão, segredo de callback e toda mutação exigem `owner` ou `admin`. ### GET Rotas e RBAC O inventário abaixo contém **24 combinações método+rota em 18 paths públicos únicos**. | Método e rota | Papel | Resposta/efeito | | --- | --- | --- | | `GET /overview` | autenticado | métricas agregadas | | `GET /jobs` · `GET /jobs/{jobId}` | autenticado | lista/detalhe do job | | `POST /jobs` | owner/admin | upload multipart idempotente | | `POST /jobs/{jobId}/cancel` | owner/admin | cancela | | `POST /jobs/{jobId}/retry` | owner/admin | recoloca **todas** as páginas falhas e zera o orçamento de tentativas | | `DELETE /jobs/{jobId}` | owner/admin | apaga objetos privados e o agregado | | `GET /jobs/{jobId}/preview` | owner/admin | URL privada temporária | | `GET /jobs/{jobId}/result` | owner/admin | resultado estruturado e auditoria | | `GET /jobs/{jobId}/deliveries` | owner/admin | ledger content-free de callbacks/retries | | `GET /schemas` · `GET /schemas/{schemaId}` | autenticado | lista/detalhe da versão atual | | `POST /schemas` · `POST /schemas/{schemaId}/versions` | owner/admin | cria/append de versão | | `DELETE /schemas/{schemaId}` | owner/admin | arquiva sem apagar versões | | `GET /synthetic-template` | autenticado | exemplo sem dados reais | | `GET /policies` · `GET /policies/{policyId}` | autenticado | lista/detalhe da versão atual | | `POST /policies` · `POST /policies/{policyId}/versions` | owner/admin | cria/append de versão | | `DELETE /policies/{policyId}` | owner/admin | arquiva sem apagar versões | | `GET /reviews` | owner/admin | fila e histórico de revisão | | `POST /reviews/{reviewId}/decisions` | owner/admin | decisão humana append-only | | `GET /callback-secret` | owner/admin | segredo do tenant para verificar callbacks | `reviewId` é atualmente o `job_id`. As quatro listas retornam arrays JSON nus, sem paginação. Filtros disponíveis: - `/jobs?status=NEEDS_REVIEW&external_reference=...`; - `/schemas?include_archived=true`; - `/policies?include_archived=true`; - qualquer GET aceita `tenant_external_id=...`. Os quatro POSTs de configuração (`/schemas`, `/schemas/{schemaId}/versions`, `/policies` e `/policies/{policyId}/versions`) exigem `Idempotency-Key`: primeira escrita `201`, replay idêntico `200` e payload diferente com a mesma chave `409 DOCUMENT_AI_IDEMPOTENCY_CONFLICT`. ### POST Criar e versionar schema O dialecto é um subconjunto autocontido de JSON Schema draft 2020-12. A raiz precisa ser `type:"object"` com `additionalProperties:false` e propriedades escalares. `$ref`, objetos/arrays aninhados e keywords fora do subconjunto são recusados. ```bash curl -X POST https://agents-api.catcher.one/v1/document-ai/schemas \ -H "X-API-Key: $CATCHER_API_KEY" \ -H "Idempotency-Key: schema-ficha-clinica-v1" \ -H "Content-Type: application/json" \ -d '{ "tenant_external_id": "tenant_opaco_opcional", "name": "ficha-clinica", "description": "Ficha clínica estruturada", "json_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, "properties": { "patient_name": { "type": ["string", "null"], "description": "Nome visível no documento", "x-risk": "SENSITIVE" }, "cpf": { "type": ["string", "null"], "description": "CPF visível no documento", "x-risk": "CRITICAL", "x-value-type": "cpf" }, "document_date": { "type": ["string", "null"], "format": "date", "x-risk": "NORMAL" } }, "required": ["patient_name", "cpf", "document_date"] } }' ``` Cada propriedade aceita: | Campo | Valores | | --- | --- | | `type` | `string`, `number`, `integer` ou `boolean`, com `null` opcional | | `description` | texto opcional para orientar a extração | | `x-risk` | `NORMAL` (default), `SENSITIVE`, `CRITICAL` | | `x-value-type` | `text`, `date`, `cpf`, `cnpj`, `phone`, `money`, `boolean` | | `format` | `date` seleciona normalização de data | Também é possível enviar `field_policies: {"cpf":{"risk":"CRITICAL"}}`; o servidor incorpora o risco no schema canônico. **Normalização de data — janela de ano aceita.** Um campo `date` só produz valor normalizado quando o ano cai entre **1900 e 2100**. Fora disso o campo é tratado como **ausente** (`normalized_value: null`) e vai para revisão, em vez de virar valor extraído. O motivo é prático: um modelo lendo `14/03/2026` sob decodificação restrita pode emitir `1403-03-14` — uma data sintaticamente válida que **não estava no documento**. Aceitá-la transformaria uma falha de leitura em dado. A janela é larga de propósito (uma certidão antiga e um contrato de longo prazo passam); ela só recusa anos que nenhum documento de negócio carrega. Resposta `201`: ```json { "id": "uuid-do-schema", "name": "ficha-clinica", "version": 1, "version_id": "uuid-da-versao", "status": "ACTIVE", "description": "Ficha clínica estruturada", "fields": [ { "name": "cpf", "description": "CPF visível no documento", "required": true, "risk": "CRITICAL", "value_type": "cpf", "json_types": ["null", "string"], "format": "" } ], "json_schema": {}, "checksum": "sha256-do-schema-canonico", "created_at": "2026-07-27T20:30:00Z", "idempotent_replay": false } ``` Para evoluir, envie o mesmo body a `POST /schemas/{schemaId}/versions`, com uma chave de idempotência própria. `name` é necessário na criação; a versão identifica o recurso pelo `schemaId`. O response traz novo `version` e `version_id`; a primeira execução retorna `201`, e o replay idêntico retorna `200` com `idempotent_replay:true`. Jobs existentes continuam presos à versão antiga. `DELETE /schemas/{schemaId}` responde `204` e arquiva. Limites do schema: 256 KiB, profundidade JSON 32 e nome de propriedade até 256 caracteres. ### POST Criar e versionar política ```bash curl -X POST https://agents-api.catcher.one/v1/document-ai/policies \ -H "X-API-Key: $CATCHER_API_KEY" \ -H "Idempotency-Key: policy-reliable-default-v1" \ -H "Content-Type: application/json" \ -d '{ "name": "reliable-default", "profile": "RELIABLE", "primary_model": "x-ai/grok-4.5", "secondary_model": "qwen/qwen3.7-plus", "confidence_threshold": 0.95, "prompt_version": "document-extract-v1", "prompt_template": "Extract only values visibly present in this document page.", "routing_version": "openrouter-structured-v1", "max_tokens": 4096, "timeout_ms": 120000, "model_allowlist_version": "document-models-v1", "allowed_models": [ "qwen/qwen3.7-plus", "x-ai/grok-4.5" ] }' ``` Perfis: | Perfil | Regra de aceitação | | --- | --- | | `DRAFT` | um modelo; tudo permanece não verificado e vai para revisão | | `RELIABLE` | dois modelos diferentes; autoaceite só quando ambos leram, atingiram o threshold, não emitiram warnings e produziram o mesmo valor normalizado | | `CRITICAL` | dois modelos + adjudicador; campo CRITICAL, warning, baixa confiança ou divergência remanescente exige humano | `confidence_threshold` defaulta para `0.95`; `prompt_version` para `document-extract-v1`; `routing_version` para `openrouter-structured-v1`; e `model_allowlist_version` para `document-models-v1`. `prompt_template` aceita 16..8.192 bytes; `max_tokens`, 256..16.384 (default 4.096); e `timeout_ms`, 1.000..120.000 (default 120.000). Sem `allowed_models`, a lista default contém `google/gemini-3.1-pro-preview`, `qwen/qwen3.7-plus` e `x-ai/grok-4.5`. Quando enviada, todos os modelos escolhidos precisam pertencer a essa lista versionada. O texto normalizado do prompt e a lista de modelos ficam preservados na versão imutável; o view da API não os devolve e expõe `prompt_checksum` e `model_allowlist_version` para auditoria. ```json { "id": "uuid-da-politica", "name": "reliable-default", "version": 1, "version_id": "uuid-da-versao", "profile": "RELIABLE", "models": ["x-ai/grok-4.5", "qwen/qwen3.7-plus"], "primary_model": "x-ai/grok-4.5", "secondary_model": "qwen/qwen3.7-plus", "adjudicator_model": "", "confidence_threshold": 0.95, "prompt_version": "document-extract-v1", "prompt_checksum": "sha256-do-prompt-normalizado", "routing_version": "openrouter-structured-v1", "max_tokens": 4096, "timeout_ms": 120000, "model_allowlist_version": "document-models-v1", "requires_human_review": false, "status": "ACTIVE", "created_at": "2026-07-27T20:30:00Z", "idempotent_replay": false } ``` `POST /policies/{policyId}/versions`, com chave própria, acrescenta versão imutável. Criar/versionar retorna `201` na primeira execução e `200` no replay idêntico. `DELETE /policies/{policyId}` arquiva e responde `204`. ### POST Processar documento O upload usa `multipart/form-data` e exige `Idempotency-Key`: ```bash curl -X POST https://agents-api.catcher.one/v1/document-ai/jobs \ -H "X-API-Key: $CATCHER_API_KEY" \ -H "Idempotency-Key: adria-documento-550e8400" \ -F "file=@documento.pdf;type=application/pdf" \ -F "tenant_external_id=tenant_opaco_opcional" \ -F "schema_version_id=uuid-da-versao-do-schema" \ -F "policy_version_id=uuid-da-versao-da-politica" \ -F "external_reference=adria:clinic:123:document:456" \ -F "callback_url=https://integrador.example.com/catcher/document-ai" ``` `file`, `schema_version_id` e `policy_version_id` são obrigatórios. Use os `version_id` retornados pelas APIs de configuração, não o número `version`. `external_reference` é correlação pesquisável; não é chave de idempotência. `callback_url` é opcional e precisa ser HTTPS público. Limites: 20 MiB por arquivo; PDF até 50 páginas; JPEG/PNG/WebP até 12.000 px por dimensão e 16 milhões de pixels. MIME declarado e detectado precisam concordar. ZIP, arquivo truncado/malformado e callback para rede privada falham antes de enfileirar. A inspeção ocorre num processo killable com limites de CPU, RSS, address space, wall clock e concorrência global/tenant; orientação, nitidez e ilegibilidade determinísticas ficam no estado da página. Primeira resposta `201`; replay idêntico `200`: ```json { "id": "uuid-do-job", "status": "QUEUED", "state": "QUEUED", "filename": "private-document", "schema": { "id": "uuid-do-schema", "version": 2, "version_id": "uuid-da-versao-do-schema", "checksum": "sha256-do-schema" }, "policy": { "id": "uuid-da-politica", "version": 3, "version_id": "uuid-da-versao-da-politica", "prompt_version": "document-extract-v1", "routing_version": "openrouter-structured-v1", "prompt_checksum": "sha256-do-prompt-normalizado", "max_tokens": 4096, "timeout_ms": 120000 }, "quality_profile": "RELIABLE", "external_reference": "adria:clinic:123:document:456", "page_count": 2, "processed_pages": 0, "failed_pages": 0, "progress_percent": 0, "last_error_code": "", "created_at": "2026-07-27T20:31:00Z", "updated_at": "2026-07-27T20:31:00Z", "retention_expires_at": "2026-08-26T20:31:00Z", "links": { "self": "/v1/document-ai/jobs/uuid-do-job", "result": "/v1/document-ai/jobs/uuid-do-job/result" }, "idempotent_replay": false } ``` O filename real e a chave do objeto nunca são expostos. ### GET Polling, estados e resultado Estados: `UPLOADED`, `QUEUED`, `PROCESSING`, `NEEDS_REVIEW`, `COMPLETED`, `FAILED`, `CANCELED`. A criação normalmente já é observada como `QUEUED`. `CANCELED`, `COMPLETED` e `FAILED` aceitam hard delete; um `FAILED` sempre pode voltar a `QUEUED` via `POST /jobs/{jobId}/retry`. ```bash curl -H "X-API-Key: $CATCHER_API_KEY" \ "https://agents-api.catcher.one/v1/document-ai/jobs/$JOB_ID" curl -H "X-API-Key: $CATCHER_API_KEY" \ "https://agents-api.catcher.one/v1/document-ai/jobs/$JOB_ID/result" ``` Resultado: ```json { "job_id": "uuid-do-job", "state": "NEEDS_REVIEW", "quality_profile": "RELIABLE", "fields": {}, "pages": [ { "page_number": 1, "status": "DONE", "attempt_count": 1, "safe_to_retry": false, "source_orientation": 1, "sharpness": 184, "unreadable": false, "preprocess_warnings": [], "fields": { "cpf": { "raw_value": "123.456.789-09", "normalized_value": "12345678909", "decision": "DIVERGENT", "confidence": 0.81, "needs_review": true, "reason": "MODEL_DISAGREEMENT", "warnings": ["MODEL_DISAGREEMENT"] } } } ], "summary": { "total_fields": 3, "review_fields": 1, "candidate_count": 6, "cost_usd": "0.017534", "processing_ms": 49140, "models": ["qwen/qwen3.7-plus", "x-ai/grok-4.5"], "providers": ["openrouter"] }, "audit_contract": { "schema_version_id": "uuid-da-versao-do-schema", "policy_version_id": "uuid-da-versao-da-politica", "prompt_version": "document-extract-v1", "routing_version": "openrouter-structured-v1", "prompt_checksum": "sha256-do-prompt-normalizado", "max_tokens": 4096, "timeout_ms": 120000 } } ``` Em documento de uma página, `fields` repete a única página. Em multipágina, consuma `pages[].fields`. `cost_usd` aqui é string decimal de seis casas. ### `preprocess_warnings` — o que a página tinha ANTES do modelo Cada `pages[]` traz `sharpness`, `unreadable`, `preprocess_warnings` e a geometria do render (`render_width`, `render_height`, `render_dpi`) — sinais medidos no preparo, antes de qualquer inferência. Servem para você explicar ao seu usuário por que um documento voltou vazio, sem culpar o modelo. **Esses sinais são medidos sobre exatamente a imagem que o modelo lê**, não sobre um artefato paralelo. Um `sharpness` alto significa que a página que foi ao modelo está nítida — nada mais é medido em outro lugar. `render_dpi` é a resolução efetiva da página em dpi (`0` quando a geometria física é desconhecida — uma imagem enviada direto não tem tamanho físico). É o sinal para recusar um documento **antes** de gastar a extração: abaixo de ~200 dpi, caligrafia começa a ficar ambígua. ### PDF e imagem são o mesmo caminho Você pode enviar **PDF ou imagem** para o mesmo documento digitalizado e esperar o mesmo resultado. Toda página de PDF é rasterizada por nós **na resolução que o documento carrega** (limitada a 12.000 px por dimensão e 16 Mpx) e é esse render que vai ao modelo — nunca o PDF bruto. Não há parser de PDF de terceiros no meio, e portanto não há diferença de leitura, de custo ou de latência entre os dois transportes. Você **não precisa** converter PDF para imagem antes de enviar. Se já tem a imagem original, mandá-la direto também é ótimo — é só um passo a menos. | Warning | Significado | O que fazer | |---|---|---| | `blank_page` | a página é uniforme: **não há nada nela**. Vale para folha em branco e para o verso não digitalizado | não peça um scan melhor — peça a página certa | | `low_sharpness` | a página tem conteúdo, mas está borrada/fora de foco | peça uma nova foto, com mais luz e sem tremer | | `dimensions_too_small` | a página é pequena demais para leitura confiável | envie em resolução maior | `blank_page` e `low_sharpness` costumavam vir juntos como um único `low_sharpness`, o que induzia a pedir "uma foto melhor" de uma folha que simplesmente estava vazia. Agora são distintos — e um `blank_page` aparece **junto** com `low_sharpness`, porque uma página vazia também não tem nitidez. Imagens com **canal alfa** (transparência) são achatadas sobre **branco** antes de ir ao modelo. Isso é deliberado: sem o achatamento, o transparente vira preto no provedor e o modelo descreve corretamente "imagem totalmente preta" — devolvendo todos os campos vazios com alta confiança e nenhuma explicação. ### `last_error_code` — por que o job falhou Quando um job termina em `FAILED`, `last_error_code` (no job e em cada `pages[]`) diz **o que aconteceu** — é um código operacional, não um `error_code` HTTP. Ele existe para você distinguir "meu documento/config está errado" de "o serviço falhou", sem abrir chamado. | `last_error_code` | Significado | De quem é a ação | |---|---|---| | `PROVIDER_ACCOUNT_UNAVAILABLE` | a conta de provedor de modelos da plataforma não pôde atender; o provedor recusou **antes** de qualquer inferência (por isso `cost_usd` fica `0`) | **nossa** — não reenvie, não é o seu documento | | `PROVIDER_MODEL_NOT_FOUND` | o slug de modelo fixado na sua policy não existe no provedor | sua — corrija a policy | | `PROVIDER_CONTEXT_LENGTH` | a página excede a janela de contexto do modelo | sua — outro modelo ou documento menor | | `PROVIDER_INVALID_REQUEST` | o provedor rejeitou a requisição | abra chamado com o `job_id` | | `PROVIDER_SCHEMA_VIOLATION` | a saída do modelo não satisfez o seu JSON Schema | sua — revise o schema | | `PROVIDER_MODEL_MISMATCH` | o provedor respondeu com um modelo diferente do pedido | nossa | | `PROVIDER_RATE_LIMITED` | throttling do provedor | transitório — chame `/retry` | | `PROVIDER_TIMEOUT` | a tentativa estourou o tempo da policy | transitório — chame `/retry` | | `PROVIDER_UNAVAILABLE` | indisponibilidade genérica, sem classificação mais específica | transitório — chame `/retry` | | `MODEL_ATTEMPT_LIMIT` | o orçamento durável de tentativas do job/tenant foi atingido | sua | | `PAGE_ATTEMPTS_EXHAUSTED` | a página foi reivindicada o número máximo de vezes sem concluir. É o teto que garante que **todo job chega a um estado terminal**: em vez de ficar `PROCESSING` indefinidamente, o job vai a `FAILED` com este código | **nossa** — abra chamado com o `job_id` | | `PRIVATE_OBJECT_INVALID` | a página armazenada não pôde ser lida | nossa | | `SCHEMA_VERSION_UNAVAILABLE` · `SCHEMA_VERSION_INVALID` | a versão de schema fixada no job sumiu ou não parseia | sua | | `POLICY_VERSION_UNAVAILABLE` | a versão de policy fixada no job sumiu | sua | Regras de consumo: - **Todo job chega a um estado terminal.** `COMPLETED`, `NEEDS_REVIEW`, `FAILED` ou `CANCELED` — nunca `PROCESSING` para sempre. Uma página tem um teto de reivindicações; esgotado, o job vai a `FAILED` com `PAGE_ATTEMPTS_EXHAUSTED` em vez de repetir indefinidamente. Você pode, portanto, tratar "sem estado terminal depois de muito tempo" como incidente nosso, e não como lentidão. - **Trate a lista como aberta.** Códigos novos podem aparecer; qualquer valor desconhecido deve cair no seu caminho genérico de falha, nunca quebrar o parser. - **Nunca deduza retryability do texto** — use a operação `/retry` e o campo `safe_to_retry` da página. - **`/retry` é sempre uma saída válida.** Ele recoloca **todas** as páginas falhas do job — inclusive as com `safe_to_retry: false` — e **zera o orçamento de tentativas** da página. `safe_to_retry` diz se a NOSSA máquina pode retomar sozinha, e nunca impede a sua decisão explícita de tentar de novo. Tentativas de modelo já concluídas são **reaproveitadas**, então um `/retry` não paga duas vezes pelo que já foi extraído — prefira `/retry` a reenviar o documento. - Os códigos `PROVIDER_*` derivam do status HTTP do provedor (e, só em 400/422, de uma checagem de palavra-chave no erro dele para separar estouro de contexto de requisição malformada); nunca carregam bytes do documento, prompt, schema, prosa do provedor ou valor extraído. - `PROVIDER_ACCOUNT_UNAVAILABLE` **não** distingue "sem saldo" de "credencial revogada", e isso é deliberado: a credencial de extração é da plataforma, e o código é visível a qualquer conta autenticada. Para você a decisão é a mesma — é nosso, pare de reenviar, o documento não tem culpa. - **Pode ser específico por formato.** `PROVIDER_ACCOUNT_UNAVAILABLE` num PDF **não** significa que a extração inteira caiu: página PDF e página de imagem seguem caminhos distintos no provedor, e um pode estar indisponível enquanto o outro atende. Se você tem o documento em imagem (JPEG/PNG/WebP), reenviar nesse formato é um caminho legítimo — não é gambiarra, é outro transporte. ### Saber ANTES de enviar: `last_failure` no overview `GET /v1/document-ai/overview` traz a falha terminal mais recente **do seu tenant**, para você avisar seu operador antes de enfileirar outro documento: ```json { "total_documents": 13, "failed_documents": 1, "last_failure": { "code": "PROVIDER_ACCOUNT_UNAVAILABLE", "at": "2026-07-28T12:00:00Z" } } ``` `last_failure` é `null` quando o tenant nunca falhou — o campo está sempre presente, é seguro ler sem checar. Regra de consumo sugerida: se `code` é `PROVIDER_ACCOUNT_UNAVAILABLE` e `at` é recente, avise o operador em vez de aceitar o upload; para os demais códigos, siga normalmente (a falha era daquele documento, não do serviço). **Não expomos saldo do provedor nem um "health" global**, por decisão: o saldo é estado financeiro nosso e não é seu para ler, e um health global honesto ou custaria uma chamada paga a cada consulta ou serviria cache — que mente exatamente no minuto em que importa. `last_failure` é observação, não previsão. Preview autorizado: ```http GET /v1/document-ai/jobs/{jobId}/preview GET /v1/document-ai/jobs/{jobId}/preview?page=2 ``` Resposta: `{ "url":"https://...", "expires_in_seconds":300, "content_type":"image/jpeg" }`. O `content_type` **acompanha a origem**: uma digitalização (JPEG) volta como `image/jpeg`; um PNG/WebP volta como `image/png`. Página de PDF volta como `image/jpeg` (o render). **`content_type` diz o que a URL entrega — e nem sempre é imagem.** Para um PDF, a página armazenada continua sendo um **PDF de uma página** (o split preserva o original), mas o preview entrega o **render** dessa página (`image/jpeg`). Um cliente que assume "página = imagem" para TODO artefato acaba exibindo um arquivo que o navegador recusa a mostrar embutido — em `