Curviate: inbox and messaging
Messaging is the highest-consequence surface here: every send lands in a real person's inbox and
cannot be unsent. Preview first, always.
Command surface established against CLI 0.33.0.
Before any command
npm install -g @curviate/cli && curviate --version # needs Node 18 or newer
curviate login --api-key <key> # or export CURVIATE_API_KEY
curviate account list --json # the acc_id for --account
- Credentials resolve flag > environment > stored profile (
CURVIATE_API_KEY,
CURVIATE_BASE_URL, CURVIATE_ACCOUNT).
--profile <name> picks the stored credential set; --account <acc_id> picks which connected
LinkedIn account sends this message. On a multi-account tenant, getting this wrong sends from the
wrong person. Name the account explicitly on every write.
--preview before every send. It renders the resolved request (recipient, text, acting
account) without sending. On a read command it is refused with exit 2.
--json on anything you parse; --fields a,b,c to project (a message carries 22 fields);
--verbose when a slim response looks suspiciously empty.
- Put global flags at the end of the command.
- Branch on the exit code, never on prose. See the table at the end.
Text input
Message text is a positional argument, and - reads stdin. Use a quoted heredoc: the quoted
delimiter disables every shell expansion, so apostrophes, accents, $ and backticks survive intact.
An unquoted heredoc has produced an empty message that went out blank.
cat <<'EOF' | curviate message send "<chat_id>" - --preview
Hi Thomas,
thanks for connecting. I saw the work you shared last week.
EOF
Retrieval mode: --mode and --max-age
Exactly four reads decide between a stored copy and a live LinkedIn call: profile me,
profile <id>, inbox get and inbox messages. Two of them are here.
--mode | Behaviour |
|---|
auto (default) | A stored copy while it is fresh, otherwise fetch. |
live | Always fetch from LinkedIn. |
refill | A stored copy at any age; fetch only when nothing is stored. |
cache_only | Never fetch. A store miss is refused, not fetched. |
--max-age <seconds> (0 to 31536000) overrides those presets in both directions; --max-age 0 is
the same as --mode live. Every response carries source: store | live plus observed_at under
--json, and a provenance: line on stderr in human mode. Read source rather than assuming.
cache_only with --max-age is a usage error, exit 2, raised before any network call.
cache_only never reaches LinkedIn at any age, so a freshness threshold cannot change its answer.
Drop --max-age, or use --mode refill.
cache_only on a store miss is exit 14 (NOT_STORED): the chat may exist perfectly well on
LinkedIn, this API just holds no copy. It is not "not found" (4), so re-checking the chat id is
the wrong move, and it is not retryable as sent. Re-read with refill, auto or live.
inbox messages: one bare --mode live page restarts the chat walk and leaves it unservable by
cache_only afterwards. A listing is served from the store only over a closed walk.
inbox messages --all walks to cursor: null and closes it. When you need a chat to be
cache_only-servable later, reach for --all rather than a single live page.
- Every other command refuses the flags outright rather than ignoring them:
unknown flag --mode, exit 2.
Reading: inbox
| Command | What it does | Confidence |
|---|
curviate inbox list | Conversations, newest activity first, 20 by default. --unread / --no-unread filter by read state; --inbox <folder> selects primary (default), inmail, archived, spam, jobs or starred. | proven |
curviate inbox get <chat_id> | One chat's detail including last_message (full text and sender), the cheap triage read. Accepts --mode/--max-age. | proven |
curviate inbox messages <chat_id> | The messages in one chat. Full text per message; is_sender (0 or 1) says who sent each. --before/--after take ISO-8601 UTC with a Z suffix. Accepts --mode/--max-age. | proven |
curviate inbox search "<query>" | Free-text search of the account's own inbox: participant names and message content. | proven |
curviate inbox mark-read <chat_id> | Mark a chat read. | proven |
Listing chats does not mark anything read, so triage freely.
A chat item already carries the counterpart's identity: user_id (the ACoAA… member id), an
embedded user{} (id, type, display_name, profile_url, public_picture_url), and name,
the chat's own display name, which is populated on direct messages. Escalate to curviate profile <user_id> only for what user{} does not carry: headline, network distance, the full profile.
Traps
--limit on inbox list and inbox messages is validated client-side to 1-25 (default 20).
Outside that range the command exits 2.
- Neither
inbox list nor inbox search reliably answers "does a chat with X exist". A single
inbox list --limit 25 call has repeatedly omitted a chat created seconds earlier, across
candidates, not a one-off race. inbox search has a different defect: its result set is ranked
and capped rather than exhaustive, so a common first name can omit an exact match that a rarer name
finds immediately. When the answer matters, walk inbox list by --cursor to exhaustion (or a
generous --max-pages) rather than trusting one page or the search.
- Inbox threads carry no vanity slug.
user.profile_url is built from the member id and
public_identifier is absent even under --verbose. Join inbox-driven flows on the member id.
inbox list has no date-range flags. --before/--after exist on inbox messages only, and
filter messages within one chat. Filter a chat list client-side on last_message_timestamp.
- A very recent send or delete can take minutes to appear in
inbox messages (LinkedIn-side
indexing). message get <chat_id> <message_id> reflects it immediately, use that to confirm a
send, not a re-list.
Discovering inboxes: inboxes
| Command | What it does | Confidence |
|---|
curviate inboxes list | Every inbox the account can act in: its own, plus the company pages it administers. --kind personal|company and --company-id <id> narrow it. Beta. | proven |
curviate inboxes chats <inbox_id> | One inbox's conversations. Each chat id is send-ready. Beta. | proven |
A COMPANY_… chat id sends as that page, with no extra flag. Pass it to message send and the
message goes out from the page rather than from you; the output confirms with
Sent as <name> (company page). Company inboxes are reply-only: they cannot start a conversation.
The page's own admin inbox has a second, richer surface under company (see curviate-profile).
Sending: message
| Command | What it does | Confidence |
|---|
curviate message new --to <recipient> "<text>" | Start a new chat. --to takes a profile URL, a bare slug or a member id, and resolves it for you. --attach <file> is repeatable. | proven |
curviate message send <chat_id> "<text>" | Reply in an existing chat. A COMPANY_ chat id sends as that page. | proven |
curviate message get <chat_id> <message_id> | One message. Reflects a very recent send immediately, unlike the thread listing. | proven |
curviate message edit <chat_id> <message_id> "<text>" | Edit a message within LinkedIn's allowed window. | proven |
curviate message delete <chat_id> <message_id> | Delete a message. | proven |
curviate message react <chat_id> <message_id> <emoji> | Add an emoji reaction to a message. | proven |
curviate message attachment <chat_id> <message_id> <attachment_id> | Download an attachment. | proven |
curviate message inmail --to <recipient> --subject "<subject>" "<text>" | Send an InMail. Both --to and --subject are required. Consumes an InMail credit. | proven |
curviate message inmail-balance | Remaining InMail credits. Check before a run that depends on them. | proven |
Chat ids look like CLASSIC_2-MzJmZTg1… for a personal chat and COMPANY_<id>_2-… for a page.
There is no idempotency key and no server-side de-duplication. A send that times out may already
have landed. Re-read the thread (inbox messages, or message get for the id you just wrote)
before re-issuing anything.
Event delivery: webhook
Message and account events arrive by webhook rather than by polling.
| Command | What it does | Confidence |
|---|
curviate webhook create --source <s> --request-url <https url> --account-ids <ids> | Register an endpoint. --source is messaging, user or account_status. Also --name, --events, --data, --no-enabled. | proven |
curviate webhook list | Registered webhooks. | proven |
curviate webhook events | The canonical event catalogue, read it before subscribing to a name. | proven |
curviate webhook get <id> | One webhook. | proven |
curviate webhook update <id> | Update in place. --request-url, --name, --enabled, --events, --data, --account-ids. The source is immutable. | proven |
curviate webhook delete <id> | Remove a subscription permanently. | proven |
curviate webhook verify --secret <s> --header <sig> --body <json|file|-> | Verify a delivery signature offline, no network call. --max-age-secs rejects a replay older than the given age (default 300). | proven |
Verify the signature on every delivery before acting on its body.
Full command surface
Read from the CLI's own --help at version 0.33.0. Descriptions, traps and confidence
tags elsewhere in this skill are hand-written and carry the version they were established against.
Every command below that takes flags at all also accepts --json.
| Command | Arguments | Flags |
|---|
curviate inbox list | (none) | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --all, --max-pages, --page-delay, --preview, --verbose, --beta, --unread, --inbox |
curviate inbox get | CHATID | --api-key, --profile, --account, --base-url, --timeout, --fields, --preview, --verbose, --beta, --mode, --max-age |
curviate inbox mark-read | CHATID | --api-key, --profile, --account, --base-url, --timeout, --fields, --preview, --verbose, --beta |
curviate inbox messages | CHATID | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --all, --max-pages, --page-delay, --preview, --verbose, --beta, --mode, --max-age, --before, --after |
curviate inbox search | QUERY | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --all, --max-pages, --page-delay, , , |
Exit codes to branch on here
| Code | Meaning | What to do |
|---|
1 | INTERNAL from the server itself: a genuine bug on the platform side. | Worth one retry; if it repeats it is a bug to report, not a state to work around. |
2 | Usage or invalid input, often raised before any network call: a --limit outside 1-25, cache_only with --max-age, a missing --subject on an InMail. | Fix the invocation. Never retry unchanged. |
4 | Not found: a wrong chat, message or member identifier. | Re-resolve the id; do not retry as sent. |
5 | Three causes, one code: read error.code. NO_ACTIVE_SEAT: the account is on no active seat. LINKEDIN_FEATURE_NOT_SUBSCRIBED: LinkedIn itself lacks the feature. BETA_NOT_ENABLED: the operation is beta-gated and this workspace has not opted in (pass --beta for one call, or a human enables it in Settings). | Branch on error.code: the three fixes have nothing in common, and none is fixed by retrying unchanged. |
6 | PLATFORM_RATE_LIMIT and its siblings. Carries retry_after in whole seconds. A response naming budgetRow means only that row is paused; every other row on the account keeps working. | Back off and retry after that many seconds. On a named budgetRow, switch to other work on the account rather than backing off across the board. |
7 | Transient platform fault: a request that got no response at all (network error, DNS failure, timeout) or one that came back as something other than a valid API answer. Carries retryLikelyToSucceed: true. | Retry with backoff. |
8 | Account or connection state. Read error.code: ACCOUNT_RESTRICTED, LINKEDIN_AUTH_FAILED, LINKEDIN_COOKIE_INVALID need a reconnect. | Depends on error.code. |
10 | The edit or delete window expired, or the recipient is unreachable. | Not retryable as sent. Do not resend. |
13 |