predictable-ai
v0.4.0
Published
Command-line client for the Predictable agent surface.
Readme
predictable-ai
The Predictable agent surface, on the command line. One command per verb in the
manifest, line-oriented output by default and --json for piping.
Install
npm install -g predictable-ai
npx predictable-ai --versionThe package ships one binary, predictable. npx runs it without installing it
globally first. Either way --version prints this client's version and the
agent-surface hash compiled into it — and, once a token is configured, the hash
the deployment serves, so a client that disagrees with its deployment says so
rather than failing later on a verb that moved.
To work from a checkout instead:
cd cli
npm ci
npm run build
node dist/bin.js --versionQuickstart
Mint a token. In the web app, open Settings → Agent token (
/settings/agent-token) and mint one. It is shown once.Export it. The environment variable is the channel:
export PREDICTABLE_TOKEN='paste-the-minted-value-here'Typing it into an interactive shell leaves it in your history file. The config-file channel described below avoids that, and a secret store beats both.
Say who you are. The token smoke-test verb:
predictable whoamiSee what the token reaches.
predictable lists listin its default text mode prints one line per client — id and name — and no lists at all, so it will not hand you the list id step 5 needs. Ask for the JSON instead, where each client carries itslists:predictable lists list --jsonThat is the server's body verbatim, so it arrives on one line, with every list's
id,nameanddiscovery_sourcein it. If you havejq— a separate tool, not something this package installs — the same output reads as a table:predictable lists list --json | jq -r '.clients[].lists[] | [.id, .name, .discovery_source] | @tsv'Either way, copy one of those list ids for the next step.
Preview a search. The preview prices the run and mints a quote — it spends nothing. Only the second step, the one carrying
--quote, spends:predictable search run --preview --list <list-id>Not every list is eligible: base search runs only on a list whose
discovery_sourceisexa_agent(step 4 shows it), that is not alumni-seeded, and that already has a search query from the describe step — any other list is refused with a 400 rather than quoted.
predictable --help lists every verb and predictable <command> --help
explains one. --json prints the server's response body verbatim, so it pipes:
predictable lists list --json | jq '.clients[].lists[].name'Authenticate
The CLI reads your token from one of two places, in this order:
- The
PREDICTABLE_TOKENenvironment variable. ~/.predictable/config.json, a file holding{"token": "..."}.
It never reads a token from the command line. A token typed as an argument is
saved in your shell history and is visible in ps output to every other user on
the machine, so no command here accepts one. Pass one as a flag and the CLI
refuses by name and exits 1 — before printing help, before printing a version,
and before sending anything — rather than ignoring the flag and making an
anonymous request. Diagnostics never repeat back a value you typed, so a
credential in the wrong place does not end up in a CI log.
To get a token, open Settings → Agent token in the web app and mint one. It's shown once. Then store it:
mkdir -p ~/.predictable
chmod 700 ~/.predictable
# Paste the token when the editor opens, as {"token": "<the token>"}
$EDITOR ~/.predictable/config.json
chmod 600 ~/.predictable/config.jsonSetting PREDICTABLE_TOKEN in your environment works too, and is the right
channel in CI. Prefer a secret store over typing it into an interactive shell,
where it lands in your history file.
By default the CLI talks to the production deployment. Set
PREDICTABLE_BASE_URL to point it at a preview.
Claude Code and other agents
This CLI is the client-facing contract, and Claude Code is meant to drive it the
way it drives git: install the package, put PREDICTABLE_TOKEN in the
environment of the shell it runs in, and ask for the verb you want. Output is
line-oriented by default and --json for structured reads, so an agent can
parse what comes back. The two-step rule on paid verbs holds under an agent
exactly as it does under a person: the preview mints a quote, the acting call
needs --quote, and a paid command without one refuses locally and sends
nothing.
For clients that will install nothing, the same manifest is also served as MCP tools by a hosted head on the same deployment:
POST https://www.predictable.ai/api/agent/mcp— streamable HTTP, stateless, a fresh server per request.- It authenticates with the same minted agent token, presented as a bearer credential by the MCP client's own configuration. The rule this CLI enforces holds there too: the credential travels in a header, never in an argument.
- Both heads register the same verbs, generated from the one manifest, so the two cannot drift apart.
Claude Code takes that as .mcp.json, and other MCP clients take the same URL
and bearer header in their own config format:
{
"mcpServers": {
"predictable": {
"type": "http",
"url": "https://www.predictable.ai/api/agent/mcp",
"headers": {
"Authorization": "Bearer <the token>"
}
}
}
}The criteria coaching is served both ways: predictable guide criteria on the
CLI, and the agent://guide resource over MCP.
Connecting to the Predictable MCP
The hosted head takes one credential and one only: an agent token, presented
as a bearer in the Authorization header of every request. There is no
sign-in step. The server publishes no OAuth authorization-server metadata and
no client-registration endpoint, so a client that tries to sign in to this
server, rather than present a token to it, cannot get through. Whatever the
client, connecting is the same two moves: mint a token, then give the client
that token as a header.
- Mint the token in the web app at Settings → Agent token
(
https://www.predictable.ai/settings/agent-token). It is shown once. It acts as you, so treat it as a password: it goes into the client's configuration and nowhere else, and the same page revokes it. - The endpoint is
https://www.predictable.ai/api/agent/mcp, POST only. - The header is
Authorization: Bearer <the token>.
Claude Code
Claude Code reads the header from .mcp.json. Use the .mcp.json block in
the section above — the minted token in place of <the token> — in the
project you want to work from, start claude, and /mcp should list
predictable as connected.
Claude web UI (custom connector)
In the Claude web app the hosted head is a custom connector, added under
Connectors in the app's settings: you give it the endpoint URL and choose how
it authenticates. This server takes a token, not a sign-in. Choose the
option that lets you add an authorization header — the exact label varies by
client and release — and supply the header Authorization with the value
Bearer <the token>, the minted token pasted in place of <the token>.
Do not pick the sign-in (OAuth) option, and leave any OAuth client ID and secret fields empty. That path has the client try to register itself with a sign-in service this server does not have, and it fails with an error along the lines of "Couldn't register with Predictable's sign-in service". The error means the client went looking for OAuth; it does not mean the URL or your token is wrong. Switch to the header option and try again.
If your client offers only a sign-in flow and no place to put a header, it cannot attach to this server yet — the header is the only door.
When it refuses you
The head answers a bearer-only caller in JSON, never with a login page or a redirect, so HTML in a client's error came from somewhere other than the head.
- 401 with a JSON-RPC body —
"code": -32001,"message": "Unauthorized"— the head saw no agent token: the header is missing, or its value is not an agent token. Check the header name and theBearerprefix. - 401
{"error":"invalid_agent_token"}— an agent token was presented but it is not live: mistyped, or revoked. Mint a new one. - 429 — the token is over its rate limit; wait the
Retry-Afterseconds. - 405 Method not allowed — you sent a GET. Opening the URL in a browser does exactly that, and proves only that the address resolves.
Spending credits
Paid verbs are two steps. The first prices the work and mints a quote; the second spends against that quote:
predictable contacts source --preview --list <list-id> # prints a quote id, credits, expiry
predictable contacts source --quote <quote-id> --list <list-id>Run the acting command without --quote and it refuses before sending
anything, and tells you the preview command to run.
Creating a list: the onboard body
lists create posts your --body to POST /api/onboard unchanged. That
route's schema is not in the manifest, so it is transcribed here from the
handler itself — dashboard/src/app/api/onboard/route.ts, the destructure and
its type annotation. Read that file when this and the server disagree: it is
the authority, and this section is a copy. A test in this package re-reads the
handler and reds when a top-level or icp field name is added or removed on
either side, and when the example below stops satisfying a refusal it can
model — it does not, and cannot, prove the route accepts what you post.
discovery_source is effectively required, even though the annotation marks it
optional. An absent or unrecognized value falls through to 'exa', and
creating new legacy Exa (Websets) lists is gated off, so a body without it is
refused:
400 New legacy Exa (Websets) lists can no longer be created. Create an Exa Agent
Search list instead (discovery_source=exa_agent). Existing Exa lists keep working.The accepted values are exa_agent, ctd, lookalike, engagement and watch.
Top-level fields
clientId? string
clientName? string
listName string (required)
discovery_source? string ('exa_agent' | 'ctd' | 'lookalike' | 'engagement' | 'watch')
ctd_connector_linkedin_url? string | null
lookalike_seeds? unknown (string[]; 1-5 seed companies for lookalike lists)
lookalike_filters? unknown
engagement_post_url? string | null
engagement_unipile_account_id? string | null
engagement_rep_id? string | null
engagement_include_reactions? boolean | null
engagement_min_headcount? number | string | null
engagement_max_headcount? number | string | null
exa_agent_musthaves unknown (required for exa_agent lists posted without alumni_seed)
exa_agent_nicetohaves? unknown
alumni_seed? unknown (exa_agent lists only)
target_account_profile_id? string | null
target_contact_profile_id? string | null
offering_id? string | null
watch_phrases? unknown (string[]; required for watch lists, 1-25 phrases, each <= 500 chars)
watch_category? string | null (required for watch lists; what the buying ask is about)
icp object (required; the nested block below)The nested icp object
exa_search_query? string
exa_criteria? Array<{ description }>
exa_enrichments? Array<{ description, format?, options?: Array<{ label }> }>
firmographic_weights? Record<string, number>
tier_thresholds? { high: number, mid: number }
contact_titles string[] (required on every list; at least one non-empty title)
contacts_per_company_cap? number
company_classifier_criteria? string
discovery_monitor_enabled? boolean
discovery_monitor_cron? string
discovery_monitor_timezone? string
discovery_monitor_count? number
refresh_monitor_enabled? boolean
refresh_monitor_cron? string
refresh_monitor_timezone? string
refresh_monitor_target? 'enrichments' | 'contents'Refusals worth knowing before you post
These are the route's own 400s, quoted from its source. Most are conditional on
discovery_source, which is why a body that works for one list type fails for
another; the last one applies to every list:
company_classifier_criteria required for network-mode ICPs— forctd,lookalike,engagementandwatch.engagement_post_url is required for engagement lists.At least one seed company is required for lookalike lists, and at most 5.alumni_seed is only valid on discovery_source='exa_agent' lists, and an alumni seed withfit_mode: 'classify'also requirescompany_classifier_criteria.At least one must-have criterion is required for Exa Agent lists— forexa_agentlists posted without analumni_seed. An alumni list is sourced from its seed rather than a base search, so it needs no must-haves.A watch list needs you set up as a rep, for its daily sweep and digest. In Settings, click "Set yourself up as a rep" in the Your email & LinkedIn accounts section, then create the watch again.— forwatchlists: the creating user'susers.rep_idmust resolve, and the rep is stamped from it, never from the body. A user with no client (an admin, who cannot self-promote) getsYour user has no rep record, and a watch list needs one for the sweep schedule and the digest. Ask an admin to link your user to a rep.instead.At least one target job title is required.— every list, whatever thediscovery_source:icp.contact_titlesmust hold at least one non-empty string.
A minimal Agent Search list:
predictable lists create --body '{
"listName": "Acme mid-market",
"discovery_source": "exa_agent",
"exa_agent_musthaves": ["Ships physical goods across the EU"],
"icp": { "contact_titles": ["Head of Logistics", "VP Operations"] }
}'icp.exa_search_query is absent on purpose. On an exa_agent list the route
synthesizes the base query from exa_agent_musthaves and persists that,
discarding whatever you post in that field.
Request bodies: one working example per route
Every example below is a body its route ACCEPTS, and each one is replayed
against that route's own validator by test/live-run-client-bugs.test.ts, so an
example cannot drift away from the server. --help on any of these commands
prints the same field list in short form. Ids are placeholders; the shapes are
not.
When a route refuses a body it says which field and which rule — the CLI now prints the server's whole structured answer under the error line, so a mapping with three problems comes back naming all three.
import csv — open a staged CSV import
column_mapping names EXACTLY the cells in header: an unmapped column and a
column the header does not have are both refused. name, linkedin_url and
company_domain are required, because a contact staged without them can never
promote. entity_kind defaults to contacts, accepts accounts — one account
per row, no contacts; contact columns ignored; rows − minted − invalid = already
in the list. A merge_var column also feeds {{<name>}} in a native sequence
once the contact is enrolled with contacts enroll.
predictable import csv --action create --body '{
"list_id": "11111111-1111-4111-8111-111111111111",
"entity_kind": "contacts",
"header": "Name,Title,Work Email,LinkedIn,Company,Domain,Segment",
"column_mapping": {
"Name": { "field": "name" },
"Title": { "field": "title" },
"Work Email": { "field": "email" },
"LinkedIn": { "field": "linkedin_url" },
"Company": { "field": "company_name" },
"Domain": { "field": "company_domain" },
"Segment": { "merge_var": "segment" }
}
}'contacts source — source contacts for selected accounts
A closed field set: any field outside candidate_ids, method, titles and
quote_id is refused by name. --quote supplies quote_id for you, and the
idempotency key rides in the Idempotency-Key header, so a request_key in
this body is an unsupported field and fails the request.
predictable contacts source --list <list-id> --quote <quote-id> --body '{
"candidate_ids": ["22222222-2222-4222-8222-222222222222"],
"method": "ai_ark",
"titles": ["Head of Logistics", "VP Operations"]
}'emails enrich --preview — price an enrichment run
The preview takes candidate_ids, scope and narrow, and nothing else. The
run that follows takes NO body of its own: the quote already names the
candidates it priced.
predictable emails enrich --list <list-id> --preview --body '{
"candidate_ids": ["33333333-3333-4333-8333-333333333333"],
"scope": "matching",
"narrow": "no_email"
}'push instantly — route staged contacts into a campaign
At most 500 candidate ids per call. The Instantly API key is never read from the body; the server resolves it from the authenticated client's stored credential.
predictable push instantly --list <list-id> --body '{
"candidate_ids": ["44444444-4444-4444-8444-444444444444"],
"campaign_id": "acme-q3-outbound",
"mappings": [{ "column_id": "55555555-5555-4555-8555-555555555555", "merge_var": "segment" }]
}'destination map --action set — set the list's standing destination
Exactly one of three shapes, carrying exactly its own keys. A shape that also
carries another arm's coordinate names two destinations and belongs to neither,
so it is refused. --action show and --action clear take no body.
predictable destination map --list <list-id> --action set --body '{
"destination": { "kind": "instantly", "campaign_id": "acme-q3-outbound" }
}'Reading a LinkedIn watch
watch read returns one LinkedIn watch by its list id. It writes and spends nothing.
predictable watch read --list <uuid> --status all --json
predictable watch read --list <uuid> --fit partial --limit 1 --jsonThe body carries lists[0] (the watch's settings: phrases, category, countries,
description, titles, headcount band, owner), its owner_reps (the reps it can be
assigned to), last_statistics.rejected (the last completed sweep's rejected posts)
and digest (the latest digest record); hits[], the Full matches or, with
--fit partial, the Partial matches, each with its reason and the phrase that
found it (null when the stored row has none); scan_cap; and served_at.
--status is new, done, dismissed or all (the default); --limit is 100 by
default and 200 at most. Text output prints the list only; use --json for the
nested data.
Changing a LinkedIn watch
watch settings changes one watch by its list id; watch file files one of its posts by hit id.
predictable watch settings --list <uuid> --body '{"countries":[]}' --json
predictable watch file --hit <uuid> --body '{"status":"done"}' --json
echo '{"description":"Seed-stage B2B SaaS"}' | predictable watch settings --list <uuid> --body - --jsonA settings body names one or more groups: phrases with category, countries ([] means
any country), description, titles, min_headcount with max_headcount, and owner_rep_id.
A named group is overwritten whole: nothing is appended, and a missing category is not fetched.
Groups the body leaves out are not changed. The answer is {"ok":true}.
watch file takes {"status":"new"}, {"status":"done"} or {"status":"dismissed"} and answers
{"ok":true,"id":"<hit id>","hit_status":"done"}.
If the server refuses a body, its own message is printed as it comes, for example
At most 25 watch phrases are allowed (got 26). The values for owner_rep_id and the hit id
come from watch read --json (lists[0].owner_reps[].id and hits[].id).
Exporting a campaign bundle
export campaign is the one verb that writes FILES rather than printing
records. It produces the seven-file bundle for a list:
predictable export campaign --list <list-id> --out ./acme-bundle| File | What it holds |
| ---- | ------------- |
| accounts.csv | The approved-accounts export, byte for byte as the server sent it. |
| contacts.csv | Every contact on the list. |
| sequence.json | One sequence's detail. |
| events.csv | Per-send rows, paged to exhaustion. |
| replies.json | The list's replies. |
| stats.json | Three reads: the sequences list with its per-sequence stats, billing usage, and the pipeline. Each is recorded as ok with its data or unavailable with the reason, so a source you were refused never looks like a source that returned nothing. |
| manifest.json | What was exported, the server's exclusion counts, the row counts, the byte size of every other file, and which stats sources it actually carries. Anything a count does not cover is named in completeness_scope with the reason. |
Add --sequence <id> to pick which sequence sequence.json describes and to
narrow events.csv to that sequence.
The directory is replaced, and only on success. The bundle is assembled in
full before anything is written, then moved into place as the last act. If a
read fails, a size cap is breached, or the write itself fails partway, --out
is left exactly as it was — there is no partial bundle to mistake for a whole
one. A bundle over 250,000 rows in any file, or 100 MB in total, exits with
bundle_envelope_exceeded naming the file that broke the cap.
The one bound on that guarantee, stated rather than glossed: replacing an
existing bundle is two moves, and a process killed between them leaves
--out absent for an instant. Nothing is lost — the previous bundle is beside
it under a .replaced-… name and a later run puts it back — but a run
interrupted by kill -9 can leave you one bundle behind rather than exactly
where you were. Closing that instant needs an atomic directory swap the
platform does not portably offer.
The put-back needs a run that reaches the write. It is the first act of the
write phase, and the write phase begins only after every read has succeeded. A
following run that fails earlier — a 500, an offline host — exits with --out
still absent and the previous bundle still under its .replaced-… name.
Nothing is lost on that path either, but it is the next run that reads
cleanly, not simply the next run, that restores it.
contacts.csv is checked against the server's own total for the list before
anything is written. A short read is an error, not a smaller file.
Suppression and --purpose
By default the accounts export is suppression-filtered. --purpose
reporting_history skips that filter on purpose — it is for reporting on
what you already did, not for choosing who to contact next:
predictable export campaign --list <list-id> --out ./acme-bundle --purpose reporting_historyThe bundle says so in two places: a note on stderr when it runs, and
manifest.json's purpose. Do not send outreach to a list built from a
reporting_history export.
A note on spreadsheets
Cells that a spreadsheet would treat as a formula are written with a leading
apostrophe so they open as text. That includes values that only look like
formulas — a phone number starting +, an address starting - — so those
gain an apostrophe too. manifest.json counts how many cells were affected.
The stored data is unchanged; this applies to the exported file only.
Exit codes
| Code | Meaning |
| ---- | ------- |
| 0 | Success. |
| 1 | Bad flags, or the server rejected the request as given. |
| 2 | The credential was refused (401 or 403). |
| 3 | Rate limited (429). The server's Retry-After is echoed on stderr. |
| 4 | Server error, or a verb this deployment does not serve yet. |
| 5 | A paid command needs --quote. Nothing was sent. |
Records go to stdout. Warnings, errors and hints go to stderr, so a pipeline never eats a diagnostic as data.
Versions
predictable --version prints this package's version, the agent-surface hash
compiled into it, and — when a token is configured — the hash the deployment
serves. When those two hashes differ, this client and that deployment disagree
about which verbs exist; update the client.
Development
npm ci
npm run typecheck
npm test
npm run buildsrc/generated/commands.json is generated from
dashboard/src/lib/agent/manifest.ts and must never be hand-edited. The parity
test in test/parity.test.ts fails if the command set here and the verb set
there ever disagree.
The end-to-end walk in scripts/e2e-walk.mjs runs the five read commands
against whatever PREDICTABLE_BASE_URL names: a local stub in CI, or a
deployment when you have a token for one.
