Super Evo Lab
← Todos os textos
Lab · mão na massaLicitações

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.

Capa do Lab 02: terminal rodando o agente e o fluxo Cron 06h, API PNCP, Supabase, Claude e WhatsApp, em amarelo sobre preto.

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 em yyyyMMdd; tamanhoPagina vai 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á em objetoCompra (e às vezes informacaoComplementar); o órgão em orgaoEntidade.razaoSocial.
  • O link público é https://pncp.gov.br/app/editais/{cnpj}/{anoCompra}/{sequencialCompra}, e não o numeroControlePNCP bruto (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-20250514 foi aposentado em 15/06/2026 (página de deprecações da Anthropic). Trocado por claude-haiku-5-5, o modelo atual para classificação.
  • Corrigidos if __name__ == "__main__", cp .env.example .env e o título do arquivo .env.example. A função enviar_whatsapp é a do Lab 01 e vem copiada abaixo.

Arquitetura

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

sql
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)

python
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)

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

python
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)

python
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

bash
# 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:

text
requests
anthropic
supabase
python-dotenv

.env.example:

bash
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

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

Compartilhar:WhatsAppXFacebookLinkedIn

Comentários

Ainda não há comentários aprovados. Seja o primeiro.

Entre ou crie uma conta para comentar.

Gostou? Receba o próximo texto

Um por vez, direto no e-mail.

Usamos seu e-mail só para mandar os textos. Para sair, é só responder pedindo.