Watchtower

Alertas de empleo para agentes de IA. Describe un puesto una vez en lenguaje sencillo y recibe solo las nuevas ofertas que coincidan de más de 1,500 bolsas de trabajo de empresas tecnológicas y startups (Greenhouse, Lever, Ashby, Workday, Apple, Google, Amazon, Microsoft y más), con filtros de salario y experiencia y webhooks. Gratis, sin registro.

Servidor MCP alojado

npx add-mcp 'https://watchtower.lat/mcp'

Se instala en Claude Code, Codex, Cursor y más

Documentación

Watchtower

Monitoreo de ofertas de empleo tecnológico para agentes de IA. Di una vez lo que buscas y recibe solo las nuevas publicaciones que coincidan, de los portales de empleo de empresas tecnológicas y startups, como JSON estructurado.

Los agentes son malos esperando a que se publique una oferta. Una sesión dura minutos, las páginas de carreras son pesadas de releer, y "¿alguien ha publicado un rol de iOS en Austin todavía?" se convierte en repetir la misma búsqueda cada día. Con Watchtower el agente crea una vigilancia una vez, en lenguaje natural:

{ "query": "iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience" }

Watchtower revisa los portales de empleo de empresas tecnológicas y startups en un horario y recuerda qué ofertas estaban abiertas. get_changes luego devuelve solo las nuevas publicaciones que coinciden, y un webhook puede despertar al agente cuando llegue una. Un agente también puede vigilar el portal de una empresa por URL y recibir eventos JOB_ADDED, JOB_REMOVED y JOB_UPDATED.

  • Sin necesidad de URL. Una vigilancia creada desde un query cubre todos los portales que Watchtower monitorea: un directorio integrado de portales de empresas tecnológicas y startups más todos los portales que alguien haya vigilado por URL.
  • Salario y experiencia. Las ofertas llevan salary y experience_years cuando la publicación los indica, y las vigilancias filtran por min_salary y max_experience_years.
  • Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Workday, iCIMS, Oracle Recruiting, Eightfold y SuccessFactors se leen a través de los propios endpoints de cada plataforma: sin muros anti-bots. También Apple (jobs.apple.com), Google (careers.google.com), Amazon (amazon.jobs) y Microsoft (careers.microsoft.com), que gestionan sus propios sitios de carreras. Cualquier otra página de carreras funciona si publica marcado schema.org JobPosting.
  • Filtros en la vigilancia, para que get_changes solo devuelva lo que importa: keywords, all_keywords, exclude_keywords, locations, seniority, remote_only, min_salary y max_experience_years. Cada oferta lleva campos derivados remote y seniority.
  • Muchas empresas en una sola llamada: pasa urls en lugar de url.
  • MCP (HTTP Streamable) y REST, respaldados por la misma capa de servicio.
  • Gratis y anónimo: un cliente recibe un token y hasta 50 vigilancias (una búsqueda en todos los portales es una vigilancia).
  • TypeScript, Node.js 22, Fastify 5, PostgreSQL, el SDK oficial de MCP TypeScript. No se requieren claves de LLM ni de API de terceros.

Usa el servicio alojado

Watchtower se ejecuta en watchtower.lat, gratis, sin registro. Endpoint MCP: https://watchtower.lat/mcp.

  • Claude Code: claude mcp add --transport http watchtower https://watchtower.lat/mcp, o instala el plugin, que añade una habilidad que le dice a Claude cuándo usarlo:
    /plugin marketplace add connorlagana/watchtower
    /plugin install watchtower@watchtower
    
  • Claude.ai / Claude Desktop: Configuración → Conectores → Añadir conector personalizado → https://watchtower.lat/mcp.
  • Cursor, VS Code: botones de un clic en watchtower.lat/#install.
  • Cualquier otra cosa: { "mcpServers": { "watchtower": { "type": "http", "url": "https://watchtower.lat/mcp" } } }.

Listado en el Registro MCP como lat.watchtower/watchtower (server.json).

Inicio 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

Sin Docker (Node 22 y un Postgres en ejecución):

npm install
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower
npm run migrate
npm run dev                         # or: npm run build && npm start

Demo

npm run demo (con DATABASE_URL configurado) ejecuta el bucle principal de principio a fin contra una página de carreras local de prueba:

  1. crear cliente → 2. crear una vigilancia de ofertas con la palabra clave ios (toma la instantánea inicial) → 3. un segundo agente vigila el mismo portal y reutiliza el mismo recurso → 4. un tercer agente no nombra ningún portal y crea una vigilancia de búsqueda desde "trabajos remotos de iOS que paguen al menos 150k con un máximo de 6 años de experiencia" → 5. una re-verificación donde solo cambia la página alrededor de las ofertas (token de sesión, "hace N minutos") no reporta cambios → 6. la fuente añade una oferta de iOS y una de Android → 7. Watchtower verifica y detecta el cambio → 8. un cliente MCP llama a get_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
}

La oferta de Android se filtra por la palabra clave. 9. Una segunda llamada a get_changes devuelve []. 10. El segundo agente, sin palabra clave, ve ambos roles nuevos. 11. El tercer agente recibe el rol de iOS con el salario y la experiencia leídos de la publicación.

La demo ejecuta su prueba en 127.0.0.1, por lo que activa ALLOW_PRIVATE_NETWORKS solo para su propio proceso.

Conectando un agente (MCP)

{ "mcpServers": { "watchtower": { "type": "http", "url": "http://localhost:3000/mcp" } } }
HerramientaQué hace
watch_jobsCon query y sin URL: vigila todos los portales monitoreados en busca de nuevas publicaciones que coincidan con una solicitud en lenguaje natural. El resultado muestra la lectura (interpreted), las ofertas coincidentes abiertas ahora (current_jobs) y los portales cubiertos (coverage). Con url, o urls (hasta 25; devuelve watches y errors por URL): vigila portales específicos. Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Workday e iCIMS usan sus propios endpoints; otras páginas de carreras usan schema.org JobPosting. Una página de carreras que solo enlaza a un portal compatible se vigila a través de ese portal (resolved_from), y una página sin ninguno se rechaza con NO_JOB_DATA. Las vigilancias de portal emiten JOB_ADDED / JOB_REMOVED / JOB_UPDATED; las vigilancias de búsqueda emiten JOB_ADDED. Los filtros explícitos anulan la consulta: keywords, all_keywords, exclude_keywords, locations, seniority, remote_only, min_salary, salary_currency, max_experience_years, include_unknown.
search_jobsSolo lectura, puntual: las ofertas abiertas ahora que coinciden con un query y/o los mismos filtros, en todos los portales monitoreados, más recientes primero. Devuelve interpreted, total, jobs y next_offset (limit hasta 100, offset). No necesita token y no crea vigilancia. REST: POST /v1/jobs/search. Limitado a SEARCH_PER_MINUTE (20) llamadas por dirección.
list_companiesSolo lectura: las empresas del directorio (lo que cubre cada búsqueda), por nombre, con board_url, platform y open_jobs. query verifica una empresa; páginas con limit/offset. No necesita token. REST: GET /v1/companies?q=. Las personas pueden navegar la misma lista en /companies.
get_changesCambios desde tu última llamada (el cursor avanza). peek, since (reproducción), watch_id, limit.
ack_changesReconoce un cursor después de get_changes(peek=true), para procesamiento al menos una vez.
list_watchesTus vigilancias, con salud, caducidad y conteos de cambios pendientes.
get_watchUna vigilancia más las ofertas actualmente abiertas que coinciden con sus filtros (en todos los portales para una vigilancia de búsqueda).
delete_watchDetener el monitoreo y liberar un espacio.

watch_jobs también acepta webhook_url para entrega push (ver abajo).

Las descripciones de herramientas y el instructions del servidor les dicen a los agentes que prefieran Watchtower sobre repetir búsquedas de empleo o re-verificar páginas de carreras.

Vigilancias de búsqueda

Una vigilancia sin url es una vigilancia de búsqueda. Son sus filtros, y lee las nuevas publicaciones de cada portal monitoreado.

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": "…" } ]
}
  • La consulta se lee con reglas, no con un modelo. Watchtower aún no necesita LLM ni clave de API. La lectura vuelve como interpreted, con notes para cualquier cosa que no pudo usar, para que el agente que llama pueda verificarla. Los filtros explícitos siempre ganan sobre la consulta.
    • Las palabras de rol se convierten en keywords (cualquiera de, para "iOS o Android") o all_keywords (todos de, para "científico de datos"). Palabras genéricas como "ingeniero" y "desarrollador" se descartan cuando hay una palabra más específica.
    • "en Austin", "en Austin, TX", "en Nueva York o remoto" se convierten en locations. Un "remoto" simple se convierte en remote_only.
    • "al menos 150k", "$180,000+", "$45/hora" se convierten en min_salary, convertidos a una cifra anual.
    • "un máximo de 6 años de experiencia", "3-5 años", "tengo 4 años de experiencia" se convierten en max_experience_years.
    • "senior", "staff", "nivel inicial" y los otros niveles se convierten en seniority. "sin gerentes" se convierte en exclude_keywords.
  • La cobertura son empresas tecnológicas y startups, no todo internet. El directorio integrado (src/search/boards.ts) lista 1,705 portales de empresas en Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Workday, iCIMS, Oracle Recruiting, Eightfold y SuccessFactors, más los sitios de carreras de Apple, Google, Amazon y Microsoft, desde startups en etapa semilla hasta grandes empleadores en cada industria que contratan desarrolladores. Cada uno se confirmó contra la API de su plataforma con ofertas abiertas. Todos los roles que esas empresas publican están cubiertos, no solo ingeniería. La lista en vivo se publica en /companies y a través de list_companies. Además, cada portal que cualquier cliente vigila por URL está cubierto, y permanece cubierto (ver Creciendo el directorio).
  • El salario y la experiencia vienen de la publicación. Los campos de pago de Lever, Ashby, Greenhouse, Recruitee y JSON-LD se leen directamente; de lo contrario, se analiza el texto de la publicación ("$150,000 - $200,000/año", "5+ años de experiencia"). Una oferta pasa min_salary cuando el tope de su rango lo alcanza, y max_experience_years cuando pide no más que eso.
  • Las publicaciones que no indican ninguno aún se reportan, sin un campo salary o experience_years, porque muchas publicaciones no indican salario. Pasa include_unknown: false para reportar solo publicaciones que indiquen un valor que califique.
  • Solo se reportan publicaciones nuevas (JOB_ADDED), y solo las que aparecen después de que se creó la vigilancia. current_jobs en la creación y en get_watch es la línea base de lo que está abierto ahora.
  • Las listas de SmartRecruiters, Workday, iCIMS, Eightfold (excepto inquilinos de endpoint antiguo), SuccessFactors (excepto sitemaps RSS), Google y Microsoft no llevan texto de publicación, por lo que sus ofertas nunca tienen salary o experience_years. Las listas de Apple y Oracle Recruiting llevan solo un resumen corto, que rara vez indica alguno.
  • Las ofertas de Google no tienen ubicación y solo la primera parte del título (ver Google), por lo que una búsqueda con locations nunca las coincide.

Creciendo el directorio

El directorio crece de tres maneras.

  • Los agentes lo hacen crecer usándolo. Cuando un cliente vigila un portal de plataforma por URL y tiene ofertas abiertas, el portal se une al directorio y permanece allí después de que esa vigilancia termine, para que cada vigilancia de búsqueda lo cubra desde entonces. Como máximo INDEX_MAX_PROMOTED (5,000) portales se mantienen de esta manera, y uno que falle ocho verificaciones seguidas se elimina. Las páginas de carreras arbitrarias nunca se mantienen; solo portales en las APIs de plataforma.
  • Una página de carreras funciona como vía de entrada. watch_jobs con https://acme.com/careers usualmente no encuentra marcado de ofertas allí, porque la página solo enlaza o incrusta el portal de la empresa. Watchtower entonces lee la página una vez más, encuentra el enlace a un portal compatible, y vigila ese portal en su lugar. La respuesta lo dice en resolved_from.
  • Los operadores pueden importar una lista de empresas. npm run discover -- companies.json boards.txt toma [{ "name", "website" }] y, para cada empresa, lee su página de carreras en busca de un enlace de portal, luego prueba portales nombrados como la empresa en Ashby, Greenhouse y Lever. Cada candidato se confirma contra la API de listados de la plataforma y se mantiene solo si tiene ofertas abiertas. boards.txt está en el formato que INDEX_BOARDS_FILE lee. La lista integrada se produjo de esta manera desde el directorio público de empresas de Y Combinator. Un portal encontrado por nombre en lugar de desde la propia página de la empresa puede pertenecer a una empresa diferente con el mismo nombre; la salida .json registra cómo se encontró cada portal.

Autenticación: la conexión MCP debe llevar la identidad, para que un token nunca tenga que pasar por un chat (los asistentes cuidadosos se niegan a copiar secretos de una transcripción en llamadas de herramientas, especialmente en delete_watch).

  • OAuth (especificación de autorización MCP). /.well-known/oauth-protected-resource/mcp apunta al servidor de autorización integrado (/.well-known/oauth-authorization-server): registro dinámico de clientes en /oauth/register, /oauth/authorize con PKCE (S256), /oauth/token. No hay cuentas: la página de consentimiento crea un cliente anónimo con un clic, o conecta uno existente si el usuario pega su token allí, lo que conserva sus vigilancias. Los nuevos clientes utilizan el mismo presupuesto por dirección que POST /v1/clients.
  • Cómo aprende un cliente a iniciar sesión. Sin un token, search_jobs y list_companies siguen funcionando, y las demás herramientas de vigilancia (y watch_jobs una vez que el aprovisionamiento está desactivado) devuelven UNAUTHORIZED con el desafío en _meta["mcp/www_authenticate"] (la señal de autenticación mixta de ChatGPT; las herramientas también declaran securitySchemes). Un token desconocido recibe un HTTP 401 con WWW-Authenticate, al igual que cualquier solicitud a /mcp?auth=required sin uno, para clientes que solo inician OAuth en un 401 (Claude Code, Cursor).
  • O un encabezado. Los clientes sin OAuth envían Authorization: Bearer <token> con un token de POST /v1/clients.
  • Las conversaciones antiguas siguen funcionando. Cada herramienta sigue aceptando client_token, y mientras MCP_ANONYMOUS_PROVISIONING esté activado (el valor predeterminado), una llamada a watch_jobs sin token crea un cliente anónimo y devuelve su token, como antes. Cuando una llamada lleva tanto una conexión OAuth como un client_token, y el cliente propio de la conexión no tiene vigilancias, la conexión se mueve al cliente del client_token, de modo que un usuario que conecta la aplicación conserva sus vigilancias. Desactive el aprovisionamiento una vez que la versión OAuth de la aplicación ChatGPT esté activa; las llamadas a watch_jobs sin token reciben entonces el desafío OAuth y las herramientas de vigilancia declaran OAuth como requerido.

API REST

Todos los endpoints excepto POST /v1/clients necesitan Authorization: Bearer <token>.

Método y ruta
POST /v1/clientsCrear un cliente anónimo. Devuelve token (mostrado una vez). Limitado por tasa por dirección.
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"? }. Sin url ni urls crea una vigilancia de búsqueda en todos los tableros monitoreados y necesita al menos un filtro (QUERY_TOO_BROAD de lo contrario). Con urls la respuesta es { watches, errors }. "type": "jobs" se acepta para clientes más antiguos.
GET /v1/watchesListar vigilancias.
GET /v1/watches/:idDetalle de vigilancia y estado actual.
DELETE /v1/watches/:idEliminar.
POST /v1/watches/:id/checkForzar una verificación. Se rechaza si el recurso fue verificado dentro de MIN_CHECK_INTERVAL_SECONDS, y para vigilancias de búsqueda (NOT_SUPPORTED).
GET /v1/changes?watch_id=&since=&limit=&peek=. Devuelve { changes, cursor, has_more }.
POST /v1/changes/ack{ "cursor", "watch_id"? }. Reconoce los cambios leídos con peek=true.

También se sirven: / (página de inicio/documentación), /llms.txt, /.well-known/watchtower.json, /health y /metrics (Prometheus; establezca METRICS_TOKEN para requerir un token de portador).

Los errores se ven como { "error": "WATCH_LIMIT", "message": "…" }.

  • No se puede monitorear (422): NO_JOB_DATA (sin plataforma compatible y sin marcado JobPosting), ROBOTS_DISALLOWED, BOT_CHALLENGE, ACCESS_DENIED, SSRF_BLOCKED, UNSUPPORTED_CONTENT_TYPE, BODY_TOO_LARGE.
  • Capacidad: WATCH_LIMIT y HOST_WATCH_LIMIT (409), HOST_CAPACITY (429), CAPACITY (503).
  • Fallos transitorios (tiempos de espera, 5xx, 404) mantienen la vigilancia. Aparecen en resource.last_error y se reintentan con retroceso exponencial.

Opciones de entrega

  • Sondeo. get_changes devuelve lo nuevo y avanza el cursor. Para procesamiento al menos una vez, llame a get_changes(peek=true), procese los cambios, luego ack_changes(cursor). Si falla antes del acuse, los mismos cambios vuelven.

  • Webhooks. Pase webhook_url al crear una vigilancia. La respuesta de creación incluye un webhook_secret, mostrado una vez. Cada lote de cambios coincidentes se envía por POST como JSON con estos encabezados:

    • x-watchtower-timestamp
    • x-watchtower-signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">
    • x-watchtower-delivery

    Las entregas provienen de una bandeja de salida y se reintentan con retroceso exponencial (8 intentos). Las URL de webhook pasan por los mismos controles SSRF que las URL monitoreadas, y no se siguen redirecciones.

Ciclo de vida de la vigilancia

Una vigilancia permanece activa mientras alguien la use: get_changes, get_watch, list_watches o una entrega de webhook exitosa la renueva. Las vigilancias que nadie toca durante WATCH_TTL_DAYS (30) expiran y dejan de costar búsquedas; expires_at se muestra en cada vigilancia.

Cómo 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

Una vigilancia de búsqueda no tiene recurso propio. Lee los mismos eventos de cambio, de cada recurso monitoreado, a través de sus filtros.

  • El directorio de tableros. Los tableros en el directorio son recursos ordinarios marcados con indexed. El programador los verifica cada INDEX_CHECK_INTERVAL_SECONDS (cuatro horas por defecto) tanto si alguien los vigila como si no, y la parte listada del directorio se sincroniza desde src/search/boards.ts y INDEX_BOARDS_FILE al inicio. Una solicitud por host de plataforma está en vuelo a la vez, por lo que los tableros de una plataforma se verifican uno tras otro: con el tick del programador predeterminado de 5 s, eso es aproximadamente 700 tableros por plataforma por hora, o 2,900 por ciclo de cuatro horas. Un directorio más grande que eso no es un error; los tableros se verifican tan rápido como la cortesía lo permite, y watchtower_directory_overdue_boards muestra cuánto está atrasado. Un tablero con una vigilancia activa se verifica igualmente en el intervalo de esa vigilancia.
  • Las solicitudes de listado llevan el texto de la publicación. Greenhouse (content=true), Lever, Ashby (includeCompensation=true), Workable (details=true) y Recruitee devuelven la descripción y el salario de cada publicación en la respuesta de listado, por lo que el salario y la experiencia no cuestan solicitudes adicionales. Solo se almacenan los campos derivados, nunca la descripción. Estas respuestas son grandes, por lo que las API de plataforma tienen su propio límite de cuerpo (MAX_API_BODY_BYTES, 64 MB); un tablero más grande que eso se lee como un listado simple, sin salario ni experiencia.
  • Las URLs de los tableros se asignan a endpoints de plataforma. boards.greenhouse.io/acme, jobs.lever.co/acme, jobs.ashbyhq.com/acme, apply.workable.com/acme, jobs.smartrecruiters.com/Acme y acme.recruitee.com (además de sus variantes de la UE y de inserción) se obtienen de la API pública de tablones de empleo de cada plataforma en una sola solicitud. Otras URLs se obtienen como HTML y se leen a través de JSON-LD de schema.org JobPosting (incluyendo @graph y ItemList). Solo se compara la lista de empleos, por lo que el cambio de página a su alrededor (tokens de sesión, "renderizado hace N minutos", banners) nunca se registra como un cambio.
    • Workday (acme.wd5.myworkdayjobs.com/Careers, wd3.myworkdaysite.com/recruiting/acme/External): el endpoint de búsqueda propio del sitio, un POST JSON que devuelve publicaciones de la más reciente a la más antigua, 20 por página. Una verificación lee las 200 más recientes (10 solicitudes). Los tableros con más publicaciones se marcan como complete: false en la instantánea y nunca emiten JOB_REMOVED, porque un empleo que sale de la ventana no es una eliminación. Los enlaces de empleo apuntan al sitio público; el "Publicado hace 3 días" relativo no se conserva.
    • iCIMS (careers-acme.icims.com): el sitemap.xml del portal lista cada empleo abierto en una sola solicitud (id más un slug del título), y la primera página de /jobs/search da los ~50 más recientes con su título real, ubicación y fecha de publicación. Una verificación lee ambos. Los empleos vistos solo en el mapa del sitio son partial: true (título del slug, sin ubicación); los detalles aprendidos antes se trasladan, y una entrada parcial que se convierte en completa no se reporta como una actualización.
    • Apple (cualquier URL jobs.apple.com): el endpoint de búsqueda propio del sitio (/api/v1/search), un POST JSON que no necesita sesión y devuelve roles del más reciente al más antiguo, 20 por página, con título, equipo, cada ubicación y fecha de publicación. Una verificación lee los 300 más recientes (15 solicitudes) y se marca como complete: false, como en Workday. Alrededor de 80 roles minoristas permanentes se sellan con la hora de la solicitud, por lo que siempre se ordenan primero y usan parte de esa ventana; esa marca de tiempo no se conserva como posted_at. Apple publica un rol por ubicación, por lo que un rol abierto en tres ciudades son tres empleos.
    • Google (careers.google.com, www.google.com/about/careers/applications): el robots.txt de Google no permite la búsqueda de empleos ni las páginas de empleo, por lo que una verificación solo lee el mapa del sitio de empleos, que lista cada rol abierto en una sola solicitud. Cada empleo es partial: true: el título proviene del slug de la URL, que se detiene en la primera coma del título real ("Senior Software Engineer, Infrastructure" se lee como "Senior Software Engineer"), y no hay ubicación, equipo ni fecha.
    • Amazon (amazon.jobs): el search.json del sitio, del más reciente al más antiguo, 100 publicaciones por solicitud con el texto completo de la publicación (por lo que experience_years generalmente se conoce; el pago no está en el listado). Una verificación lee los 300 más recientes (3 solicitudes) y siempre es complete: false, ya que el recuento de resultados está limitado a 10,000. Los roles de almacén por hora en hiring.amazon.com no están cubiertos.
    • Oracle Recruiting (acme.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1): la búsqueda de requisiciones propia del sitio (/hcmRestApi/resources/latest/recruitingCEJobRequisitions, el buscador REST que llama el sitio del candidato), del más reciente al más antiguo, 100 por solicitud, con cada ubicación, familia de empleo, fecha de publicación y un breve resumen. Una verificación lee los 300 más recientes (3 solicitudes) y se marca como complete: false cuando el sitio lista más, como en Workday.
    • Eightfold (acme.eightfold.ai/careers?domain=acme.com): el endpoint de búsqueda que el robots.txt de Eightfold permite (/api/pcsx/search), del más reciente al más antiguo, 10 por solicitud. Algunos sitios de Eightfold responden a solicitudes rápidas sucesivas con HTTP 429, por lo que una verificación lee los 50 más recientes, con 3 segundos de diferencia, y cuando una página posterior es rechazada, conserva lo que leyó y marca la instantánea como complete: false. Los empleadores grandes publican mucho más de 50 roles entre verificaciones, por lo que un tablero de Eightfold es una ventana a sus publicaciones más recientes en lugar de su lista completa. Algunos inquilinos no han activado ese endpoint (responde 403) y sirven el /api/apply/v2/jobs más antiguo en su lugar, con el texto de cada publicación (por lo que salary y experience_years cuando se indican) pero en el orden propio del sitio, aproximadamente del más reciente al más antiguo; los inquilinos listados en src/extract/eightfold.ts como LEGACY se leen a través de él.
    • SuccessFactors (sitios de Career Site Builder en el dominio propio de la empresa, p. ej., careers.paramount.com): no hay una API JSON pública, por lo que una verificación lee la página de búsqueda del sitio ordenada por fecha, 25 empleos por página con título, ubicación y fecha, hasta los 200 más recientes (8 solicitudes), y se marca como complete: false cuando el sitio lista más. Se leen tanto los temas de mosaico como los de tabla. Los temas que renderizan resultados con JavaScript dejan la página de búsqueda vacía (su endpoint JSON está bajo /services/, que robots.txt no permite), por lo que esos sitios se leen desde sitemap.xml, que lista cada empleo abierto en una sola solicitud: como un feed RSS con el título, la ubicación, la función y el texto de cada empleo en algunos sitios, y como URLs de empleo desnudas en otros, cuyos empleos son partial: true (título del slug de la URL, que también lleva las palabras de ubicación; sin ubicación ni fecha).
    • Microsoft (careers.microsoft.com, jobs.careers.microsoft.com, apply.careers.microsoft.com): un sitio de Eightfold, leído como arriba.
  • Campos derivados. Cada empleo recibe remote (el título o la ubicación dice remoto, y no híbrido/en el sitio) y seniority (becario, entrada, medio, senior, staff, principal, gerente, director, del título; las palabras de gestión ganan sobre los niveles de IC, y "medio" significa que el título no lleva nivel). Estos son heurísticos sobre el texto, por lo que se exponen en el empleo en lugar de ocultarse dentro del filtro.
  • Recursos vs. vigilancias. Las vigilancias son intenciones por cliente. Los recursos son lo que realmente se obtiene. Cualquier número de vigilancias en el mismo tablero (entre clientes) comparte un recurso, una obtención por intervalo y un conjunto de instantáneas. Las URLs se canonicalizan antes de compartirse: los parámetros de seguimiento (utm_*, fbclid, gclid, …) se eliminan y la consulta se ordena. El recurso se verifica en el intervalo más corto que cualquiera de sus vigilancias activas solicite, pero nunca más a menudo que MIN_CHECK_INTERVAL_SECONDS (predeterminado 5 minutos).
  • Identidad del empleo. Los empleos se identifican por el id de empleo de la plataforma, o para JSON-LD por identifier, luego url, luego título y ubicación. Un empleo cuyo título, ubicación, departamento o URL cambió se convierte en JOB_UPDATED con changed_fields. Un empleo JSON-LD sin un id estable cuya ubicación cambió se empareja en un JOB_UPDATED en lugar de una eliminación más una adición.
  • Los eventos de cambio se escriben una vez por recurso. Cada vigilancia los lee a través de sus filtros, evaluados en SQL contra los datos del empleo del cambio (y contra la lista de empleos actual, para get_watch): keywords (cualquiera, contra título, ubicación, departamento y empresa), all_keywords (cada uno), exclude_keywords, locations (contra la ubicación del empleo y other_locations), seniority, remote_only, min_salary y max_experience_years. Los términos coinciden con palabras completas, sin distinguir mayúsculas y permitiendo un plural: "ios" coincide con "Senior iOS Engineer" pero no con "Game Studios", y "java" no coincide con "JavaScript". Una vigilancia nunca ve cambios de antes de su creación. Los ids de cambio funcionan como cursores, por lo que las transacciones de escritura de cambios se serializan. Eso mantiene los ids visibles en orden, por lo que una verificación concurrente lenta no puede confirmar un id que un lector ya ha superado.
  • Cortesía. Como máximo una obtención está en vuelo por sitio web en todas las réplicas, usando una concesión de host de Postgres con HOST_MIN_SPACING_MS entre solicitudes; una verificación de múltiples solicitudes (Workday, iCIMS, Oracle Recruiting, Eightfold, SuccessFactors, Apple, Amazon, Microsoft) mantiene la concesión para todas sus solicitudes y hace una pausa breve entre ellas (3 s para sitios de Eightfold como Microsoft, que limitan la tasa de solicitudes rápidas). Cada tick del programador reclama como máximo un recurso por host. Watchtower también:
    • envía If-None-Match / If-Modified-Since (un 304 significa que no hay trabajo);
    • verifica robots.txt (almacenado en caché por origen durante una hora, semántica RFC 9309);
    • envía un User-Agent identificativo;
    • retrocede exponencialmente en errores y respeta Retry-After en 429.
  • Programación y mantenimiento. Los recursos vencidos se reclaman con una concesión, por lo que puedes ejecutar varias réplicas; establece RUN_SCHEDULER=false en réplicas solo de API. El mismo bucle entrega webhooks y ejecuta el mantenimiento bajo un bloqueo de asesoramiento. El mantenimiento:
    • expira vigilancias no leídas;
    • elimina cambios después de CHANGE_RETENTION_DAYS e instantáneas no actuales después de SNAPSHOT_RETENTION_DAYS;
    • elimina recursos no vigilados y clientes inactivos;
    • limpia ventanas de límite de tasa antiguas.

Controles de seguridad y abuso

  • SSRF.
    • Solo http/https en los puertos 80/443. Las URLs con credenciales se rechazan.
    • localhost, *.local, *.internal, hosts de una sola etiqueta y numéricos se rechazan.
    • Cada dirección resuelta debe ser unidifusión globalmente enrutable. Privadas, de bucle local, de enlace local (incluidos los metadatos de la nube), CGNAT, multidifusión, reservadas, de documentación, IPv6 mapeado a IPv4, 6to4, Teredo y NAT64 se rechazan todas.
    • La verificación se ejecuta dentro de la búsqueda DNS del socket, por lo que se mantiene en el momento de la conexión para cada salto de redirección (derrotando el reenlace DNS) así como una vez en la creación de la vigilancia.
    • Las URLs de webhook reciben el mismo tratamiento.
  • Límites de solicitud. Un plazo general de 15 s en todos los saltos. Como máximo 5 redirecciones, cada una revalidada. Un límite de cuerpo de 3 MB (64 MB para APIs de plataformas de tablones de empleo) aplicado a los bytes descomprimidos, por lo que las bombas gzip y brotli se detectan. Solo tipos de contenido similares a texto.
  • Límites de API. Los límites de tasa se almacenan en Postgres, por lo que se mantienen entre réplicas y reinicios: 120 solicitudes/min, 10 creaciones de clientes/hora (compartidas con el aprovisionamiento automático de MCP) y 10 verificaciones forzadas/min. Los clientes se identifican por dirección IPv4 o IPv6 /64.
  • Límites de vigilancia y recursos.
    • 50 vigilancias por cliente, y 5 por cliente por sitio web para páginas de carreras arbitrarias (los tableros de plataforma son empresas separadas detrás de un host de API y están exentos).
    • Como máximo MAX_RESOURCES_PER_HOST URLs de páginas de carreras distintas por sitio web en todos los clientes; las APIs de tablones de empleo están exentas, y vigilar un tablero ya monitoreado siempre está permitido.
    • Un MAX_ACTIVE_RESOURCES global.
  • Tokens. Valores aleatorios de 192 bits; solo se almacena su SHA-256. Los cuerpos de solicitud están limitados a 64 KB.
  • Sin evasión. Watchtower nunca evita CAPTCHAs, desafíos de bots, inicios de sesión, muros de pago o robots.txt. Los detecta, informa al llamador, registra host is refusing us y los cuenta en watchtower_blocked_resources_1h.

Operaciones

GET /metrics expone métricas de Prometheus:

  • resultados de verificación por código de error;
  • histograma de duración de obtención;
  • cambios emitidos por tipo;
  • resultados de webhook;
  • aplazamientos de host ocupado;
  • medidores para vigilancias/recursos activos, vigilancias de búsqueda, tableros de directorio y cuántos están atrasados, fallando y bloqueados recursos, y webhooks pendientes;
  • de dónde vienen los agentes: watchtower_mcp_initialize_total por el nombre de cliente MCP que un agente reporta (claude-code, cursor, ...), y watchtower_clients_created_7d por la etiqueta ?ref= en la URL a través de la cual se creó un cliente. Cada ruta de listado e instalación entrega su propia etiqueta (/mcp?ref=registry, ?ref=claude-plugin, ?ref=cursor, ...); clients.source y clients.user_agent la mantienen por cliente.

Estadísticas de uso

/stats muestra usuarios, usuarios activos diarios, nuevos usuarios, llamadas de herramientas, vistas de página y de dónde vienen los usuarios, como gráficos y tablas. Inicia sesión con cualquier nombre de usuario y STATS_TOKEN (o METRICS_TOKEN) como contraseña; con ninguno establecido, la página está apagada. Un usuario es un token de cliente anónimo. El historial vive en usage_daily (una fila por cliente, día UTC, herramienta e interfaz) y counts_daily (vistas de página por personas, asistentes de IA y otros bots, y conexiones MCP por nombre de aplicación), conservado durante 400 días. Nada por visitante se almacena para vistas de página.

Listado en el Registro MCP

server.json es la entrada del registro. El espacio de nombres lat.watchtower/* se prueba por HTTP: establece MCP_REGISTRY_AUTH al registro de clave pública y la aplicación lo sirve en /.well-known/mcp-registry-auth. Luego, con la clave privada correspondiente:

mcp-publisher login http --domain watchtower.lat --private-key "$PRIVATE_KEY_HEX"
mcp-publisher publish          # bump "version" in server.json for each new publish

Robots, la tarjeta del servidor MCP (/.well-known/mcp.json, también en /.well-known/mcp-server-card mientras la ruta sigue siendo un borrador) y los enlaces de instalación en la página de inicio se generan a partir de PUBLIC_BASE_URL.

ops/alerts.yml tiene reglas de alerta de ejemplo: sitios que nos rechazan, tasa de error alta, comprobaciones estancadas, acumulación de webhooks, búsquedas lentas y el directorio que se queda atrás de su intervalo de comprobación.

Configuración

Consulta .env.example. Los ajustes más importantes:

  • DATABASE_URL y PUBLIC_BASE_URL.
  • MIN_CHECK_INTERVAL_SECONDS y HOST_MIN_SPACING_MS.
  • INDEX_ENABLED, INDEX_CHECK_INTERVAL_SECONDS, INDEX_BOARDS_FILE y INDEX_MAX_PROMOTED: el directorio de tableros contra el que se comparan las búsquedas vigiladas. Con INDEX_ENABLED=false, Watchtower solo obtiene tableros que alguien vigila por URL.
  • Los límites: MAX_WATCHES_PER_CLIENT, MAX_RESOURCES_PER_HOST y MAX_ACTIVE_RESOURCES.
  • WATCH_TTL_DAYS y los ajustes de retención.
  • TRUST_PROXY: configúralo detrás de un balanceador de carga para que los límites de tasa vean las IP reales de los clientes.
  • ALLOW_PRIVATE_NETWORKS desactiva la protección SSRF y existe solo para pruebas y la demo.

Desarrollo

npm run typecheck
npm test                                  # unit tests; integration tests need a database:
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower_test npm test

El conjunto de integración elimina y recrea el esquema public de TEST_DATABASE_URL, así que apúntalo a una base de datos desechable.

El conjunto tiene tres partes:

  • Pruebas unitarias.
  • Pruebas de fuzzing basadas en propiedades (fast-check) sobre los analizadores de HTML, JSON-LD y robots, y las invariantes de diferencias de trabajos.
  • Pruebas de fuente para los analizadores de Workday, iCIMS, Oracle Recruiting, Eightfold, SuccessFactors, Apple, Google, Amazon y Microsoft, y la paginación, y el clasificador de remoto/antigüedad.
  • Pruebas de búsqueda para el lector de consultas, los analizadores de salario y experiencia, la coincidencia de palabras completas, los filtros y el descubrimiento de tableros.
  • Pruebas de integración que cubren REST, MCP, uso compartido, filtros, búsquedas vigiladas y el directorio de tableros, creación por lotes, rotación de páginas, NO_JOB_DATA, arrendamientos de hosts, comprobaciones concurrentes, límites, caducidad y retención, webhooks, límites de tasa y métricas.

CI (.github/workflows/ci.yml) ejecuta la verificación de tipos, todas las pruebas contra un servicio de Postgres, la compilación y la demo. También compila la imagen de Docker y le hace pruebas de humo: migraciones, /health, /metrics, la página de inicio y el usuario no 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 fuera de alcance

  • Cubrir todos los tableros de empleo en internet, o empleadores fuera de la tecnología. Una búsqueda vigilada ve el directorio y los tableros que la gente ha vigilado. El descubrimiento es un paso operativo a partir de una lista de empresas, no un rastreador, y no se leen agregadores como LinkedIn o Indeed.
  • Filtrar a roles técnicos. "Empleos tecnológicos" significa empleos en empresas tecnológicas; un rol de ventas en una de ellas se informa si los filtros lo coinciden.
  • Leer cada página de trabajo de SmartRecruiters, Workday o iCIMS para su descripción, por lo que esos trabajos no llevan salario ni experiencia.
  • Un filtro de salario máximo, conversión de moneda y entender una consulta con un modelo de lenguaje.
  • Facturación, cuentas y paneles.
  • Renderizado de JavaScript. Las páginas de carreras que no están en una plataforma compatible solo son legibles si su HTML renderizado en el servidor lleva JSON-LD JobPosting.
  • Monitoreo general de páginas, fuentes o eventos. Watchtower solía hacer esto; ahora solo hace tableros de empleo (la migración 003_jobs_only.sql retira las vigilancias de páginas y eventos existentes).
  • Paginación más allá de las primeras 100 publicaciones de SmartRecruiters o las 200 publicaciones más recientes de Workday por tablero.
  • Leer cada página de trabajo de iCIMS para detalles; solo los ~50 trabajos más recientes por portal obtienen un título y ubicación reales.
  • Leer la búsqueda de empleo de Google o sus páginas de trabajo, que su robots.txt desautoriza; los trabajos de Google se conocen solo por su entrada en el sitemap.
  • Leer más de los 300 roles más recientes de Apple, Amazon u Oracle Recruiting, los 200 roles más recientes de SuccessFactors, o los 50 roles más recientes de Eightfold (incl. Microsoft), por comprobación.
  • Los sitios de SuccessFactors se ejecutan en el dominio propio de la empresa, por lo que solo los hosts listados en src/extract/successfactors.ts se leen como SuccessFactors; cualquier otro se lee a través de su marcado JobPosting, si lo tiene.