Skip to content

Documentation

ContactCTL product documentation

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 executed 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.

agent — zsh
$ ctc find "Jane Smith" acme.com
status: found
work_email: jane.smith@acme.com (deliverable, confidence 0.98)
charged: 1 credit
$

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.

install + auth
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

0.02 credits per verification · sub-second

syntax
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.

example
$ 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

1 credit per work email found · 3 credits per personal email found · a miss is free

syntax
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

10 credits per number found · a miss is free

syntax
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

1 credit per profile found · a miss is free

syntax
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.

lookalike — similar companies or people

0.35 credits per returned row

syntax
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

syntax
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.

example
$ 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

FlagApplies toEffect
--jsonevery commandDeterministic JSON output, stable field order.
--estimateevery paid commandPrint the credit cost and exit. Computed locally; zero spend, zero network.
--helpevery commandDetailed usage for that command.
--max-cost Nfind, phone, whois, verifyHard spend cap in credits. Batch runs stop before exceeding it (exit 4).
--timeout Dfind, phone, whoisAsync wait cap, e.g. 30s or 5m (default 120s). On expiry: prints run-id, exit 9.
--run-id IDfind, 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-runbatch verbsPreflight: rows detected, rows ready, missing fields, cost estimate. No spend.
--in F / --out Fverify, whois, find, phoneExplicit batch form; --in - reads stdin. Mutually exclusive with positional files.
--personalfind onlyPersonal email (3 credits) instead of work email.
--limit Nsearch, lookalikeResult 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 shapeRouted as
Matches an email regexEMAIL
Is a URL or contains “linkedin.com”LINKEDIN_URL
Contains a dot, no spacesDOMAIN (preferred when ambiguous)
Anything elseCOMPANY_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.

batch examples
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:

reattach (single mode)
$ 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:

  • --estimate prints 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-run adds batch readiness: rows detected, rows with enough input, rows missing fields, and the low/high cost estimate.
  • --max-cost is 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.

ActionCommandCreditsBilling
Email verificationctc verify <email>0.02 creditsper verification
Work email findctc find <name> <domain>1 creditper result found · miss is free
Personal email findctc find <name> <domain> --personal3 creditsper result found · miss is free
Mobile phone findctc phone <name> <domain>10 creditsper result found · miss is free
Reverse email lookupctc whois <email>1 creditper result found · miss is free
People searchctc search people ...0.25 creditsper search request
Company searchctc search companies ...0.25 creditsper search request
Lookalike companies or peoplectc lookalike <seed>0.35 creditsper returned row
Filter introspectionctc search filtersfreefree, 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).

find --json (abridged)
{
  "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 shape (all failures)
{ "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.

CodeMeaning
0Success with a usable result
1General error
2Completed, but no result found (the free miss)
3Result found but below the confidence threshold
4Budget exceeded — --max-cost hit or insufficient credits
5Missing or invalid API key
6Invalid input
7Upstream error (including rate limit after retries are exhausted)
8Compliance or suppression block
9Timeout — 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

SettingPurpose
~/.config/contactctl/config.jsonWritten by contactctl auth with file mode 0600. Holds api_key and optional api_url.
CONTACTCTL_API_KEYOverrides the config file — use in CI and ephemeral environments.
CONTACTCTL_API_URLOverrides 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.