Skip to main content

loxo-cli

A fast, ergonomic command-line interface for the Loxo recruiting ATS/CRM REST API. It offers typed subcommands for the common resources (people, jobs, companies, deals, candidates, activities, webhooks, reference data) plus a generic loxo api escape hatch that can call any endpoint. Output is human-friendly tables on a terminal and clean JSON when piped, so it fits both interactive use and scripts.

Unofficial — not affiliated with Loxo, Inc.

Install

uvx loxo-cli          # run without installing
pipx install loxo-cli # or install as a user tool

Quickstart

loxo configure                       # set up a profile
loxo people list --query "engineer"  # human table
loxo people list --json | jq '.'     # JSON for scripts
loxo api GET jobs/123                # raw escape hatch

Configuration

Credentials resolve with the precedence flags > environment > config file.

Environment variables:

Variable Meaning
LOXO_API_KEY API bearer token
LOXO_API_SLUG Agency slug (the {slug} in every request URL)
LOXO_BASE_URL API base URL (default https://app.loxo.co/api)
LOXO_PROFILE Default profile name to use
LOXO_MAX_RETRIES Retries for throttled/failed requests (default 3; 0 disables)

The config file lives at ~/.config/loxo/config.toml (or $XDG_CONFIG_HOME/loxo/config.toml) and is written with 0600 permissions. Example:

default_profile = "prod"

[profile.prod]
slug = "acme"
base_url = "https://app.loxo.co/api"
api_key = "your-token"

[profile.staging]
slug = "acme-staging"
# Pull the key from a secrets manager instead of storing it in plaintext:
api_key_cmd = "op read op://Private/loxo-staging/credential"

api_key_cmd is run on demand and its stdout is used as the key, so the secret never has to live in the file. Set it without hand-editing the file via loxo configure --api-key-cmd "op read op://Private/loxo/credential". The key is never printed by loxo configure list, logged, or shown in --verbose output.

Commands

Group What it does
people List/search, get, create, update people
jobs List, get, create, update jobs
companies List/search, get, create, update companies
deals List, get, create, update deals
candidates List/get/add/update candidates under a job
activities List and add person events (activities)
webhooks Full CRUD for webhooks (with enum validation)
ref Reference lookups: job/activity/source/person types, lists, custom fields, hierarchies
api Generic escape hatch — call any endpoint directly
configure Create and list credential profiles

Custom (dynamic) fields are supported on writes via repeatable --field key=value (use key[]=value to force a list, e.g. hierarchy fields). Discover valid keys with loxo ref custom-fields, which maps each key (custom_text_3) to its plain-language name and type. Filter to one object with --object deal (matches the field's item_type, case-insensitive) and hide built-ins with --custom-only. For a hierarchy field, loxo ref hierarchies custom_hierarchy_4 --object deal lists its options (name + id); the FIELD argument also accepts the numeric field id.

Output

On a terminal, list and object results render as Rich tables. Pipe the command or pass --json to get machine-readable JSON; --jq '<path>' applies a small built-in selector (e.g. --jq '.results', --jq '.[].id', --jq '.results.0.title' — the leading . is optional) without needing the jq binary.

--json output is always plain (never ANSI-colored) so it can be piped straight into jq, json.loads, etc. Table color is disabled automatically when stdout is not a terminal, and can be turned off explicitly with --no-color or the NO_COLOR environment variable.

Filtering vs. search

--query/-q (and api ... -p query=) is a ranked full-text search, not an exact filter: the API returns a broad, relevance-ordered set (e.g. -q "VP of Digital" also matches unrelated VP * roles further down). To narrow a result set to exact matches, add --filter field=value (repeatable) on list commands — it post-filters the returned records client-side. Object-valued fields match on their name, so --filter status=Active matches a status of {"id": 70251, "name": "Active"}. Example:

loxo jobs list -q "VP of Digital" --filter status=Active

Retries

Retries are on by default for every invocation: a throttled (429), 5xx, timed-out, or connection-refused request is retried up to 3 times with exponential backoff and jitter, honoring a Retry-After header when the server sends one. A 60-second wall-clock budget caps the accumulated backoff for a single request; because that budget only gates the next sleep, the attempt already in flight still gets its own 30-second timeout, so the worst case for one request is nearer ~90 seconds. Any wait of a second or more prints a one-line notice to stderr (stdout stays clean for --json).

Retries are method-aware: GET/HEAD/PUT/DELETE/OPTIONS retry on all of the above, while POST retries only when the request provably did not take effect (a 429, or a connection that was never established), so a timed-out write is never replayed into a duplicate record.

loxo --retries 0 jobs list          # fail fast, the pre-0.6.0 behavior
LOXO_MAX_RETRIES=0 loxo jobs list   # same, via the environment
loxo --retries 5 jobs list --all    # more patient

Exit codes

Code Meaning
0 Success
1 Generic error
2 Usage error (bad flags/arguments)
3 Authentication/authorization failure (401/403)
4 Not found (404)
5 Rate limited (429)
6 Server error (5xx)
7 Timeout or network failure

Since 0.6.0 the retryable codes (5, 6, 7) are reached only after retries are exhausted, so they are no longer immediate — exit 5 in particular can now take up to ~90 seconds. Pass --retries 0 to get the old fail-fast behavior back.

Pagination

Loxo paginates differently per endpoint: cursor (scroll_id), offset (page), and keyset (after_id). loxo-cli detects and handles all three. List commands fetch a single page by default; pass --all to transparently walk every page. The generic loxo api ... --all auto-detects the scheme (or force it with --paginate scroll_id|page|after_id).

Async

Scripts: async with builds a client, runs the work, and closes the pool on exit.

import asyncio

from loxo_cli.client import AsyncLoxoClient
from loxo_cli.config import load_settings
from loxo_cli.pagination import apaginate


async def main() -> None:
    settings = load_settings()
    async with AsyncLoxoClient(settings) as client:
        job = await client.get("jobs/123")
        print(job)

        async for candidate in apaginate(
            client, "jobs/123/candidates", scheme="scroll_id", items_key="candidates"
        ):
            print(candidate["id"])


asyncio.run(main())

Long-lived services build one client at startup and aclose() it at shutdown, so the connection pool is reused across requests. AsyncLoxoClient is safe to share across concurrent tasks. In FastAPI that is a lifespan:

from contextlib import asynccontextmanager

from fastapi import FastAPI

from loxo_cli.client import AsyncLoxoClient
from loxo_cli.config import load_settings


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.loxo = AsyncLoxoClient(load_settings())
    try:
        yield
    finally:
        await app.state.loxo.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/jobs/{job_id}")
async def get_job(job_id: int):
    return await app.state.loxo.get(f"jobs/{job_id}")

The retry budget (max_elapsed) is per request, so apaginate gives every page its own budget: under sustained throttling a large sweep can run for far longer than any single request's bound. Bound a whole sweep with asyncio.timeout(...) rather than expecting max_elapsed to do it.

async def sweep(client: AsyncLoxoClient) -> None:
    async with asyncio.timeout(120):
        async for candidate in apaginate(
            client, "jobs/123/candidates", scheme="scroll_id", items_key="candidates"
        ):
            print(candidate["id"])

Retries are on by default. Pass a policy to tune or disable them — a service answering an HTTP request should be far less patient than a CLI:

from loxo_cli.client import AsyncLoxoClient
from loxo_cli.config import load_settings
from loxo_cli.retry import RetryPolicy

client = AsyncLoxoClient(
    load_settings(), retry=RetryPolicy(max_retries=1, max_delay=2.0, max_elapsed=5.0)
)

Contributing

uv sync                 # install dependencies
uv run pytest           # run the test suite (HTTP is mocked; no live calls)
uv run ruff check src tests
uv run black --check src tests
uv run mypy

Commits follow Conventional Commits.

License

MIT. See LICENSE.

Metadata

Release files for loxo-cli 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for loxo-cli 0.6.0
File Size Uploaded
loxo_cli-0.6.0.tar.gz 86.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for loxo-cli 0.6.0
File Interpreter ABI Platform
loxo_cli-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 126.4 kB

Release files / loxo_cli-0.6.0.tar.gz

Download URL loxo_cli-0.6.0.tar.gz
Size 86.8 kB
Tags Source
SHA-256 checksum
How to use checksums
0057fa2f8c124d85963d4e13c81c5d9a47360998156f9cf15aae28ab232107dc
BLAKE2b-256 checksum
How to use checksums
88938f8073fe43317b695d3bb325cadc52c9f2f1023ae1472bfb80e81111ec1f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 24, 2026.

Transparency log

Release files / loxo_cli-0.6.0-py3-none-any.whl

Download URL loxo_cli-0.6.0-py3-none-any.whl
Size 39.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
59a4cec560a55e7c9341ba0c67b9edb6a6f2c52d7ea13eedbbf5985c27ec1ac5
BLAKE2b-256 checksum
How to use checksums
47ca7c2d70490205aa1d8ef2b29b473f825c1f8b48a8ccade9f88c926a2b06be
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 24, 2026.

Transparency log

Release history Release notifications | RSS feed

0.8.0

2 release files

0.7.0

2 release files

0.6.2

2 release files

0.6.1

2 release files

This release

0.6.0 This release

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page