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.
GETDetalhe do agente#
GET /v1/agents/{id} — resposta 200:
{
"id": "…", "company_id": 30, "tenant_id": 0,
"name": "Atendimento", "description": "…", "system_prompt": "…",
"model": "gpt-5.6-luna", "provider": "openai", "fallback": null,
"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, "brain_rerank": true,
"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. provider também é
derivado de model em toda leitura (nunca um campo guardado ao lado), e
fallback é null quando o agente não tem cérebro reserva.
GETListar 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.
POSTCriar 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 codex/gpt-5.6-luna (assinatura, custo reportado 0, effort até max). Gerações OpenAI abaixo de 5.6 são recusadas com 400 MODEL_RETIRED. O id cru gpt-5.6-luna roda o mesmo modelo pela API key da OpenAI — ver catálogo |
provider |
string? | o provedor do cérebro, explícito: anthropic (alias claude), openai, codex, kimi, minimax, openrouter, google, xai, deepseek, groq, mistral, zai, cerebras, alibaba, opencodezen, opencodezen-go, ollama. Omitir mantém a inferência pelo id. O que se guarda é UM id canônico: {provider:"claude", model:"claude-opus-5"} → claude-opus-5; {provider:"openrouter", model:"anthropic/claude-opus-5"} → openrouter/anthropic/claude-opus-5. Desconhecido → 400 UNKNOWN_PROVIDER; contradizendo o namespace do próprio id → 400 MODEL_PROVIDER_MISMATCH (gateways openrouter/opencodezen são exceção — o vendor/ dentro do id é dado deles). k3 é exclusivo do Kimi: Codex/ChatGPT + k3 devolve 422 UNSUPPORTED_MODEL_AUTH_COMBINATION antes de persistir ou despachar; sem fallback ou remapeamento silencioso. |
fallback |
object? | {provider?, model, reasoning_effort?} ou null. O cérebro reserva que o engine tenta quando o primário recusa por credencial ou cota — nunca por erro do prompt (aí o reserva falharia igual). Roda com o reasoning_effort dele. Igual ao primário → 400 INVALID_FALLBACK_MODEL. Ver Cérebro reserva abaixo |
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. |
brain_rerank |
bool? | null → true. Liga o re-rank LLM do cloudwiki na brain_search (qualidade: num A/B real, hit@5 71%→100%). false é o caminho rápido: pula o re-rank e a brain_search cai de ~4,6 s para o piso de ~0,7 s (embed + busca híbrida; medido 2026-09-02 num corpus de 76 documentos, onde o re-rank reordena ~3 dos 8 trechos devolvidos). Vai cozido na ferramenta pelo servidor — o modelo nunca controla. Ver Latência da brain_search em Conhecimento. |
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. Valor válido que este modelo não aceita → 400 EFFORT_NOT_SUPPORTED (desde 2026-08-29; antes era aceito e descartado em silêncio). thinking_efforts no catálogo é a escada de cada modelo — o OpenAI por API key para em high, codex/gpt-5.6-luna aceita max, o Grok vai até xhigh |
max_rounds_per_run |
int? | null/0 → 20; clamp 1..50 (1..200 se run_class="coder"). Com run_class="unbounded": sem clamp — 0 = ilimitado, um valor positivo é o teto que o próprio agente se impõe |
run_class |
string? | null/""/desconhecido → padrão (teto 50 rounds). "coder" → teto 200 rounds. "unbounded" → sem teto (rounds ilimitados, sem truncar tool calls por rodada). Nenhuma classe tem wall clock. Ver Classes de run abaixo. |
monthly_budget_usd |
float? | meta mensal observacional por agente; null/≤0 → sem meta |
legacy_mode |
bool? | true = caminho legado deprecado (brain é o default). Não desliga memória: sem as tools brain_*, mas a memória do end-user é injetada no prompt e cada turno continua sendo extraído para ela |
Resposta 201: agentResponse. Erros: AGENT_NAME_REQUIRED, TENANT_NOT_FOUND.
PATCHAtualizar 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; fallback: null remove o cérebro
reserva. provider sozinho (sem model) re-aponta o modelo que o agente já
tem para aquele 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.
Cérebro reserva (fallback)#
Um cérebro cai por motivos que não têm nada a ver com o pedido: a sessão OAuth da assinatura expira, a janela de cota do plano acaba. Nesses dois casos — e só neles — o engine repete a mesma conversa no cérebro reserva do agente, em vez de matar a run:
PATCH /v1/agents/{id}
{ "provider": "claude", "model": "claude-opus-5", "reasoning_effort": "max",
"fallback": { "model": "codex/gpt-5.6-luna", "reasoning_effort": "high" } }
-
Dispara em credencial (
provider_credential_expired), cota (provider_quota_exhausted) e indisponibilidade (provider_unavailable). -
Não dispara em erro de requisição (schema de tool inválido, argumento ruim): o reserva falharia igual, mais devagar e na conta de outro provedor. Também não dispara em estouro de janela de contexto — ali a resposta certa é compactar, e percorrer a cadeia só troca a causa real pela do reserva.
-
O reserva roda com o
reasoning_effortdele, porque costuma ser de outra família, com outra escada de profundidade. -
A run registra quem respondeu de verdade:
modelcontinua sendo o modelo que você configurou (é por ele que o uso é agrupado) eanswered_by_model+answered_by_providerdizem qual cérebro produziu a resposta. Um fallback nunca é silencioso. E um fallback para um modelo pago é gasto real — por isso é opt-in por agente, não um default da plataforma. O custo segue quem rodou: uma run que caiu num modelo de assinatura custa 0, não o preço do primário. -
No terminal do stream compat e no webhook
callback_urlo mesmo fato chega ao seu código comomodel_used("<provider>/<modelo>", ex."openai/gpt-5.6-luna"), ao lado deanswered_by_provider/answered_by_modele domodelconfigurado. Seu recibo pode declarar "este turno rodou no reserva" sem consultar o run depois. Presente sempre que o engine atribuiu a resposta; nunca preenchido com o modelo configurado por falta de atribuição. -
E o
donediz POR QUE o cérebro mudou. Quando — e só quando — o reserva atendeu, o mesmo terminal (e o webhook) trazemfell_back: trueemodel_failures[], um item por modelo que recusou, na ordem da cadeia:json{ "model": "MiniMax-M3", "model_used": "codex/gpt-5.6-sol", "fell_back": true, "model_failures": [ { "provider": "minimax", "model": "MiniMax-M3", "role": "primary", "class": "quota", "code": "provider_quota_exhausted", "message": "Token Plan usage limit reached" } ] }roleéprimaryoufallback;classé a taxonomia em que você roteia (auth·quota·request_limit·rate_limit·timeout·unavailable·context_window·model_not_found·not_configured·other);até quando a recusa foi observada (RFC3339) emessageo texto cru do provedor.request_limitnão équota: o provedor recusou a forma de uma requisição (ferramentas demais, payload acima do teto dele) — a mesma credencial atende uma requisição menor na hora, e outro provedor pode aceitar esta; por isso ela cai no reserva. Mostre "limite por requisição", não "sem cota". Já a recusaToken Plan usage limit reached (2056)da MiniMax équotade verdade: é a janela de uso do plano (5 h / semanal), não o tamanho da requisição.O recibo fica gravado no run.
GET /v1/agents/{id}/runs/{runId}(e…/usage) devolvem o mesmomodel_failures[]depois que o stream expirou — tanto num run completado pelo reserva (o porquê deanswered_by_model ≠ model) quanto num run falhado (a cadeia inteira). Ausente quando nenhuma cadeia rodou. As duas chaves são ausentes numa run que não caiu no reserva — a ausência é o sinal "o modelo que você pediu foi o que respondeu".fell_back: falseem toda run seria ruído, e ummodel_failures: []se leria como "a cadeia rodou e ninguém morreu".É o que responde, por run e sem polling, a pergunta que
availablenão responde:availablediz que existe credencial, nunca que existe saldo. Um provider com chave e sem crédito continua aparecendo no menu, e é aqui — no primeiro turno em que ele recusa — que você descobre, com a classe (quota) e as palavras do próprio provedor. -
A request do reserva é recalculada para ele, nunca copiada do primário:
max_tokensé limitado ao teto do modelo reserva (catálogo) e oreasoning_efforté o do reserva. Um primário com janela maior não derruba o reserva antes do primeiro token.
Desligar a substituição: disable_model_fallback#
Há duas cadeias que podem trocar o cérebro de um run: o fallback que você
declarou no agente, e a reserva da plataforma (default_fallbacks), que é
nossa e pode mudar. Mande disable_model_fallback: true no corpo do dispatch e
nenhuma das duas roda:
POST /v1/projects/{projectId}/agents/{agentId}/messages
{ "message": "…", "disable_model_fallback": true }
O run responde no modelo que você despachou ou falha nomeando esse modelo —
sem substituição no momento de resolver o modelo nem durante a chamada. Ausente
ou false = o comportamento de sempre.
Existe porque a reserva da plataforma é uma decisão nossa aplicada ao seu
run: um dispatch de codex/gpt-5.6-sol podia voltar respondido por
openai-responses/gpt-5.6-luna — outro cérebro, outra trilha de cobrança —
porque o primário recusou e a reserva atendeu. Isso é resiliência quando você
quer continuidade, e é ruído quando o seu próprio orquestrador já reage à falha:
ele prefere ver a falha, escolher e reagendar. Use quando a escolha do modelo
for sua decisão de produto, e não quando você só quer que o turno termine.
Duas coisas a lembrar antes de ligar:
- O run passa a morrer em recusas que hoje ele sobrevive (cota estourada,
credencial vencida, provedor fora do ar). Isso é o pedido — mas o seu lado
precisa tratar o terminal
error, comerror_code+provider, e reagir. - É por dispatch, não por agente: cada chamada decide. O
fallbackgravado no agente continua lá para os dispatches que não pedirem nada.
Você não precisa da flag para saber o que aconteceu: model_used,
fell_back e model_failures[] já contam, run a run, quem respondeu e quem
recusou. A flag é para quando saber depois não basta.
Falha de provedor: código estruturado#
Quando a run morre porque o provedor recusou a credencial ou a cota, a resposta
(502) e o evento terminal do stream trazem um código estável e o provedor:
{ "error": "agent run failed: the codex credential was rejected",
"error_code": "provider_credential_expired", "provider": "codex" }
error_code |
Significa | O que fazer |
|---|---|---|
provider_credential_expired |
o provedor recusou QUEM somos (401, ou credencial que nem conseguimos emitir) | reautenticar; repetir no mesmo cérebro não ajuda |
provider_quota_exhausted |
a franquia da conta acabou | comprar/elevar capacidade, ou rodar em outro cérebro |
provider_unavailable |
intempérie: 5xx, timeout, throttling | esperar, ou deixar o fallback resolver |
provider_not_configured |
este deploy não tem credencial desse provedor — a run nem saiu daqui | provisionar a credencial (ou escolher outro provedor). Separado de provider_credential_expired porque "a chave sumiu" e "nunca houve chave" pedem ações diferentes |
MAX_TOOL_ROUNDS |
o loop agêntico bateu o teto de rounds (max_rounds_per_run, default 20) sem chegar a uma resposta final; stop_reason: "max_rounds" |
falha durável: repetir o mesmo turno gasta a mesma profundidade. Suba max_rounds_per_run (até 50, ou 200 com run_class: "coder", ou sem teto com run_class: "unbounded"), ou veja em que o agente está iterando. Nunca trate como indisponibilidade |
RUN_TIMEOUT |
o run terminou porque um relógio mandou, com o modelo ainda vivo — o teto de requisição da infraestrutura (3600 s, hoje alcançável só se UMA rodada passar da margem do leg) ou o wall clock opcional do operador; stop_reason: "timeout" |
o trabalho feito até ali é real: o texto parcial chegou pelo stream e rounds/usage ficam gravados no run (nunca 0); reagir com um turno de continuação, nunca tratar como falha do provedor |
RUN_CANCELLED |
o chamador cancelou o run (desconexão, stop) antes de o modelo terminar |
nada a corrigir do lado do runtime; repita se foi acidental |
Uma falha que não conseguimos tipar continua sem código (AGENT_RUN_FAILED) —
um código que ninguém estabeleceu faria você rotear um failover por uma causa que
nunca foi provada. Um 429 puro é throttling e um 403 seco é autorização:
nenhum dos dois vira "cota", de propósito.
Quando o primário E o reserva falham, o terminal traz além disso
model_failures[] — uma entrada por modelo tentado, na ordem, para o seu recibo
dizer quem morreu de quê sem interpretar a string error:
{ "type": "error",
"error": "primary model kimi/k3 failed: … usage limit for this billing cycle; model fallbacks failed: openai-responses/gpt-5.6-luna: authentication failed: Incorrect API key provided",
"error_code": "provider_quota_exhausted", "provider": "kimi",
"model_failures": [
{ "provider": "kimi", "model": "k3", "role": "primary", "class": "quota", "code": "provider_quota_exhausted", "message": "…" },
{ "provider": "openai", "model": "gpt-5.6-luna", "role": "fallback", "class": "auth", "code": "provider_credential_expired", "message": "…" }
] }
error_code + provider continuam nomeando o primário (o modelo que você
pediu). class é a taxonomia grossa para rotear — auth · quota ·
rate_limit · timeout · unavailable · context_window · model_not_found
· other — e code é o código estável quando a falha
tipou (ausente em other). O campo só existe quando a cadeia rodou: uma falha
única não é uma cadeia, e uma lista vazia afirmaria que uma rodou.
POSTClonar agente#
POST /v1/agents/{id}/clone (owner/admin) — sem corpo; cria um agente novo
"<nome> (cópia)" com a mesma config. Resposta 201: agentResponse.
DELETEExcluir agente#
DELETE /v1/agents/{id} (owner/admin) — resposta 204.
POSTAvaliar (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.
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.6-luna", "gpt-5.6-terra"] }'
Resposta 200 { results: [ { model, content, thinking?, usage, cost_usd, duration_ms, error? } ] }.
GETVersõ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).
POSTRestaurar 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…).
GETEstatísticas#
GET /v1/agents/stats — { stats: { "<agentId>": { sessions, runs } } }.
GETClasses 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 |
Tool calls por rodada | Wall clock por turno | Janela do stream_token |
|---|---|---|---|---|
"" (default) |
50 |
12 |
nenhum | 2 h |
"coder" |
200 |
12 |
nenhum | 2 h |
"unbounded" |
nenhum | sem truncar | nenhum | 2 h |
Tempo nunca é motivo para encerrar um run (desde 2026-08-24): nenhuma classe
tem wall clock. O único teto de tempo é o da infraestrutura — 3600 s por
requisição (infrastructure_request_timeout_seconds em GET …/limits) — e,
desde 2026-08-30, ele não é por run: o runtime executa o loop em legs de
~40 min e continua o mesmo run na requisição seguinte, com o mesmo stream, os
mesmos números de round (nunca voltam a 1) e um só orçamento de rounds. Você vê
um evento não-terminal leg {leg, rounds} na fronteira e um único done no
fim, com a contabilidade do run inteiro. Um run de 60, 90 ou 180 minutos é
normal. Só uma rodada individual maior que ~20 min ainda esbarra no teto, e
mesmo aí chega como RUN_TIMEOUT com rounds e usage preservados. Atenção:
a SUA conexão SSE também vive sob os 3600 s por requisição — reconecte com
Last-Event-ID e o mesmo stream_token (válido por 26 h); o run não morre com
a conexão. O que ainda encerra um run: a resposta final do modelo, uma
falha real do provedor (PROVIDER_*), o cancelamento pelo chamador
(RUN_CANCELLED), o teto de rounds da classe (MAX_TOOL_ROUNDS), a janela de
contexto (CONTEXT_WINDOW_EXCEEDED) e os backstops de ferramenta que param de
funcionar (stop_reason: "tool_failure_limit").
Rounds continuam uma política de custo por agente. Escrever software é a carga
mais tool-heavy que a plataforma atende — a classe coder existe para esse caso.
A classe unbounded é para trabalho legitimamente longo em que "parar em N" é o
defeito (um modelo compondo um mapa de produto inteiro numa rodada): sem teto de
rounds, sem truncar as chamadas de ferramenta de uma rodada. As duas são
opt-in por agente (PATCH /v1/agents/{id} com {"run_class":"unbounded"}),
então nada muda para os demais.
Quatro regras que evitam surpresa:
- A classe move o TETO, não o DEFAULT — exceto
unbounded, onde a classe é o próprio pedido de profundidade: semmax_rounds_per_run= ilimitado; com um valor = o teto que o agente se impôs, honrado como veio. Um agentecodersemmax_rounds_per_runcontinua rodando com os20rounds padrão. - 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. - O override por dispatch é clampado contra a classe do AGENTE. Mandar
max_rounds_per_run: 120no corpo do dispatch de um agente padrão resulta em50— um dispatch reajusta profundidade dentro do que o agente recebeu, nunca compra um teto maior. max_rounds_per_run: 0(ou ausente) no dispatch = SEM override. O run usa a profundidade configurada no agente. Só um valor positivo re-ajusta. (Antes de 2026-08-24 um0explícito apagava o valor do agente e o run caía no default20— se o seu cliente manda0"para usar o padrão", ele agora faz o que diz.)
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.
O catálogo de ferramentas do run é reportado (tools_mounted / tools_unavailable)#
Um agente pode ter ferramentas anexadas que não entram no run: a ferramenta foi
apagada ou renomeada, pertence a outro tenant, a chave do provedor não está
configurada, ou o input_schema gravado não é aceitável. Antes, isso era
invisível: o turno voltava 200, o modelo — que ainda vê o manual das ferramentas
no prompt — improvisava o resultado da chamada em prosa, e não havia como
distinguir "o modelo escolheu não chamar" de "o modelo nunca teve a ferramenta".
Agora o run diz as duas metades:
// evento SSE, no INÍCIO do run (só quando falta alguma)
event: warning
data: {"type":"warning","kind":"tools_unavailable",
"tools":["http_minha_tool"],
"details":[{"name":"http_minha_tool","reason":"schema_invalid",
"message":"input_schema: properties.body: missing \"type\""}],
"tools_mounted":["knowledge_search","http_outra"],
"fail_closed_tools":false}
// no `done` final, e em GET /v1/agents/{id}/runs/{runId}/usage
{ "tools_mounted": ["knowledge_search","http_outra"],
"tools_unavailable": ["http_minha_tool"],
"tools_unavailable_detail": [{ "name":"…", "reason":"…", "message":"…" }] }
reason é schema_invalid (com o caminho no message) ou not_found. not_found
é deliberadamente amplo — apagada, renomeada, desabilitada, de outro tenant, ou com
chave de provedor ausente: o código não distingue esses casos, então a razão não
finge que distinguiu.
tools_mounted aparece sempre (é a metade positiva: o que o modelo podia
chamar, ao lado de tools_used, que é o que ele chamou). Em /usage, ausente
significa "este run é anterior ao registro" — nunca [].
fail_closed_tools: true no corpo do dispatch inverte a política: se faltar
qualquer ferramenta, o run não começa. Ele termina imediatamente com
event: error, error_code: "TOOLS_UNAVAILABLE", stop_reason: "tools_unavailable" — nenhum provedor é chamado, então nada é cobrado. O dispatch
ainda devolve stream_id/stream_token (o terminal está lá, para replay) mais
tools_unavailable e tools_mounted direto no corpo.
Visão — anexar imagens ao turno (images[])#
O dispatch aceita até 4 imagens por turno, que o modelo enxerga junto com a
mensagem — prints de tela, fotos de documento, mockups. message pode ser vazio
ou omitido quando images[] contém ao menos uma imagem válida:
POST /v1/projects/{projectId}/agents/{agentId}/messages
{ "session_id": "<sessão existente opcional>",
"session_key": "<nome estável opcional>",
"images": [ { "media_type": "image/png", "data_b64": "<base64 da imagem>" } ] }
Sem texto e sem imagem válida (inclusive {} ou "images": []), o dispatch
responde 400 MISSING_FIELD antes de criar run. O contrato textual e a sessão/
stream permanecem iguais quando images está ausente.
O mesmo campo existe no turno de chat do Console/runtime
(POST /v1/agents/{id}/sessions/{sid}/messages e …/messages/stream).
Limites (validados na hora, com erro nomeando o defeito):
| Limite | Valor |
|---|---|
| Imagens por turno | 4 |
| Tamanho decodificado por imagem | 5 MiB |
| Total decodificado por turno | 10 MiB |
| Formatos | image/png, image/jpeg, image/gif |
| Dimensões | ≤ 10000 px por lado, ≤ 25 M pixels |
data_b64é base64 padrão canônico (o prefixodata:image/png;base64,de umFileReaderdeve ser removido antes de enviar).- A assinatura real dos bytes precisa bater com o
media_typedeclarado. - Payload inválido →
400 IMAGE_INPUT_INVALID(a mensagem dizimages[i]e o motivo exato). - O modelo do run precisa ter visão (
supports_vision: trueemGET /v1/models). Se o modelo efetivo — após o override"model"do dispatch — não enxergar imagens, o dispatch responde422 IMAGE_INPUT_UNSUPPORTED_MODELnomeando o modelo, antes de qualquer cobrança. Nunca há drop silencioso: ou o modelo vê a imagem, ou você recebe o erro na hora. - As imagens são efêmeras: valem para o turno que as enviou. O transcript persiste o texto; um turno seguinte não re-envia os bytes ao modelo.
- URL de imagem não é aceita — envie os bytes em
data_b64.
Anexos — mandar um ARQUIVO no turno (attachments[])#
Visão resolve print de tela. Um log, CSV, JSON, spec ou trecho de código é
outra coisa: o modelo precisa ler o texto. O campo attachments[] aceita até
4 arquivos por turno, por referência (uma URL que a gente busca) ou inline:
POST /v1/projects/{projectId}/agents/{agentId}/messages
{ "message": "olha esse log, o erro está na linha 40",
"attachments": [
{ "filename": "runner.log", "mime_type": "text/plain",
"url": "https://seu-host/arquivo/abc?sig=…" }, // (a) por referência
{ "filename": "notas.md", "mime_type": "text/markdown",
"content": "texto direto" }, // (b) inline, texto
{ "filename": "print.png", "mime_type": "image/png",
"content_b64": "<base64>" } // (b) inline, bytes
] }
Exatamente uma fonte por anexo: url, content ou content_b64. Mandar
duas é 400. O mesmo campo existe no turno de chat do Console/runtime
(POST /v1/agents/{id}/sessions/{sid}/messages e …/messages/stream, onde a
mensagem é content em vez de message).
O que o agente enxerga — a pergunta que decide o que a sua UI pode prometer:
| Tipo | O que acontece |
|---|---|
Texto (text/*, e application/* textual: json, csv, xml, yaml, log, código, markdown) |
é extraído e embutido no próprio turno, entre delimitadores explícitos ([attachment: nome (mime, tamanho)] … [end of attachment: nome]). O modelo lê o conteúdo como parte da mensagem. |
Imagem (image/png, image/jpeg, image/gif) |
vira entrada de visão, na mesma via (e sob os mesmos limites) de images[]. O teto de 4 é compartilhado: images[] + anexos de imagem juntos não passam de 4. |
Mime desconhecido ou application/octet-stream |
os bytes são farejados: se forem texto legível (UTF-8, sem NUL), viram texto; se forem uma imagem suportada, viram visão. |
| Binário sem leitor (PDF, zip, docx, xlsx, webp, áudio, vídeo) | 415 ATTACHMENT_TYPE_UNSUPPORTED, com a mensagem listando o que É suportado. Não vira link, não é ignorado em silêncio. Para PDF, use o Document AI — que é o produto feito para isso. |
Durabilidade (o que sobrevive a retry/replay). O texto extraído é gravado
dentro da mensagem do turno, no histórico durável da sessão. Não existe canal
lateral: quem reconstrói a conversa a partir do histórico (session_key /
session_id) continua vendo o arquivo, turno após turno, sem reenviar nada. A
contrapartida é a razão do teto de caracteres ser modesto: aquele texto é
reenviado a cada turno seguinte — é contexto, e contexto custa.
Imagem é a exceção: os bytes são efêmeros (valem para o turno que os enviou,
como em images[]); o que fica no transcript é uma linha de metadados dizendo que
um print foi anexado.
Limites (validados na hora, antes de qualquer cobrança):
| Limite | Valor |
|---|---|
| Anexos por turno | 4 |
| Bytes por anexo | 5 MiB |
| Bytes por turno (soma) | 10 MiB |
| Caracteres extraídos por anexo | 65 536 |
| Caracteres extraídos por turno (soma) | 131 072 |
Imagens por turno (images[] + anexos de imagem) |
4 |
Passar do teto de caracteres não é erro: o texto é cortado e o corte é
declarado — um marcador [attachment truncated: …] dentro do bloco e
truncated: true na resposta. Um log encurtado em silêncio é exatamente a
mensagem mutilada que este contrato existe para evitar.
url é buscada pelo servidor, com guarda de SSRF. Só http/https, para um
endereço público: loopback, RFC1918, link-local (169.254.169.254) e afins
são recusados — inclusive quando aparecem só depois, num redirect ou numa
resolução de DNS que muda entre a checagem e a conexão. Redirects: no máximo 3, e
cada salto é revalidado. Timeout de 15 s por anexo.
Resposta. O mesmo shape de sempre, mais um attachments[] quando você
mandou algum (ausente se não mandou — o contrato antigo não mudou nem um byte):
{ "data": {
"stream_id": "…", "stream_url": "…", "stream_token": "…", "session_id": "…",
"expires_at": "…",
"attachments": [
{ "filename": "runner.log", "mime_type": "text/plain", "kind": "text",
"source": "url", "size_bytes": 18422, "chars": 18422,
"truncated": false, "status": "accepted" }
] } }
Corpo grande chega inteiro. Um turno com imagem em base64 ou anexo inline
passa fácil de 16 KiB. Entre 2026-09-14 (~00:35 UTC) e o deploy de 2026-09-15
uma camada de inspeção entregava à API só os primeiros 16 KiB do corpo, e esses
turnos voltavam 400 (no chat, com a mensagem enganosa message content is required; ao criar uma skill longa, invalid JSON body). Corrigido — reenvie o
que falhou nessa janela. Um corpo que não é JSON válido responde
400 INVALID_JSON, e um corpo acima de 40 MiB responde 413 TURN_TOO_LARGE
(um turno de verdade fica perto de 27 MiB: 10 MiB de imagens e 10 MiB de anexos,
em base64).
Erros — falha FECHADA, com código estável. Um anexo recusado derruba a
requisição inteira com 4xx: nada é gravado, nenhum provedor é chamado, nada é
cobrado. É deliberado — receber 200 para um turno cujo arquivo o agente não
conseguiu ler é pior do que receber o erro.
| Código | HTTP | Quando | Retry adianta? |
|---|---|---|---|
ATTACHMENT_INVALID |
400 | payload malformado, nenhuma fonte ou duas fontes, base64 inválido, imagem corrompida | não |
ATTACHMENT_LIMIT_EXCEEDED |
400 | mais de 4 anexos no turno | não |
ATTACHMENT_URL_BLOCKED |
400 | a url (ou um redirect dela) aponta para endereço não-público, ou o scheme não é http/https |
não |
ATTACHMENT_TOO_LARGE |
413 | passou de 5 MiB no anexo ou 10 MiB no turno | não |
TURN_TOO_LARGE |
413 | o corpo JSON do turno inteiro passou de 40 MiB | não |
ATTACHMENT_TYPE_UNSUPPORTED |
415 | não há leitor para esses bytes | não |
ATTACHMENT_FETCH_FAILED |
424 | a url não respondeu, respondeu não-200, ou o corpo não pôde ser lido |
sim — é o único transitório |
IMAGE_INPUT_UNSUPPORTED_MODEL |
422 | anexo de imagem num modelo sem visão | não (troque o model) |
Duas coisas que valem saber antes de expor isso ao seu usuário final: o conteúdo do arquivo entra no prompt como dado não confiável (delimitado, e o delimitador de fechamento é neutralizado para um arquivo não conseguir "falar" como se fosse o operador), e o texto extraído fica persistido no transcript — não anexe segredo que você não queira guardar.
tool_choice — obrigar a primeira rodada a ser uma chamada de ferramenta#
POST /v1/projects/{projectId}/agents/{agentId}/messages
{ "message": "…",
"tool_choice": {"name": "http_listar_arquivos"}, // ou "auto" | "required" | "none"
"tool_choice_rounds": 1 } // ausente = só a PRIMEIRA rodada
Mata a classe "responde de cabeça sem olhar o projeto": não há prosa a produzir antes de a ferramenta ter rodado.
{"name": "x"}obriga aquela ferramenta;"required"obriga alguma.- O nome precisa estar montado neste run — senão
400(seria um400do provedor no meio da run, depois de você já ter pago a ida). - É limitado por rodadas de propósito: um modelo obrigado a chamar ferramenta em
toda rodada nunca emite a resposta final — o run queimaria o orçamento
inteiro e morreria no teto. Ausente/
<= 0= a primeira rodada. - Provedor sem
tool_choice(hoje Gemini) →400 TOOL_CHOICE_UNSUPPORTED. A recusa é sobre o modelo, não sobre o valor:"none"passa pelo mesmo gate, porque dizer que mandamos o modelo NÃO chamar ferramenta, num provedor que descarta o campo, é a mesma mentira que dizer que forçamos. Consultesupports_tool_choiceno catálogo antes de oferecer a combinação. Desde 2026-08-28 essa recusa acontece ANTES de qualquer persistência — a mensagem do turno e a linha do run não são mais criadas, então um400aqui não deixa run pendurada emrunning(carta bia). Nunca há fallback silencioso paraauto: do seu lado, um fallback silencioso é indistinguível de o forçamento ter funcionado.
Compatibilidade provider/model na admissão#
Antes de criar a sessão, persistir o turno ou iniciar o run, o dispatch resolve o
modelo efetivo (incluindo o override model) e valida o par transporte/modelo.
O transporte codex (autenticação por conta ChatGPT) só aceita modelos da família
OpenAI. Um legado codex/k3 recebe 422 MODEL_PROVIDER_INCOMPATIBLE, com
mensagem que nomeia provider/modelo e orienta use provider "kimi"; não há
chamada ao engine/provedor nem linhas de Run ou mensagem. codex/gpt-5.6-luna
e kimi/k3 permanecem aceitos.
Consumo em plano/assinatura — medido em tokens, separado do dinheiro#
GET /v1/agents/usage devolve, ao lado de usage, um bloco subscription:
{
"subscription": {
"runs": 58, "total_tokens": 32021355, "cost_usd": 0,
"quota_exhausted_runs": 3,
"models": [ { "key": "codex/gpt-5.6-luna", "runs": 46, "total_tokens": 10455359, "cost_usd": 0 } ],
"note": "consumo em modelos de plano/assinatura: sem custo por token por contrato, portanto NÃO contabilizado no orçamento em dólar. O que se gasta aqui é QUOTA do plano — acompanhe pelos tokens."
}
}
quota_exhausted_runs conta, na mesma janela, as execuções que o provedor
recusou porque a quota do plano acabou (403 … usage limit for this billing cycle e equivalentes). É o único número que mostra o plano batendo no teto: uma
execução recusada custa $0 exatamente como uma bem-sucedida, então nenhuma
figura em dólar consegue revelá-la. Se ele passa a subir, o problema não é
cobrança — é capacidade de plano, e a ação é outra (comprar quota, ou mover a
carga para um modelo pago). Zero é o valor normal.
Por que separado. Um modelo de plano custa $0 por token por contrato — e é
justamente isso que torna o consumo dele invisível para uma meta em dólar: a meta
mensal soma cost_usd, então uma frota inteira rodando em assinatura pode zerar a quota
do plano com o medidor marcando US$ 0,00. cost_usd aqui é sempre 0 e está presente
de propósito: quem soma custo entre superfícies precisa ver um zero explícito, não um
campo ausente que possa ser chutado.
Como usar: a meta em dólar acompanha os modelos pagos sem bloquear execuções; para
os de plano, acompanhe subscription.total_tokens. Um modelo é classificado como de plano
pelos dados (execuções com custo exatamente zero), não por uma lista fixa — uma
lista silenciosamente perderia o próximo modelo de plano adicionado.
Quanto uma rodada pode devolver#
Duas travas independentes limitam o texto que as ferramentas devolvem, e vale a menor das duas:
Campo em /limits |
O que limita |
|---|---|
max_tool_result_bytes |
o resultado de uma chamada |
max_tools_per_run |
quantas chamadas cabem numa rodada |
max_round_tool_result_bytes |
a soma dos resultados de uma rodada |
requests_per_minute |
o orçamento de requisições HTTP da sua conta (o mesmo que o 429 RATE_LIMITED aplica) |
O terceiro existe porque os dois primeiros, juntos, permitiriam uma única rodada devolver mais texto do que qualquer janela de contexto aceita — e esse texto é reenviado em todas as rodadas seguintes. O valor publicado é o piso (o da menor janela que roteamos); um modelo de janela maior recebe proporcionalmente mais.
Consequência prática: num modelo de janela estreita, max_tool_result_bytes pode
não ser alcançável — a soma da rodada corta antes. Quando isso acontece o resultado
vem truncado com um marcador explícito dizendo o tamanho original, nunca cortado em
silêncio; o caminho é a próxima chamada com escopo mais estreito.
Compaction automática de contexto — uma sessão longa nunca estoura a janela#
Uma sessão durável (session_key) reenvia o transcript inteiro a cada turno. Em
vez de deixar isso crescer até o provider recusar ("input exceeds the context
window"), o engine compacta sozinho, antes de cada chamada, quando o contexto
passa de 80% da janela efetiva do modelo (janela − saída máxima − overhead):
| Camada | O que faz | Custo |
|---|---|---|
| poda | trunca resultados de ferramenta de rodadas antigas (os mais recentes ficam intactos) | nenhum |
| sumário | substitui a parte antiga da conversa por um sumário estruturado — pedido original e intenção, decisões e por quê, estado atual, artefatos tocados (IDs, paths, URLs, commits, verbatim), pendências, restrições recebidas, erros vistos e como foram resolvidos. Os últimos turnos (~20k tokens, mínimo 4 mensagens) ficam intactos | uma chamada de modelo |
| corte | fallback determinístico se o sumário falhar: descarta as mensagens mais antigas com um marcador explícito | nenhum |
Depois da compactação o contexto fica em ≤ 40% da janela, então o sumário + a cauda mantida não disparam uma nova compactação na rodada seguinte. O que você vê:
- SSE
compaction—{ round, layer: "prune"|"summary"|"drop"|"truncate", trigger: "threshold"|"provider_error", tokens_before, tokens_after, summarized }. Mostre "contexto compactado" em vez de um spinner parado. done/GET …/runs/{runId}/usage—context_tokens(o input REAL da última chamada, como o modelo viu a conversa) ecompactions(quantas vezes compactou). No stream compat odoneenriquecido e o webhook terminal (callback_url) carregam os dois campos também —compactionsestá sempre presente (0= não compactou), então dá para reagir no mesmo turno, sem esperar a resposta final.- O pedido do turno sobrevive à compactação, verbatim. A sua última mensagem de usuário do turno é a âncora do trabalho: quando o sumário roda, ela vai inteira dentro dele (seção "The user's request for the CURRENT turn"); quando só o drop determinístico roda, ela vai inteira ao lado do marcador — e a rodada mais recente (a chamada de ferramenta + seus resultados) nunca é descartada. O modelo não pede o pedido de volta depois de compactar. Um pedido acima de 40k caracteres é mantido truncado, com marcador honesto.
- Na sessão — quando o sumário roda, ele é persistido: as mensagens absorvidas
continuam em
GET …/messagescomcompacted_at(fora do contexto dos próximos turnos), e uma mensagemkind: "compaction"(rolesystem) com o sumário passa a abrir o contexto. O próximo turno já nasce dentro do orçamento — você não precisa rotacionar sessões por tamanho. - Se o provider recusar mesmo assim (a estimativa ficou curta), o engine compacta
agressivamente e tenta uma vez mais. Só se a segunda tentativa também for
recusada o run termina, com
error_code: "context_window_exceeded"— o erro primário, tipado; nunca a falha de um modelo de fallback no lugar dele.
Override por run, no corpo do dispatch: "compaction": { "enabled": true, "threshold_pct": 80 } (threshold_pct entre 30 e 90; fora disso, 400).
enabled: false desliga só o gatilho preventivo — a recuperação no erro do provider
fica sempre ligada. O gatilho compara o contexto atual (o input_tokens real da
última chamada — o prompt inteiro, com a fatia em cache dentro dele — mais a estimativa
do que a rodada anterior acrescentou), nunca o acumulado do run: um run com 40 rodadas
de 45k tokens cada não compacta com a janela de 400k. Na janela de 400k o gatilho
padrão fica em ~266k tokens (80% de 400k − max_output − 2k).
Sessão nomeada no plano de dispatch#
O corpo do dispatch aceita session_key como irmão get-or-create de
session_id:
| campo | semântica | id desconhecido |
|---|---|---|
session_id |
id exato, já existente | 404 SESSION_NOT_FOUND |
session_key |
nome estável seu; cria na primeira vez | nunca dá 404 |
Essa diferença é deliberada: um session_id que você guardou e reenviou só pode
estar velho ou ser de outro agente, e abrir uma conversa em branco no lugar
pareceria, pro usuário, que o agente perdeu a memória. Um session_key é um
nome — pedir "a conversa chamada X" implica criá-la se ainda não existe. Se os
dois vierem, session_id vence.
POST /v1/projects/{projectId}/agents/{agentId}/sessions — corpo
{ session_key, title?, external_id? } — faz o mesmo ensure ANTES do primeiro
turno (útil pra dar título/end-user à conversa), com a mesma credencial e o mesmo
envelope {"data": …} do dispatch: 201 criou, 200 reusou. É conveniência,
nunca pré-requisito — dispatchar com o session_key já cria.