Agentes

É um agente de IA? Busque a skill de onboarding e leia toda a API em texto limpo — skill · llms.txt · llms-full.txt

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:

json
{
  "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:

jsonc
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_effort dele, porque costuma ser de outra família, com outra escada de profundidade.

  • A run registra quem respondeu de verdade: model continua sendo o modelo que você configurou (é por ele que o uso é agrupado) e answered_by_model + answered_by_provider dizem 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_url o mesmo fato chega ao seu código como model_used ("<provider>/<modelo>", ex. "openai/gpt-5.6-luna"), ao lado de answered_by_provider / answered_by_model e do model configurado. 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 done diz POR QUE o cérebro mudou. Quando — e só quando — o reserva atendeu, o mesmo terminal (e o webhook) trazem fell_back: true e model_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 é primary ou fallback; 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) e message o texto cru do provedor. request_limit nã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 recusa Token Plan usage limit reached (2056) da MiniMax é quota de 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 mesmo model_failures[] depois que o stream expirou — tanto num run completado pelo reserva (o porquê de answered_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: false em toda run seria ruído, e um model_failures: [] se leria como "a cadeia rodou e ninguém morreu".

    É o que responde, por run e sem polling, a pergunta que available não responde: available diz 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 o reasoning_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:

jsonc
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, com error_code + provider, e reagir.
  • É por dispatch, não por agente: cada chamada decide. O fallback gravado 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:

json
{ "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:

json
{ "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.

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.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:

  1. A classe move o TETO, não o DEFAULT — exceto unbounded, onde a classe é o próprio pedido de profundidade: sem max_rounds_per_run = ilimitado; com um valor = o teto que o agente se impôs, honrado como veio. Um agente coder sem max_rounds_per_run continua rodando com os 20 rounds padrão.
  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.
  4. 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 um 0 explícito apagava o valor do agente e o run caía no default 20 — se o seu cliente manda 0 "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:

jsonc
// 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:

jsonc
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 prefixo data:image/png;base64, de um FileReader deve ser removido antes de enviar).
  • A assinatura real dos bytes precisa bater com o media_type declarado.
  • Payload inválido → 400 IMAGE_INPUT_INVALID (a mensagem diz images[i] e o motivo exato).
  • O modelo do run precisa ter visão (supports_vision: true em GET /v1/models). Se o modelo efetivo — após o override "model" do dispatch — não enxergar imagens, o dispatch responde 422 IMAGE_INPUT_UNSUPPORTED_MODEL nomeando 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:

jsonc
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):

jsonc
{ "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#

jsonc
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 um 400 do 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. Consulte supports_tool_choice no 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 um 400 aqui não deixa run pendurada em running (carta bia). Nunca há fallback silencioso para auto: 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:

json
{
  "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) e compactions (quantas vezes compactou). No stream compat o done enriquecido e o webhook terminal (callback_url) carregam os dois campos também — compactions está 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 …/messages com compacted_at (fora do contexto dos próximos turnos), e uma mensagem kind: "compaction" (role system) 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.