Bullhorn is the enterprise end of recruitment CRM, and your agent talks to it properly: search candidates, contacts, companies and job orders, then create and update records. Writes are real and hard to undo, so it pilots one and shows you the result before continuing.
Bullhorn is the destination. Everything upstream converges here.
Ask for the outcome and your agent composes the run itself. Or start from a skill, a whole pipeline it already knows end to end.
browse all skills →Every tool ships with a written playbook, and the agent loads it before the first call. Auth, rate limits, what each call costs, which actions need your approval, and the mistakes worth avoiding. It is the difference between an agent that knows the tool and one that guesses at it.
Read the Bullhorn docs →HOW in lib/bullhorn.py. Docs: bullhorn.github.io/rest-api-docs.
_login)GET auth.bullhornstaffing.com/oauth/authorize (client_id + username/password + action=Login) → 302 with ?code=.POST .../oauth/token (grant_type=authorization_code) → access_token.POST rest.bullhornstaffing.com/rest-services/login?version=*&access_token=… → BhRestToken + restUrl.
Then every call hits {restUrl}<path> with BhRestToken. The client re-logs in once on 401.BULLHORN_CLIENT_ID, BULLHORN_CLIENT_SECRET, BULLHORN_USERNAME, BULLHORN_PASSWORD, BULLHORN_REDIRECT_URI (must match the OAuth app). Never hardcode.auth/rest hosts can be cluster-specific — if your tenant differs, override AUTH_BASE/LOGIN_BASE. Verify the OAuth app + redirect_uri before first run. ping() = safe read-only pilot.search(entity, query, fields) — GET /search/{Entity} Lucene (indexed: Candidate, JobOrder, ClientContact, ClientCorporation…). query(entity, where, fields) — GET /query/{Entity} SQL-style (non-indexed). get_entity (GET /entity/{Entity}/{id}), create_entity (PUT /entity/{Entity}), update_entity (POST /entity/{Entity}/{id}). request() covers the rest. BhRestToken via query param is valid (also accepted as a header). All path patterns verified.
Production ATS writes — pilot one record, confirm, then bulk.
ATS write target: enriched candidates → create_entity("Candidate", …).
lib/bullhorn.pyImport: from lib.bullhorn import Bullhorn → instantiate Bullhorn() (reads key from env). Generic passthrough: request(method, path, *, params, json).
create_entity(entity: str, payload: dict) -> Any — PUT /entity/{entity} — create a record.get_entity(entity: str, entity_id: int, *, fields: str = '*') -> Any — GET /entity/{entity}/{id}?fields=...ping() -> Any — GET ping — verify session (safe read-only pilot).query(entity: str, where: str, *, fields: str = '*', **params) -> Any — GET /query/{entity}?where=...&fields=... — SQL-style where (non-indexed entities).search(entity: str, query: str, *, fields: str = '*', **params) -> Any — GET /search/{entity}?query=...&fields=... — Lucene-style search (indexed entities).update_entity(entity: str, entity_id: int, payload: dict) -> Any — POST /entity/{entity}/{id} — update a record.