@curviate/sdk 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,256 @@ Versioning: semantic. Minor for additive changes, patch for bug fixes; no stabil
7
7
 
8
8
  ---
9
9
 
10
- ## [Unreleased]
10
+ ## [0.43.0] - Unreleased
11
+
12
+ Fixture and types regenerated from deployed staging (server
13
+ `7359b1b1be0584f7f368f1367d39261f2de8f079`, 193 paths; earlier regens at
14
+ `16b2eaf1473e4fb2a7b7b4a0308b9e248b30a93e`, 193 paths,
15
+ `091f53a69decbc55c53336bbbbfa1dc5fd56e859`, 193 paths,
16
+ `13d5357a7f74772782872c791ec8a5835060e9fe`, 191 paths, and
17
+ `3b0b7503a516f6a1f39622bd40ec6fec9c217c38`, 176 paths). The previous fixture
18
+ was older, so this regen also carries earlier, unrelated server changes (see
19
+ Changed). Regenerate from production and confirm an empty diff before publishing.
20
+
21
+ ### Added
22
+
23
+ - **`network`** (root, 2 methods plus **`network.connections`**, 1 method):
24
+ `connections.list`, `syncStatus`, `resync`. The stored LinkedIn Connections
25
+ of your accounts, read with no LinkedIn call and no limit used; `account_id`
26
+ takes several accounts. `resync` re-reads one account's Connections, once
27
+ per account per 24 hours.
28
+ - **`unibox`** (root, 6 methods plus **`unibox.accounts`**, 2 methods):
29
+ `list`, `get`, `unreadCount`, `refresh`, `tagContact`, `enroll`,
30
+ `accounts.list`, `accounts.update`. One inbox over every account you can
31
+ operate, from what Curviate stores; `accounts.update` turns full-inbox sync
32
+ on or off for an account. Messages and chat actions stay on `messaging`.
33
+ - **`analytics`** (root, 1 method plus **`analytics.motions`**, 2 methods,
34
+ and **`analytics.events`**, 1 method): `activity`, `motions.list`,
35
+ `motions.get`, `events.list`. Read-only, any Tier.
36
+ - **List iterators**: `network.connections.list`, `unibox.list` and
37
+ `analytics.events.list` resolve to their first page when awaited and yield
38
+ every item across pages under `for await` (type `Pageable`).
39
+ - **`BudgetRow`** type: the budget rows, `connections_sync` and `inbox_sync`
40
+ included.
41
+ - **Needs Automate** in the JSDoc of `network.resync`, `unibox.refresh`,
42
+ `unibox.tagContact`, `unibox.enroll` and `unibox.accounts.update` (turning
43
+ full-inbox sync on; off passes on any Tier).
44
+ - **Error code** `CLIENT_TIMEOUT`: no response within the client's `timeout`.
45
+ Before, it was `INTERNAL` ("Request timed out."), which read like a server
46
+ fault. A read is retried first; a write is not, may still have reached the
47
+ API, and carries `retryLikelyToSucceed: false` so a generic retry loop
48
+ does not resend it. A network failure with no response stays `INTERNAL`.
49
+
50
+ - **Offline Sequence module** (no network, key or file system; TypeScript 5.0
51
+ or later): `Sequence` (an immutable builder: `create`, `add`,
52
+ `insertBefore`, `replace`, `remove`, `onReply`, `validate`, `toDocument`,
53
+ `fromDocument`), `step` (one constructor per step kind: `do`, `wait`,
54
+ `until`, `if`, `emit`, `tag`), `validateSequence` and the published JSON
55
+ Schema `SEQUENCE_SCHEMA_V1` with `SEQUENCE_SCHEMA_URL`. The builder emits
56
+ exactly the Sequence document a Motion runs, keys in one canonical order, so
57
+ diffs show only real changes; `fromDocument` loads a fetched document and
58
+ re-emits it unchanged. With literal arguments a typo'd action, event,
59
+ reaction or predicate, a `{{...}}` that is not a v1 variable, a text with a
60
+ variable and no fallback, a malformed duration and a jump to a step you never
61
+ added fail to compile. `validateSequence` and `toDocument()` give the API's
62
+ own verdict offline: every error as `{path, rule}`, in step order, and the
63
+ warnings (`W1`, `W5` to `W9`). A document using an action or event added to
64
+ the API after this release is refused offline while the API accepts it:
65
+ upgrade the SDK. Not on the client: no request is ever sent. Like the API,
66
+ the first 100 errors are listed (the message counts all), an oversized
67
+ document gets the size error alone, and a NUL or lone surrogate in any
68
+ string is refused (`toDocument()` throws `INVALID_REQUEST`, as the API's 400).
69
+ - **`CurviateError.errors`**: every failure as `{path, rule}` on a refusal that
70
+ lists them (`SEQUENCE_INVALID`, `MOTION_NOT_STARTABLE`), decoded from the
71
+ response's `errors[]` and emitted by `toJSON()`. The offline `SEQUENCE_INVALID`
72
+ a builder throws carries the same list.
73
+ - **Error code** `SEQUENCE_INVALID` (422): the Sequence document breaks one or
74
+ more rules; nothing is saved.
75
+
76
+ - **`contacts`** (root, 9 methods): `list`, `get`, `upsert`, `update`,
77
+ `delete`, `deleteMany`, `updateMany`, `enrich`, `export`, plus
78
+ **`contacts.imports`** (4 methods): `create`, `get`, `list`, `rows`. Your
79
+ team's Contacts: a LinkedIn URL plus your own fields, Tags and list
80
+ memberships; any spelling of one person's URL resolves to one Contact, so
81
+ `upsert` creates or updates and reports `outcome`. `updateMany` adds or
82
+ removes Tags and list memberships on up to 1,000 Contacts. `enrich` fills
83
+ profile fields from what Curviate stores, else queues paced live profile
84
+ views (a real view the person may see). `export` returns the whole matching
85
+ set in one download: `format: "json"` resolves with the Contact array, `csv`
86
+ and `ndjson` (default) with the raw bytes. Imports take `rows` or a `csv`
87
+ string, up to 10,000 rows; up to 500 run inline, larger ones in the
88
+ background (poll `imports.get`, read the report with `imports.rows`).
89
+ `list` and `export` send an `enrollment_status` array as the one comma
90
+ list the API reads.
91
+ - **`contactLists`** (root, 5 methods): `create`, `list`, `get`, `update`,
92
+ `delete`. Static lists; membership changes go through
93
+ `contacts.updateMany` (`add_list_id`, `remove_list_id`).
94
+ - **`tags`** (root, 3 methods): `list`, `update` (rename), `delete`. No create
95
+ call: a write that names a new Tag creates it.
96
+ - **Error codes** for Contacts, lists and Tags: `NAME_TAKEN` (409),
97
+ `RESOURCE_IN_USE` (409, a live Motion or running import uses the list or
98
+ Tag), `CONTACT_LIMIT_REACHED` (422), `IMPORT_QUEUE_FULL` (429).
99
+ - **`motions`** (root, 13 methods): `create`, `list`, `get`, `update`,
100
+ `delete`, `start`, `pause`, `resume`, `archive`, `preview`,
101
+ `getSequenceVersion`, `addContacts`, `removeContacts`. `sequence` in
102
+ `create` and `update` takes a `Sequence` builder besides a document: the
103
+ builder is converted with `toDocument()` first, so an invalid one rejects
104
+ with `SEQUENCE_INVALID` and nothing is sent, and one with a jump to a step
105
+ never added does not compile. A plain object is sent as is (the API
106
+ validates it); `null` clears a draft's Sequence. `preview` is a read: it
107
+ never reaches LinkedIn.
108
+ - **`enrollments`** (root, 5 methods): `list`, `get` (with the Step
109
+ history), `stop`, `resume` (`at` a Step), `signal`.
110
+ - **Error codes** for Motions: `MOTION_NOT_STARTABLE` (422, every gap in
111
+ `errors`), `MOTION_STATUS_CONFLICT` (409), `ENROLLMENT_NOT_ACTIVE` (409),
112
+ `CONTACT_NOT_ENROLLABLE` (422).
113
+
114
+ - **`tier`** (root, 1 method): `get` reads `GET /v1/billing/tier`: the Tier the
115
+ team has now (`null` for none), the bought Tier, `period`, the free `trial`,
116
+ a granted `override`, a downgrade `pending` for the next renewal, the
117
+ `features` it includes and `early_access` per announced Tier. No prices.
118
+ - **`TIER_REQUIRED` details** on `CurviateError`: `feature`, `requiredTier`,
119
+ `currentTier` (both `null` when no Tier has the feature / the team has none)
120
+ and `upgradeUrl` (team Owners only), decoded from the 403 and emitted by
121
+ `toJSON()`. Writes in Drafts, Contacts, Motions (and later Network and
122
+ Unibox) on a team below Automate are refused with it; reads, exports,
123
+ deletes and stopping running work pass. The "Needs Automate" note is now in
124
+ the JSDoc of the 19 gated write methods: `drafts.create`, `update`,
125
+ `publish`, `uploadAttachment`; `contacts.upsert`, `update`, `updateMany`,
126
+ `enrich`, `imports.create`; `contactLists.create`, `update`; `tags.update`;
127
+ `motions.create`, `update`, `start`, `resume`, `addContacts`;
128
+ `enrollments.resume`, `signal`.
129
+ - **`tier_required`** is a member of the Motion `pause_reason`, the sync
130
+ `paused_reason` and the Contact enrichment `failure_code` types, and the
131
+ reason a `motion.status_changed` event carries when the team loses the Tier.
132
+
133
+ - **Unibox types** (no SDK method yet): `POST
134
+ /v1/unibox/chats/{account_id}/{chat_id}/contact` and `.../enrollments`, and
135
+ the typed `motion_context` on Unibox chats (was an empty object type).
136
+
137
+ - **`notifications` at the root** (4 methods): `list`, `unreadCount`,
138
+ `update`, `markAllRead`. Curviate's own Notifications for you in this team
139
+ (an account needs reconnecting, a Motion paused, an import finished, a
140
+ teammate joined or left), with per-person read and archived state. Check
141
+ `unreadCount()` at session start. `curviate.account(id).notifications` is
142
+ unchanged and still means the account's LinkedIn notifications: the same key
143
+ now carries a different class at each scope.
144
+ - **`connectRequests`** (root, 3 methods): `create`, `get`, `cancel`
145
+ (`/v1/auth/connect-requests`). The credential-free way for an agent to get an
146
+ account connected or reconnected: create one, relay its `url` to the person,
147
+ poll `get` until `status` leaves `pending`. Your code never handles a
148
+ LinkedIn password, cookie or code. `create` is not retry-safe: a retry makes
149
+ a second request.
150
+ - **`teams`** (root, 10 methods): `list`, `getCurrent`, `updateCurrent`,
151
+ `listMembers`, `updateMember`, `removeMember` (`"me"` leaves the team),
152
+ `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`.
153
+ - **`accounts.setTeamAccess(accountId, teamAccess)`**
154
+ (`PUT /v1/accounts/{account_id}/team-access`): opt an account you connected
155
+ out of team use, or share it again.
156
+ - **`team` client option**, sent as `X-Curviate-Team`: the team a dashboard
157
+ session token acts in. An API key acts only in the team it was created in;
158
+ leave `team` unset with one. An empty `team` throws `INVALID_REQUEST` at
159
+ construction.
160
+ - **Error codes** that decoded to `INTERNAL` until now: `TEAM_ACCESS_DENIED`,
161
+ `ACCOUNT_NOT_SHARED`, `ACCOUNT_OWNER_ONLY`, `LAST_OWNER`,
162
+ `BILLING_OWNER_REQUIRED`, `DEFAULT_TEAM_PROTECTED`, `ALREADY_MEMBER`,
163
+ `INVITE_ALREADY_PENDING`, `INVITE_NOT_PENDING`, `INVITE_LIMIT_REACHED`,
164
+ `CONNECT_REQUEST_NOT_PENDING`, `CONNECT_REQUEST_IN_USE`,
165
+ `LINKEDIN_ACCOUNT_MISMATCH`.
166
+
167
+ - **`activity_window.effective_timezone` and `activity_window.source`** on the
168
+ account object (`accounts.get`, `accounts.update`) and on the per-account
169
+ safety policy (`GET`/`PATCH /v1/{account_id}/safety-policy`, types only: the
170
+ SDK has no safety-policy method). `effective_timezone` is the IANA zone the activity window is
171
+ read in; `source` is `account`, `browser`, `location` or `default`.
172
+
173
+ ### Changed
174
+
175
+ - **Connect request types**: every field of the object `connectRequests`
176
+ returns is required (`id`, `url`, `status`, ...), so `req.id` needs no `!`.
177
+ - **Contacts export types**: the `csv` and `json` response media types are
178
+ typed beside `ndjson`.
179
+
180
+ - **`SequenceDocument` type**: `post`, `newer_than` and `reaction` in a
181
+ `post.react` or `post.comment` step's `with` are optional (the API applies
182
+ their defaults), and a `contact.extra.<key>` predicate compiles. Before, a
183
+ valid document omitting them, or using one, did not type-check.
184
+
185
+ - **The activity window applies to every account, with or without a
186
+ `timezone`.** It is no longer switched off when no zone is set: the zone is
187
+ the one you set, else the one the user's browser reported, else one inferred
188
+ from the LinkedIn profile location when it maps to one zone, else
189
+ `Europe/Berlin`. `timezone` and `timezone_unset` keep their meaning: what you
190
+ set. Behavior change on the server: under `enforce` an account with no
191
+ `timezone` is now refused outside 07:00 to 22:00 in its effective zone
192
+ (`BUDGET_EXHAUSTED`, `safetyReason: "activity_window"`), where before nothing
193
+ was refused. Description changes only on the generated types.
194
+ - Types regenerated from the staging document also include server changes that
195
+ were not in the previous fixture: the contacts, tags, contact lists, Motions,
196
+ enrollments, unibox, notifications, network sync and billing tier paths; new
197
+ `Error` fields and `reason` values; `owner_user_id` and `team_access` on the
198
+ account object. Types only, except notifications, teams and team access
199
+ (Added above).
200
+ - **An API key is one person's key in one team**, no longer one key per
201
+ workspace: it acts as the person who created it, with their role in that
202
+ team. `owner_user_id` on the account object is the person who connected it;
203
+ `team_access: false` means only they can use it.
204
+ - **`auth_method` on the account object** is the method of the last successful
205
+ connect or reconnect (`credentials`, `cookie` or `hosted`), not the method
206
+ of the first connect.
207
+ - **Two drift fixes the regen forced** (the SDK's own pins went red against the
208
+ new document): the seven `motion` webhook events (`motion.status_changed`,
209
+ `enrollment.started`, `enrollment.skipped`, `enrollment.finished`,
210
+ `step.executed`, `step.failed`, `motion.signal`) join `CurviateEvent` with a
211
+ loose `MotionPayload` (`motion_id`, `account_id` nullable), and the error
212
+ codes `INSUFFICIENT_ROLE` and `TIER_REQUIRED` join `ERROR_CODES` instead of
213
+ decoding to `INTERNAL`.
214
+
215
+ ### Fixed
216
+
217
+ - **`jobs.get` `with_sections`** goes as one comma value. Several sections
218
+ were sent as a repeated key, of which the API keeps only the last.
219
+ - **Package size**: the tarball no longer ships `src/generated/types.ts`. The
220
+ same types are in `dist/index.d.ts`, and `paths`, `components` and
221
+ `operations` are exported from the package root; the file was not
222
+ importable through the package's `exports` anyway.
223
+ - **`motions.preview` `sample` and `motions.getSequenceVersion` `version`**
224
+ are typed as numbers, as the API reads them.
225
+ - **An empty array in a comma-list filter is no filter.** `status: []`
226
+ (`drafts.list`, `motions.list`, `enrollments.list`), `enrollment_status: []`
227
+ (`contacts.list`, `contacts.export`) and `contact_ids: []`
228
+ (`motions.preview`) leave the parameter out instead of sending it empty,
229
+ which the API refused.
230
+
231
+ ## [0.42.0] - 2026-10-07
232
+
233
+ Fixture and types regenerated from the server integration branch (134 paths,
234
+ server `b1c443c6a3a0e1c2a09944be39f01c6921fca46c`), **not** from a deployed
235
+ document: regenerate from deployed staging and confirm an empty diff before
236
+ publishing.
237
+
238
+ ### Added
239
+
240
+ - **`drafts` resource** (root-scoped, 7 methods): `create`, `list`, `get`,
241
+ `update`, `delete`, `publish`, `uploadAttachment`. A Draft is a stored,
242
+ editable post that Curviate publishes at `scheduled_at` (ISO 8601 with
243
+ offset, passed through unchanged). `list` takes `status` as an array and
244
+ sends it as the API's comma list; `status: ["published"]` returns publish
245
+ records. `uploadAttachment(id, data, { filename, contentType })` sends a
246
+ file (an MP4 video or PDF up to 50 MiB; an image is capped at 5 MiB) as the raw request body.
247
+ `delete` resolves with `undefined` (204). (`posts.delete` and `comments.delete`
248
+ still hand back the transport's empty buffer under a `void` type; unchanged here.)
249
+ - **Six new error codes** decode to themselves instead of `INTERNAL`:
250
+ `DRAFT_LIMIT_REACHED`, `MEDIA_QUOTA_EXCEEDED`, `ACCOUNT_REQUIRED`,
251
+ `DRAFT_NOT_PUBLISHABLE`, `SCHEDULE_CONFLICT`, `DRAFT_PUBLISHING`.
252
+ - **`post.published` and `post.publish_failed` webhook events** join
253
+ `CurviateEvent` (`PostPublishedPayload`, `PostPublishFailedPayload`).
254
+ Payloads carry ids and times, never post text or media.
255
+ - `Draft*` request and result types are exported.
256
+
257
+ ### Changed
258
+
259
+ - The webhook `source` enum includes `post`, from the regenerated types.
11
260
 
12
261
  ## [0.41.0] - 2026-10-03
13
262
 
package/README.md CHANGED
@@ -33,20 +33,40 @@ const curviate = new Curviate({
33
33
  });
34
34
  ```
35
35
 
36
+ An API key belongs to one person in one team and acts with that person's role there. `curviate.teams.getCurrent()` says which team and role. A dashboard session token may act in any team you belong to: pick one with `team: "tnt_..."` (sent as `X-Curviate-Team`). Leave `team` unset with an API key; naming another team is refused with `TEAM_ACCESS_DENIED`.
37
+
36
38
  ---
37
39
 
38
- ## Account-scoped accessor
40
+ ## Connecting an account
41
+
42
+ Every LinkedIn operation (messages, member profiles, invites, posts) is tied to a **managed account**, a LinkedIn session connected to Curviate.
39
43
 
40
- Every LinkedIn operation (messages, member profiles, invites, posts) is tied to a **managed account**, a LinkedIn session you have connected via the connect flow (`curviate.auth.intent()`). A new connect names where LinkedIn sees the account connecting from, with exactly one of `country` (e.g. `"US"`), `ip` or your own `proxy`:
44
+ **Agents: use a connect request.** Your code never handles a LinkedIn password, cookie or verification code, so never ask for one. Create a request, give its `url` to the person, and they connect in their own Curviate dashboard:
45
+
46
+ ```ts
47
+ const req = await curviate.connectRequests.create(); // new account
48
+ // const req = await curviate.connectRequests.create({ account_id: "acc_123" }); // reconnect
49
+ console.log(`Connect your LinkedIn account here: ${req.url}`);
50
+
51
+ // Poll until it leaves `pending`: completed, expired (15 minutes), failed or cancelled.
52
+ const done = await curviate.connectRequests.get(req.id);
53
+ if (done.status === "completed") console.log(done.account_id);
54
+ ```
55
+
56
+ `create` is not retry-safe: a retry makes a second request. A pending request can be withdrawn with `connectRequests.cancel(id)`.
57
+
58
+ **Your own app, with the person's credentials in hand:** `curviate.auth.intent()`. A new connect names where LinkedIn sees the account connecting from, with exactly one of `country` (e.g. `"US"`), `ip` or your own `proxy`:
41
59
 
42
60
  ```ts
43
61
  await curviate.auth.intent({ seat_id, auth_method: "credentials", credentials: { email, password }, country: "US" });
44
62
  ```
45
63
 
64
+ ## Account-scoped accessor
65
+
46
66
  The `curviate.account(id)` accessor fixes the `account_id` on every call so you do not have to thread it manually:
47
67
 
48
68
  ```ts
49
- // Root-level: tenant-wide operations (accounts, auth, webhooks)
69
+ // Root-level: team-wide operations (accounts, auth, connectRequests, webhooks, drafts, teams, notifications)
50
70
  const { items: accounts } = await curviate.accounts.list();
51
71
 
52
72
  // Account-scoped: all LinkedIn ops under a specific account
@@ -58,6 +78,186 @@ const me = await acc.users.get("me"); // your own profile
58
78
  const profile = await acc.users.get("some-user-id"); // someone else's
59
79
  ```
60
80
 
81
+ ## Notifications
82
+
83
+ `curviate.notifications` is Curviate telling you something needs attention: an account needs reconnecting, a Motion paused, an import finished, a teammate joined or left. Each person has their own list and read state. Check it at session start:
84
+
85
+ ```ts
86
+ const { unread_count } = await curviate.notifications.unreadCount();
87
+ if (unread_count > 0) {
88
+ const { items } = await curviate.notifications.list({ filter: "unread" });
89
+ for (const n of items) console.log(n.type, n.title);
90
+ // Mark only what you saw: one that arrived since the list stays unread.
91
+ await curviate.notifications.markAllRead({ before: items[0]!.created_at });
92
+ }
93
+ ```
94
+
95
+ `type` is an open list: tolerate values you do not know. `curviate.account(id).notifications` is something else: that account's LinkedIn notifications.
96
+
97
+ ## Teams
98
+
99
+ `curviate.teams` manages the team the credential acts in: `getCurrent`, `updateCurrent`, `listMembers`, `updateMember` (role), `removeMember` (`"me"` leaves), `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`, plus `list` for every team you belong to. Member and invite writes are for Owners (`INSUFFICIENT_ROLE` otherwise). `curviate.accounts.setTeamAccess(accountId, false)` keeps an account you connected to yourself.
100
+
101
+ ## Tiers
102
+
103
+ A Team's Tier decides which features it has. Drafts, Contacts, Motions (and Network and Unibox) need Automate: a write in one of those groups on a Team below it is refused with `TIER_REQUIRED`, before the request is even validated. Reads, exports, deletes and stopping running work (`motions.pause`, `motions.archive`, `enrollments.stop`) pass on any Tier. The refusal names what is missing:
104
+
105
+ ```ts
106
+ try {
107
+ await curviate.motions.start("mot_123");
108
+ } catch (err) {
109
+ if (isCurviateError(err) && err.code === "TIER_REQUIRED") {
110
+ console.warn(`${err.feature} needs ${err.requiredTier}; this team has ${err.currentTier ?? "no Tier"}.`);
111
+ // A team Owner also gets err.upgradeUrl; any other role has none and should ask an Owner.
112
+ }
113
+ }
114
+
115
+ const { tier, features } = await curviate.tier.get(); // check first: no write is sent
116
+ ```
117
+
118
+ `curviate.tier.get()` returns the Tier now (`null` for none), the bought Tier, the free trial or granted Tier that raises it, a downgrade due at the next renewal (`pending`), and the `features` it includes. It reports no prices. A Motion or a sync that stopped for the Tier reads `pause_reason` / `paused_reason: "tier_required"`.
119
+
120
+ ## Contacts, lists and Tags
121
+
122
+ `curviate.contacts` keeps the people your team works with: a LinkedIn URL plus your own fields, Tags and Contact lists. Any spelling of one person's URL resolves to one Contact, so `upsert` is safe to repeat:
123
+
124
+ ```ts
125
+ const { id, outcome } = await curviate.contacts.upsert({
126
+ linkedin_url: "https://www.linkedin.com/in/ana/",
127
+ job_title: "CTO",
128
+ tags: ["warm"],
129
+ list_name: "Q4 webinar", // created if new
130
+ });
131
+
132
+ // Bulk: rows or a CSV string, up to 10,000 rows. Over 500 runs in the background.
133
+ const csv = await readFile("leads.csv", "utf8"); // node:fs/promises
134
+ const imp = await curviate.contacts.imports.create({ csv, list_name: "Q4 webinar" });
135
+ // Once `status` is `completed` (poll `imports.get` for a background import):
136
+ const { items: skipped } = await curviate.contacts.imports.rows(imp.id, { outcome: "skipped" });
137
+
138
+ await curviate.contacts.updateMany({ contact_ids: [id], add_tags: ["vip"] });
139
+ const all = await curviate.contacts.export({ format: "json", tag: "vip" });
140
+ ```
141
+
142
+ `contacts.enrich` fills profile fields from what Curviate already stores, else queues paced live profile views from an account you name: a live view is a real profile view the person may see. `curviate.contactLists` and `curviate.tags` rename and delete; a Tag has no create call, the first write naming it creates it. Refusals: `NAME_TAKEN`, `RESOURCE_IN_USE` (a live Motion or a running import uses it), `CONTACT_LIMIT_REACHED`, `IMPORT_QUEUE_FULL`. Deletes are for Owners and Managers.
143
+
144
+ ## Sequences
145
+
146
+ A Motion runs one Sequence: a JSON document of steps (`do`, `wait`, `until`, `if`, `emit`, `tag`), run from the first step and ended explicitly with `"end"`. The SDK has an offline module for it: a typed builder that emits exactly that document, and `validateSequence`, which gives the verdict the API gives. Nothing in it touches the network, a key or the file system, so it runs in CI, browsers and edge functions. It needs TypeScript 5.0 or later.
147
+
148
+ ```ts
149
+ import { Sequence, step } from "@curviate/sdk";
150
+
151
+ const seq = Sequence.create({ on_reply: "end" }).add(
152
+ step.do("invite", "invite.send", { with: { note: null, withdraw_after: "21d" } }),
153
+ step.until("accepted", "invite.accepted", { timeout: "21d", then: "opener", else: "end" }),
154
+ step.do("opener", "message.send", {
155
+ with: { text: "Hi {{contact.first_name}}, thanks for connecting.", fallback: "Hi, thanks for connecting." },
156
+ next: "end",
157
+ }),
158
+ );
159
+
160
+ const document = seq.toDocument(); // the Sequence document, keys in canonical order
161
+ ```
162
+
163
+ Field names are the document's keys (`on_reply`, `note_fallback`, `withdraw_after`). With literal arguments, the type checker refuses what the API would: a typo'd action, event, reaction or predicate, a `{{...}}` that is not a v1 variable (`{{firstName}}`, a `| default:` filter), a text with a variable and no fallback, a duration like `"1w"`, the reserved id `"end"`, and a jump to a step you never added (at `toDocument()`). Text the type checker cannot see (a `string` variable) is checked when you call `validate()` or `toDocument()`.
164
+
165
+ Every builder operation (`add`, `insertBefore`, `replace`, `remove`, `onReply`) returns a new builder. `toDocument()` throws `SEQUENCE_INVALID` with every error in `err.errors` as `{path, rule}`, the same list the API returns; warnings never block, read them with `validate()`. `JSON.stringify(seq)` equals `JSON.stringify(seq.toDocument())`.
166
+
167
+ Raw JSON stays first class. Validate a document you wrote by hand, or load one (a Motion's `sequence`) to edit it:
168
+
169
+ ```ts
170
+ import { Sequence, step, validateSequence } from "@curviate/sdk";
171
+
172
+ const { errors, warnings } = validateSequence(JSON.parse(text));
173
+ for (const e of errors) console.error(`${e.path}: ${e.rule}`);
174
+
175
+ const edited = Sequence.fromDocument(motion.sequence) // throws SEQUENCE_INVALID when invalid
176
+ .insertBefore("opener", step.wait("settle", "1d"))
177
+ .toDocument();
178
+ ```
179
+
180
+ `{{motion.<key>}}` is warning `W9` offline: only the API knows the Motion's `settings.variables`. Warnings that need the Motion's sending accounts or settings come from the API only. `SEQUENCE_SCHEMA_V1` is the published JSON Schema (`SEQUENCE_SCHEMA_URL`) for editors and tooling. The validator knows the vocabulary of the schema it ships with: a document using an action or event added to the API later is refused offline while the API accepts it; upgrade the SDK.
181
+
182
+ ## Motions and Enrollments
183
+
184
+ A Motion runs one Sequence for the Contacts of its Contact lists, on its sending accounts, inside its schedule. `sequence` in `motions.create` and `motions.update` takes a builder or a document:
185
+
186
+ ```ts
187
+ import { Sequence, step } from "@curviate/sdk";
188
+
189
+ const motion = await curviate.motions.create({
190
+ name: "Q4 webinar follow-up",
191
+ contact_list_ids: ["cl_123"],
192
+ account_ids: ["acc_123"],
193
+ schedule: { timezone: "Europe/Berlin" },
194
+ sequence: seq, // a builder: converted with toDocument(); an invalid one throws SEQUENCE_INVALID, nothing is sent
195
+ });
196
+
197
+ const { paths, samples, projection } = await curviate.motions.preview(motion.id); // a read: nothing is sent to LinkedIn
198
+ await curviate.motions.start(motion.id);
199
+
200
+ // Edit later: load the stored document into a builder.
201
+ const current = await curviate.motions.get(motion.id);
202
+ await curviate.motions.update(motion.id, {
203
+ sequence: Sequence.fromDocument(current.sequence).insertBefore("opener", step.wait("settle", "1d")),
204
+ });
205
+ ```
206
+
207
+ A plain object goes as is and the API validates it; wrap hand-written JSON in `Sequence.fromDocument(json)` to have it checked offline first. `sequence: null` clears a draft's. Status operations: `start`, `pause`, `resume`, `archive` (irreversible), `delete` (drafts only); `addContacts` / `removeContacts` enroll or take out Contacts directly; `getSequenceVersion` reads a past version. `curviate.enrollments` lists one Contact's run per account (`list`, `get` with its Step history) and acts on it: `stop`, `resume({ at })`, `signal({ name })`. Refusals: `MOTION_NOT_STARTABLE` (every gap in `err.errors`), `MOTION_STATUS_CONFLICT`, `ENROLLMENT_NOT_ACTIVE`, `SEQUENCE_INVALID`.
208
+
209
+ ## Network, Unibox and Analytics
210
+
211
+ Reads here come from what Curviate has stored: no LinkedIn call, no limit used. Lists with a cursor resolve to their first page when awaited and yield every item across pages under `for await`:
212
+
213
+ ```ts
214
+ // Stored Connections of several accounts; read coverage before trusting an empty page.
215
+ const { items, coverage } = await curviate.network.connections.list({ account_id: ["acc_1", "acc_2"] });
216
+ for await (const c of curviate.network.connections.list({ q: "founder" })) console.log(c.public_profile_url);
217
+ const { items: sync } = await curviate.network.syncStatus();
218
+ await curviate.network.resync({ account_id: "acc_1" }); // once per account per 24 h
219
+
220
+ // One inbox over every account you can operate.
221
+ for await (const chat of curviate.unibox.list({ unread: true })) console.log(chat.chat_id);
222
+ const { unread_chats } = await curviate.unibox.unreadCount();
223
+ await curviate.unibox.accounts.update("acc_1", { sync_full_inbox: true }); // by the person who connected it
224
+ await curviate.unibox.refresh();
225
+ await curviate.unibox.tagContact("acc_1", "chat_1", { tags: ["warm"] });
226
+ await curviate.unibox.enroll("acc_1", "chat_1", { motion_id: "mot_1" });
227
+
228
+ // Motion results, activity in budget units, the events feed.
229
+ const overview = await curviate.analytics.motions.list({ from: "2026-10-01", to: "2026-10-31" });
230
+ const detail = await curviate.analytics.motions.get("mot_1");
231
+ const { types } = await curviate.analytics.activity(); // per BudgetRow; never add rows together
232
+ for await (const event of curviate.analytics.events.list({ kind: ["message.replied"] })) console.log(event.motion_name);
233
+ ```
234
+
235
+ Needs Automate: `network.resync`, `unibox.refresh`, `unibox.tagContact`, `unibox.enroll` and turning full-inbox sync on (off passes on any Tier). Messages and actions on a chat stay on `curviate.account(id).messaging`.
236
+
237
+ ## Drafts and scheduled posts
238
+
239
+ A Draft is a stored, editable, unpublished post. Give it an account and a `scheduled_at` and Curviate publishes it at that time. Drafts are tenant-wide, so `drafts` hangs off the root client (a Draft's `account_id` is a field, and optional). `acc.posts.create()` still publishes immediately and does not schedule.
240
+
241
+ ```ts
242
+ const draft = await curviate.drafts.create({
243
+ account_id: "acc_...",
244
+ text: "Three things we learned shipping our first agent integration.",
245
+ scheduled_at: "2026-10-12T09:00:00+02:00", // ISO 8601 with offset; 5 min to 365 days ahead
246
+ });
247
+
248
+ // A video or PDF (up to 50 MiB) goes up as the raw body; images (up to 5 MiB) can ride inline as base64.
249
+ await curviate.drafts.uploadAttachment(draft.id, await readFile("clip.mp4"), {
250
+ filename: "clip.mp4",
251
+ contentType: "video/mp4",
252
+ });
253
+
254
+ await curviate.drafts.update(draft.id, { scheduled_at: null }); // unschedule
255
+ const { id: postId } = await curviate.drafts.publish(draft.id); // or publish now
256
+ const { items } = await curviate.drafts.list({ status: ["scheduled", "published"] });
257
+ ```
258
+
259
+ Refusals carry their own codes: `DRAFT_LIMIT_REACHED`, `MEDIA_QUOTA_EXCEEDED`, `ACCOUNT_REQUIRED`, `DRAFT_NOT_PUBLISHABLE`, `SCHEDULE_CONFLICT` and `DRAFT_PUBLISHING`. A scheduled publish that fails raises the `post.publish_failed` webhook (`post.published` on success); a `failure_code` of `outcome_unknown` means the post may be live, so check the account's posts before retrying.
260
+
61
261
  ---
62
262
 
63
263
  ## First end-to-end call
@@ -126,6 +326,11 @@ try {
126
326
  console.warn(`${err.budgetRow} is spent until ${err.resetAt ?? "no fixed time"}`);
127
327
  console.warn(`change ${err.safetyHint?.parameter} to lift it`);
128
328
  break;
329
+ case "TIER_REQUIRED":
330
+ // The team's Tier lacks err.feature; err.requiredTier has it. A team
331
+ // Owner also gets err.upgradeUrl. See "Tiers" above.
332
+ console.warn(`${err.feature} needs ${err.requiredTier}`);
333
+ break;
129
334
  case "ACCOUNT_NOT_FOUND":
130
335
  console.error("Account does not exist for this tenant.");
131
336
  break;
@@ -145,6 +350,8 @@ try {
145
350
  }
146
351
  ```
147
352
 
353
+ No response within the client's `timeout` throws `CLIENT_TIMEOUT` (not `INTERNAL`): a read is retried first and carries `retryLikelyToSucceed: true`; a write is not retried, carries `false`, and may still have reached the API, so read back before resending. A network failure with no response is `INTERNAL` with no `httpStatus`.
354
+
148
355
  Every error code is documented in the [API reference](https://docs.curviate.com). The
149
356
  exported `ErrorCode` type is the complete set, so `tsc` tells you when a `switch` over
150
357
  `err.code` has missed one.
@@ -284,7 +491,7 @@ may fetch.
284
491
 
285
492
  ## Cursor pagination
286
493
 
287
- Resources that return lists support cursor pagination. `curviate.paginate()` is an async iterator that follows the `cursor` field automatically, so you pull items one at a time without managing cursors:
494
+ `network.connections.list`, `unibox.list` and `analytics.events.list` iterate themselves (`for await (const x of curviate.unibox.list())`). For the other lists, `curviate.paginate()` is an async iterator that follows the `cursor` field automatically, so you pull items one at a time without managing cursors:
288
495
 
289
496
  ```ts
290
497
  // Iterate over every chat across all pages