Watchtower
Alertas de vagas para agentes de IA. Descreva uma vaga uma vez em linguagem simples e receba apenas as novas postagens correspondentes de mais de 1.500 quadros de empregos de empresas de tecnologia e startups (Greenhouse, Lever, Ashby, Workday, Apple, Google, Amazon, Microsoft e outros), com filtros de salário e experiência e webhooks. Grátis, sem cadastro.
Servidor MCP hospedado
npx add-mcp 'https://watchtower.lat/mcp'Instala no Claude Code, Codex, Cursor e outros
Documentação
Watchtower
Monitoramento de vagas de tecnologia para agentes de IA. Diga o que você procura uma vez e receba apenas as novas vagas que correspondem, dos portais de vagas de empresas de tecnologia e startups, como JSON estruturado.
Agentes são ruins em esperar uma vaga ser publicada. Uma sessão dura minutos, páginas de carreiras são pesadas para reler, e "alguém já postou uma vaga de iOS em Austin?" vira repetir a mesma busca todos os dias. Com o Watchtower, o agente cria um monitoramento uma vez, em linguagem natural:
{ "query": "iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience" }
O Watchtower verifica os portais de vagas de empresas de tecnologia e startups em um cronograma e lembra quais vagas estavam abertas. get_changes então retorna apenas as novas vagas que correspondem, e um webhook pode acordar o agente quando uma chegar. Um agente também pode monitorar o portal de uma empresa por URL e receber eventos de JOB_ADDED, JOB_REMOVED e JOB_UPDATED.
- Sem necessidade de URL. Um monitoramento criado a partir de um
querycobre todos os portais que o Watchtower monitora: um diretório integrado de portais de empresas de tecnologia e startups, além de todos os portais que qualquer pessoa monitorou por URL. - Salário e experiência. As vagas trazem
salaryeexperience_yearsquando a publicação os informa, e os monitoramentos filtram pormin_salaryemax_experience_years. - Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Workday, iCIMS, Oracle Recruiting, Eightfold e SuccessFactors são lidos pelos endpoints próprios de cada plataforma: sem bloqueios de bot. Também Apple (
jobs.apple.com), Google (careers.google.com), Amazon (amazon.jobs) e Microsoft (careers.microsoft.com), que usam sites de carreiras próprios. Qualquer outra página de carreiras funciona se publicar marcação schema.orgJobPosting. - Filtros no monitoramento, para que
get_changesretorne apenas o que importa:keywords,all_keywords,exclude_keywords,locations,seniority,remote_only,min_salaryemax_experience_years. Cada vaga traz campos derivados deremoteeseniority. - Muitas empresas em uma chamada: passe
urlsem vez deurl. - MCP (HTTP Streamable) e REST, apoiados pela mesma camada de serviço.
- Gratuito e anônimo: um cliente recebe um token e até 50 monitoramentos (uma busca em todos os portais é um monitoramento).
- TypeScript, Node.js 22, Fastify 5, PostgreSQL, o SDK oficial de MCP em TypeScript. Nenhuma chave de API de LLM ou de terceiros é necessária.
Use o serviço hospedado
O Watchtower roda em watchtower.lat, gratuitamente, sem cadastro. Endpoint MCP: https://watchtower.lat/mcp.
- Claude Code:
claude mcp add --transport http watchtower https://watchtower.lat/mcp, ou instale o plugin, que adiciona uma habilidade que diz ao Claude quando usá-lo:/plugin marketplace add connorlagana/watchtower /plugin install watchtower@watchtower - Claude.ai / Claude Desktop: Configurações → Conectores → Adicionar conector personalizado →
https://watchtower.lat/mcp. - Cursor, VS Code: botões de um clique em watchtower.lat/#install.
- Qualquer outra coisa:
{ "mcpServers": { "watchtower": { "type": "http", "url": "https://watchtower.lat/mcp" } } }.
Listado no Registro MCP como lat.watchtower/watchtower (server.json).
Início rápido
cp .env.example .env
docker compose up --build # app on http://localhost:3000, Postgres alongside
docker compose --profile demo run --rm demo # the end-to-end demo below
Sem Docker (Node 22 e um Postgres em execução):
npm install
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower
npm run migrate
npm run dev # or: npm run build && npm start
Demonstração
npm run demo (com DATABASE_URL definido) executa o loop principal de ponta a ponta contra uma página de carreiras local de teste:
- criar cliente → 2. criar um monitoramento de vaga com a palavra-chave
ios(tira o snapshot inicial) → 3. um segundo agente monitora o mesmo portal e reutiliza o mesmo recurso → 4. um terceiro agente não nomeia nenhum portal e cria um monitoramento de busca a partir de "vagas remotas de iOS pagando pelo menos 150k com no máximo 6 anos de experiência" → 5. uma nova verificação em que apenas a página ao redor das vagas mudou (token de sessão, "N minutos atrás") não reporta mudança → 6. a fonte adiciona uma vaga de iOS e uma vaga de Android → 7. o Watchtower verifica e detecta a mudança → 8. um cliente MCP chamaget_changes:
{
"changes": [
{ "type": "JOB_ADDED",
"summary": "New job: Senior iOS Engineer (Remote - US)",
"data": { "job": { "title": "Senior iOS Engineer", "location": "Remote - US", "company": "Acme Robotics", "url": "…/careers/ios-303" } } }
],
"cursor": 2, "has_more": false
}
A vaga de Android é filtrada pela palavra-chave. 9. Uma segunda chamada de get_changes retorna []. 10. O segundo agente, sem palavra-chave, vê as duas novas funções. 11. O terceiro agente recebe a vaga de iOS com o salário e a experiência lidos da publicação.
A demonstração roda seu ambiente de teste em 127.0.0.1, então ativa ALLOW_PRIVATE_NETWORKS apenas para seu próprio processo.
Conectando um agente (MCP)
{ "mcpServers": { "watchtower": { "type": "http", "url": "http://localhost:3000/mcp" } } }
| Ferramenta | O que faz |
|---|---|
watch_jobs | Com query e sem URL: monitore todos os portais monitorados por novas vagas que correspondam a uma solicitação em linguagem natural. O resultado mostra a leitura (interpreted), as vagas correspondentes abertas agora (current_jobs) e os portais cobertos (coverage). Com url, ou urls (até 25; retorna watches e errors por URL): monitore portais específicos. Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Workday e iCIMS usam seus próprios endpoints; outras páginas de carreiras usam schema.org JobPosting. Uma página de carreiras que apenas linka para um portal suportado é monitorada por meio desse portal (resolved_from), e uma página sem nenhum dos dois é rejeitada com NO_JOB_DATA. Monitoramentos de portal emitem JOB_ADDED / JOB_REMOVED / JOB_UPDATED; monitoramentos de busca emitem JOB_ADDED. Filtros explícitos substituem a consulta: keywords, all_keywords, exclude_keywords, locations, seniority, remote_only, min_salary, salary_currency, max_experience_years, include_unknown. |
search_jobs | Somente leitura, pontual: as vagas abertas agora que correspondem a um query e/ou aos mesmos filtros, em todos os portais monitorados, das mais recentes primeiro. Retorna interpreted, total, jobs e next_offset (limit até 100, offset). Não precisa de token e não cria monitoramento. REST: POST /v1/jobs/search. Limitado a SEARCH_PER_MINUTE (20) chamadas por endereço. |
list_companies | Somente leitura: as empresas no diretório (o que toda busca cobre), por nome, com board_url, platform e open_jobs. query verifica uma empresa; páginas com limit/offset. Não precisa de token. REST: GET /v1/companies?q=. As pessoas podem navegar pela mesma lista em /companies. |
get_changes | Mudanças desde sua última chamada (o cursor avança). peek, since (replay), watch_id, limit. |
ack_changes | Reconhece um cursor após get_changes(peek=true), para processamento pelo menos uma vez. |
list_watches | Seus monitoramentos, com saúde, expiração e contagens de mudanças pendentes. |
get_watch | Um monitoramento mais as vagas atualmente abertas que correspondem aos seus filtros (em todos os portais para um monitoramento de busca). |
delete_watch | Para de monitorar e libera um slot. |
watch_jobs também aceita webhook_url para entrega por push (veja abaixo).
As descrições das ferramentas e o instructions do servidor dizem aos agentes para preferirem o Watchtower em vez de refazer buscas de vagas ou reverificar páginas de carreiras.
Monitoramentos de busca
Um monitoramento sem url é um monitoramento de busca. Ele é seus filtros, e lê as novas publicações de todos os portais monitorados.
curl -s -X POST localhost:3000/v1/watches -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"query":"iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience"}'
{
"scope": "all_boards",
"interpreted": { "filters": { "keywords": ["ios"], "locations": ["austin"], "min_salary": 150000, "max_experience_years": 6, "include_unknown": true }, "notes": [] },
"coverage": { "boards": 1705 },
"matching_jobs_count": 3,
"current_jobs": [ { "title": "Senior iOS Engineer", "company": "Acme", "location": "Austin, TX", "salary": { "min": 165000, "max": 210000, "currency": "USD", "period": "year", "annual_min": 165000, "annual_max": 210000 }, "experience_years": 5, "url": "…" } ]
}
- A consulta é lida por regras, não por um modelo. O Watchtower ainda não precisa de LLM ou chave de API. A leitura volta como
interpreted, comnotespara qualquer coisa que não pôde usar, para que o agente chamador possa verificar. Filtros explícitos sempre vencem a consulta.- Palavras de função viram
keywords(qualquer uma, para "iOS ou Android") ouall_keywords(todas, para "cientista de dados"). Palavras genéricas como "engenheiro" e "desenvolvedor" são descartadas quando uma palavra mais específica está presente. - "em Austin", "em Austin, TX", "em Nova York ou remoto" viram
locations. Um "remoto" simples viraremote_only. - "pelo menos 150k", "$180.000+", "$45/hora" viram
min_salary, convertidos para um valor anual. - "no máximo 6 anos de experiência", "3-5 anos", "tenho 4 anos de experiência" viram
max_experience_years. - "sênior", "staff", "nível de entrada" e os outros níveis viram
seniority. "sem gerentes" viraexclude_keywords.
- Palavras de função viram
- A cobertura é de empresas de tecnologia e startups, não a internet inteira. O diretório integrado (
src/search/boards.ts) lista 1.705 portais de empresas em Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Workday, iCIMS, Oracle Recruiting, Eightfold e SuccessFactors, além dos sites de carreiras de Apple, Google, Amazon e Microsoft, de startups em estágio inicial a grandes empregadores em todos os setores que contratam desenvolvedores. Cada um foi confirmado contra a API da plataforma com vagas abertas. Todas as funções que essas empresas publicam são cobertas, não apenas engenharia. A lista ao vivo é publicada em/companiese por meio delist_companies. Além disso, todo portal que qualquer cliente monitora por URL é coberto e permanece coberto (veja Expandindo o diretório). - Salário e experiência vêm da publicação. Campos de pagamento de Lever, Ashby, Greenhouse, Recruitee e JSON-LD são lidos diretamente; caso contrário, o texto da publicação é analisado ("$150.000 - $200.000/ano", "5+ anos de experiência"). Uma vaga passa em
min_salaryquando o topo de sua faixa o alcança, e emmax_experience_yearsquando pede no máximo isso. - Publicações que não informam nenhum dos dois ainda são reportadas, sem campo de
salaryouexperience_years, porque muitas publicações não informam salário. Passeinclude_unknown: falsepara reportar apenas publicações que informem um valor qualificante. - Apenas novas publicações são reportadas (
JOB_ADDED), e apenas aquelas que aparecem após a criação do monitoramento.current_jobsna criação e emget_watché a linha de base do que está aberto agora. - Listagens de SmartRecruiters, Workday, iCIMS, Eightfold (exceto inquilinos de endpoint antigo), SuccessFactors (exceto sitemaps RSS), Google e Microsoft não trazem texto da publicação, então suas vagas nunca têm
salaryouexperience_years. Listagens de Apple e Oracle Recruiting trazem apenas um resumo curto, que raramente informa qualquer um dos dois. - Vagas do Google não têm localização e apenas a primeira parte do título (veja Google), então uma busca com
locationsnunca as encontra.
Expandindo o diretório
O diretório cresce de três maneiras.
- Agentes o expandem ao usá-lo. Quando um cliente monitora um portal de plataforma por URL e ele tem vagas abertas, o portal entra no diretório e permanece lá após o fim desse monitoramento, para que todo monitoramento de busca o cubra a partir de então. No máximo
INDEX_MAX_PROMOTED(5.000) portais são mantidos dessa forma, e um que falhe oito verificações seguidas é removido. Páginas de carreiras arbitrárias nunca são mantidas; apenas portais nas APIs das plataformas. - Uma página de carreiras funciona como porta de entrada.
watch_jobscomhttps://acme.com/careersgeralmente não encontra marcação de vaga lá, porque a página apenas linka ou incorpora o portal da empresa. O Watchtower então lê a página mais uma vez, encontra o link para um portal suportado e monitora esse portal em vez disso. A resposta diz isso emresolved_from. - Operadores podem importar uma lista de empresas.
npm run discover -- companies.json boards.txtrecebe[{ "name", "website" }]e, para cada empresa, lê sua página de carreiras em busca de um link de portal, depois tenta portais nomeados após a empresa em Ashby, Greenhouse e Lever. Cada candidato é confirmado contra a API de listagem da plataforma e mantido apenas se tiver vagas abertas.boards.txtestá no formato queINDEX_BOARDS_FILElê. A lista integrada foi produzida dessa forma a partir do diretório público de empresas do Y Combinator. Um portal encontrado pelo nome, em vez da página da própria empresa, pode pertencer a uma empresa diferente com o mesmo nome; a saída de.jsonregistra como cada portal foi encontrado.
Autenticação: a conexão MCP deve carregar a identidade, para que um token nunca precise passar por um chat (assistentes cuidadosos recusam copiar segredos de uma transcrição para chamadas de ferramenta, especialmente para delete_watch).
- OAuth (especificação de autorização MCP).
/.well-known/oauth-protected-resource/mcpaponta para o servidor de autorização integrado (/.well-known/oauth-authorization-server): registro dinâmico de clientes em/oauth/register,/oauth/authorizecom PKCE (S256),/oauth/token. Não há contas: a página de consentimento cria um cliente anônimo com um clique, ou conecta um existente se o usuário colar seu token lá, o que mantém suas vigias. Novos clientes usam o mesmo orçamento por endereço quePOST /v1/clients. - Como um cliente aprende a entrar. Sem um token,
search_jobselist_companiesainda funcionam, e as outras ferramentas de vigia (ewatch_jobsquando o provisionamento está desligado) retornamUNAUTHORIZEDcom o desafio em_meta["mcp/www_authenticate"](o sinal de autenticação mista do ChatGPT; as ferramentas também declaramsecuritySchemes). Um token desconhecido recebe um HTTP 401 comWWW-Authenticate, assim como qualquer solicitação para/mcp?auth=requiredsem um, para clientes que só iniciam OAuth em um 401 (Claude Code, Cursor). - Ou um cabeçalho. Clientes sem OAuth enviam
Authorization: Bearer <token>com um token dePOST /v1/clients. - Conversas antigas continuam funcionando. Toda ferramenta ainda aceita
client_token, e enquantoMCP_ANONYMOUS_PROVISIONINGestiver ativo (o padrão), uma chamadawatch_jobssem token algum cria um cliente anônimo e retorna seu token, como antes. Quando uma chamada carrega tanto uma conexão OAuth quanto umclient_token, e o cliente da própria conexão não tem vigias, a conexão é movida para o cliente doclient_token, então um usuário que conecta o aplicativo mantém suas vigias. Desligue o provisionamento assim que a versão OAuth do aplicativo ChatGPT estiver no ar; chamadaswatch_jobssem token então recebem o desafio OAuth e as ferramentas de vigia declaram OAuth como obrigatório.
API REST
Todos os endpoints, exceto POST /v1/clients, precisam de Authorization: Bearer <token>.
| Método e caminho | |
|---|---|
POST /v1/clients | Cria um cliente anônimo. Retorna token (mostrado uma vez). Limitado por taxa por endereço. |
POST /v1/watches | { "query"?, "url"? | "urls"?, "keywords"?, "all_keywords"?, "exclude_keywords"?, "locations"?, "seniority"?, "remote_only"?, "min_salary"?, "salary_currency"?, "max_experience_years"?, "include_unknown"?, "interval_minutes"?, "label"?, "webhook_url"? }. Sem url nem urls, cria uma vigia de busca em todos os quadros monitorados e precisa de pelo menos um filtro (QUERY_TOO_BROAD caso contrário). Com urls, a resposta é { watches, errors }. "type": "jobs" é aceito para clientes mais antigos. |
GET /v1/watches | Lista vigias. |
GET /v1/watches/:id | Detalhe da vigia e estado atual. |
DELETE /v1/watches/:id | Excluir. |
POST /v1/watches/:id/check | Força uma verificação. Recusado se o recurso foi verificado dentro de MIN_CHECK_INTERVAL_SECONDS, e para vigias de busca (NOT_SUPPORTED). |
GET /v1/changes | ?watch_id=&since=&limit=&peek=. Retorna { changes, cursor, has_more }. |
POST /v1/changes/ack | { "cursor", "watch_id"? }. Reconhece alterações lidas com peek=true. |
Também servidos: / (página inicial/documentação), /llms.txt, /.well-known/watchtower.json, /health e /metrics (Prometheus; defina METRICS_TOKEN para exigir um token bearer).
Erros têm a forma { "error": "WATCH_LIMIT", "message": "…" }.
- Não pode ser monitorado (422):
NO_JOB_DATA(sem plataforma suportada e sem marcaçãoJobPosting),ROBOTS_DISALLOWED,BOT_CHALLENGE,ACCESS_DENIED,SSRF_BLOCKED,UNSUPPORTED_CONTENT_TYPE,BODY_TOO_LARGE. - Capacidade:
WATCH_LIMITeHOST_WATCH_LIMIT(409),HOST_CAPACITY(429),CAPACITY(503). - Falhas transitórias (timeouts, 5xx, 404) mantêm a vigia. Elas aparecem em
resource.last_errore são repetidas com backoff exponencial.
Opções de entrega
-
Polling.
get_changesretorna o que há de novo e avança o cursor. Para processamento pelo menos uma vez, chameget_changes(peek=true), processe as alterações e entãoack_changes(cursor). Se você falhar antes do reconhecimento, as mesmas alterações voltam. -
Webhooks. Passe
webhook_urlao criar uma vigia. A resposta de criação inclui umwebhook_secret, mostrado uma vez. Cada lote de alterações correspondentes é enviado via POST como JSON com estes cabeçalhos:x-watchtower-timestampx-watchtower-signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">x-watchtower-delivery
As entregas vêm de uma caixa de saída e são repetidas com backoff exponencial (8 tentativas). URLs de webhook passam pelas mesmas verificações SSRF que URLs monitoradas, e redirecionamentos não são seguidos.
Ciclo de vida da vigia
Uma vigia permanece ativa enquanto alguém a usa: get_changes, get_watch, list_watches ou uma entrega de webhook bem-sucedida a renova. Vigias que ninguém toca por WATCH_TTL_DAYS (30) expiram e param de custar buscas; expires_at é mostrado em cada vigia.
Como funciona
watches (per client) ──many-to-one──▶ resources (fetch url + adapter)
│ scheduler: due? one per host, host lease held
▼
fetch ─▶ job-board API adapter, or JobPosting JSON-LD
│ diff the job set vs the previous snapshot
▼
snapshot + typed job events, one transaction
│ read through each watch's keyword filter
▼
get_changes (per-watch cursor) · webhook outbox → signed POST
Uma vigia de busca não tem recurso próprio. Ela lê os mesmos eventos de alteração, de cada recurso monitorado, através de seus filtros.
- O diretório de quadros. Quadros no diretório são recursos comuns marcados com
indexed. O agendador os verifica a cadaINDEX_CHECK_INTERVAL_SECONDS(padrão de quatro horas), quer alguém os vigie ou não, e a parte listada do diretório é sincronizada desrc/search/boards.tseINDEX_BOARDS_FILEna inicialização. Uma solicitação por host de plataforma está em andamento por vez, então os quadros de uma plataforma são verificados um após o outro: com o tick padrão de 5 s do agendador, isso é cerca de 700 quadros por plataforma por hora, ou 2.900 por ciclo de quatro horas. Um diretório maior que isso não é um erro; os quadros são verificados o mais rápido que a cortesia permite, ewatchtower_directory_overdue_boardsmostra o quanto está atrasado. Um quadro com uma vigia ativa ainda é verificado no intervalo dessa vigia. - Solicitações de listagem carregam o texto da vaga. Greenhouse (
content=true), Lever, Ashby (includeCompensation=true), Workable (details=true) e Recruitee retornam a descrição e o salário de cada vaga na resposta de listagem, então salário e experiência não custam solicitações extras. Apenas os campos derivados são armazenados, nunca a descrição. Essas respostas são grandes, então as APIs de plataforma têm seu próprio limite de corpo (MAX_API_BODY_BYTES, 64 MB); um quadro maior que isso é lido como uma listagem simples, sem salário ou experiência. - URLs de quadros mapeiam para endpoints de plataforma.
boards.greenhouse.io/acme,jobs.lever.co/acme,jobs.ashbyhq.com/acme,apply.workable.com/acme,jobs.smartrecruiters.com/Acmeeacme.recruitee.com(além de suas variantes da UE e de incorporação) são buscados na API pública de quadros de empregos de cada plataforma em uma única solicitação. Outras URLs são buscadas como HTML e lidas por meio do JSON-LDJobPostingdo schema.org (incluindo@grapheItemList). Apenas a lista de empregos é comparada, então a rotatividade de página ao redor dela (tokens de sessão, "renderizado há N minutos", banners) nunca é registrada como uma alteração.- Workday (
acme.wd5.myworkdayjobs.com/Careers,wd3.myworkdaysite.com/recruiting/acme/External): o endpoint de busca próprio do site, um POST JSON que retorna postagens das mais recentes para as mais antigas, 20 por página. Uma verificação lê as 200 mais recentes (10 solicitações). Quadros com mais postagens são marcados comocomplete: falseno instantâneo e nunca emitemJOB_REMOVED, porque um emprego saindo da janela não é uma remoção. Links de empregos apontam para o site público; o relativo "Postado há 3 dias" não é mantido. - iCIMS (
careers-acme.icims.com): ositemap.xmldo portal lista cada emprego aberto em uma solicitação (id mais um slug do título), e a primeira página de/jobs/searchfornece os ~50 mais recentes com seu título real, localização e data de postagem. Uma verificação lê ambos. Empregos vistos apenas no sitemap sãopartial: true(título do slug, sem localização); detalhes aprendidos anteriormente são transportados, e uma entrada parcial se tornando completa não é relatada como uma atualização. - Apple (qualquer URL
jobs.apple.com): o endpoint de busca próprio do site (/api/v1/search), um POST JSON que não requer sessão e retorna funções das mais recentes para as mais antigas, 20 por página, com título, equipe, cada localização e data de postagem. Uma verificação lê as 300 mais recentes (15 solicitações) e é marcada comocomplete: false, como no Workday. Cerca de 80 funções de varejo perenes são carimbadas com o horário da solicitação, então elas sempre classificam primeiro e usam parte dessa janela; esse carimbo de tempo não é mantido comoposted_at. A Apple publica uma função por localização, então uma função aberta em três cidades é três empregos. - Google (
careers.google.com,www.google.com/about/careers/applications): o robots.txt do Google desautoriza a busca de empregos e as páginas de empregos, então uma verificação lê apenas o sitemap de empregos, que lista cada função aberta em uma solicitação. Cada emprego épartial: true: o título vem do slug da URL, que para na primeira vírgula do título real ("Senior Software Engineer, Infrastructure" é lido como "Senior Software Engineer"), e não há localização, equipe ou data. - Amazon (
amazon.jobs): osearch.jsondo site, das mais recentes para as mais antigas, 100 postagens por solicitação com o texto completo da postagem (entãoexperience_yearsgeralmente é conhecido; o pagamento não está na listagem). Uma verificação lê as 300 mais recentes (3 solicitações) e é semprecomplete: false, já que o limite de contagem é de 10.000. Funções de armazém por hora em hiring.amazon.com não são cobertas. - Oracle Recruiting (
acme.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1): a busca de requisições própria do site (/hcmRestApi/resources/latest/recruitingCEJobRequisitions, o localizador REST que o site do candidato chama), das mais recentes para as mais antigas, 100 por solicitação, com cada localização, família de empregos, data de postagem e um resumo curto. Uma verificação lê as 300 mais recentes (3 solicitações) e é marcada comocomplete: falsequando o site lista mais, como no Workday. - Eightfold (
acme.eightfold.ai/careers?domain=acme.com): o endpoint de busca que o robots.txt do Eightfold permite (/api/pcsx/search), das mais recentes para as mais antigas, 10 por solicitação. Alguns sites Eightfold respondem a solicitações rápidas sucessivas com HTTP 429, então uma verificação lê as 50 mais recentes, com 3 s de intervalo, e quando uma página posterior é recusada, mantém o que leu e marca o instantâneo comocomplete: false. Grandes empregadores publicam muito mais de 50 funções entre verificações, então um quadro Eightfold é uma janela para suas postagens mais recentes, em vez de sua lista completa. Alguns locatários não ativaram esse endpoint (ele responde 403) e servem o/api/apply/v2/jobsmais antigo, com o texto de cada postagem (entãosalaryeexperience_yearsquando declarados), mas na ordem própria do site, aproximadamente das mais recentes para as mais antigas; os locatários listados emsrc/extract/eightfold.tscomoLEGACYsão lidos por meio dele. - SuccessFactors (sites Career Site Builder no domínio próprio da empresa, ex.:
careers.paramount.com): não há API JSON pública, então uma verificação lê a página de busca do site ordenada por data, 25 empregos por página com título, localização e data, até as 200 mais recentes (8 solicitações), e é marcada comocomplete: falsequando o site lista mais. Temas de bloco e tabela são ambos lidos. Temas que renderizam resultados com JavaScript deixam a página de busca vazia (seu endpoint JSON está sob/services/, que o robots.txt desautoriza), então esses sites são lidos desitemap.xml, que lista cada emprego aberto em uma solicitação: como um feed RSS com o título, localização, função e texto de cada emprego em alguns sites, e como URLs de empregos simples em outros, cujos empregos sãopartial: true(título do slug da URL, que também carrega as palavras de localização; sem localização ou data). - Microsoft (
careers.microsoft.com,jobs.careers.microsoft.com,apply.careers.microsoft.com): um site Eightfold, lido como acima.
- Workday (
- Campos derivados. Cada emprego recebe
remote(título ou localização diz remoto, e não híbrido/presencial) eseniority(estagiário, júnior, pleno, sênior, staff, principal, gerente, diretor, do título; palavras de gestão vencem sobre níveis de IC, e "pleno" significa que o título não carrega nível). Essas são heurísticas sobre o texto, por isso são expostas no emprego em vez de escondidas dentro do filtro. - Recursos vs. observações. Observações são intenções por cliente. Recursos são o que realmente é buscado. Qualquer número de observações no mesmo quadro (entre clientes) compartilha um recurso, uma busca por intervalo e um conjunto de instantâneos. URLs são canonicalizadas antes do compartilhamento: parâmetros de rastreamento (
utm_*,fbclid,gclid, …) são descartados e a consulta é ordenada. O recurso é verificado no menor intervalo que qualquer uma de suas observações ativas solicita, mas nunca mais frequentemente queMIN_CHECK_INTERVAL_SECONDS(padrão 5 minutos). - Identidade do emprego. Empregos são chaveados pelo id de emprego da plataforma, ou para JSON-LD por
identifier, depoisurl, depois título e localização. Um emprego cujo título, localização, departamento ou URL mudou se tornaJOB_UPDATEDcomchanged_fields. Um emprego JSON-LD sem um id estável cuja localização mudou é pareado em umJOB_UPDATEDem vez de uma remoção mais uma adição. - Eventos de alteração são gravados uma vez por recurso. Cada observação os lê por meio de seus filtros, avaliados em SQL contra os dados do emprego da alteração (e contra a lista de empregos atual, para
get_watch):keywords(qualquer, contra título, localização, departamento e empresa),all_keywords(cada um),exclude_keywords,locations(contra a localização do emprego eother_locations),seniority,remote_only,min_salaryemax_experience_years. Termos correspondem a palavras inteiras, sem diferenciar maiúsculas de minúsculas e permitindo plural: "ios" corresponde a "Senior iOS Engineer", mas não a "Game Studios", e "java" não corresponde a "JavaScript". Uma observação nunca vê alterações de antes de sua criação. Ids de alteração funcionam como cursores, então transações de gravação de alterações são serializadas. Isso mantém ids visíveis em ordem, então uma verificação concorrente lenta não pode confirmar um id que um leitor já passou. - Polidez. No máximo uma busca está em andamento por site em todas as réplicas, usando uma concessão de host Postgres com
HOST_MIN_SPACING_MSentre solicitações; uma verificação de múltiplas solicitações (Workday, iCIMS, Oracle Recruiting, Eightfold, SuccessFactors, Apple, Amazon, Microsoft) mantém a concessão para todas as suas solicitações e pausa brevemente entre elas (3 s para sites Eightfold, como Microsoft, que limitam solicitações rápidas). Cada tick do agendador reivindica no máximo um recurso por host. Watchtower também:- envia
If-None-Match/If-Modified-Since(um 304 significa nenhum trabalho); - verifica robots.txt (armazenado em cache por origem por uma hora, semântica RFC 9309);
- envia um User-Agent identificador;
- recua exponencialmente em erros e honra
Retry-Afterem 429.
- envia
- Agendamento e manutenção. Recursos devidos são reivindicados com uma concessão, então você pode executar várias réplicas; defina
RUN_SCHEDULER=falseem réplicas somente de API. O mesmo loop entrega webhooks e executa manutenção sob um bloqueio consultivo. Manutenção:- expira observações não lidas;
- exclui alterações após
CHANGE_RETENTION_DAYSe instantâneos não atuais apósSNAPSHOT_RETENTION_DAYS; - remove recursos não observados e clientes ociosos;
- limpa janelas de limite de taxa antigas.
Controles de segurança e abuso
- SSRF.
- Apenas http/https nas portas 80/443. URLs com credenciais são rejeitadas.
localhost,*.local,*.internal, hosts de rótulo único e numéricos são rejeitados.- Cada endereço resolvido deve ser unicast globalmente roteável. Privado, loopback, link-local (incluindo metadados de nuvem), CGNAT, multicast, reservado, documentação, IPv4-mapeado IPv6, 6to4, Teredo e NAT64 são todos recusados.
- A verificação é executada dentro da busca DNS do socket, então ela se mantém no momento da conexão para cada salto de redirecionamento (derrotando rebinding de DNS), bem como uma vez na criação da observação.
- URLs de webhook recebem o mesmo tratamento.
- Limites de solicitação. Um prazo geral de 15 s em todos os saltos. No máximo 5 redirecionamentos, cada um revalidado. Um limite de corpo de 3 MB (64 MB para APIs de plataforma de quadro de empregos) aplicado nos bytes descomprimidos, então bombas gzip e brotli são capturadas. Apenas tipos de conteúdo semelhantes a texto.
- Limites de API. Limites de taxa são armazenados no Postgres, então eles se mantêm entre réplicas e reinicializações: 120 solicitações/min, 10 criações de cliente/hora (compartilhadas com provisionamento automático de MCP) e 10 verificações forçadas/min. Clientes são identificados por endereço IPv4 ou IPv6 /64.
- Limites de observação e recurso.
- 50 observações por cliente, e 5 por cliente por site para páginas de carreira arbitrárias (plataformas de quadro são empresas separadas atrás de um host de API e são isentas).
- No máximo
MAX_RESOURCES_PER_HOSTURLs distintas de páginas de carreira por site em todos os clientes; APIs de plataforma de quadro de empregos são isentas, e observar um quadro já monitorado é sempre permitido. - Um
MAX_ACTIVE_RESOURCESglobal.
- Tokens. Valores aleatórios de 192 bits; apenas seu SHA-256 é armazenado. Corpos de solicitação são limitados a 64 KB.
- Sem bypass. Watchtower nunca contorna CAPTCHAs, desafios de bot, logins, paywalls ou robots.txt. Ele os detecta, informa o chamador, registra
host is refusing use os conta emwatchtower_blocked_resources_1h.
Operações
GET /metrics expõe métricas Prometheus:
- resultados de verificação por código de erro;
- histograma de duração de busca;
- alterações emitidas por tipo;
- resultados de webhook;
- adiamentos de host ocupado;
- medidores para observações/recursos ativos, observações de busca, quadros de diretório e quantos estão atrasados, falhando e bloqueados, e webhooks pendentes;
- de onde os agentes vêm:
watchtower_mcp_initialize_totalpelo nome de cliente MCP que um agente relata (claude-code,cursor, ...), ewatchtower_clients_created_7dpela tag?ref=na URL pela qual um cliente foi criado. Cada caminho de listagem e instalação distribui sua própria tag (/mcp?ref=registry,?ref=claude-plugin,?ref=cursor, ...);clients.sourceeclients.user_agenta mantêm por cliente.
Estatísticas de uso
/stats mostra usuários, usuários ativos diários, novos usuários, chamadas de ferramenta, visualizações de página e de onde os usuários vêm, como gráficos e tabelas. Faça login com qualquer nome de usuário e STATS_TOKEN (ou METRICS_TOKEN) como senha; com nenhum definido, a página está desligada. Um usuário é um token de cliente anônimo. O histórico vive em usage_daily (uma linha por cliente, dia UTC, ferramenta e interface) e counts_daily (visualizações de página por pessoas, assistentes de IA e outros bots, e conexões MCP por nome de aplicativo), mantido por 400 dias. Nada por visitante é armazenado para visualizações de página.
Listagem no Registro MCP
server.json é a entrada do registro. O namespace lat.watchtower/* é comprovado por HTTP: defina MCP_REGISTRY_AUTH para o registro de chave pública e o aplicativo o serve em /.well-known/mcp-registry-auth. Então, com a chave privada correspondente:
mcp-publisher login http --domain watchtower.lat --private-key "$PRIVATE_KEY_HEX"
mcp-publisher publish # bump "version" in server.json for each new publish
Robôs, o cartão do servidor MCP (/.well-known/mcp.json, também em /.well-known/mcp-server-card enquanto o caminho ainda é um rascunho) e os links de instalação na página inicial são gerados a partir de PUBLIC_BASE_URL.
ops/alerts.yml tem regras de alerta de exemplo: sites que nos recusam, alta taxa de erros, verificações travadas, fila de webhooks, buscas lentas e o diretório ficando para trás do intervalo de verificação.
Configuração
Veja .env.example. As configurações mais importantes:
DATABASE_URLePUBLIC_BASE_URL.MIN_CHECK_INTERVAL_SECONDSeHOST_MIN_SPACING_MS.INDEX_ENABLED,INDEX_CHECK_INTERVAL_SECONDS,INDEX_BOARDS_FILEeINDEX_MAX_PROMOTED: o diretório de quadros contra o qual as buscas monitoradas são comparadas. ComINDEX_ENABLED=false, o Watchtower busca apenas quadros que alguém monitora por URL.- Os limites:
MAX_WATCHES_PER_CLIENT,MAX_RESOURCES_PER_HOSTeMAX_ACTIVE_RESOURCES. WATCH_TTL_DAYSe as configurações de retenção.TRUST_PROXY: defina-o atrás de um balanceador de carga para que os limites de taxa vejam os IPs reais dos clientes.ALLOW_PRIVATE_NETWORKSdesativa a proteção SSRF e existe apenas para testes e para a demonstração.
Desenvolvimento
npm run typecheck
npm test # unit tests; integration tests need a database:
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower_test npm test
A suíte de integração descarta e recria o esquema public de TEST_DATABASE_URL, então aponte-a para um banco de dados descartável.
A suíte tem três partes:
- Testes de unidade.
- Testes de fuzzing baseados em propriedades (fast-check) sobre os parsers de HTML, JSON-LD e robots e os invariantes de diff de empregos.
- Testes de fonte para os parsers e paginação da Workday, iCIMS, Oracle Recruiting, Eightfold, SuccessFactors, Apple, Google, Amazon e Microsoft, e o classificador de remoto/senioridade.
- Testes de busca para o leitor de consultas, os parsers de pagamento e experiência, correspondência de palavras inteiras, os filtros e a descoberta de quadros.
- Testes de integração cobrindo REST, MCP, compartilhamento, filtros, buscas monitoradas e o diretório de quadros, criação em lote, rotatividade de páginas,
NO_JOB_DATA, leases de host, verificações concorrentes, limites, expiração e retenção, webhooks, limites de taxa e métricas.
CI (.github/workflows/ci.yml) executa typecheck, todos os testes contra um serviço Postgres, a compilação e a demonstração. Também compila a imagem Docker e faz smoke test nela: migrações, /health, /metrics, a página inicial e o usuário não-root.
src/
server.ts, app.ts entrypoint; Fastify app (site, REST, MCP)
config.ts, db.ts env config; pg pool + migration runner
security/ssrf.ts URL validation + connect-time DNS guard
fetch/ safeFetch (redirects, limits, decompression), robots.txt
extract/ job-board adapters (incl. Workday, iCIMS, Oracle, Eightfold, SuccessFactors, Apple, Google, Amazon, Microsoft), JobPosting JSON-LD, remote/seniority
classifier, pay/experience parsing, term matching, job diff (identity, pairing)
search/ plain-language query reader; the built-in board directory; board discovery
(careers-page links, name guesses)
services/ clients, watches (board and search), checker, scheduler (+ maintenance), board
directory sync, host leases, rate limits, webhooks, metrics
mcp/server.ts MCP tools
web/site.ts homepage, llms.txt, well-known metadata
migrations/ SQL migrations
ops/alerts.yml example Prometheus alert rules
scripts/demo.ts end-to-end demo
scripts/discover-boards.ts grow the directory from a list of companies
Deliberadamente fora do escopo
- Cobrir todos os quadros de empregos na internet, ou empregadores fora da tecnologia. Uma busca monitorada vê o diretório e os quadros que as pessoas monitoraram. A descoberta é uma etapa operacional a partir de uma lista de empresas, não um rastreador, e agregadores como LinkedIn ou Indeed não são lidos.
- Filtrar para funções técnicas. "Empregos de tecnologia" significa empregos em empresas de tecnologia; uma função de vendas em uma delas é reportada se os filtros a corresponderem.
- Ler cada página de emprego da SmartRecruiters, Workday ou iCIMS para sua descrição, então esses empregos não trazem pagamento ou experiência.
- Um filtro de salário máximo, conversão de moeda e entender uma consulta com um modelo de linguagem.
- Cobrança, contas e painéis.
- Renderização JavaScript. Páginas de carreiras que não estão em uma plataforma suportada só são legíveis se seu HTML renderizado no servidor trouxer JSON-LD
JobPosting. - Monitoramento geral de páginas, feeds ou eventos. O Watchtower costumava fazer isso; agora faz apenas quadros de empregos (a migração
003_jobs_only.sqlaposenta as buscas de páginas e eventos existentes). - Paginação além das primeiras 100 postagens da SmartRecruiters ou das 200 postagens mais recentes da Workday por quadro.
- Ler cada página de emprego da iCIMS para detalhes; apenas os ~50 empregos mais recentes por portal recebem um título e localização reais.
- Ler a busca de empregos do Google ou páginas de empregos, que seu robots.txt desautoriza; os empregos do Google são conhecidos apenas pela entrada do sitemap.
- Ler mais do que os 300 papéis mais recentes da Apple, Amazon ou Oracle Recruiting, os 200 papéis mais recentes da SuccessFactors, ou os 50 mais recentes da Eightfold (incl. Microsoft), por verificação.
- Sites da SuccessFactors rodam no domínio próprio da empresa, então apenas os hosts listados em
src/extract/successfactors.tssão lidos como SuccessFactors; qualquer outro é lido através de sua marcação JobPosting, se houver.