# Protocolo & transporte

O SDK é um **cliente HTTP puro**, sem dependências de runtime: ele só fala com o app da Hobots por HTTP. Não abre conexão com banco e **não** roda `SKIP LOCKED` — a reivindicação atômica de runs é server-side (veja o fim desta página).

Os arquivos `src/protocol.ts` e `src/tasks/protocol.ts` são a **fonte de verdade do wire format**: o app valida os payloads com zod travados nesses tipos (`satisfies z.ZodType<...>`), então não podem sofrer drift. Todos os tipos são exportados por `hobots/protocol` (inclusive `InstanceMode` e `ItemsDeclaration`) (e reexportados pelo import raiz `hobots`).

## O Client Secret e os endpoints derivados

Cada agente tem **um único Client Secret**, o mesmo para as duas capabilities. O agente é a própria tarefa: não há slug de task no wire — o que o agente faz é identificado pelo secret; a instância só declara **como** trabalha (`mode`).

```
hb_<64 hex>
```

| Canal | Endpoint | Autenticação |
| --- | --- | --- |
| Telemetria (ingestão) | `POST <baseUrl>/v1/ingest` | `Authorization: Bearer <clientSecret>` |
| Execuções (poll, runs, itens, anexos) | `<baseUrl>/v1/runs/*` | `Authorization: Bearer <clientSecret>` |
| Anexos (binário) | URL presignada do S3 (PUT) | assinatura na própria URL — **sem** header `Authorization` |

A credencial vem da opção `clientSecret` ou da env `HOBOTS_CLIENT_SECRET` (resolvida em `src/config.ts` no primeiro `register()`/`start()`, com a URL base `https://api2.hobots.app`). Em desenvolvimento a env `HOBOTS_API_URL` sobrescreve a URL base — apenas `http(s)`, e `http` para host fora de loopback gera aviso porque o secret trafega sem TLS. A credencial é **secreta** (formato `hb_` + 64 hex — outro formato é rejeitado no `register()`/`start()`, sem ecoar o valor) e viaja **só no header** `Authorization: Bearer` — nunca na URL da requisição.

## Telemetria — ingestão

Endpoint fixo `POST /v1/ingest` recebe **envelopes** JSON autenticados por Bearer. O transport mantém uma fila em memória, agrupa itens em envelopes e faz POST via `fetch`.

```jsonc
{
  "sdk": { "name": "hobots", "version": "2.0.0" },
  "sent_at": "2024-01-01T00:00:00.000Z",
  "items": [
    { "type": "event",       "payload": { /* EventPayload */ } },
    { "type": "transaction", "payload": { /* TransactionPayload */ } },
    { "type": "heartbeat",   "payload": { /* HeartbeatPayload */ } }
  ]
}
```

Tipos de item: `event`, `transaction`, `heartbeat` (definidos em `hobots/protocol`).

Todo `transaction` carrega `run_id`: é assim que a [transaction da execução](./concepts.md#transaction) (`hobots.transaction`) é correlacionada, e o que permite o app montar o waterfall de performance dentro da solicitação.

`items` aceita de 1 a **50** itens por envelope — acima disso a API responde `422`. O `maxBatchSize` é só o gatilho do flush: quando a fila tem mais que esse teto (burst de captures, ou fila acumulada durante uma janela de rede ruim), o transport a fatia em lotes de 50 e faz um POST por lote.

**Flush & retries:** o transport flusha a cada 2 s ou ao acumular 10 itens. Ladder de retry de 3 tentativas; respostas 4xx (exceto 429) são descartadas sem retry; a fila tem teto fixo e descarta os itens **mais antigos** ao encher. Todos os valores em [Tempos e intervalos](./api-reference.md#tempos-e-intervalos).


### MachineSnapshot

Recursos da máquina onde a instância roda. Vai em `HeartbeatPayload.machine`, em `instance.machine` do `POST /v1/runs/poll` e em `contexts.machine` dos eventos. Bytes em bytes, percentuais 0..100; campo ausente = não mensurável nesta plataforma. Teto: **4 KiB** serializado (heartbeat/poll → 422; `contexts.machine` idem). Chaves desconhecidas são descartadas pelo servidor.

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `sampled_at` | ISO 8601 | momento da coleta (relógio do cliente) — chave de dedupe da série |
| `instance_id` | string | o `instanceId` da instância — liga o snapshot de um evento à série da instância |
| `hostname`, `os`, `arch` | string | identificação da máquina (`process.platform` / `sys.platform`) |
| `cpu_count` | int | cores lógicos |
| `cpu_percent` | number \| null | uso de CPU do sistema — média entre duas coletas; `null` até haver 2 amostras |
| `load_1` | number | load average de 1 min (POSIX; ausente no Windows) |
| `mem_total_bytes`, `mem_available_bytes`, `mem_percent` | int, int, number | memória do sistema |
| `disk_path`, `disk_total_bytes`, `disk_free_bytes`, `disk_percent` | string, int, int, number | volume do diretório de trabalho do bot |
| `process.pid`, `process.uptime_s` | int, number | **obrigatórios** |
| `process.rss_bytes`, `process.cpu_percent` | int, number \| null | memória residente e CPU do processo (normalizada pelos cores) |
| `process.heap_used_bytes` / `process.threads` | int | só Node / só Python |
| `source` | `stdlib` \| `psutil` \| `stdlib-loadavg` | de onde vieram os números (`stdlib-loadavg` = CPU estimada pelo load, macOS sem psutil) |

O servidor guarda o último snapshot em `instances.machine` e um ponto por heartbeat/poll (máx. 1 a cada 30s por instância) em `instance_metrics`, expurgado em 14 dias.

## Execuções — polling, criação e reporte

Endpoints sob `<baseUrl>/v1/runs`, todos com `Authorization: Bearer <clientSecret>` (a credencial única do agente):

### `POST /v1/runs/poll`

Corpo `PollRequest`:

```jsonc
{
  "instance": {
    "instance_id": "vm-01",       // identidade canônica da instância (obrigatória)
    "name": "VM 01",
    "version": "3.0.0",
    "host": "vm-01",
    "environment": "production", // ambiente da instância: development | testing | staging | production
    "mode": "on_demand",          // como a instância trabalha: on_demand (register) | always_on (start)
    "poll_interval_ms": 2000,
    // colunas de itens declaradas no código (opção `items`) — uma declaração, do agente;
    // ausente = nada declarado (o servidor nunca apaga a declaração anterior por ausência)
    "items": { "attributes": [{ "key": "numero_nota" }, { "key": "valor", "label": "valor (R$)" }] }
  },
  "max": 2                        // slots livres de `concurrency` (1..5)
}
```

Resposta `PollResponse` = `{ runs: RunDescriptor[] }`, cada `{ id, params, form, source, created_at }`.

O `instance.instance_id` é a **mesma identidade** do heartbeat de telemetria (`instance_id`): o servidor correlaciona os dois canais numa tabela `instances` por `(agent_id, instance_id)`. Os dois derivam do mesmo `instanceId` — o robô aparece como um agente só no painel. O próprio poll também marca a presença da instância (`last_poll_at`). Um processo no modo `start()` nunca faz poll.

**Backoff:** um request por poll; o loop decide o ritmo. HTTP 401 (key rejeitada) → retry lento a 30s com log throttled; outros erros → ladder `[1s, 4s, 16s]`.

### `POST /v1/runs`

Modo `start`: a instância abre a **própria run**. Corpo `RunCreateRequest` — `{ "instance": { /* InstanceInfo (com `mode: "always_on"`), obrigatório */ } }`. Resposta `201` `RunCreateResponse` = o mesmo `RunDescriptor` do poll, com `params: {}`, `form: null` e `source: 'agent'`.

- A run nasce **`running`** (`started_at`, `claimed_at` e `last_report_at` = agora, `claim_count` 1), sem formulário nem solicitante, vinculada à instância informada. Dali em diante é reportada pelas rotas abaixo exatamente como uma run reivindicada no poll.
- `touchInstance` roda antes do insert (presença); se falhar, a API responde `503` e o SDK insiste com o ladder do poll (o handler só começa quando a run existe). Corpo vazio ou sem `instance.instance_id` → `422`.
- Sem dedupe: um processo reiniciado em menos de 120 s deixa a run anterior `running` até o sweep marcá-la `AgentTerminated`. O app não oferece "reexecutar" para runs `agent` — ninguém faria poll por elas.

### `PATCH /v1/runs/{id}`

Corpo `RunUpdateRequest`:

```jsonc
{
  "status": "running",            // 'running' | 'succeeded' | 'failed'
  "logs":   [
    { "ts": "…", "level": "info", "message": "…" },            // delta; o servidor concatena
    { "ts": "…", "level": "info", "message": "portal após o login",
      "attachments": ["<32 hex>"] }                            // opcional; ids de anexo (máx. 4) — hobots.image() no log
  ],
  "error":  { "type": "Error", "message": "…", "stack": "…" },  // só com 'failed'
  "progress": { "current": 10, "total": 100 }                     // opcional: snapshot de hobots.progress
}
```

- `status: 'running'` pode repetir para enviar **logs incrementais** (throttle por `logFlushIntervalMs`, flush ao acumular 20, chunk de 100, teto de 1000 logs/run, mensagem cap 10k). O aviso de cada tentativa do retry do handler é uma dessas linhas.
- `progress` é **opcional** e aditivo: snapshot absoluto de `hobots.progress` (`current` 0..total, `total` ≥ 1) — o servidor **sobrescreve** `progress_current`/`progress_total` e grava `progress_started_at` no primeiro PATCH que o traga (relógio do servidor; âncora da ETA no app). `current > total` é clampado; progresso enviado a uma run já terminal é descartado. O SDK anexa o snapshot mais recente a **qualquer** PATCH que saia (logs, heartbeat, terminal) e só dispara um PATCH próprio, no máximo 1/s, quando nada mais está a caminho. Uma API anterior ao recurso descarta o campo (objeto não-strict).
- `attachments` é **opcional** e aditivo: SDKs anteriores à 1.9 simplesmente não enviam o campo, e uma API anterior ao recurso descarta o campo na validação (objeto não-strict) sem rejeitar o PATCH. Os ids são gerados pelo agente **antes** do upload, o que permite enviar a linha de log sem esperar o binário subir.
- Ordem garantida `running → logs → terminal` (PATCHes serializados numa cadeia de promise).
- Retry `[1s, 4s, 16s]`; 4xx exceto 429 é terminal (**409** = run cancelada / transição inválida → marca a run como morta e dispara `hobots.signal`).
- Todo PATCH atualiza `last_report_at` no servidor (relógio do servidor) — é o **sinal de vida da run**. O heartbeat de cancelamento (PATCH `running` vazio a cada `cancelCheckIntervalMs`, máx 15s) garante reporte contínuo; um sweep server-side marca como `failed` com erro `AgentTerminated` ("execução encerrada inesperadamente") toda run `running` sem reporte há **120s**, sem re-entrega. No exit path (sinal/erro fatal com runs ativas), o SDK envia um único PATCH `failed`/`AgentTerminated` com timeout curto (~2s), sem retry — o sweep é o backstop.
- O protocolo **não transporta resultado** de execução: o retorno do handler é ignorado pelo SDK e o servidor descarta qualquer campo `result` (dados de negócio podem ser sensíveis e não são coletados).

Ciclo de vida completo da run no servidor (`RunStatus`): `pending → claimed → running → succeeded | failed | canceled` (runs `agent` nascem em `running`). O agente só transiciona para `running`/`succeeded`/`failed` (`RunUpdateStatus`).

### `POST /v1/runs/{id}/items`

Registra em lote os **itens de execução** (a entrega do bot — `hobots.items` no SDK). Insert-only: cada item chega com o status final; um `id` repetido na mesma run é ignorado (retry de rede idempotente).

Corpo `RunItemsCreateRequest`:

```jsonc
{
  "items": [                             // 1..500 por request
    {
      "id": "12345",                     // identidade do item na run (dev) ou uuid do SDK
      "seq": 0,                          // ordem de emissão no agente
      "status": "succeeded",             // 'succeeded' | 'failed' | 'occurrence'
      "message": "…",                    // opcional — motivo (failed/occurrence), máx. 2100
      "payload": { "numero_nota": "12345", "cnpj_prestador": "…" },  // opcional, máx. 16 KB
      "ts": "2026-08-10T12:00:00.000Z",  // opcional — relógio do agente, só exibição
      "attachments": ["<32 hex>"]        // opcional — ids de anexo (máx. 4): print do item
    }
  ]
}
```

Resposta `200` `RunItemsCreateResponse`: `{ "accepted": 1, "duplicates": 0 }` — `duplicates` conta os colapsados por `(run, id)` repetido no mesmo lote (vale o último reporte; no banco é upsert).

- `attachments` é **opcional** e aditivo (uma API anterior ao recurso descarta o campo). As steps carregam o print do mesmo jeito, em `data.attachments` do `StepData` que vai no `POST /v1/ingest` da transaction — sem campo novo no wire.

- **Run cancelada/terminal aceita itens** (200): item é fato de trabalho já feito — mesmo racional do log tardio. Esta rota não tem 409.
- O insert e os **contadores materializados** da run (`items_total/succeeded/failed/occurrence`, que alimentam os totalizadores no app) saem num único statement atômico no servidor.
- Retry `[1s, 4s, 16s]` para rede/5xx; `404` = servidor sem o recurso (o SDK degrada em silêncio); `402` = armazenamento do plano esgotado. Não há teto de itens por run.

| Status | Quando |
| --- | --- |
| `401` | chave do agente inválida ou inativa |
| `404` | execução inexistente ou de outro projeto |
| `413` | corpo acima de 1 MB |
| `422` | payload inválido |
| `402` | armazenamento do plano esgotado (`storage_limit`) |

### `POST /v1/runs/{id}/attachments`

Registra um anexo de imagem e devolve uma **URL presignada de PUT**. O binário nunca passa pela API: o agente sobe direto no S3.

Corpo `RunAttachmentCreateRequest`:

```jsonc
{
  "id": "<32 hex>",              // gerado pelo agente, já referenciado no log/item/step
  "content_type": "image/png",   // 'image/png' | 'image/jpeg' | 'image/webp'
  "size_bytes": 184320,          // exato — entra na assinatura da URL
  "caption": "portal após o login",  // opcional, máx. 500
  "log_ts": "…",                 // opcional, ISO 8601 — ts do evento (log/item/step)
  "width": 1920, "height": 1080  // opcionais
}
```

Resposta `200` `RunAttachmentCreateResponse`:

```jsonc
{
  "id": "<32 hex>",
  "upload_url": "https://…",     // PUT direto no S3, SEM Authorization
  "headers": { "content-type": "image/png", "content-length": "184320" },
  "expires_in": 900
}
```

O PUT no S3 precisa mandar exatamente esses headers — eles fazem parte da assinatura. `content-length` é assinado de propósito: sem isso a URL aceitaria um upload de qualquer tamanho.

| Status | Quando |
| --- | --- |
| `401` | chave do agente inválida ou inativa |
| `404` | execução inexistente ou de outro projeto |
| `409` | id de anexo já registrado |
| `413` | corpo acima de 8 KB (é só metadado) |
| `422` | payload inválido |
| `429` | limite de 20 anexos por execução atingido |
| `503` | anexos não habilitados nesta instalação (sem bucket S3 configurado) |
| `502` | falha ao presignar — o registro é desfeito |

### `PATCH /v1/runs/{id}/attachments/{attachmentId}`

Confirma o upload. Corpo `RunAttachmentConfirmRequest`: `{ "status": "uploaded" | "failed", "size_bytes"?, "width"?, "height"? }`; resposta `{ "ok": true }`, ou `404` se o anexo não existe no projeto da chave.

É **conforto de UX**, não correção: enquanto não confirmado, o app mostra a miniatura como "enviando imagem…". O agente engole falhas desta chamada.

Limites de anexo (aplicados no SDK **e** no servidor): PNG/JPEG/WEBP, 5 MB por imagem, 20 por execução, 4 por evento (linha de log, item ou step).

### `POST /v1/runs/{id}/files/{fileId}/download`

Arquivo de campo de upload do formulário com `storage: 's3'` (o valor do campo nos params é um `FileRef` `{ id, name, size, contentType, storage }`, ou uma lista). Corpo vazio. Resposta `FileDownloadResponse`: `{ "url", "expires_in", "name", "size", "content_type" }` — `url` é um GET pré-assinado direto no S3 (o SDK **não** manda `Authorization` nele). `404` = o arquivo não existe nesta execução; `410` = já baixado (confirmado) ou expirado.

### `DELETE /v1/runs/{id}/files/{fileId}`

Confirma o download: o servidor apaga o objeto do S3 na hora. `204`, idempotente. O SDK só chama depois do GET completo e do tamanho conferido; `404`/`410` aqui contam como confirmado.

Arquivos com `storage: 'local'` (escolhidos no Studio) não passam pela API: ficam em `$HOBOTS_LOCAL_FILES_DIR/<id>/<nome>` no computador do bot.

## Onde vive o claim atômico

O `POST /v1/runs/poll` no app chama `claimPendingRuns()` em
[`apps/api/lib/tasks/claim.ts`](../../../apps/api/lib/tasks/claim.ts): um CTE que seleciona runs `pending` vencidas com `FOR UPDATE SKIP LOCKED` e as promove a `claimed` num único `UPDATE ... RETURNING`. Isso garante que dois agentes concorrentes nunca peguem a mesma run. **Nada disso está no SDK** — ele só faz o HTTP.

## Identidade do SDK

`SDK_NAME = 'hobots'` e `SDK_VERSION` ficam em `src/utils.ts` e vão em todo envelope (campo `sdk`) e no `instance.version` do poll e da criação de run. A `SDK_VERSION` é **injetada pelo build** (o `define` do tsup lê a `version` do `package.json`) — não há sincronização manual ao publicar.
