---
name: iptv
description: Operate Grade — public IPTV catalog, channel health, personal feeds, chat, and authorized streaming inquiries for producers, with contact by email or API. Use when working with Grade, gradetv.net or the iptv workspace.
---

# Grade — skill para agentes

**Live:** https://gradetv.net
**UI humana:** TV/rádio → buscar → abrir → favoritar/guardar → voltar. Refinamentos e detalhes
são progressivos. Agentes começam em `/developers` e usam API/OpenAPI/MCP, sem depender do HTML.

**Descoberta:** `GET /api/` · `/llms.txt` · `/openapi.json`  
**Catálogo:** `src/lib/apidocs.js`  
**MCP (remoto, recomendado):** `POST https://gradetv.net/mcp` — Streamable HTTP, JSON-RPC 2.0.
Pluga direto no cliente MCP; não precisa deste repositório. Confira com `GET https://gradetv.net/mcp`.
**MCP (stdio, local):** `node ~/src/mm/scripts/mcp/server.mjs --product iptv`

## Regra de ouro

Paridade UI/API/skill/MCP no mesmo commit: `AGENTS.md` do produto e `AGENTS-API.md` da raiz.

**Relay continua proibido na operação atual.** Não contornar CORS/HTTP/geobloqueio baixando ou
repassando segmentos, áudio ou chaves pela Grade, c3 ou fornecedor. Licença de metadados, URL
pública, resposta 200 e pagamento x402 não concedem direitos audiovisuais. Pesquisa e contato de
produtor não ativam transmissão. Antes de alterar operação que multiplica custo, ler as
[regras duras do produto](../../../AGENTS.md) e o
[estado das proteções](../../../docs/decisoes-distribuicao-custo.md): orçamento, limites executáveis,
alerta e interrupção são requisitos; deduplicação por cache não é teto global de gasto.

## Auth

1. `POST /api/guest` → `X-Guest-Token: ipt_…` — o convidado é a identidade do agente.
2. Conta e pagamento são do SDK (21/09/2026): pessoa com sessão é a conta (cookie, só no
   navegador). A chave `iptk_…` saiu: `/api/keys*` e chave apresentada respondem 410.

## Operações

| Tool | HTTP |
|------|------|
| `api_index` | `GET /api/` |
| `producer_services` | `GET /api/producers?lang=pt` — oferta e contatos para produtores; pt/en/es/fr/de, sem ativação |
| `geo` | `GET /api/geo` — país do Cloudflare + idioma do `Accept-Language` (ISO 639-3; fallback `por`) |
| `list_countries` | `GET /api/countries?kind=tv|radio|all` — `{code,name,count,flag}` só com canal tocável (toda faceta aceita `kind`; padrão TV) |
| `origem` e `health.sources` | Cada canal e stream diz de onde veio (`origem`: `iptv-org`, `free-tv`, `kodinerds`, `radio-browser`, `tdt`) e `GET /api/health` traz `sources` por fonte (`fetched_at`, `stale`, `ausente`, `itens`). Rádio vem do Radio Browser (com votos/tags) e do TDTChannels (Espanha, sem checker). |
| stream `kind: youtube` | `streams[]` de `get_channel` pode trazer `kind: "youtube"` com `youtube {video|channel, embed, assistir}`: o browser embute o player oficial; o hop (`/api/s/:id`) só redireciona; M3U/XSPF do feed não o levam (o JSON traz `kind: "youtube"`). Lista `youtube-br` (SBT News, Record News, Jovem Pan…) + Ⓨ do Free-TV. |
| `get_channel_guide` | `GET /api/channels/:id/guia` — programação de hoje grabada por nós (iptv-org/epg no c3, sites como mi.tv e meuguia.tv): `agora`, `a_seguir`, `programas[]` (o dia). A ficha (`get_channel`) traz o resumo em `guide_now`. A guia é produto. 404 sem guia fresca. |
| `list_tags` | `GET /api/tags?country=BR&limit=40` — tags das estações de rádio tocáveis (vocabulário livre do Radio Browser), `{id,name,count}` |
| `list_languages` | `GET /api/languages` — `{code,name,count}` |
| `list_networks` | `GET /api/networks` — `{name,count}` |
| `list_qualities` | `GET /api/qualities` — `{id,name,count}` |
| `list_subdivisions` | `GET /api/subdivisions?country=BR` |
| `list_cities` | `GET /api/cities?country=BR&subdivision=BR-SP` |
| `search_channels` | `GET /api/channels?q=&country=BR&language=por&playable=1` — filtros: category, network, quality, guide, subdivision, city; `kind=tv` (padrão), `kind=radio` (estações do Radio Browser) ou `kind=all`; `tag=` (tag de rádio); `sort=score` ordena pela saúde medida por terceiro (IPTV Nexus), `sort=votes` pelos votos da rádio, e `online=1` traz só quem foi visto online nas últimas 48 h. Item traz network, owners, launched, quality, feed, website, idiomas (`language_labels`), categorias em PT (`category_labels`), `kind`, `origem`, `health_ext` (`score`, `online`, `checked_at`; `null` quando ninguém mediu) e, em rádio, `radio` (`tags`, `votes`, `clicks`, `codec`, `bitrate`, `geo`). **Sem `kind`, rádio nunca aparece** |
| `get_channel` | `GET /api/channels/:id` — ficha completa + streams disponíveis |
| `legacy_stream` | `GET /api/legacy/:id` — metadados públicos, `website` oficial (anulável), `provider_url` copiável e `legacy_url`, sem buscar mídia |
| `create_guest` | `POST /api/guest` |
| `get_library` | `GET /api/library` |
| `create_category` | `POST /api/categories` `{name}` → `{id, group_id, …library}` (já nasce com a sub-aba **Geral**) |
| `create_group` | `POST /api/groups` `{category_id,name}` |
| `add_item` | `POST /api/items` `{group_id,channel_id}` → `{id, group_id, category_id, …library}` |
| `get_history` | `GET /api/history?limit=&offset=` — canais que o dono assistiu, do mais recente ao mais antigo |
| `record_watch` | `POST /api/history` `{channel_id}` — reassistir soma em `plays` em vez de duplicar linha |
| `forget_watch` | `DELETE /api/history/:channel_id` |
| `clear_history` | `DELETE /api/history` |
| `report_play` | `POST /api/play-report` `{channel_id, ok, code?}` — relata se tocou |
| `channel_health` | `GET /api/channels/:id/health` — saúde por ambiente, geo por país, latência do hop |
| `play_reports` | `GET /api/play-reports?channel_id=&ok=0` — relatos crus **(só operador)** |
| `list_favorites` | `GET /api/favorites` |
| `add_favorite` | `POST /api/favorites` `{channel_id}` |
| `remove_favorite` | `DELETE /api/favorites/:channel_id` |
| `list_comments` | `GET /api/channels/:id/comments` |
| `post_comment` | `POST /api/channels/:id/comments` `{body, author?}` |
| `delete_comment` | `DELETE /api/comments/:id` |
| `chat_history` | `GET /api/chat/:channel_id/mensagens` |
| `chat_send` | `POST /api/chat/:channel_id/mensagens` `{body, author?}` — sem passe mensal → 402 $0.10 |
| `chat_pass` | `POST /api/chat/pass` — $0.10 / 30 dias (x402 ou crédito), do convidado que fala no chat |
| `me` | `GET /api/me` (cookie da conta MM) — e-mail e tamanho da biblioteca da conta |
| `billing` | `GET /api/billing` — `prices.contact_agent_usd`, `prices.chat_month_usd`, `prices.abuso_24h_usd`, `chat.active/until` |
| `contact` | `POST /api/contact` (agente $0.10 x402) · 1º envio sai na hora; seguintes 429 + `Retry-After` (60s→2×, teto 1h) |
| (operador) | `GET /api/metrics` Bearer `METRICS_TOKEN` |

## Projetos de produtores sob consulta

`GET /api/producers` é público e informativo: `status: sob_consulta`, `activation_available: false`.
Retorna a mesma oferta de `/produtores` (e traduções): distribuição autorizada, player e acompanhamento
como escopo a avaliar. Use `contact.form_url` para o formulário humano, `contact.email`
(`contato@gradetv.net`) para e-mail ou `contact.api_url` (`POST /api/contact`) para enviar a proposta.
`contact.message_template` orienta canal/evento, direitos, audiência, duração e data. Não envie segredos.
Agentes pagam somente pelo envio do contato, via x402 ou crédito; isso não contrata transmissão.
O canal precisa de autorização dos titulares para sinal e obras da programação, território e prazo.
Nenhuma rota desta oferta inicia relay: `/api/m/` continua 410.

## Catálogo comunitário

Cada canal do `search_channels`/`get_channel` traz `social`:
`{plays, fails, favorites, comments, health, last_fail_code}`. `health` é o percentual de sucesso e
vem **`null`** até haver 3 relatos — número de uma amostra de um não é estatística, e um único
azar não pode marcar um canal como quebrado para todo mundo.

**Relate o que aconteceu no seu player** (`report_play`). Sem relato o catálogo não aprende quais
canais estão realmente no ar. Vocabulário de `code` (só quando `ok:false`):
`cors` · `geo` · `sumiu` · `codec` · `playlist` · `sem_resposta` · `protocolo` · `sem_stream` · `outro`.
O que vier fora da lista entra como `outro` — o vocabulário é fechado para que as falhas agrupem.

Conta **uma vez por dono, por canal, por dia e por resultado**: repetir o POST devolve
`counted:false` com `reason:"ja_relatado_hoje"`. Não é erro, não retente em laço.

Navegador e sistema saem do `User-Agent`, o país do `CF-IPCountry`. Mandar `browser`/`country` no
corpo não muda nada.

**Endereço:** o relato cru guarda o IP de quem relatou, porque canal bloqueado por operadora (e não
por região) só aparece nele. Ele fica **só** na linha crua: nenhum agregado tem, o painel público
`channel_health` nunca devolve, ler exige `METRICS_TOKEN` (`play_reports`) e a linha é apagada
depois de 90 dias — a estatística fica, o endereço não.

`channel_health` responde a pergunta que a média global esconde: **"quebrou para todos ou só para
mim?"**. `your_environment` é o recorte de quem está chamando; `environments[]` é o panorama.

**Geo e latência (por país):** o painel também agrega por PAÍS. `regions[]` traz plays/fails/health
por país; `geo` dá o veredito — `tipo:"geo"` (falha concentrada, com `bloqueado_em` e `funciona_em`),
`"down"` (falha em toda região com relato) ou `"unknown"` (relato de menos). Como toda playlist passa
pelo hop `GET /api/s/:id`, a Grade mede o tempo de abertura por país: `latency[]` (avg_ms + nota
`otima|boa|lenta|ruim`) e `your_country.latency_ms` para a borda de quem chama. `pra_voce.nivel`
resume para o país de quem chamou: `geo_bloqueado` · `lenta` · `instavel` · `boa` · `sem_dado`.
No catálogo, cada card traz `social.your_geo_ok` (false = todas as tentativas do seu país falharam),
`social.your_country_ok/fail` e `social.your_latency_ms/grade` — mesma informação, sem segunda chamada.

## Chat da sala (WebSocket)

`GET /api/chat/:channel_id/ws` com `Upgrade: websocket`. O socket **entra mudo** — sem token na URL
de propósito. Protocolo:

1. `{"t":"hello","token":"ipt_…","autor":"Nome"}` → `{"t":"pronto","autor":"…","items":[…],"watching":N}`
2. `{"t":"msg","body":"…"}` → todos recebem `{"t":"msg","id","autor","body","at"}`
3. Quem entra ou sai da sala: `{"t":"presenca","watching":N}` — gente com o player aberto, não o histórico de plays.
4. Erros: `{"t":"erro","code":"auth|vazio|rajada|json|tipo|formato|grande|pago"}` — `pago` = precisa do passe mensal (`chat_pass`).

Teto de 6 mensagens por 10s por conexão e 500 caracteres por mensagem. Só as últimas 50 ficam.
Sem WebSocket, use `chat_history` / `chat_send`.

**Passe mensal:** enviar (HTTP ou `{t:"msg"}`) exige `POST /api/chat/pass` — **$0.10 / 30 dias**
x402 ou crédito. Sem pagamento → **402**. Ler histórico e `hello` são grátis. O passe é de quem fala
no chat: o convidado do `X-Guest-Token` (o mesmo do `hello`). `GET /api/billing` com o convidado
devolve `chat.active` / `chat.until`.

## Conta MM (reivindica o convidado)

Conta e pagamento são do SDK (21/09/2026). Sem conta, o convidado `ipt_…` é o dono. Com a sessão
da conta (a pessoa entra pela modal da conta ou em `/conta/global`: código ou link por e-mail, senha
ou passkey), a pessoa **é a conta**: pastas, favoritos, histórico, comentários e feed passam a ser do
id da conta, e escrever com a sessão exige a mesma origem e o `X-CSRF-Token` de
`/api/auth/bootstrap`. Cookie da conta com a sessão vencida → **401 `session_ended`** (nunca vira
convidado). A modal, logo depois de entrar, chama `POST /api/auth/claim {guest_token}`, que **move**
para a conta o que o convidado daquele aparelho guardou — `claimed.product.movidos` diz quanto, por
tabela, e `claimed.product.direitos` quantas compras passaram; o que colide com o que a conta já tem
fica no convidado. De outro aparelho a biblioteca já vem da conta. Token de antes da assinatura que
passou tudo para a conta deixa de valer (401 com ele): peça outro em `POST /api/guest`.
`POST /api/auth/start` e `/verify` respondem 410; agente não tem bearer de conta nem chave de API —
usa o convidado.

Feeds públicos (sem header): `GET /f/{iptf_…}/library.m3u` e `/c/{slug}.m3u` · `.json` · `.xspf`.  
A árvore em `GET /api/library` já traz essas URLs em cada nó. Cada item do M3U aponta para
`https://m3m8.gradetv.net/api/s/:streamId.m3u8`. Links antigos em `gradetv.net/api/s/` ainda
302 para o mesmo path (`curl -L`). Path, formato, IDs e tickets são preservados.
Playlists principais e internas passam ao o2 com os headers da origem e cache de 1 s na CDN.
Copie as URLs internas completas com `p` assinado (até 6000 caracteres, mesmo stream e
origem); não construa tickets. Links públicos duram enquanto a origem/bloqueio permitirem.
Segmentos e chaves vêm direto da origem; `/api/m/` responde 410.
Cada busca tem teto de 512 KiB/8 s/3 redirects, até 128 referências internas e 4 níveis. Falha
no o2 retorna 422 JSON (`playlist_origem`/`playlist_acesso`/`playlist_limite`; 502 no Worker
de fallback) para tentar outro espelho, sem redirecionar a playlist para HTTP. Sem chave, 503 `playlist_config`; ticket inválido/expirado, 404. Refresh interno não conta outro play.
Após a migração, filhas geradas apontam diretamente ao o2. A cobrança 402 por player externo permanece pausada.

IDs de stream são opacos para o cliente e não mudam pela ordem da recarga. Hops posicionais antigos
ausentes e itens salvos recuperam o mesmo canal, feed e fonte na leitura; não recrie pastas ou itens.
Sem stream correspondente, a resposta continua 404. Os bloqueios continuam valendo.

**Toda escrita devolve a árvore inteira** (`categories[]` com sub-abas, itens e feeds). Depois de
`POST/DELETE`, use a resposta — um `GET /api/library` em seguida é ida perdida ao servidor.

A UI persiste busca/aba em `localStorage` (`ipt_ui`) e as pastas no guest. Não peça para “limpar
estado” no boot e não recrie `POST /api/guest` se já houver `ipt_…` — isso some com a galeria.

O histórico é do dono (a conta da sessão ou o convidado `ipt_…`), teto de 200 canais, e a listagem pagina no
servidor. Histórico de outro dono responde lista vazia / 404 — nunca o dado alheio.

## Páginas HTML (sem JS)

| URL | O que é |
|-----|---------|
| `/brasil` | TV pública do Brasil. Cards abrem o player (`/?q=`). `/pais/br` redireciona pra cá |
| `/como-usar` | Player, pastas, M3U no VLC |
| `/sobre` | O que é / o que não é; fonte iptv-org; takedown |
| `/en` `/es` `/fr` `/de` | Mesma superfície nos outros 4 idiomas (hreflang). PT sem prefixo é o canônico. UI (chat, paywall, conta) segue o idioma do path |
| `/en/brazil` `/en/how-to` `/en/about` | Traduções; slugs nativos (es/fr/de no mesmo padrão) |
| `/sitemap.xml` | Home + guias nos 5 idiomas + `/llms.txt` + `/openapi.json` |

A guia do dia é produto (`get_channel_guide`). Ficha de canal com programação + saúde + play usa o dado; despejar o catálogo iptv-org em dezenas de milhares de URLs é scaling. Catálogo = `GET /api/channels?country=BR&playable=1`.
`GET /api/channels/:id` traz `_links.self` (API) e `_links.app` (player).
Path inexistente responde **404 de verdade**. URL de stream só na API.

Tocar no browser = `https://m3m8.gradetv.net/api/s/:id`, a mesma URL do VLC. As playlists passam pela Grade; a mídia
direta ainda precisa ser acessível pelo browser. Um 401/403 da origem vem como 422
`playlist_acesso` no o2 (502 no Worker de fallback), com `X-Grade-Upstream-Status`, e oferece **Abrir site do canal** (`website`,
também em `X-Grade-Website`) e **Copiar playlist** (`provider_url`, também em `X-Grade-Source`,
URL original para colar em outro player). Sem site cadastrado, apenas a cópia. Clipboard bloqueado
oferece campo selecionado para cópia manual; o botão de site nunca abre o M3U8.
`X-Grade-Media-HTTP: 1` identifica mídia/chave HTTP no manifesto, inclusive interno: o player
HTTPS interrompe e oferece **Abrir no player legado**. `legacy_url` abre
`http://legacy.gradetv.net:8080/legacy?stream=ID`, sem login ou ticket; playlists
seguem no hop HTTPS e mídia direto na origem. HTTP não remove CORS nem acesso negado.

`logo_url` no JSON: se o espelho R2 já tem o arquivo, é `https://gradetv.net/logos/{id}` (variante do card, não o original); senão continua a URL de origem (imgur/wikimedia/etc). `GET /logos/:id` sem objeto = 404, sem baixar na hora.

## Cota (leia antes de gastar chamada)

`GET /api/pricing` (tool `pricing`) apresenta as franquias e tarifas públicas.
`GET /api/billing` (tool `billing`) conserva preços e o estado privado do passe de quem chama.
Consulte antes de uma operação paga; o desafio 402 informa o valor do pedido.

- **Grátis, sem cota:** catálogo, busca, facets, ficha de canal, saúde, ler chat e comentários,
  galeria pessoal, pastas e feeds M3U/JSON/XSPF por convidado.
- **Pago:** escrever no chat **$0.10 / 30 dias** ·
  contato de agente **$0.10** — x402, USDC na Base. UA vazio e curl não são cobrados.
- Bloco `quota` em `GET /api/`, preços em `GET /api/billing`.

## Cota estourada

**402** com `accepts[]`. Pague e **repita a mesma chamada** com `X-PAYMENT`.

## Acervos públicos de dados

`GET /api/` → `docs.data_indexes` descobre quatro acervos de leitura: endereços CNEFE,
metadados PNCP, domínios observados em CT e arquivos de programação XMLTV. As mesmas raízes
estão em `/llms.txt`, `/llms-full.txt`, `/okf/index.md` e `/developers#dados`. Abra o
`formats.json` adequado e siga a hierarquia e `links.proximo` (até 20 itens por página).
Atualização manual: confira fonte e referência. Respeite `Retry-After` em 429/503. Não
encaminhe credenciais do produto a esses hosts. Leia somente o recorte necessário à tarefa.

<!-- GERADO por scripts/monta-ui.mjs — fonte: .agents/skills/<produto>/SKILL.md. Não edite. npm run ui -->
