@typeb-digital/nucleus-sdk 0.6.0 → 0.8.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
@@ -6,6 +6,102 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the
7
7
  package is pre-`1.0.0`, **breaking changes may land in a minor version** (e.g. `0.0.x` → `0.1.0`).
8
8
 
9
+ ## [0.8.0] - 2026-09-08
10
+
11
+ ### Added
12
+
13
+ - **Google Chat messaging — `chat.send`.** Nucleus can now message people on Google Chat
14
+ as the company's Chat app, giving apps a push channel that reaches a phone without
15
+ handling a Google identity or a bot token of their own. Address a message to
16
+ `{ employeeId }` and Nucleus opens the direct-message conversation with that person the
17
+ first time, caches it, and reuses it forever after; or address `{ space: 'spaces/…' }`
18
+ to post into a shared space the app belongs to. Send `text`, a small `card` (title,
19
+ body, one link button, expanded into Google's `cardsV2` for you), or raw `cardsV2`.
20
+ Needs the new `chat:send` scope, which is approval-gated.
21
+ - **`requestId` makes a send idempotent.** Google returns the message already created
22
+ under that id rather than posting a second one, so a retried send cannot double-notify.
23
+ Key it on the event (`"leave-42-approved"`), not the attempt. Keys are scoped to your
24
+ app, so two apps cannot suppress each other's messages.
25
+ - **`thread.key` groups related messages** into one Chat thread — every update about one
26
+ leave request in one place. Named spaces only: Google does not thread direct messages,
27
+ so it is ignored for an `employeeId` target rather than failing.
28
+
29
+ ### Notes
30
+
31
+ - **This is a transport, not a notification system.** There are no templates, no audience
32
+ resolution, no retry policy and no per-person preferences. Your app decides who should
33
+ hear what; Nucleus puts one message in one place. A durable outbox on your side is the
34
+ intended pattern, which is what `requestId` exists for.
35
+ - **A Chat message is more interruptive than an in-app badge.** It reaches a phone.
36
+ Choose the handful of notification kinds that genuinely warrant that, and give people a
37
+ way to turn them off — Nucleus deliberately holds no preferences of its own.
38
+ - **Test tokens cannot reach a real person.** Under an `el_test_` token the message is
39
+ redirected to a configured sandbox space with a banner naming who it was meant for, and
40
+ no direct message with a real person is opened. `sandbox: true` and `redirectedFrom`
41
+ on the response tell you what happened. With no sandbox space configured the send is
42
+ refused (`SANDBOX_NOT_CONFIGURED`) rather than reaching someone real.
43
+ - **`CHAT_NOT_AVAILABLE_FOR_EMPLOYEE` (422) is not retryable.** It means the Chat app's
44
+ visibility list does not cover that person, which only a Workspace administrator can
45
+ change. Drop the message rather than retrying it.
46
+
47
+ ## [0.7.0] - 2026-09-08
48
+
49
+ ### Added
50
+
51
+ - **Google Calendar writes — `calendar.createEvent`, `calendar.updateEvent`,
52
+ `calendar.deleteEvent`.** The calendar capability was read-only; it can now put real
53
+ events on real calendars. Every write names the identity it acts as, so the organiser
54
+ is never ambiguous: `{ employeeId }` for an event that person organises on their own
55
+ calendar, or `{ organizer: 'company' }` for an org-wide event owned by the platform's
56
+ company mailbox. Attendees may be given as `{ employeeId }` or `{ email }` — employee
57
+ ids are resolved to Workspace emails server-side, so your app never needs a Google
58
+ identity. `createMeet: true` attaches a Google Meet conference and returns it as
59
+ `meetLink`. Needs the new `calendar:create` / `calendar:update` / `calendar:delete`
60
+ scopes, which are approval-gated: ask the platform team to grant them.
61
+ - **`clientReference` makes a create idempotent.** Nucleus derives a stable Google event
62
+ id from it, so replaying a create — a queue retry, a redeploy mid-request — returns the
63
+ original event with `created: false` instead of double-booking. Give it a value that
64
+ identifies the _thing_ being scheduled (`"milestone:cycle-42"`), not the attempt. A
65
+ delete is likewise safe to repeat: an event Google reports as already cancelled counts
66
+ as deleted.
67
+ - **`error.apiCode` on every failed result.** `error.code` stays the small union you
68
+ branch on; `apiCode` carries the server's specific reason —
69
+ `GOOGLE_DELEGATION_NOT_GRANTED`, `SANDBOX_NOT_CONFIGURED`, `ATTENDEE_NOT_RESOLVABLE`,
70
+ `EVENT_NOT_FOUND` — for logging and for telling two 4xx cases apart.
71
+ - **`calendar.listEvents` accepts `calendarId`**, to read a calendar other than the
72
+ employee's `primary`. The returned event now also carries `eventType`, `meetLink`,
73
+ `iCalUID`, `updated` and `recurrence`.
74
+
75
+ ### Fixed
76
+
77
+ - **`INVALID_SCOPE` now actually reaches you.** The transport read the API's error
78
+ _message_ where it meant to read the error _code_, so a missing-scope rejection always
79
+ arrived as the generic `FORBIDDEN`. Code that branches on `FORBIDDEN` keeps working;
80
+ code that wanted `INVALID_SCOPE` starts working.
81
+ - **Failed results carry the API's message, not ofetch's.** `error.message` was
82
+ `"[POST] …: 400 Bad Request"`; it is now the reason the API gave, e.g.
83
+ `"start.timeZone: is required when dateTime has no UTC offset"`.
84
+
85
+ ### Notes
86
+
87
+ - **`sendUpdates` defaults to `none`.** Creating or changing an event emails nobody
88
+ unless you pass `'all'` (or `'externalOnly'`). This is deliberate: an automation that
89
+ quietly mails a hundred people is worse than one that mails nobody. Pass it explicitly
90
+ for anything a human is expected to accept, such as a meeting invitation.
91
+ - **Test tokens cannot reach a real calendar.** Under an `el_test_` token every write is
92
+ redirected to a dedicated sandbox calendar as the company mailbox, the summary is
93
+ stamped `[SANDBOX]`, attendees are stripped and returned in `suppressedAttendees`,
94
+ invitations are forced off, and out-of-office is downgraded to a plain event. The
95
+ response's `sandbox: true` tells you it happened. If the deployment has no sandbox
96
+ calendar configured, the write is refused (`SANDBOX_NOT_CONFIGURED`) rather than
97
+ landing somewhere real.
98
+ - **`eventType` cannot be changed after creation** (Google's rule), so `updateEvent`
99
+ does not accept it. `updateEvent` is a patch — omitted fields are left alone — with one
100
+ exception: `attendees` replaces the whole list, and `[]` clears it.
101
+ - **This release does not write the Nucleus company calendar.** `calendarEvents` (the
102
+ `calendar-events` resource) is unchanged and remains the date-only company calendar;
103
+ `calendar` is an employee's real Google Calendar. They are different things.
104
+
9
105
  ## [0.6.0] - 2026-09-07
10
106
 
11
107
  ### Added
package/README.md CHANGED
@@ -259,6 +259,143 @@ Same pattern — `list(params?)` and `getById(id, options?)` on every resource a
259
259
 
260
260
  ---
261
261
 
262
+ ## Capabilities
263
+
264
+ Most scopes are CRUD over a resource. A few are **actions the platform performs on your
265
+ behalf** — sending mail, writing a calendar — and they work differently: they are granted
266
+ per app at registration rather than declared in the client's typed `scopes` config, and
267
+ the accessor is always present (the server rejects the call if the capability was not
268
+ granted). Nucleus holds the Google credentials, so your app never handles a Google
269
+ identity: you address people by Nucleus `employeeId`.
270
+
271
+ ### Email — needs `email:send`
272
+
273
+ ```typescript
274
+ await nucleus.email.send({ to: 'ada@example.com', subject: 'Payslip ready', html });
275
+ ```
276
+
277
+ The From address is platform-configured per app and cannot be set from the request body.
278
+
279
+ ### Google Calendar — needs `calendar:read`, and `calendar:create|update|delete` to write
280
+
281
+ Reading an employee's calendar:
282
+
283
+ ```typescript
284
+ const { data } = await nucleus.calendar.listEvents({
285
+ employeeId,
286
+ timeMin: '2026-09-01T00:00:00Z',
287
+ timeMax: '2026-09-30T23:59:59Z',
288
+ });
289
+ ```
290
+
291
+ Every write names the identity it acts as, so the organiser is never ambiguous — either
292
+ the employee whose calendar it is, or the company mailbox for an org-wide event:
293
+
294
+ ```typescript
295
+ // A 1:1, organised by the manager, with a Meet link and a real invitation.
296
+ const { data } = await nucleus.calendar.createEvent(
297
+ { employeeId: managerId },
298
+ {
299
+ summary: 'Quarterly check-in',
300
+ start: { dateTime: '2026-09-10T09:00:00+05:30' },
301
+ end: { dateTime: '2026-09-10T09:30:00+05:30' },
302
+ attendees: [{ employeeId: reportId }],
303
+ createMeet: true,
304
+ sendUpdates: 'all',
305
+ clientReference: `milestone:${cycleId}`,
306
+ },
307
+ );
308
+ data.event.meetLink; // https://meet.google.com/…
309
+ data.created; // false if this was a retry of a create that already succeeded
310
+
311
+ // An all-hands, owned by the company mailbox rather than a person.
312
+ await nucleus.calendar.createEvent(
313
+ { organizer: 'company' },
314
+ {
315
+ summary: 'Town hall',
316
+ start: { dateTime: '2026-10-01T15:00:00+05:30' },
317
+ end: { dateTime: '2026-10-01T16:00:00+05:30' },
318
+ attendees: [{ email: 'everyone@typeb.digital' }],
319
+ sendUpdates: 'all',
320
+ },
321
+ );
322
+
323
+ // Leave, as an out-of-office block on that person's own calendar.
324
+ await nucleus.calendar.createEvent(
325
+ { employeeId },
326
+ {
327
+ summary: 'Annual leave',
328
+ eventType: 'outOfOffice',
329
+ outOfOffice: { autoDeclineMode: 'declineAllConflictingInvitations' },
330
+ start: { dateTime: '2026-09-14T00:00:00+05:30' },
331
+ end: { dateTime: '2026-09-19T00:00:00+05:30' },
332
+ clientReference: `leave:${leaveRequestId}`,
333
+ },
334
+ );
335
+
336
+ await nucleus.calendar.updateEvent({ employeeId: managerId }, eventId, { location: 'Room 2' });
337
+ await nucleus.calendar.deleteEvent({ employeeId: managerId }, eventId, { sendUpdates: 'all' });
338
+ ```
339
+
340
+ Four things worth knowing before you ship against this:
341
+
342
+ - **Nothing is emailed unless you ask.** `sendUpdates` defaults to `none`. Pass `'all'`
343
+ for anything a human is expected to accept.
344
+ - **Retries are safe if you pass `clientReference`.** Nucleus derives a stable Google
345
+ event id from it, so a replayed create returns the original event with
346
+ `created: false` instead of double-booking. Key it on the thing being scheduled, not on
347
+ the attempt. Deletes are idempotent already.
348
+ - **`updateEvent` is a patch**, except `attendees`, which replaces the whole list (`[]`
349
+ clears it). `eventType` cannot be changed after creation, so it is not accepted.
350
+ - **A test token cannot reach a real calendar.** Writes are redirected to a sandbox
351
+ calendar, stamped `[SANDBOX]`, with attendees stripped into `suppressedAttendees` and
352
+ invitations forced off. Check `sandbox` on the response.
353
+
354
+ Nucleus's own **company calendar** is a different thing: that is `nucleus.calendarEvents`
355
+ (the `calendar-events` CRUD resource, date-only), not `nucleus.calendar`.
356
+
357
+ ### Google Chat — needs `chat:send`
358
+
359
+ Messages are posted as the company's Chat app. Address a person by `employeeId` and
360
+ Nucleus opens their direct message with the app the first time, then reuses it:
361
+
362
+ ```typescript
363
+ await nucleus.chat.send({
364
+ to: { employeeId },
365
+ card: {
366
+ title: 'Leave approved',
367
+ subtitle: '14–18 September',
368
+ body: 'Your request was approved by Grace.',
369
+ link: { text: 'Open OS', url: 'https://os.typeb.digital/hr/my-leave' },
370
+ },
371
+ requestId: `leave-${leaveRequestId}-approved`,
372
+ });
373
+
374
+ // Or into a shared space the app belongs to, threaded by a key you choose.
375
+ await nucleus.chat.send({
376
+ to: { space: 'spaces/AAAA' },
377
+ text: 'Two expense claims are waiting for approval.',
378
+ thread: { key: `expenses-${weekOf}` },
379
+ });
380
+ ```
381
+
382
+ Worth knowing:
383
+
384
+ - **This is a transport, not a notification system.** No templates, no audiences, no
385
+ preferences — your app decides who hears what. Drive it from a durable outbox and use
386
+ `requestId` so a retry cannot double-notify.
387
+ - **A Chat message reaches a phone.** Reserve it for the few kinds that warrant an
388
+ interruption, and give people a way to opt out; Nucleus holds no preferences.
389
+ - **Threads are for named spaces only.** Google does not thread direct messages, so
390
+ `thread` is ignored for an `employeeId` target.
391
+ - **A test token cannot reach a real person.** Messages are redirected to a sandbox space
392
+ with a banner naming the intended recipient. Check `sandbox` and `redirectedFrom`.
393
+ - **`CHAT_NOT_AVAILABLE_FOR_EMPLOYEE` (422) is final**, not transient: the Chat app's
394
+ visibility list does not cover that person. Read it from `error.apiCode` and drop the
395
+ message rather than retrying.
396
+
397
+ ---
398
+
262
399
  ## Result type
263
400
 
264
401
  Every method returns a result object — **never throws** by default:
@@ -10,14 +10,23 @@ function statusToCode(status, apiCode) {
10
10
  if (status === 429) return 'RATE_LIMITED';
11
11
  return 'FORBIDDEN';
12
12
  }
13
- function makeErrorResult(code, message) {
13
+ function makeErrorResult(code, message, apiCode) {
14
14
  return {
15
15
  error: {
16
16
  code,
17
- message
17
+ message,
18
+ ...apiCode ? {
19
+ apiCode
20
+ } : {}
18
21
  }
19
22
  };
20
23
  }
24
+ function stripUndefined(query) {
25
+ if (!query) return undefined;
26
+ return Object.fromEntries(Object.entries(query).filter((entry)=>{
27
+ return entry[1] !== undefined;
28
+ }));
29
+ }
21
30
  class NucleusTransport {
22
31
  constructor(token, baseUrl){
23
32
  this.bodylessHeaders = {
@@ -29,117 +38,78 @@ class NucleusTransport {
29
38
  };
30
39
  this.baseUrl = (baseUrl ?? DEFAULT_BASE_URL$1).replace(/\/$/, '');
31
40
  }
32
- async get(path, query) {
41
+ /**
42
+ * The one place a request is issued and a failure is interpreted. Every verb below
43
+ * funnels through here: when the error mapping lived in each verb, a fix to it had to
44
+ * be made five times, and one of them (reading the API's `code`) was wrong for years.
45
+ */ async request(method, path, opts, onSuccess) {
33
46
  try {
34
- const params = query ? Object.fromEntries(Object.entries(query).filter(([, v])=>v !== undefined)) : undefined;
35
47
  const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
36
- method: 'GET',
37
- headers: this.bodylessHeaders,
38
- params
48
+ method,
49
+ headers: opts.hasBody ? this.headers : this.bodylessHeaders,
50
+ ...opts.hasBody ? {
51
+ body: JSON.stringify(opts.body)
52
+ } : {},
53
+ params: stripUndefined(opts.query)
39
54
  });
55
+ // A 2xx carrying `success: false` shouldn't happen, but the envelope allows it.
40
56
  if (!envelope.success) {
41
- return makeErrorResult('FORBIDDEN', envelope.error);
57
+ return makeErrorResult(statusToCode(0, envelope.code), envelope.error, envelope.code);
42
58
  }
43
- return {
44
- data: envelope.data
45
- };
59
+ return onSuccess(envelope);
46
60
  } catch (err) {
47
61
  if (err instanceof ofetch.FetchError) {
48
62
  const body = err.data;
49
- const code = statusToCode(err.status ?? 0, body?.error);
50
- return makeErrorResult(code, err.message);
51
- }
52
- return makeErrorResult('NETWORK_ERROR', String(err));
53
- }
54
- }
55
- async post(path, body) {
56
- try {
57
- const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
58
- method: 'POST',
59
- headers: this.headers,
60
- body: JSON.stringify(body)
61
- });
62
- if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
63
- return {
64
- data: envelope.data
65
- };
66
- } catch (err) {
67
- if (err instanceof ofetch.FetchError) {
68
- const errBody = err.data;
69
- return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
63
+ // Prefer the API's own message ("start.timeZone: is required") over ofetch's
64
+ // generic "[POST] …: 400 Bad Request", which says nothing actionable.
65
+ return makeErrorResult(statusToCode(err.status ?? 0, body?.code), body?.error ?? err.message, body?.code);
70
66
  }
71
67
  return makeErrorResult('NETWORK_ERROR', String(err));
72
68
  }
73
69
  }
74
- async put(path, body) {
75
- try {
76
- const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
77
- method: 'PUT',
78
- headers: this.headers,
79
- body: JSON.stringify(body)
80
- });
81
- if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
82
- return {
83
- data: envelope.data
84
- };
85
- } catch (err) {
86
- if (err instanceof ofetch.FetchError) {
87
- const errBody = err.data;
88
- return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
89
- }
90
- return makeErrorResult('NETWORK_ERROR', String(err));
91
- }
70
+ single(envelope) {
71
+ return {
72
+ data: envelope.data
73
+ };
92
74
  }
93
- async patch(path, body) {
94
- try {
95
- const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
96
- method: 'PATCH',
97
- headers: this.headers,
98
- body: JSON.stringify(body)
99
- });
100
- if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
101
- return {
102
- data: envelope.data
103
- };
104
- } catch (err) {
105
- if (err instanceof ofetch.FetchError) {
106
- const errBody = err.data;
107
- return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
108
- }
109
- return makeErrorResult('NETWORK_ERROR', String(err));
110
- }
75
+ async get(path, query) {
76
+ return this.request('GET', path, {
77
+ query,
78
+ hasBody: false
79
+ }, (e)=>this.single(e));
80
+ }
81
+ async post(path, body, query) {
82
+ return this.request('POST', path, {
83
+ body,
84
+ query,
85
+ hasBody: true
86
+ }, (e)=>this.single(e));
87
+ }
88
+ async put(path, body, query) {
89
+ return this.request('PUT', path, {
90
+ body,
91
+ query,
92
+ hasBody: true
93
+ }, (e)=>this.single(e));
94
+ }
95
+ async patch(path, body, query) {
96
+ return this.request('PATCH', path, {
97
+ body,
98
+ query,
99
+ hasBody: true
100
+ }, (e)=>this.single(e));
111
101
  }
112
102
  async del(path, query) {
113
- try {
114
- const params = query ? Object.fromEntries(Object.entries(query).filter(([, v])=>v !== undefined)) : undefined;
115
- const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
116
- method: 'DELETE',
117
- headers: this.bodylessHeaders,
118
- params
119
- });
120
- if (!envelope.success) return makeErrorResult('FORBIDDEN', envelope.error);
121
- return {
122
- data: envelope.data
123
- };
124
- } catch (err) {
125
- if (err instanceof ofetch.FetchError) {
126
- const errBody = err.data;
127
- return makeErrorResult(statusToCode(err.status ?? 0, errBody?.error), err.message);
128
- }
129
- return makeErrorResult('NETWORK_ERROR', String(err));
130
- }
103
+ return this.request('DELETE', path, {
104
+ query,
105
+ hasBody: false
106
+ }, (e)=>this.single(e));
131
107
  }
132
108
  async getList(path, query) {
133
- try {
134
- const params = query ? Object.fromEntries(Object.entries(query).filter(([, v])=>v !== undefined)) : undefined;
135
- const envelope = await ofetch.$fetch(`${this.baseUrl}${path}`, {
136
- method: 'GET',
137
- headers: this.bodylessHeaders,
138
- params
139
- });
140
- if (!envelope.success) {
141
- return makeErrorResult('FORBIDDEN', envelope.error);
142
- }
109
+ return this.request('GET', path, {
110
+ query,
111
+ hasBody: false
112
+ }, (envelope)=>{
143
113
  const { data, meta } = envelope;
144
114
  const listMeta = {
145
115
  total: meta.total,
@@ -151,14 +121,7 @@ class NucleusTransport {
151
121
  data,
152
122
  meta: listMeta
153
123
  };
154
- } catch (err) {
155
- if (err instanceof ofetch.FetchError) {
156
- const body = err.data;
157
- const code = statusToCode(err.status ?? 0, body?.error);
158
- return makeErrorResult(code, err.message);
159
- }
160
- return makeErrorResult('NETWORK_ERROR', String(err));
161
- }
124
+ });
162
125
  }
163
126
  }
164
127
 
@@ -1645,9 +1608,22 @@ class TimesheetsAccessor {
1645
1608
  }
1646
1609
  }
1647
1610
 
1611
+ function actorQuery(actor) {
1612
+ return {
1613
+ employeeId: actor.employeeId,
1614
+ organizer: actor.organizer,
1615
+ calendarId: actor.calendarId
1616
+ };
1617
+ }
1648
1618
  /**
1649
- * CalendarAccessor — read workspace users' Google Calendar events (WS3).
1650
- * Requires the app's `calendar:read` scope. Not part of the bucket/CRUD scope system.
1619
+ * CalendarAccessor — read and write workspace users' Google Calendar events (WS3).
1620
+ * Requires the app's `calendar:read` scope to list, and `calendar:create` /
1621
+ * `calendar:update` / `calendar:delete` to write. Not part of the bucket/CRUD scope
1622
+ * system, so these are not declared in the client's typed `scopes` config.
1623
+ *
1624
+ * Writes are mode-aware: under a test token every event is redirected to a sandbox
1625
+ * calendar with attendees stripped (reported in `suppressedAttendees`) and invitations
1626
+ * forced off, so a sandbox caller can never touch a real calendar or email a real guest.
1651
1627
  */ class CalendarAccessor {
1652
1628
  constructor(transport){
1653
1629
  this.transport = transport;
@@ -1657,7 +1633,32 @@ class TimesheetsAccessor {
1657
1633
  employeeId: params.employeeId,
1658
1634
  timeMin: params.timeMin,
1659
1635
  timeMax: params.timeMax,
1660
- q: params.q
1636
+ q: params.q,
1637
+ calendarId: params.calendarId
1638
+ });
1639
+ if ('error' in result) return result;
1640
+ return {
1641
+ data: result.data
1642
+ };
1643
+ }
1644
+ async createEvent(actor, input) {
1645
+ const result = await this.transport.post('/api/v1/data/calendar/events', input, actorQuery(actor));
1646
+ if ('error' in result) return result;
1647
+ return {
1648
+ data: result.data
1649
+ };
1650
+ }
1651
+ async updateEvent(actor, eventId, input) {
1652
+ const result = await this.transport.patch(`/api/v1/data/calendar/events/${encodeURIComponent(eventId)}`, input, actorQuery(actor));
1653
+ if ('error' in result) return result;
1654
+ return {
1655
+ data: result.data
1656
+ };
1657
+ }
1658
+ async deleteEvent(actor, eventId, opts) {
1659
+ const result = await this.transport.del(`/api/v1/data/calendar/events/${encodeURIComponent(eventId)}`, {
1660
+ ...actorQuery(actor),
1661
+ sendUpdates: opts?.sendUpdates
1661
1662
  });
1662
1663
  if ('error' in result) return result;
1663
1664
  return {
@@ -1666,6 +1667,29 @@ class TimesheetsAccessor {
1666
1667
  }
1667
1668
  }
1668
1669
 
1670
+ /**
1671
+ * ChatAccessor — send Google Chat messages as the company's Chat app. Requires the
1672
+ * app's `chat:send` scope. Not part of the bucket/CRUD scope system.
1673
+ *
1674
+ * This is a transport, not a notification system: no templates, no audience resolution,
1675
+ * no per-person preferences. Your app decides who should hear what.
1676
+ *
1677
+ * Mode-aware: under a test token the message is redirected to a sandbox space with a
1678
+ * banner naming the intended recipient, and no direct message with a real person is ever
1679
+ * opened.
1680
+ */ class ChatAccessor {
1681
+ constructor(transport){
1682
+ this.transport = transport;
1683
+ }
1684
+ async send(input) {
1685
+ const result = await this.transport.post('/api/v1/data/chat/messages', input);
1686
+ if ('error' in result) return result;
1687
+ return {
1688
+ data: result.data
1689
+ };
1690
+ }
1691
+ }
1692
+
1669
1693
  /**
1670
1694
  * EmailAccessor — send Nucleus-mediated email (WS5). Requires the app's `email:send` scope.
1671
1695
  * In test mode the message is marked `[SANDBOX]` and external recipients are suppressed.
@@ -2034,6 +2058,7 @@ class AuthAccessor {
2034
2058
  this.timesheets = new TimesheetsAccessor(transport);
2035
2059
  this.calendar = new CalendarAccessor(transport);
2036
2060
  this.email = new EmailAccessor(transport);
2061
+ this.chat = new ChatAccessor(transport);
2037
2062
  this.employeeCompensation = new EmployeeCompensationAccessor(transport);
2038
2063
  this.employeeProfiles = new EmployeeProfilesAccessor(transport);
2039
2064
  this.employeeReviews = new EmployeeReviewsAccessor(transport);
@@ -2055,6 +2080,9 @@ function isError(result) {
2055
2080
  return 'error' in result;
2056
2081
  }
2057
2082
 
2083
+ exports.CalendarAccessor = CalendarAccessor;
2084
+ exports.ChatAccessor = ChatAccessor;
2085
+ exports.EmailAccessor = EmailAccessor;
2058
2086
  exports.FilesAccessor = FilesAccessor;
2059
2087
  exports.NucleusClient = NucleusClient;
2060
2088
  exports.isError = isError;