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.--previewbefore every send. It renders the resolved request (recipient, text, acting account) without sending. On a read command it is refused with exit2.--jsonon anything you parse;--fields a,b,cto project (a message carries 22 fields);--verbosewhen 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_onlywith--max-ageis a usage error, exit2, raised before any network call.cache_onlynever reaches LinkedIn at any age, so a freshness threshold cannot change its answer. Drop--max-age, or use--mode refill.cache_onlyon a store miss is exit14(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 withrefill,autoorlive.inbox messages: one bare--mode livepage restarts the chat walk and leaves it unservable bycache_onlyafterwards. A listing is served from the store only over a closed walk.inbox messages --allwalks tocursor: nulland closes it. When you need a chat to becache_only-servable later, reach for--allrather than a single live page.- Every other command refuses the flags outright rather than ignoring them:
unknown flag --mode, exit2.
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
--limitoninbox listandinbox messagesis validated client-side to 1-25 (default 20). Outside that range the command exits2.- Neither
inbox listnorinbox searchreliably answers "does a chat with X exist". A singleinbox list --limit 25call has repeatedly omitted a chat created seconds earlier, across candidates, not a one-off race.inbox searchhas 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, walkinbox listby--cursorto exhaustion (or a generous--max-pages) rather than trusting one page or the search. - Inbox threads carry no vanity slug.
user.profile_urlis built from the member id andpublic_identifieris absent even under--verbose. Join inbox-driven flows on the member id. inbox listhas no date-range flags.--before/--afterexist oninbox messagesonly, and filter messages within one chat. Filter a chat list client-side onlast_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
<!-- generated: command surface, CLI 0.33.0 -->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, --preview, --verbose, --beta |
curviate inboxes list | (none) | --api-key, --profile, --account, --base-url, --timeout, --fields, --preview, --verbose, --beta, --kind, --company-id |
curviate inboxes chats | INBOXID | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --all, --max-pages, --page-delay, --preview, --verbose, --beta |
curviate message | CHATID TEXT | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta, --attach |
curviate message new | TEXT | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta, --to (required), --attach |
curviate message send | CHATID TEXT | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta, --attach |
curviate message get | CHATID MESSAGEID | --api-key, --profile, --account, --base-url, --timeout, --fields, --preview, --verbose, --beta |
curviate message edit | CHATID MESSAGEID TEXT | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta |
curviate message delete | CHATID MESSAGEID | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta |
curviate message react | CHATID MESSAGEID EMOJI | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta, -emoji, --emojiAlias |
curviate message attachment | CHATID MESSAGEID ATTACHMENTID | --api-key, --profile, --account, --base-url, --timeout, --fields, --preview, --verbose, --beta, -o, --output |
curviate message inmail | TEXT | --api-key, --profile, --account, --base-url, --timeout, --preview, --verbose, --beta, --to (required), --subject (required) |
curviate message inmail-balance | (none) | --api-key, --profile, --account, --base-url, --timeout, --fields, --preview, --verbose, --beta |
curviate webhook create | (none) | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --preview, --verbose, --beta, --source (required), --request-url (required), --account-ids (required), --name, --no-enabled, --events, --data |
curviate webhook list | (none) | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --all, --max-pages, --page-delay, --preview, --verbose, --beta |
curviate webhook events | (none) | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --preview, --verbose, --beta |
curviate webhook get | ID | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --preview, --verbose, --beta |
curviate webhook update | ID | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --preview, --verbose, --beta, --request-url, --name, --enabled, --events, --data, --account-ids |
curviate webhook delete | ID | --api-key, --profile, --account, --base-url, --timeout, --fields, --limit, --cursor, --preview, --verbose, --beta |
curviate webhook verify | (none) | --secret (required), --header (required), --body (required), --max-age-secs |
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 | BUDGET_EXHAUSTED: a safety rule of your own refused the send, not LinkedIn. Read error.safetyReason: ceiling means the row named in error.budgetRow hit its configured limit; activity_window means the account is outside the hours it works in (no budgetRow on that one). Nothing reached LinkedIn and nothing was spent; the message was not delivered. reset_at can be weeks out, and may be null where no clock frees it. | Do not back off and retry. error.safetyHint.parameter names the exact setting to change. Read quotas[] via curviate account get <acc_id> --json, then wait for the named reset or change that setting. |
14 | NOT_STORED: a cache_only read the store cannot answer. | Re-read with refill, auto or live. |