Official command-line interface for the Curviate API.
Built for coding agents and power users: JSON output on pipes, structured exit codes,
and shell-native composition with jq, xargs, and curl.
Global install (recommended for interactive use):
npm install -g @curviate/cliOne-off via npx (no install required):
npx @curviate/cli --helpRequires Node.js 18 or later.
Option 0: curviate setup (the one-command path). It prints a link, opens
it if there is a browser here, and waits. Sign in, press Authorize, paste the
code it shows you back into the terminal, and the key lands in a local profile:
curviate setup
There is no browser on the machine, or you are on a remote shell? Nothing
changes: the link is printed either way, and the code can be opened on a phone
and pasted back. --no-browser forces that shape.
Agents run the same flow in two steps, because an agent cannot read a browser:
curviate setup --json # prints {"authorize_url": ..., "next_step": "curviate setup --code -"}
curviate setup --code - # the code arrives on stdin, so it stays out of argv and shell history
Self-hosting, or serving the dashboard somewhere unusual? Set
CURVIATE_APP_URL to the host that serves the authorize page. Otherwise the
link is derived from the API base URL.
Then curviate doctor answers "can I run?" in one call: version, config path,
active profile, base URL, which precedence tier the credential came from
(never its value), whether the API is reachable and the key valid, and the
connected accounts with their status. It exits 0 when every check passes and
with the first failing check's code otherwise.
doctor also names the workspace the credential belongs to, but only when the
key it resolved is one curviate setup wrote to a profile. A key from
CURVIATE_API_KEY or --api-key may belong to another workspace entirely, so
it is reported as unknown rather than guessed at.
Option 1: interactive login (stores a profile in ~/.config/curviate/):
curviate loginOption 2: environment variable (preferred in CI and agent loops):
export CURVIATE_API_KEY=<your-api-key>
curviate account listOption 3: per-command flag:
curviate --api-key <your-api-key> account listSecurity note: a key passed via
--api-keyis visible to other users on the machine throughps/process listings and is recorded in your shell history. Prefercurviate loginor theCURVIATE_API_KEYenvironment variable; reserve--api-keyfor one-off, low-trust contexts.
Get your API key from the Curviate dashboard.
curviate [flags] [command] [subcommand] [flags]
Global flags may appear anywhere in the invocation, before the command or after it.
Global flags available on every command:
--account Target a specific account ID
--api-key Override the API key for this invocation
--profile Use a named profile from ~/.config/curviate/
--json Force JSON output even when stdout is a TTY
--fields Comma-separated list of fields to include in JSON output
--limit Maximum number of results to return per page
--cursor Pagination cursor from a previous response (an empty value is a usage error, never a restart)
--all Stream all pages as NDJSON
--max-pages Cap on the number of pages fetched with --all (a positive integer)
--page-delay Milliseconds to pause between pages when --all is used (default 400)
--preview Show what would happen without sending any write request
--verbose Output the full SDK response instead of the slim default
--beta Allow beta operations for this call only (--beta, --beta=false)
--base-url Override the API base URL (for testing)
--timeout Request timeout in milliseconds (default: 30000)
For full command reference see docs.curviate.com.
These examples show how coding agents compose the CLI in real workflows.
Search for matching profiles, preview the invitations, then send them once satisfied.
--location on search people takes location ids, not free text; resolve a
human-readable place name to an id first with curviate search parameters --type LOCATION:
curviate search parameters --type LOCATION --keywords "Berlin" --account acc_1 --json
# {"items":[{"id":"103035651","name":"Berlin, Germany"}, {"id":"106967730","name":"Berlin, Berlin, Germany"}, ...]}
# Look first, a search is a read, so it just runs (no --preview on reads)
curviate search people \
--keywords "AI engineer" \
--location 103035651 \
--account acc_1 \
--limit 10 \
--json
# Preview a single write before sending, then pipe IDs into connect, one request per person
curviate connect "$SOME_ID" --account acc_1 --note "Hi, I'd love to connect." --preview
curviate search people --keywords "AI engineer" --location 103035651 --account acc_1 --all \
| jq -r '.id' \
| head -5 \
| xargs -I{} curviate connect {} --account acc_1 --note "Hi, I'd love to connect."Pull the inbox, filter unread chats, and surface the most recent message from each.
--all streams NDJSON (one JSON object per line), not a {items:[...]} envelope or a
bare array, so pipe each line straight into jq with no .[]. The chat's own id is id
(chat_id only appears nested inside last_message, pointing back at its own chat); the
unread signal is the integer unread_count, not a boolean unread; and last_message has
no sender object, only sender_id (a provider id, not a display name), so a
human-readable sender comes from the chat's own user.display_name instead (present on
1:1 chats):
curviate inbox list --json --all --account acc_1 \
| jq -c 'select(.unread_count > 0) | {chat_id: .id, sender: (.user.display_name // .last_message.sender_id), preview: .last_message.text[0:80]}'inbox list reads one folder per call, primary by default. An archived chat never appears
in a primary walk, no matter the --limit or how far --cursor/--all page through it;
pass --inbox archived to reach it. inmail and starred are alternate views over chats
primary already returns, useful as a narrower filter rather than for reachability. All six
values: primary, inmail, archived, spam, jobs, starred.
curviate inbox list --json --all --account acc_1 --inbox archivedRead recent posts from a profile, then react to each, useful for ambient warm-up before outreach.
A post's id is id (there is no post_id field), and --posts returns a {items:[...]}
envelope, not a bare array:
PROFILE_URL="https://www.linkedin.com/in/example"
curviate profile "$PROFILE_URL" --posts --fields id --account acc_1 --json \
| jq -r '.items[].id' \
| xargs -I{} curviate post react {} --account acc_1 --reaction likeExit code 5 is the entitlement refusal. Three different things produce it and the
code in the JSON body says which: NO_ACTIVE_SEAT (no Curviate seat covers the
account), LINKEDIN_FEATURE_NOT_SUBSCRIBED (the LinkedIn account lacks its own Sales
Navigator subscription) or BETA_NOT_ENABLED (the workspace has not opted into beta).
Branch on the exit code for the retry decision and read the code for the remedy:
curviate sales-nav search people --keywords "VP Engineering" --account acc_1 --json \
|| {
code=$?
if [ "$code" -eq 5 ]; then
echo "Entitlement refused. Read .error.code for which of the three it was."
else
echo "Search failed with exit code $code"
exit "$code"
fi
}Validate a webhook payload before processing it, with no network call:
# Pipe the raw request body from stdin; pass the signature header and secret as flags
cat webhook-payload.json \
| curviate webhook verify \
--secret "$CURVIATE_WEBHOOK_SECRET" \
--header "$CURVIATE_SIG_HEADER" \
--body -Exit 0 means the signature is valid and the parsed event is written to stdout as JSON.
Exit 2 means the signature is invalid or the replay window has expired.
List every connected account, select key fields, and format as CSV with jq. --all streams
NDJSON, so slurp it into an array first with jq -s; the fields are account_id and
full_name, not id / name:
curviate account list --all --json \
| jq -s -r '["account_id","full_name","status"], (.[] | [.account_id, .full_name, .status]) | @csv' \
> accounts.csvjob get accepts either a job URL or the bare numeric id, including the job_urn field a
job-search result already returns:
curviate search jobs --keywords "founding engineer" --location "Berlin" --account acc_1 --json \
| jq -r '.items[0].job_urn' \
| xargs -I{} curviate job get {} --account acc_1 --json
# A pasted job URL works identically:
curviate job get "https://www.linkedin.com/jobs/view/4428113858" --account acc_1Company commands (curviate company ...) are Core-tier reads. company <id> accepts a public
handle (the slug in linkedin.com/company/<handle>) or a numeric id; the four sub-resource
commands require the company's numeric provider id, the id field company <id> returns.
--account (or a configured default account) is required on all of them, unless exactly one account is connected.
curviate company t-systems --account acc_1 --json | jq -r '.id' \
| xargs -I{} curviate company employees {} --keywords "engineer" --limit 10 --account acc_1 --jsoncurviate company posts 112013061 --limit 5 --account acc_1 --json
curviate company jobs 112013061 --all --account acc_1 --json # streams every pageA Draft is a stored, editable, unpublished post. Give it an account and --schedule-at and Curviate publishes it at that time. Drafts are tenant-wide: --account is optional and is never defaulted from your config, so a Draft you create without it has no account yet. curviate post create still publishes immediately and does not schedule.
curviate draft create "Three things we learned shipping our first agent integration." --account acc_1 --schedule-at 2026-10-12T09:00:00+02:00 --json
curviate draft update drf_1 --unschedule --json # back to a plain Draft
curviate draft publish drf_1 --json # or publish now: prints the post id--schedule-at is an ISO 8601 time with an offset, 5 minutes to 365 days ahead, passed to the API unchanged. Two scheduled Drafts on one account must be at least 5 minutes apart.
curviate draft create "Launch day." --account acc_1 --attach shot1.png --attach shot2.png --json
curviate draft update drf_1 --attach demo.pdf --json # appends; existing files stayUp to 20 images (JPEG, PNG, GIF, WEBP, each up to 5 MiB), or one MP4 video, or one PDF (each up to 50 MiB), never mixed. An image over 5 MiB is refused before anything is sent. Files up to 5 MiB go inline in the request; larger ones are uploaded separately, in the order you gave them.
curviate draft list --status scheduled --account acc_1 --order asc --json
curviate draft list --status published --from 2026-10-01T00:00:00Z --json # publish records, not Drafts
curviate draft list --account none --all --json # Drafts with no account, every pageA scheduled post that fails to publish becomes a failed Draft with a failure code; any draft update on it clears the failure. Failed outcome_unknown means the post may be live: check the account's posts before retrying.
Sales Navigator commands (curviate sales-nav ...) are beta: this surface has not been
exercised against a real Sales Navigator subscription yet, so its responses may still move.
There is no Sales Navigator add-on to buy from Curviate and no tier on a seat: one ordinary
paid seat entitles every command here. What these commands do need is the LinkedIn account's
own Sales Navigator subscription. A refusal is exit code 5 with one of three codes in
the JSON body: NO_ACTIVE_SEAT (no Curviate seat covers the account, fix it in billing),
LINKEDIN_FEATURE_NOT_SUBSCRIBED (the LinkedIn account lacks the subscription, fix it on
LinkedIn) or BETA_NOT_ENABLED (a human enables beta in Settings, or pass --beta for this
call). Branch on the exit code the same way as example 4 above, and read the code for the
remedy. Write commands (save-lead, save-account, message new) accept --preview to
render the request without sending it.
curviate sales-nav search people \
--keywords "VP Engineering" \
--account acc_1 \
--limit 5 \
| jq -r '.items[0].id' \
| xargs -I{} curviate sales-nav profile {} --account acc_1Preview first, then send. --list is required; the save always targets a specific list.
curviate sales-nav save-lead ACwAAA1234567 \
--account acc_1 \
--list 987654 \
--preview
curviate sales-nav save-lead ACwAAA1234567 --account acc_1 --list 987654--subject is required for Sales Navigator messaging.
curviate sales-nav message new \
--to ACwAAA1234567 \
--account acc_1 \
--subject "An opportunity at our company" \
"Hi, I'd love to connect about an opportunity at our company."curviate sales-nav search companies \
--keywords "series B fintech" \
--account acc_1 \
--limit 5 --json \
| jq -r '.items[] | "\(.id)\t\(.name)"'curviate sales-nav account-lists --account acc_1
curviate sales-nav lead-lists --account acc_1curviate sales-nav browse-account-list 987654 \
--account acc_1 \
--filter STARRED \
--sort-by NAME \
--json \
| jq -r '.items[] | "\(.id)\t\(.display_name)"'curviate sales-nav browse-lead-list 456789 \
--account acc_1 \
--spotlight RECENT_POSITION_CHANGE \
--json \
| jq -r '.items[] | "\(.id)\t\(.display_name)"'curviate sales-nav save-account 112013061 \
--account acc_1 \
--list 987654 \
--preview
curviate sales-nav save-account 112013061 --account acc_1 --list 987654Recruiter commands (curviate recruiter ...) are beta: this surface has not been exercised
against a real Recruiter subscription yet, so its responses may still move.
There is no Recruiter add-on to buy from Curviate and no tier on a seat: one ordinary paid seat
entitles every command here. What these commands do need is the LinkedIn account's own Recruiter
subscription. A refusal is exit code 5 with one of three codes in the JSON body:
NO_ACTIVE_SEAT (no Curviate seat covers the account, fix it in billing),
LINKEDIN_FEATURE_NOT_SUBSCRIBED (the LinkedIn account lacks the subscription, fix it on
LinkedIn) or BETA_NOT_ENABLED (a human enables beta in Settings, or pass --beta for this
call). The surface is project-centric: most
operations are scoped to a hiring project id. Write commands (save-candidate, project update,
project-job create/update, job create/publish/close, message new) accept --preview
to render the request without sending it.
curviate recruiter projects --account acc_1 --limit 20 --json \
| jq -r '.items[] | "\(.id)\t\(.name)"'recruiter project-job get returns the single job posting attached to a project (a
RESOURCE_NOT_FOUND / exit 4 when none is attached).
curviate recruiter project "$PROJECT_ID" --account acc_1 --json
curviate recruiter pipeline "$PROJECT_ID" --account acc_1 --json
curviate recruiter project-job get "$PROJECT_ID" --account acc_1 --jsonrecruiter job create requires --project-name (the hiring project the posting opens) plus the
full v2 job body: --job-title, --company-id/--company-name, --workplace-type, --location,
--employment-status, --seniority-level, --description (200 characters minimum), --industry,
--job-function, and --apply-method. --location/--industry/--job-function take resolved
parameter ids, the same search parameters --type LOCATION/--type INDUSTRY/--type JOB_FUNCTION
resolution from example 1 above. recruiter job publish is project-scoped and requires --mode
(FREE | PROMOTED | PROMOTED_PLUS); the paid modes also require the full --budget-* triple.
curviate recruiter job create \
--account acc_1 \
--project-name "Backend Hiring 2026" \
--job-title "Senior Backend Engineer" \
--company-name "Curviate GmbH" \
--workplace-type REMOTE \
--location 103035651 \
--employment-status FULL_TIME \
--seniority-level MID_SENIOR_LEVEL \
--description "We are looking for a senior backend engineer to join our remote-first team building the core platform that powers agent-native LinkedIn automation for thousands of developers and their AI agents worldwide." \
--industry 96 \
--job-function 15 \
--apply-method linkedin \
--json
curviate recruiter job publish "$PROJECT_ID" "$JOB_ID" --account acc_1 --mode FREE --jsonrecruiter applicants is project-scoped and requires --channel-id (the project's own
JOB_POSTING talent-pool channel). Applicant detail and résumé are also project-scoped.
curviate recruiter applicants "$PROJECT_ID" --channel-id "$CHANNEL_ID" --account acc_1 --limit 10 --json \
| jq -r '.items[0].id' \
| xargs -I{} curviate recruiter applicant "$PROJECT_ID" {} --account acc_1curviate recruiter applicant resume "$PROJECT_ID" APPLICANT_ID --account acc_1 -o resume.pdfcurviate recruiter save-candidate "$PROJECT_ID" \
--account acc_1 \
--stage-id "$STAGE_ID" \
--candidate-id AEM789curviate recruiter search people \
--keywords "senior backend engineer" \
--account acc_1 \
--limit 5 --json \
| jq -r '.items[] | "\(.id)\t\(.full_name // .headline)"'
# A pasted Recruiter search / talent-pool URL runs directly:
curviate recruiter search "https://www.linkedin.com/talent/search?..." --account acc_1 --jsonrecruiter message new is JSON-only and requires --subject and --signature.
curviate recruiter profile "https://www.linkedin.com/in/example" --account acc_1 --json
curviate recruiter message new \
--to AEM789 \
--account acc_1 \
--subject "A role you'd be a great fit for" \
--signature "Alex, Talent Team" \
"Hi, I came across your profile and think you'd be a great fit for a role we're hiring for."Unlike recruiter jobs (which lists postings you manage), recruiter job get retrieves the full
detail of any public LinkedIn job posting, the Recruiter-seated counterpart to the top-level
job get command:
curviate recruiter jobs --account acc_1 --limit 10 --json \
| jq -r '.items[] | "\(.id)\t\(.title)\t\(.state)"'
curviate recruiter job get "https://www.linkedin.com/jobs/view/4428113858" --account acc_1 --jsonSome reads can be answered from the copy Curviate already holds instead of
calling LinkedIn. --mode says how willing a read is to fetch, and --max-age
sets the oldest stored copy it will accept.
--mode |
Behaviour |
|---|---|
auto |
Default. Serve a stored copy while it is within the resource's freshness threshold, otherwise fetch. |
live |
Always fetch. Same as --max-age 0. |
refill |
Serve a stored copy at any age; fetch only when nothing is stored yet. |
cache_only |
Never fetch. A store miss is refused rather than fetched (see the exit-14 note below). |
--max-age <seconds> is a whole number from 0 to 31536000 (one year). It
overrides the auto, live and refill presets in both directions. It cannot
be combined with --mode cache_only, whose guarantee is not a freshness
threshold: that combination is a usage error (exit 2) before any request is
sent.
Available on the four reads that can be served from a stored copy:
profile <id>, profile me, inbox get and inbox messages. The activity
listings on the profile commands (--posts, --comments, --reactions,
--followers) do not accept them and reject them with exit 2.
Every response from these reads carries source (store or live) and
observed_at, and they survive --fields and the slim projection. In human
mode the same facts go to stderr as a one-line provenance: note, so stdout
stays parseable.
A stored copy can carry less than a live one: message bodies and contact fields
are stripped before anything is stored, so source: "store" is the signal to
re-read with --mode live when you need them.
A --mode cache_only read the store cannot answer exits 14. It is not
"not found" (exit 4): the resource may exist perfectly well on LinkedIn and
this API simply holds no copy, so re-checking the id is the wrong move. It is
not a failure either. The fix is another mode, and it is never worth retrying
as sent, because the answer cannot change until you change the mode.
inbox messages is the strictest case. The store answers it only for an
unfiltered, uncursored first page, only after a walk of the whole chat reached
its end, and only when the chat's whole message set fits in that one page
(--limit, at most 25). Any unfiltered page fetched from LinkedIn (such as
--mode live without --all) restarts the walk, and
inbox messages <chat_id> --all walks to the end and closes it. A read
carrying --before, --after or --cursor is never served from the store and
--all does not change that, so a chat with more messages than one page holds
exits 14 under --mode cache_only however it is walked.
# The default: a fresh stored copy if there is one, otherwise fetch
curviate profile me --account acc_1 --json
# Serve whatever is stored at any age, and fetch only if nothing is
curviate profile me --account acc_1 --mode refill --json
# Accept a stored copy up to five minutes old, otherwise fetch
curviate profile me --account acc_1 --max-age 300 --fields first_name,source,observed_at --json
# A single chat from the store, never LinkedIn. Exit 14 if nothing is stored.
curviate inbox get 2-AbCdEf== --account acc_1 --mode cache_only --json| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Unexpected error |
| 2 | Usage / argument error |
| 3 | Authentication or authorization failure |
| 4 | Resource not found |
| 5 | No active seat, the LinkedIn account lacks the subscription, or beta consent is missing |
| 6 | Rate limited |
| 7 | Transient platform error (retry likely to succeed) |
| 8 | Account or connection state blocks the request |
| 9 | Checkpoint flow error (expired, invalid code, or too many attempts) |
| 10 | Messaging window expired or recipient unreachable |
| 11 | Billing issue (payment required, failed, or seat cancelled) |
| 12 | Auth action needed (a pending checkpoint; not an error) |
| 13 | Account-safety budget: Curviate's own ceiling refused the action |
| 14 | Nothing stored: a --mode cache_only read the store cannot answer. Retry with another mode, not with the same request |
Exit 6 means LinkedIn or Curviate's request limiter refused you: back off and
retry later.
Exit 13 means an account-safety ceiling YOU configured is spent, or the
account is outside the hours it works in. Nothing reached LinkedIn and nothing
was spent, so backing off is the wrong move: the reset can be a month out, and
you can lift it now. The error body names what to do, so branch on the fields
rather than parsing the message:
{
"error": {
"code": "BUDGET_EXHAUSTED",
"budgetRow": "profile_views",
"resetAt": "2026-09-06T00:00:00.000Z",
"safetyReason": "ceiling",
"safetyHint": {
"parameter": "profile_views.ceiling",
"message": "effective ceiling 15 = 100 x warm-up 0.15 (week 0, account_age)."
},
"blocked": true
}
}safetyReasonisceiling(the row's limit is spent) oractivity_window(the account is outside its working hours). The fixes differ.resetAtis an absolute instant, so it stays true however long you hold it. It isnullin the two cases where no clock frees the account: the invitation backlog, which falls when invitations are accepted or withdrawn rather than at any window edge, and an InMail credit exhaustion, which LinkedIn regrants on its own schedule. Null-check it before scheduling on it.safetyHint.parameteris addressable on the safety policy, so an agent can choose between waiting, escalating and reconfiguring without reading prose.- On the default posture nothing is refused at all: the action goes through and
the same payload rides the SUCCESS body under
safety_warningwithblocked: false.
A 429 that carries budgetRow under PLATFORM_RATE_LIMIT is a third thing:
that row is PAUSED because LinkedIn refused a recent call on it. The pause is
scoped to that one row, so every other row on the account still works and the
recovery is to switch work, not to wait out the account.
MIT. See LICENSE.
