npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 --version

The 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 --version

Quickstart

  1. Mint a token. In the web app, open Settings → Agent token (/settings/agent-token) and mint one. It is shown once.

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

  3. Say who you are. The token smoke-test verb:

    predictable whoami
  4. See what the token reaches. predictable lists list in 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 its lists:

    predictable lists list --json

    That is the server's body verbatim, so it arrives on one line, with every list's id, name and discovery_source in it. If you have jq — 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.

  5. 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_source is exa_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:

  1. The PREDICTABLE_TOKEN environment variable.
  2. ~/.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.json

Setting 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 the Bearer prefix.
  • 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-After seconds.
  • 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 — for ctd, lookalike, engagement and watch.
  • 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 with fit_mode: 'classify' also requires company_classifier_criteria.
  • At least one must-have criterion is required for Exa Agent lists — for exa_agent lists posted without an alumni_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. — for watch lists: the creating user's users.rep_id must resolve, and the rep is stamped from it, never from the body. A user with no client (an admin, who cannot self-promote) gets Your 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 the discovery_source: icp.contact_titles must 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 --json

The 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 - --json

A 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_history

The 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 build

src/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.