@curviate/sdk 0.42.0 → 0.44.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,292 @@ Versioning: semantic. Minor for additive changes, patch for bug fixes; no stabil
7
7
 
8
8
  ---
9
9
 
10
- ## [Unreleased]
10
+ ## [0.44.0] - 2026-10-22
11
+
12
+ Dashboard v2 API. Fixture and types regenerated from deployed staging (server
13
+ `15b704443878c7b589e40c7955a202b5131a20f7`, 197 paths), and the Sequence
14
+ corpus re-vendored from the same server.
15
+
16
+ ### Added
17
+
18
+ - **Contact `profile_picture_url`:** read-only, `string | null`. The person's
19
+ LinkedIn photo from profile data your team already holds; null when none is
20
+ held. The link is signed and expires.
21
+ - **Enrollments:** `enrollments.pause`, `enrollments.events` (send a custom
22
+ event to an `until event.<name>`), `enrollments.reassign` (move to another
23
+ sending account; `warnings` names Steps that will be skipped) and
24
+ `enrollments.bulk` (up to 1,000 by ids or filter, one result per
25
+ Enrollment, not atomic). `enrollments.resume` takes no body to lift your own
26
+ pause. `enrollments.retry(id)` retries a failed Step through the guarded
27
+ bulk path and throws `NOTHING_TO_RETRY` (or the Enrollment's other refusal)
28
+ as a `CurviateError`; `resume({ at })` is an explicit, unguarded jump to a
29
+ Step. New list filters:
30
+ `version`, `done_step` with `outcome`, `reason`, `variant_id`,
31
+ `relationship_at_enrollment`. Types `EnrollmentEventBody`,
32
+ `EnrollmentReassignBody`, `EnrollmentReassignResult`, `EnrollmentBulkBody`,
33
+ `EnrollmentBulkResult`, `EnrollmentBulkItem`, `EnrollmentReason` (the reasons catalogue).
34
+ - **Error codes** `SEQUENCE_CONFLICT` (with `err.current`: the stored
35
+ Sequence and its `sequence_revision`; send `if_sequence_revision` on
36
+ `motions.update`), `STEP_IN_FLIGHT`, `NOTHING_TO_RETRY`,
37
+ `ACCOUNT_ALREADY_ENGAGED`, `ALREADY_ENROLLED_ON_ACCOUNT`, `KNOWN_CHAT`,
38
+ `CHAT_NOT_ONE_TO_ONE`. Before, each decoded as `INTERNAL`.
39
+ - **Compiler-style Sequence errors:** every `validateSequence` entry, every
40
+ offline `SEQUENCE_INVALID` entry and every decoded wire `errors[]` entry of
41
+ a Sequence rule carries `code` (`R1` to `R24`, `W1` to `W13`), `message`,
42
+ `pointer` (a JSON Pointer into the document), `hint` and `docs_url`, beside
43
+ `path` and `rule`.
44
+ - **Sequence document:** short variables `{{first_name}}`,
45
+ `{{my_first_name}}` and `{{<key>}}` for a custom field (the long forms stay
46
+ valid); an optional `event` on `do` and `tag` steps; `until event.<name>`;
47
+ the `message.seen` predicate (`if` steps). Warnings `W11` (a short custom
48
+ field that looks like a typo of a standard field), `W12` (`message.seen`
49
+ before any message) and `W13` (a deprecated form).
50
+ - **`newStepId(document, kind, beforeId?)`:** the id the dashboard editor
51
+ gives a new step (`message`, `message_2`, `w_<step>`).
52
+ - **Webhooks:** `enrollment.accepted`, `enrollment.replied`, `enrollment.seen`,
53
+ `enrollment.reassigned` and `motion.custom_event` in `CurviateEvent`.
54
+ `account_ids` is optional (or `null`: every account you can operate) on
55
+ every source.
56
+
57
+ ### Changed
58
+
59
+ - **A/B variants are exactly 2** (`MAX_VARIANTS`). 3 to 5 are accepted with
60
+ warning `W13` until 2027-01-19, then refused (`R23`); 1, or 6 and more,
61
+ are refused as before, and 6 and more no longer compile.
62
+ - **`{{motion.<key>}}`** gives `W13` (Motion variables were removed) beside
63
+ `W9`; still not an error offline until 2027-01-19.
64
+
65
+ ### Deprecated
66
+
67
+ - `enrollments.signal` and `EnrollmentSignalBody`: use `enrollments.events`;
68
+ accepted until 2027-01-19.
69
+ - `until signal.<name>` (use `until event.<name>`) and the `motion.signal`
70
+ webhook event (renamed `motion.custom_event`), each accepted until
71
+ 2027-01-19.
72
+ - `emit` steps: legacy; put `event` on the `do` or `tag` step that matters.
73
+
74
+ ---
75
+
76
+ ## [0.43.0]
77
+
78
+ Fixture and types regenerated from deployed staging (server
79
+ `7359b1b1be0584f7f368f1367d39261f2de8f079`, 193 paths; earlier regens at
80
+ `16b2eaf1473e4fb2a7b7b4a0308b9e248b30a93e`, 193 paths,
81
+ `091f53a69decbc55c53336bbbbfa1dc5fd56e859`, 193 paths,
82
+ `13d5357a7f74772782872c791ec8a5835060e9fe`, 191 paths, and
83
+ `3b0b7503a516f6a1f39622bd40ec6fec9c217c38`, 176 paths). The previous fixture
84
+ was older, so this regen also carries earlier, unrelated server changes (see
85
+ Changed). Regenerate from production and confirm an empty diff before publishing.
86
+
87
+ ### Added
88
+
89
+ - **`network`** (root, 2 methods plus **`network.connections`**, 1 method):
90
+ `connections.list`, `syncStatus`, `resync`. The stored LinkedIn Connections
91
+ of your accounts, read with no LinkedIn call and no limit used; `account_id`
92
+ takes several accounts. `resync` re-reads one account's Connections, once
93
+ per account per 24 hours.
94
+ - **`unibox`** (root, 6 methods plus **`unibox.accounts`**, 2 methods):
95
+ `list`, `get`, `unreadCount`, `refresh`, `tagContact`, `enroll`,
96
+ `accounts.list`, `accounts.update`. One inbox over every account you can
97
+ operate, from what Curviate stores; `accounts.update` turns full-inbox sync
98
+ on or off for an account. Messages and chat actions stay on `messaging`.
99
+ - **`analytics`** (root, 1 method plus **`analytics.motions`**, 2 methods,
100
+ and **`analytics.events`**, 1 method): `activity`, `motions.list`,
101
+ `motions.get`, `events.list`. Read-only, any Tier.
102
+ - **List iterators**: `network.connections.list`, `unibox.list` and
103
+ `analytics.events.list` resolve to their first page when awaited and yield
104
+ every item across pages under `for await` (type `Pageable`).
105
+ - **`BudgetRow`** type: the budget rows, `connections_sync` and `inbox_sync`
106
+ included.
107
+ - **Needs Automate** in the JSDoc of `network.resync`, `unibox.refresh`,
108
+ `unibox.tagContact`, `unibox.enroll` and `unibox.accounts.update` (turning
109
+ full-inbox sync on; off passes on any Tier).
110
+ - **Error code** `CLIENT_TIMEOUT`: no response within the client's `timeout`.
111
+ Before, it was `INTERNAL` ("Request timed out."), which read like a server
112
+ fault. A read is retried first; a write is not, may still have reached the
113
+ API, and carries `retryLikelyToSucceed: false` so a generic retry loop
114
+ does not resend it. A network failure with no response stays `INTERNAL`.
115
+
116
+ - **Offline Sequence module** (no network, key or file system; TypeScript 5.0
117
+ or later): `Sequence` (an immutable builder: `create`, `add`,
118
+ `insertBefore`, `replace`, `remove`, `onReply`, `validate`, `toDocument`,
119
+ `fromDocument`), `step` (one constructor per step kind: `do`, `wait`,
120
+ `until`, `if`, `emit`, `tag`), `validateSequence` and the published JSON
121
+ Schema `SEQUENCE_SCHEMA_V1` with `SEQUENCE_SCHEMA_URL`. The builder emits
122
+ exactly the Sequence document a Motion runs, keys in one canonical order, so
123
+ diffs show only real changes; `fromDocument` loads a fetched document and
124
+ re-emits it unchanged. With literal arguments a typo'd action, event,
125
+ reaction or predicate, a `{{...}}` that is not a v1 variable, a text with a
126
+ variable and no fallback, a malformed duration and a jump to a step you never
127
+ added fail to compile. `validateSequence` and `toDocument()` give the API's
128
+ own verdict offline: every error as `{path, rule}`, in step order, and the
129
+ warnings (`W1`, `W5` to `W9`). A document using an action or event added to
130
+ the API after this release is refused offline while the API accepts it:
131
+ upgrade the SDK. Not on the client: no request is ever sent. Like the API,
132
+ the first 100 errors are listed (the message counts all), an oversized
133
+ document gets the size error alone, and a NUL or lone surrogate in any
134
+ string is refused (`toDocument()` throws `INVALID_REQUEST`, as the API's 400).
135
+ - **`CurviateError.errors`**: every failure as `{path, rule}` on a refusal that
136
+ lists them (`SEQUENCE_INVALID`, `MOTION_NOT_STARTABLE`), decoded from the
137
+ response's `errors[]` and emitted by `toJSON()`. The offline `SEQUENCE_INVALID`
138
+ a builder throws carries the same list.
139
+ - **Error code** `SEQUENCE_INVALID` (422): the Sequence document breaks one or
140
+ more rules; nothing is saved.
141
+
142
+ - **`contacts`** (root, 9 methods): `list`, `get`, `upsert`, `update`,
143
+ `delete`, `deleteMany`, `updateMany`, `enrich`, `export`, plus
144
+ **`contacts.imports`** (4 methods): `create`, `get`, `list`, `rows`. Your
145
+ team's Contacts: a LinkedIn URL plus your own fields, Tags and list
146
+ memberships; any spelling of one person's URL resolves to one Contact, so
147
+ `upsert` creates or updates and reports `outcome`. `updateMany` adds or
148
+ removes Tags and list memberships on up to 1,000 Contacts. `enrich` fills
149
+ profile fields from what Curviate stores, else queues paced live profile
150
+ views (a real view the person may see). `export` returns the whole matching
151
+ set in one download: `format: "json"` resolves with the Contact array, `csv`
152
+ and `ndjson` (default) with the raw bytes. Imports take `rows` or a `csv`
153
+ string, up to 10,000 rows; up to 500 run inline, larger ones in the
154
+ background (poll `imports.get`, read the report with `imports.rows`).
155
+ `list` and `export` send an `enrollment_status` array as the one comma
156
+ list the API reads.
157
+ - **`contactLists`** (root, 5 methods): `create`, `list`, `get`, `update`,
158
+ `delete`. Static lists; membership changes go through
159
+ `contacts.updateMany` (`add_list_id`, `remove_list_id`).
160
+ - **`tags`** (root, 3 methods): `list`, `update` (rename), `delete`. No create
161
+ call: a write that names a new Tag creates it.
162
+ - **Error codes** for Contacts, lists and Tags: `NAME_TAKEN` (409),
163
+ `RESOURCE_IN_USE` (409, a live Motion or running import uses the list or
164
+ Tag), `CONTACT_LIMIT_REACHED` (422), `IMPORT_QUEUE_FULL` (429).
165
+ - **`motions`** (root, 13 methods): `create`, `list`, `get`, `update`,
166
+ `delete`, `start`, `pause`, `resume`, `archive`, `preview`,
167
+ `getSequenceVersion`, `addContacts`, `removeContacts`. `sequence` in
168
+ `create` and `update` takes a `Sequence` builder besides a document: the
169
+ builder is converted with `toDocument()` first, so an invalid one rejects
170
+ with `SEQUENCE_INVALID` and nothing is sent, and one with a jump to a step
171
+ never added does not compile. A plain object is sent as is (the API
172
+ validates it); `null` clears a draft's Sequence. `preview` is a read: it
173
+ never reaches LinkedIn.
174
+ - **`enrollments`** (root, 5 methods): `list`, `get` (with the Step
175
+ history), `stop`, `resume` (`at` a Step), `signal`.
176
+ - **Error codes** for Motions: `MOTION_NOT_STARTABLE` (422, every gap in
177
+ `errors`), `MOTION_STATUS_CONFLICT` (409), `ENROLLMENT_NOT_ACTIVE` (409),
178
+ `CONTACT_NOT_ENROLLABLE` (422).
179
+
180
+ - **`tier`** (root, 1 method): `get` reads `GET /v1/billing/tier`: the Tier the
181
+ team has now (`null` for none), the bought Tier, `period`, the free `trial`,
182
+ a granted `override`, a downgrade `pending` for the next renewal, the
183
+ `features` it includes and `early_access` per announced Tier. No prices.
184
+ - **`TIER_REQUIRED` details** on `CurviateError`: `feature`, `requiredTier`,
185
+ `currentTier` (both `null` when no Tier has the feature / the team has none)
186
+ and `upgradeUrl` (team Owners only), decoded from the 403 and emitted by
187
+ `toJSON()`. Writes in Drafts, Contacts, Motions (and later Network and
188
+ Unibox) on a team below Automate are refused with it; reads, exports,
189
+ deletes and stopping running work pass. The "Needs Automate" note is now in
190
+ the JSDoc of the 19 gated write methods: `drafts.create`, `update`,
191
+ `publish`, `uploadAttachment`; `contacts.upsert`, `update`, `updateMany`,
192
+ `enrich`, `imports.create`; `contactLists.create`, `update`; `tags.update`;
193
+ `motions.create`, `update`, `start`, `resume`, `addContacts`;
194
+ `enrollments.resume`, `signal`.
195
+ - **`tier_required`** is a member of the Motion `pause_reason`, the sync
196
+ `paused_reason` and the Contact enrichment `failure_code` types, and the
197
+ reason a `motion.status_changed` event carries when the team loses the Tier.
198
+
199
+ - **Unibox types** (no SDK method yet): `POST
200
+ /v1/unibox/chats/{account_id}/{chat_id}/contact` and `.../enrollments`, and
201
+ the typed `motion_context` on Unibox chats (was an empty object type).
202
+
203
+ - **`notifications` at the root** (4 methods): `list`, `unreadCount`,
204
+ `update`, `markAllRead`. Curviate's own Notifications for you in this team
205
+ (an account needs reconnecting, a Motion paused, an import finished, a
206
+ teammate joined or left), with per-person read and archived state. Check
207
+ `unreadCount()` at session start. `curviate.account(id).notifications` is
208
+ unchanged and still means the account's LinkedIn notifications: the same key
209
+ now carries a different class at each scope.
210
+ - **`connectRequests`** (root, 3 methods): `create`, `get`, `cancel`
211
+ (`/v1/auth/connect-requests`). The credential-free way for an agent to get an
212
+ account connected or reconnected: create one, relay its `url` to the person,
213
+ poll `get` until `status` leaves `pending`. Your code never handles a
214
+ LinkedIn password, cookie or code. `create` is not retry-safe: a retry makes
215
+ a second request.
216
+ - **`teams`** (root, 10 methods): `list`, `getCurrent`, `updateCurrent`,
217
+ `listMembers`, `updateMember`, `removeMember` (`"me"` leaves the team),
218
+ `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`.
219
+ - **`accounts.setTeamAccess(accountId, teamAccess)`**
220
+ (`PUT /v1/accounts/{account_id}/team-access`): opt an account you connected
221
+ out of team use, or share it again.
222
+ - **`team` client option**, sent as `X-Curviate-Team`: the team a dashboard
223
+ session token acts in. An API key acts only in the team it was created in;
224
+ leave `team` unset with one. An empty `team` throws `INVALID_REQUEST` at
225
+ construction.
226
+ - **Error codes** that decoded to `INTERNAL` until now: `TEAM_ACCESS_DENIED`,
227
+ `ACCOUNT_NOT_SHARED`, `ACCOUNT_OWNER_ONLY`, `LAST_OWNER`,
228
+ `BILLING_OWNER_REQUIRED`, `DEFAULT_TEAM_PROTECTED`, `ALREADY_MEMBER`,
229
+ `INVITE_ALREADY_PENDING`, `INVITE_NOT_PENDING`, `INVITE_LIMIT_REACHED`,
230
+ `CONNECT_REQUEST_NOT_PENDING`, `CONNECT_REQUEST_IN_USE`,
231
+ `LINKEDIN_ACCOUNT_MISMATCH`.
232
+
233
+ - **`activity_window.effective_timezone` and `activity_window.source`** on the
234
+ account object (`accounts.get`, `accounts.update`) and on the per-account
235
+ safety policy (`GET`/`PATCH /v1/{account_id}/safety-policy`, types only: the
236
+ SDK has no safety-policy method). `effective_timezone` is the IANA zone the activity window is
237
+ read in; `source` is `account`, `browser`, `location` or `default`.
238
+
239
+ ### Changed
240
+
241
+ - **Connect request types**: every field of the object `connectRequests`
242
+ returns is required (`id`, `url`, `status`, ...), so `req.id` needs no `!`.
243
+ - **Contacts export types**: the `csv` and `json` response media types are
244
+ typed beside `ndjson`.
245
+
246
+ - **`SequenceDocument` type**: `post`, `newer_than` and `reaction` in a
247
+ `post.react` or `post.comment` step's `with` are optional (the API applies
248
+ their defaults), and a `contact.extra.<key>` predicate compiles. Before, a
249
+ valid document omitting them, or using one, did not type-check.
250
+
251
+ - **The activity window applies to every account, with or without a
252
+ `timezone`.** It is no longer switched off when no zone is set: the zone is
253
+ the one you set, else the one the user's browser reported, else one inferred
254
+ from the LinkedIn profile location when it maps to one zone, else
255
+ `Europe/Berlin`. `timezone` and `timezone_unset` keep their meaning: what you
256
+ set. Behavior change on the server: under `enforce` an account with no
257
+ `timezone` is now refused outside 07:00 to 22:00 in its effective zone
258
+ (`BUDGET_EXHAUSTED`, `safetyReason: "activity_window"`), where before nothing
259
+ was refused. Description changes only on the generated types.
260
+ - Types regenerated from the staging document also include server changes that
261
+ were not in the previous fixture: the contacts, tags, contact lists, Motions,
262
+ enrollments, unibox, notifications, network sync and billing tier paths; new
263
+ `Error` fields and `reason` values; `owner_user_id` and `team_access` on the
264
+ account object. Types only, except notifications, teams and team access
265
+ (Added above).
266
+ - **An API key is one person's key in one team**, no longer one key per
267
+ workspace: it acts as the person who created it, with their role in that
268
+ team. `owner_user_id` on the account object is the person who connected it;
269
+ `team_access: false` means only they can use it.
270
+ - **`auth_method` on the account object** is the method of the last successful
271
+ connect or reconnect (`credentials`, `cookie` or `hosted`), not the method
272
+ of the first connect.
273
+ - **Two drift fixes the regen forced** (the SDK's own pins went red against the
274
+ new document): the seven `motion` webhook events (`motion.status_changed`,
275
+ `enrollment.started`, `enrollment.skipped`, `enrollment.finished`,
276
+ `step.executed`, `step.failed`, `motion.signal`) join `CurviateEvent` with a
277
+ loose `MotionPayload` (`motion_id`, `account_id` nullable), and the error
278
+ codes `INSUFFICIENT_ROLE` and `TIER_REQUIRED` join `ERROR_CODES` instead of
279
+ decoding to `INTERNAL`.
280
+
281
+ ### Fixed
282
+
283
+ - **`jobs.get` `with_sections`** goes as one comma value. Several sections
284
+ were sent as a repeated key, of which the API keeps only the last.
285
+ - **Package size**: the tarball no longer ships `src/generated/types.ts`. The
286
+ same types are in `dist/index.d.ts`, and `paths`, `components` and
287
+ `operations` are exported from the package root; the file was not
288
+ importable through the package's `exports` anyway.
289
+ - **`motions.preview` `sample` and `motions.getSequenceVersion` `version`**
290
+ are typed as numbers, as the API reads them.
291
+ - **An empty array in a comma-list filter is no filter.** `status: []`
292
+ (`drafts.list`, `motions.list`, `enrollments.list`), `enrollment_status: []`
293
+ (`contacts.list`, `contacts.export`) and `contact_ids: []`
294
+ (`motions.preview`) leave the parameter out instead of sending it empty,
295
+ which the API refused.
11
296
 
12
297
  ## [0.42.0] - 2026-10-07
13
298
 
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.
43
+
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
+ ```
39
55
 
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`:
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, drafts)
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,176 @@ 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`, `tag`, and the legacy `emit`), 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" }, event: "invite_sent" }),
153
+ step.until("accepted", "invite.accepted", { timeout: "21d", then: "opener", else: "end" }),
154
+ step.do("opener", "message.send", {
155
+ variants: [
156
+ { id: "named", text: "Hi {{first_name}}, {{opener}}" },
157
+ { id: "plain", text: "{{opener}}" },
158
+ ],
159
+ with: { fallback: "Hi, thanks for connecting." },
160
+ }),
161
+ step.until("r_opener", "message.replied", { timeout: "4d", then: "end" }),
162
+ step.if("seen", { "message.seen": true }, { then: "bump", else: "end" }),
163
+ step.do("bump", "message.send", { with: { text: "{{followup}}", fallback: "Quick follow-up in case this got buried." }, next: "end" }),
164
+ );
165
+
166
+ const document = seq.toDocument(); // the Sequence document, keys in canonical order
167
+ ```
168
+
169
+ Field names are the document's keys (`on_reply`, `note_fallback`, `withdraw_after`). Variables: `{{first_name}}` (any standard Contact field), `{{my_first_name}}`, `{{my_last_name}}`, `{{my_full_name}}` (the sending account), and any other `{{<key>}}` is the custom field `contact.extra.<key>`; the long forms `{{contact.first_name}}`, `{{contact.extra.opener}}` and `{{account.first_name}}` stay valid. A messaging step takes one text in `with` or exactly 2 `variants` (A/B). An optional `event` on a `do` or `tag` step sends a custom event each time the step runs; `until event.<name>` waits for one your agent sends (`enrollments.events`). With literal arguments, the type checker refuses what the API would: a typo'd action, event, reaction or predicate, a `{{...}}` that is not a variable (`{{firstName}}`, `{{my_title}}`, a `| default:` filter), a text with a variable and no fallback, one variant, 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()`.
170
+
171
+ Every builder operation (`add`, `insertBefore`, `replace`, `remove`, `onReply`) returns a new builder. `toDocument()` throws `SEQUENCE_INVALID` with every error in `err.errors`, the same list the API returns: `path`, `rule`, and the compiler-style `code` (`R1` to `R24`), `pointer` (a JSON Pointer into the document), `hint` (the fix) and `docs_url`. Warnings (`W1` to `W13`) never block, read them with `validate()`. `JSON.stringify(seq)` equals `JSON.stringify(seq.toDocument())`. `newStepId(document, kind)` names a new step as the dashboard editor does (`message`, `message_2`, `w_<step>` for a wait before a step).
172
+
173
+ Raw JSON stays first class. Validate a document you wrote by hand, or load one (a Motion's `sequence`) to edit it:
174
+
175
+ ```ts
176
+ import { Sequence, step, validateSequence } from "@curviate/sdk";
177
+
178
+ const { errors, warnings } = validateSequence(JSON.parse(text));
179
+ for (const e of errors) console.error(`${e.path}: ${e.rule} (${e.code}: ${e.hint})`);
180
+
181
+ const edited = Sequence.fromDocument(motion.sequence) // throws SEQUENCE_INVALID when invalid
182
+ .insertBefore("opener", step.wait("settle", "1d"))
183
+ .toDocument();
184
+ ```
185
+
186
+ Deprecated forms are accepted with warning `W13` until 2027-01-19, then refused: 3 to 5 variants (use 2), `until signal.<name>` (use `until event.<name>`) and `{{motion.<key>}}` (write the text in place or use a custom field; offline it also gives `W9`). 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.
187
+
188
+ ## Motions and Enrollments
189
+
190
+ 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:
191
+
192
+ ```ts
193
+ import { Sequence, step } from "@curviate/sdk";
194
+
195
+ const motion = await curviate.motions.create({
196
+ name: "Q4 webinar follow-up",
197
+ contact_list_ids: ["cl_123"],
198
+ account_ids: ["acc_123"],
199
+ schedule: { timezone: "Europe/Berlin" },
200
+ sequence: seq, // a builder: converted with toDocument(); an invalid one throws SEQUENCE_INVALID, nothing is sent
201
+ });
202
+
203
+ const { paths, samples, projection } = await curviate.motions.preview(motion.id); // a read: nothing is sent to LinkedIn
204
+ await curviate.motions.start(motion.id);
205
+
206
+ // Edit later: load the stored document into a builder. if_sequence_revision refuses the save
207
+ // with SEQUENCE_CONFLICT (the stored document in err.current) when someone saved since.
208
+ const current = await curviate.motions.get(motion.id);
209
+ await curviate.motions.update(motion.id, {
210
+ sequence: Sequence.fromDocument(current.sequence).insertBefore("opener", step.wait("settle", "1d")),
211
+ if_sequence_revision: current.sequence_revision,
212
+ });
213
+
214
+ // Enrollments: one Contact's run on one account.
215
+ const { items: failed } = await curviate.enrollments.list({ motion_id: motion.id, done_step: "opener", outcome: "failed" });
216
+ await curviate.enrollments.bulk({ action: "retry", ids: failed.map((e) => e.id) }); // one result per Enrollment
217
+ await curviate.enrollments.retry("enr_123"); // one: throws NOTHING_TO_RETRY unless its Step failed
218
+ await curviate.enrollments.events("enr_123", { name: "research_ready" }); // moves an `until event.research_ready`
219
+ ```
220
+
221
+ 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`, filtered by status, Step, Sequence version, or how a Step ended: `done_step` with `outcome`, `reason`, `variant_id`, `relationship_at_enrollment`; `get` with its Step history) and acts on it: `pause`, `resume()` (lift your pause), `retry(id)` (retry the failed Step; it acts only on a failure, else throws `NOTHING_TO_RETRY`, so a repeated call never sends a delivered message again), `resume({ at })` (an explicit jump to a Step, not guarded: it re-enters a Step that already sent; use `retry` for a failure), `events({ name })`, `reassign({ account_id })`, `stop`, and `bulk` for up to 1,000 at once by ids or filter. `signal` is the deprecated name of `events`, accepted until 2027-01-19. Refusals: `MOTION_NOT_STARTABLE` (every gap in `err.errors`), `MOTION_STATUS_CONFLICT`, `ENROLLMENT_NOT_ACTIVE`, `SEQUENCE_INVALID`, `SEQUENCE_CONFLICT`, `STEP_IN_FLIGHT` (retry in a minute), `NOTHING_TO_RETRY` (from `retry`, or per Enrollment in `bulk`), `ACCOUNT_ALREADY_ENGAGED`, `ALREADY_ENROLLED_ON_ACCOUNT`, `KNOWN_CHAT`.
222
+
223
+ ## Network, Unibox and Analytics
224
+
225
+ 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`:
226
+
227
+ ```ts
228
+ // Stored Connections of several accounts; read coverage before trusting an empty page.
229
+ const { items, coverage } = await curviate.network.connections.list({ account_id: ["acc_1", "acc_2"] });
230
+ for await (const c of curviate.network.connections.list({ q: "founder" })) console.log(c.public_profile_url);
231
+ const { items: sync } = await curviate.network.syncStatus();
232
+ await curviate.network.resync({ account_id: "acc_1" }); // once per account per 24 h
233
+
234
+ // One inbox over every account you can operate.
235
+ for await (const chat of curviate.unibox.list({ unread: true })) console.log(chat.chat_id);
236
+ const { unread_chats } = await curviate.unibox.unreadCount();
237
+ await curviate.unibox.accounts.update("acc_1", { sync_full_inbox: true }); // by the person who connected it
238
+ await curviate.unibox.refresh();
239
+ await curviate.unibox.tagContact("acc_1", "chat_1", { tags: ["warm"] });
240
+ await curviate.unibox.enroll("acc_1", "chat_1", { motion_id: "mot_1" });
241
+
242
+ // Motion results, activity in budget units, the events feed.
243
+ const overview = await curviate.analytics.motions.list({ from: "2026-10-01", to: "2026-10-31" });
244
+ const detail = await curviate.analytics.motions.get("mot_1");
245
+ const { types } = await curviate.analytics.activity(); // per BudgetRow; never add rows together
246
+ for await (const event of curviate.analytics.events.list({ kind: ["message.replied"] })) console.log(event.motion_name);
247
+ ```
248
+
249
+ 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`.
250
+
61
251
  ## Drafts and scheduled posts
62
252
 
63
253
  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.
@@ -150,6 +340,11 @@ try {
150
340
  console.warn(`${err.budgetRow} is spent until ${err.resetAt ?? "no fixed time"}`);
151
341
  console.warn(`change ${err.safetyHint?.parameter} to lift it`);
152
342
  break;
343
+ case "TIER_REQUIRED":
344
+ // The team's Tier lacks err.feature; err.requiredTier has it. A team
345
+ // Owner also gets err.upgradeUrl. See "Tiers" above.
346
+ console.warn(`${err.feature} needs ${err.requiredTier}`);
347
+ break;
153
348
  case "ACCOUNT_NOT_FOUND":
154
349
  console.error("Account does not exist for this tenant.");
155
350
  break;
@@ -169,6 +364,8 @@ try {
169
364
  }
170
365
  ```
171
366
 
367
+ 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`.
368
+
172
369
  Every error code is documented in the [API reference](https://docs.curviate.com). The
173
370
  exported `ErrorCode` type is the complete set, so `tsc` tells you when a `switch` over
174
371
  `err.code` has missed one.
@@ -308,7 +505,7 @@ may fetch.
308
505
 
309
506
  ## Cursor pagination
310
507
 
311
- 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:
508
+ `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:
312
509
 
313
510
  ```ts
314
511
  // Iterate over every chat across all pages