The whole product,
written to be executed.
The canonical reference for the contactctl CLI and its behavior: commands, flags,
credit costs, batch semantics, async runs, JSON shapes, exit codes, and rate limits. Written
to be run verbatim — by you or by your agent.
Overview
ContactCTL is a CLI for AI agents that finds, verifies, and enriches B2B contact data
through waterfall enrichment across 20+ premium data sources. It installs as a single
binary with two names: contactctl and the short alias ctc.
Both are identical; examples here use ctc.
The surface is value-first: every work verb takes positional values and routes on structural input shape — never on natural-language meaning. One
command per job, --json everywhere, deterministic exit codes, and no interactive prompts, ever.
find, phone, and whois charge only when a result
is found. A clean miss costs nothing.
Install & authenticate
Install globally via npm, then connect your account with the API key from the
dashboard. Keys look like ctc_live_… and are shown once at creation.
npm install -g contactctl contactctl auth <api_key> # stores the key locally contactctl auth status # email, plan, balance, key prefix
contactctl auth <key> validates the key against the API and writes it
with file mode 0600 to ~/.config/contactctl/config.json. Environment
variables override the file — see Configuration. The full install
walkthrough, including a copy-paste block for handing to an agent, is on the install page.
verify — email deliverability
ctc verify <email> # single ctc verify <file.csv> [out.csv] # batch; stdout if out omitted
Answers “can I send to this address?” with a status (deliverable, undeliverable, risky, unknown) and a confidence
score. Free local syntax and MX checks run first; only the full verification is
charged.
Verification charges per check — found or not — because the answer itself is the
product. Catch-all domains surface as status risky: they accept
everything, so a catch-all “ok” is weaker evidence than a directly confirmed mailbox.
Single-mode verify exits on its verdict — the result prints first, then
the exit code carries it: 0 deliverable, 2 undeliverable/no_mx, 3 risky/catch_all/unknown, 6 invalid syntax. So ctc verify "$EMAIL" && send branches correctly with no parsing.
Batch rows are unaffected.
$ ctc verify jane.smith@acme.com --json
{
"email": "jane.smith@acme.com",
"status": "deliverable",
"confidence": 0.99,
"domain": "acme.com",
"mx_found": true,
"role_account": false,
"checked_at": "2026-05-21T10:14:04Z",
"cost": { "estimated_credits": 0.02, "actual_credits": 0.02 }
}find — work or personal email
ctc find <full name> <domain|company> # work email ctc find <full name> <domain> --personal # personal email instead ctc find <linkedin_url> # from a LinkedIn profile ctc find <file.csv> <out.csv> # batch; output required
The work-email hero command. The first positional is the full name; the second is shape-routed to a domain (token contains a dot, no spaces) or a company name (anything else). A single LinkedIn URL positional needs no company anchor.
Name split: the first whitespace token becomes first_name;
the entire remainder becomes last_name ("miha von cacic" →
first miha, last von cacic). A mononym leaves last_name empty. There is no “Last, First” comma logic.
--personal requests the personal-email field (3 credits) instead of the work email — a single-field call, never both at once. A bare
name with no domain, company, or LinkedIn URL is rejected with exit 6; use search people for role-based discovery.
find runs asynchronously: it submits the enrichment,
polls, and either prints the result or — past --timeout — prints a run-id
you can reattach to.
phone — mobile phone
ctc phone <full name> <domain|company> ctc phone <linkedin_url> ctc phone <file.csv> <out.csv> # batch; output required
Mobile phone lookup is its own top-level verb so the 10 credits spend stays
explicit — an agent never stumbles into it through a flag. Input parsing, name-split,
shape routing, async behavior, and batch rules are identical to find.
whois — reverse email lookup
ctc whois <email> ctc whois <file.csv> [out.csv] # batch; stdout if out omitted
Resolves the person and company behind a known email: name, title, company profile,
LinkedIn URL where available. This is enrichment of a contact point you already have —
not deliverability (that is verify) and not
discovery (that is search). Runs asynchronously like find.
search — scoped people & company search
ctc search people --domain D | --company C
[--titles T] [--departments D] [--seniority S]
[--name N] [--location L] [--limit N]
ctc search companies [--industry I] [--headcount 11-200]
[--location L] [--type T] [--limit N]search people finds people at a named account — one of --domain or --company is required. search companies finds companies by explicit attributes. Both return
profiles, not email addresses; run find on a selected result when you need
the email.
Filters combine with AND across fields and OR within a field
(--titles "CMO,VP Marketing" matches either title). Both commands charge
0.25 credits per request regardless of result count, reject batch files, and
paginate with --limit and --offset.
ctc search people --domain acme.com \ --titles "VP Marketing,Head of Content" --limit 10 --json
lookalike — similar companies or people
ctc lookalike <domain> # similar COMPANIES
ctc lookalike <linkedin_url> # similar PEOPLE
[--size 11-200] [--location L] [--limit N]Expands one known-good seed into similar records. The seed's shape decides the mode: a
domain returns companies, a LinkedIn URL returns people. Exactly one seed per call — no
comma lists, no batch. Charged per returned row, so --limit 25 costs at
most 8.75 credits. --estimate prices the worst case as --limit × 0.35.
Filter introspection — free, local
ctc search filters # list all filter fields ctc search filters <field> # accepted values for one field ctc lookalike filters # lookalike filter fields
Search and lookalike filters take controlled vocabularies (industries, seniorities,
company types) — and guessing values silently weakens results. The filters commands print every field with its type (enum, substring, range)
and, per field, the accepted values one per line, copy-friendly. They run entirely
locally from data embedded in the binary: no network, no key, no credits.
Validation is warn-only: an unrecognized enum value prints a closest-match suggestion to stderr and the request is still sent. An unknown field exits 6 with a suggestion.
$ ctc search filters seniorities Owner Founder C-level Partner VP Head Director Manager Senior
Utility commands
ctc instructions
The agent usage guide: roughly 60 lines covering the value-first surface, routing rules, batch behavior, costs, and exit codes. An agent reads it once per session and knows the whole tool — this is the zero-context-cost alternative to MCP tool schemas (why we chose that).
ctc usage
Credit balance (plan, top-up, total) and recent ledger entries — each with action,
credits, and status. Plan credits are spent first, then top-up credits, oldest first. Supports --json for
monitoring scripts.
ctc auth · ctc auth status
auth <api_key> validates and stores the key; auth status reports account email, plan, balance, and the key prefix
(never the full key).
ctc version
Prints the version string. version --json emits a deterministic struct
with build and output-schema version for scripts that pin behavior.
Universal flags
| Flag | Applies to | Effect |
|---|---|---|
| --json | every command | Deterministic JSON output, stable field order. |
| --estimate | every paid command | Print the credit cost and exit. Computed locally; zero spend, zero network. |
| --help | every command | Detailed usage for that command. |
| --max-cost N | find, phone, whois, verify | Hard spend cap in credits. Batch runs stop before exceeding it (exit 4). |
| --timeout D | find, phone, whois | Async wait cap, e.g. 30s or 5m (default 120s). On expiry: prints run-id, exit 9. |
| --run-id ID | find, phone, whois (single mode only) | Reattach to a previous async run — repeat the original positionals. Polls only; never re-bills. Batch invocations reject it (exit 6). |
| --dry-run | batch verbs | Preflight: rows detected, rows ready, missing fields, cost estimate. No spend. |
| --in F / --out F | verify, whois, find, phone | Explicit batch form; --in - reads stdin. Mutually exclusive with
positional files. |
| --personal | find only | Personal email (3 credits) instead of work email. |
| --limit N | search, lookalike | Result cap and pagination unit. |
Input shape routing
Routing is structural and deterministic, applied per positional. The CLI never interprets natural language — it matches shapes:
| Input shape | Routed as |
|---|---|
| Matches an email regex | |
| Is a URL or contains “linkedin.com” | LINKEDIN_URL |
| Contains a dot, no spaces | DOMAIN (preferred when ambiguous) |
| Anything else | COMPANY_NAME (2nd arg) · NAME (1st arg) |
So ctc find "Jane Smith" acme.com routes to domain, ctc find "Jane Smith" "Acme GmbH" routes to company name, and ctc lookalike flips between company mode (domain seed) and people mode
(LinkedIn seed) on the same rule. On the batch-capable verbs, file detection runs before shape routing.
Batch semantics
verify, whois, find, and phone are
batch-native: pass a file as the first positional and the verb processes it row by row.
CSV and TSV are supported.
The trigger is file-ness, never value-sniffing. A verb enters batch
mode when the first positional exists as a readable file, or ends in .csv/.tsv. A name or domain is never a file, so find "Jane Smith" acme.com always stays single. A path that looks like a data file but does not exist is a hard error (exit 6) — never a
silent fall-through to single mode.
Output rules. The second positional is the output path. find and phone batch require it (unless --dry-run); verify and whois default to stdout. When input and output are
the same path, ContactCTL overwrites in place via write-temp-then-atomic-rename — a
crash can never corrupt the input file.
Guarantees. Input row order is preserved. Output columns are appended;
input columns survive untouched. Rows are never silently dropped — errors are reported
per row. Runs stop before exceeding --max-cost. Large files are processed
in chunks of at most 100 rows per run.
ctc find leads.csv enriched.csv --max-cost 100 ctc find leads.csv leads.csv # safe in-place overwrite ctc verify list.csv # batch verify to stdout ctc whois --in - < emails.csv # explicit form, stdin ctc find leads.csv --dry-run # preflight, no spend
Recognized input columns (headers are matched case-, space-, and dash-insensitively;
synonyms in parentheses): first_name (first, firstname), last_name (last, lastname), full_name (name, person,
contact_name), company (company_name, organization), domain (company_domain, website), linkedin_url (linkedin, profile_url), and email (work_email). search and lookalike reject
batch entirely — paginate with --limit.
Async runs & run-id
Enrichment (find, phone, whois) is asynchronous
under the hood. The CLI submits the job, receives a run-id, and polls every 3 seconds
until the result arrives or --timeout (default 120s) expires. On timeout
it prints the run-id and exits 9. Nothing is lost:
$ ctc find "Jane Smith" acme.com --timeout 30 status: in_progress run_id: 7c9e6679-7425-40de-963d-1a8f7d2e9b1c (reattach with --run-id 7c9e6679-7425-40de-963d-1a8f7d2e9b1c) # exit 9 $ ctc find "Jane Smith" acme.com --run-id 7c9e6679-7425-40de-963d-1a8f7d2e9b1c status: found work_email: jane.smith@acme.com (deliverable, confidence 0.98)
Reattach is single-mode only and repeats the original positionals with --run-id added: ctc find "<full name>" <domain|company> --run-id <id>, ctc find <linkedin_url> --run-id <id>, ctc phone "<name>" <domain> --run-id <id>, or ctc whois <email> --run-id <id>. With --run-id the
CLI submits nothing new — it only polls GET /v1/runs/<id> — so
reattaching never re-bills. An unknown or expired run-id exits 6 with an “unknown run”
error.
There is no batch reattach. Batch invocations reject --run-id with exit 6 (“--run-id reattach is single-mode only”). When a
batch run outlives --timeout (exit 9), the affected rows are written with
status in_progress and the per-chunk run-id in the contactctl_detail column; recovering a full chunk from those run-ids is
not yet supported — re-run the unfinished rows as a new batch.
Credits are held when a run is submitted and settled only for rows where a result was found; holds on misses and failed runs are released in full.
Cost control
Three mechanisms, layered:
--estimateprints what an action would cost and exits — computed locally from the embedded price table, zero spend, zero network. For lookalikes it prices the worst case (--limit× 0.35).--dry-runadds batch readiness: rows detected, rows with enough input, rows missing fields, and the low/high cost estimate.--max-costis a hard cap enforced during the run — the command stops before exceeding it and exits 4.
And the structural one: find, phone, and whois charge only when a result is found. A clean miss costs nothing.
Credit costs
Credits are the only unit the CLI reports — command output never converts to EUR because top-up rates differ per plan. Plans and top-up pricing live on the pricing page.
| Action | Command | Credits | Billing |
|---|---|---|---|
| Email verification | ctc verify <email> | 0.02 credits | per verification |
| Work email find | ctc find <name> <domain> | 1 credit | per result found · miss is free |
| Personal email find | ctc find <name> <domain> --personal | 3 credits | per result found · miss is free |
| Mobile phone find | ctc phone <name> <domain> | 10 credits | per result found · miss is free |
| Reverse email lookup | ctc whois <email> | 1 credit | per result found · miss is free |
| People search | ctc search people ... | 0.25 credits | per search request |
| Company search | ctc search companies ... | 0.25 credits | per search request |
| Lookalike companies or people | ctc lookalike <seed> | 0.35 credits | per returned row |
| Filter introspection | ctc search filters | free | free, runs locally |
find, phone, and whois charge only when a result is found. A clean miss costs nothing.
JSON output shapes
Every command supports --json. Shapes are stable and versioned via ctc version --json's schema field. Abridged examples below; the HTTP API
returns the same structures (see the API reference).
{
"status": "found",
"input": {
"first_name": "Jane", "last_name": "Smith",
"full_name": "Jane Smith", "domain": "acme.com"
},
"profile": {
"full_name": "Jane Smith", "title": "VP Growth",
"company": "Acme", "domain": "acme.com"
},
"contacts": [{
"type": "work_email",
"value": "jane.smith@acme.com",
"status": "deliverable",
"confidence": 0.88,
"catch_all": false,
"verified_at": "2026-05-21T10:14:04Z"
}],
"waterfall": [{
"provider": "contactctl",
"action": "waterfall_enrichment",
"status": "found",
"cost_credits": 1,
"latency_ms": 31000
}],
"cost": { "estimated_credits": 1, "actual_credits": 1 }
}The input object echoes the positionals exactly as parsed (name split,
plus the domain or company the second positional routed to); empty fields are omitted.
The requested field shows up as the contact's type (work_email, personal_email, or mobile_phone).
On a miss, status is not_found, the command exits 2, and actual_credits is 0.
{ "error": { "code": "insufficient_credits", "message": "…" } }Batch CSV output keeps every input column untouched and appends six columns: contactctl_status, the value column named for the verb
(contactctl_email for verify, contactctl_profile for whois, contactctl_work_email for find — contactctl_personal_email with --personal — and contactctl_phone for phone), then contactctl_detail, contactctl_provider, contactctl_cost_credits, and a per-row contactctl_error.
Exit codes
Exit codes are part of the contract — scripts and agents branch on them without parsing output.
| Code | Meaning |
|---|---|
| 0 | Success with a usable result |
| 1 | General error |
| 2 | Completed, but no result found (the free miss) |
| 3 | Result found but below the confidence threshold |
| 4 | Budget exceeded — --max-cost hit or insufficient credits |
| 5 | Missing or invalid API key |
| 6 | Invalid input |
| 7 | Upstream error (including rate limit after retries are exhausted) |
| 8 | Compliance or suppression block |
| 9 | Timeout — run-id printed for reattach |
Single-mode verify additionally exits on its verdict
(the result prints first): 0 deliverable, 2 undeliverable/no_mx, 3 risky/catch_all/unknown, 6 invalid syntax. Batch
verify exits per the normal batch semantics, never per-row verdict.
Rate limits & backoff
Each API key is limited to 120 requests per minute across all endpoints. When a limit
is hit, the API responds with HTTP 429 and a retry_after_seconds value —
and the CLI handles it for you: it sleeps the indicated time (capped at 30s) and
retries, up to 4 times (roughly 90 seconds total). Your agent sees latency, not
failures. Only after retries are exhausted does the command exit 7.
Batch chunking (≤100 rows per run) keeps large files inside these limits automatically; no client-side throttling logic is required.
Configuration
| Setting | Purpose |
|---|---|
| ~/.config/contactctl/config.json | Written by contactctl auth with file mode 0600. Holds api_key and optional api_url. |
| CONTACTCTL_API_KEY | Overrides the config file — use in CI and ephemeral environments. |
| CONTACTCTL_API_URL | Overrides the API base URL (default https://contactctl.com/api). |
There is no provider-key configuration anywhere — ContactCTL is a managed service and one key covers the whole waterfall. Key management lives in the dashboard.
Related: the HTTP API reference, the CLI design page, and integrations.