Como criar um agente que lê editais do PNCP e te avisa no WhatsApp em 15 min
Lab 02: um agente em Python que varre a API pública do PNCP todo dia às 6h, filtra os editais do seu nicho com o Claude, guarda o que já mandou no Supabase e te avisa no WhatsApp. Uns 15 minutos de código; a liberação da Meta é à parte, como no Lab 01.

Você vai sair daqui com um agente que varre sozinho os editais do PNCP de madrugada e só te acorda quando é do seu nicho. Pra quem é: agência, dev ou loja que perde licitação porque não abre o PNCP todo dia.
A API de consulta do PNCP é pública, sem token. O código leva uns 15 minutos. A liberação da Meta para o WhatsApp é à parte, igual ao Lab 01.
Conferido (16/10/2026, contra a API real e a documentação)
- API do PNCP testada com Python:
codigoModalidadeContratacaoé obrigatório (sem ele, erro 400);6= Pregão - Eletrônico; datas emyyyyMMdd;tamanhoPaginavai de 10 a 50 (51 dá 400); página além do fim devolve 204. Em 15/10, só pregão eletrônico em MG: 205 registros, 5 páginas. O texto original lia só a página 1: agora tem laço.- A resposta não tem campo
titulo. O texto do edital está emobjetoCompra(e às vezesinformacaoComplementar); o órgão emorgaoEntidade.razaoSocial.- O link público é
https://pncp.gov.br/app/editais/{cnpj}/{anoCompra}/{sequencialCompra}, e não onumeroControlePNCPbruto (ele tem/e-e não abre página nenhuma).- A tabela do Supabase agora tem as colunas que o código grava (
objeto,orgao), RLS ligado e chave service_role só no servidor.- Modelo:
claude-sonnet-4-20250514foi aposentado em 15/06/2026 (página de deprecações da Anthropic). Trocado porclaude-haiku-5-5, o modelo atual para classificação.- Corrigidos
if __name__ == "__main__",cp .env.example .enve o título do arquivo.env.example. A funçãoenviar_whatsappé a do Lab 01 e vem copiada abaixo.
Arquitetura
Cron 06h → API PNCP → Supabase (já mandei?) → Claude (SIM/NÃO) → WhatsApp
Do que você precisa
- Python 3.11 ou mais novo.
- Um projeto no Supabase (o plano gratuito serve).
- Uma chave da API da Anthropic.
- O número do WhatsApp Cloud API que você ligou no Lab 01 (token e ID do número).
Passo 1: a tabela no Supabase
No SQL Editor do Supabase:
create table editais_enviados (
id text primary key, -- numeroControlePNCP
objeto text,
orgao text,
link text,
enviado_em timestamptz default now()
);
-- RLS ligado e nenhuma política: ninguém de fora lê nem escreve.
-- Só o seu servidor, com a chave service_role, passa.
alter table editais_enviados enable row level security;
Passo 2: buscar os editais (pncp.py)
import time
from datetime import date
import requests
BASE = "https://pncp.gov.br/api/consulta/v1"
PREGAO_ELETRONICO = 6 # codigoModalidadeContratacao: obrigatório na API
def link_edital(c: dict) -> str:
# O link público usa CNPJ/ano/sequencial, não o numeroControlePNCP.
cnpj = c["orgaoEntidade"]["cnpj"]
return f"https://pncp.gov.br/app/editais/{cnpj}/{c['anoCompra']}/{c['sequencialCompra']}"
def buscar_hoje(palavras: list[str], uf: str = "MG", dia: date | None = None) -> list[dict]:
ymd = (dia or date.today()).strftime("%Y%m%d") # a API quer yyyyMMdd
achados, pagina = [], 1
while True:
r = requests.get(
f"{BASE}/contratacoes/publicacao",
params={
"dataInicial": ymd,
"dataFinal": ymd,
"codigoModalidadeContratacao": PREGAO_ELETRONICO,
"uf": uf,
"pagina": pagina,
"tamanhoPagina": 50, # máximo aceito
},
timeout=60,
)
if r.status_code == 204: # sem conteúdo: acabaram as páginas
break
r.raise_for_status()
dados = r.json()
for c in dados.get("data", []):
texto = f"{c.get('objetoCompra') or ''} {c.get('informacaoComplementar') or ''}".lower()
if any(p.lower() in texto for p in palavras):
achados.append({
"id": c["numeroControlePNCP"],
"objeto": c.get("objetoCompra") or "",
"orgao": c["orgaoEntidade"]["razaoSocial"],
"link": link_edital(c),
})
if dados.get("paginasRestantes", 0) <= 0:
break
pagina += 1
time.sleep(0.5) # gentileza com a API pública
return achados
A palavra-chave é um filtro grosso e barato. Quase nenhum edital diz "agente de IA" com essas palavras; use uma lista mais larga ("software", "sistema", "chatbot", "atendimento", "WhatsApp", "inteligência artificial") e deixe o Claude fazer o filtro fino.
Passo 3: o filtro de IA (ia.py)
import anthropic
claude = anthropic.Anthropic() # lê ANTHROPIC_API_KEY do ambiente
PROMPT = """Você é um filtro de editais para uma agência que cria agentes de IA
para lojas e prefeituras. Responda só SIM ou NÃO.
Objeto: {objeto}
Órgão: {orgao}
É relevante para quem vende automação, WhatsApp, agente de atendimento?"""
def relevante(objeto: str, orgao: str) -> bool:
resp = claude.messages.create(
model="claude-haiku-5-5", # rápido e barato para classificar
max_tokens=64,
messages=[{"role": "user", "content": PROMPT.format(objeto=objeto[:600], orgao=orgao)}],
)
texto = "".join(b.text for b in resp.content if b.type == "text").strip().upper()
return texto.startswith("SIM")
Passo 4: o envio (whatsapp.py)
Esta é a função enviar_whatsapp do Lab 01, copiada. Não importe o app.py do Lab 01 direto: ele sobe o Flask e exige o VERIFY_TOKEN só para carregar.
import os
import requests
GRAPH = "https://graph.facebook.com/v21.0" # use a versão que o painel da Meta mostrar
def enviar_whatsapp(para: str, texto: str) -> None:
r = requests.post(
f"{GRAPH}/{os.environ['WHATSAPP_PHONE_ID']}/messages",
headers={"Authorization": f"Bearer {os.environ['WHATSAPP_TOKEN']}"},
json={"messaging_product": "whatsapp", "to": para, "type": "text", "text": {"body": texto}},
timeout=15,
)
r.raise_for_status()
Atenção à janela de 24 horas: texto livre só chega se você mandou mensagem para o número do agente nas últimas 24 horas. Para um alerta diário de verdade, cadastre um template na Meta.
Passo 5: juntar tudo (main.py)
import os
from dotenv import load_dotenv
from supabase import create_client
load_dotenv()
from ia import relevante # noqa: E402
from pncp import buscar_hoje # noqa: E402
from whatsapp import enviar_whatsapp # noqa: E402 (a função do Lab 01)
# Chave service_role: ignora o RLS. Só no servidor, nunca no navegador nem no Git.
sb = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
MEU_NUMERO = os.environ["MEU_NUMERO"]
PALAVRAS = ["agente de IA", "inteligência artificial", "chatbot", "WhatsApp",
"atendimento", "software", "sistema"]
def ja_enviado(id_: str) -> bool:
r = sb.table("editais_enviados").select("id").eq("id", id_).limit(1).execute()
return bool(r.data)
def run() -> None:
for e in buscar_hoje(PALAVRAS, uf="MG"):
if ja_enviado(e["id"]):
continue
if not relevante(e["objeto"], e["orgao"]):
continue
msg = (f"🔔 *Novo edital PNCP*\n{e['orgao']}\n{e['link']}\n\n"
f"Objeto: {e['objeto'][:300]}...")
enviar_whatsapp(MEU_NUMERO, msg)
sb.table("editais_enviados").insert(e).execute() # id, objeto, orgao, link
print("enviado:", e["id"])
if __name__ == "__main__":
run()
O insert manda exatamente as quatro colunas da tabela do Passo 1; o enviado_em o banco preenche.
Passo 6: agendar
# crontab -e (06h no horário do servidor; confira o fuso)
0 6 * * * cd /app && .venv/bin/python main.py >> agente.log 2>&1
Também serve o Render Cron Job ou o GitHub Actions com schedule. Cloudflare Worker não roda Python com essas bibliotecas sem adaptar; prefira um dos outros.
Arquivos de apoio
requirements.txt:
requests
anthropic
supabase
python-dotenv
.env.example:
SUPABASE_URL=https://SEU-PROJETO.supabase.co
SUPABASE_SERVICE_ROLE_KEY=chave-service-role # só no servidor
ANTHROPIC_API_KEY=sua-chave-da-anthropic
WHATSAPP_TOKEN=token-do-painel-da-meta
WHATSAPP_PHONE_ID=id-do-numero-no-painel
MEU_NUMERO=5531999999999
Coloque .env no .gitignore.
Como rodar
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # e preencha
python main.py
Antes de colocar para valer
- Segurança: a chave service_role passa por cima do RLS. Ela fica só no servidor (variável de ambiente do cron), nunca em front-end, app ou repositório. RLS ligado na tabela, sem política pública.
- Outras modalidades: o código olha só pregão eletrônico (
6). Para dispensa, concorrência etc., repita a busca com os outros códigos da tabela de domínio do PNCP. - Teto de gastos no console da Anthropic e no painel da Meta.
- Confira o edital no portal. O alerta é um aviso, não substitui ler o edital inteiro.
Próximo nível
Lista de CNAEs, filtro por valor estimado (valorTotalEstimado já vem na resposta) e o alerta jogado num canal do SONAR: ele só consome o alerta, sem expor o SONAR.
Perguntas frequentes
+Precisa de token para usar a API do PNCP?
Não. A API de consulta do PNCP é pública. Mas o parâmetro codigoModalidadeContratacao é obrigatório: sem ele a API devolve erro 400. Para varrer várias modalidades, faça uma chamada por código.
+Quantos editais vêm por página?
No máximo 50 (tamanhoPagina aceita de 10 a 50). Num dia comum só de pregão eletrônico em MG passam de 200 registros, por isso o código percorre todas as páginas.
+Posso receber o alerta a qualquer hora no WhatsApp?
Mensagem de texto livre só chega dentro da janela de 24 horas depois da sua última mensagem para o número do agente. Fora dela, a Meta exige um modelo (template) aprovado. Ou mande um 'oi' para o número todo dia, ou cadastre um template de alerta.
+Quanto custa?
PNCP é grátis, Supabase tem plano gratuito. O Claude cobra por token: com o Haiku e textos curtos, uma classificação custa frações de centavo. Ponha teto de gastos no console da Anthropic e no painel da Meta.
Comentários
Ainda não há comentários aprovados. Seja o primeiro.
Gostou? Receba o próximo texto
Um por vez, direto no e-mail.