@typeb-digital/nucleus-sdk
v0.11.0
Published
Server-side TypeScript SDK for the Nucleus data platform
Readme
@typeb-digital/nucleus-sdk
Server-side TypeScript SDK for the Nucleus data platform — the company source of truth for people, projects, and clients.
Install this in your app backend (Node.js). It holds the app token and handles all data access.
WARNING — never import this package in browser code. The app token (
NUCLEUS_TOKEN) grants your app's full data access. A browser bundle is public — anyone can extract it. Import@typeb-digital/nucleus-sdkonly in Node.js backend code. For the browser, use@typeb-digital/nucleus-client, which holds no app token and only manages auth sessions.
Installation
npm install @typeb-digital/nucleus-sdk
# or
yarn add @typeb-digital/nucleus-sdkRequires Node.js ≥ 18.
Dual-token architecture
Nucleus apps use two tokens in concert:
| Token | Where it lives | What it does |
| ------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| App token (NUCLEUS_TOKEN) | Your backend only | The ceiling — defines which resources and buckets your app can access at all. Approved when your app registers scopes on the Nucleus dashboard. |
| User access token | Forwarded from the browser via your own API requests | The floor — identifies which user is making the request. Your backend decides what to show that user. |
How it works end to end:
- The frontend uses
@typeb-digital/nucleus-clientto sign the user in and get an access token - Every request from the frontend to your backend includes that token (e.g. in a header like
X-Nucleus-Token) - Your backend receives the user token, validates it, and uses it to enforce user-level authorization in your own logic
- Your backend then calls the Nucleus SDK with the app token to read Nucleus data
What asUser applies to — files only: The server SDK data methods (employees.list(), projects.getById(), etc.) use the app token exclusively and do not accept a user token parameter. The user context floor is enforced by your backend, not by Nucleus SDK data calls. The only place the server SDK accepts a user token is files.getUrl({ asUser }), which checks that the requesting user is in the file's allowedUsers list.
End-to-end example:
// ── Frontend (uses @typeb-digital/nucleus-client) ──────────────────────────
import { nucleus } from './nucleus-client'; // NucleusClient instance
async function loadProjectData(projectId: string) {
const userToken = await nucleus.auth.getAccessToken(); // auto-refreshes if expired
const res = await fetch(`/api/projects/${projectId}`, {
headers: { 'X-Nucleus-Token': userToken },
});
return res.json();
}
// ── Your backend (uses @typeb-digital/nucleus-sdk) ─────────────────────────
import { nucleus } from './nucleus-server'; // NucleusClient instance (app token)
import { isError } from '@typeb-digital/nucleus-sdk';
// Express / Fastify / NestJS handler
async function projectHandler(req, res) {
// 1. Verify the forwarded user token using the server SDK.
// This verifies the RS256 signature against the platform's public key (JWKS),
// cached after the first call — no round-trip per request.
const authResult = await nucleus.auth.verifyToken(req.headers['x-nucleus-token'] ?? '');
if (isError(authResult)) return res.status(401).json({ error: 'Invalid token' });
const { employeeId } = authResult.data;
// 2. Use the server SDK (app token) to read Nucleus data.
// The app token defines what your app can access; your backend decides
// what to return to this specific user.
const result = await nucleus.projects.getById(req.params.projectId, {
expand: ['client', 'projectManager'],
});
if (isError(result)) return res.status(404).json({ error: result.error.message });
// 3. For a user-scoped private file, pass the user token to the server SDK.
// The platform enforces that the user is in the file's allowedUsers list.
const { data: fileData } = await nucleus.files.getUrl('contracts/2026/q1.pdf', {
asUser: req.headers['x-nucleus-token'],
});
return res.json({ project: result.data, contractUrl: fileData?.url });
}Quick start
import { NucleusClient } from '@typeb-digital/nucleus-sdk';
export const nucleus = new NucleusClient({
token: process.env.NUCLEUS_TOKEN!, // el_live_… or el_test_… (sandbox)
scopes: {
employees: { read: ['identity', 'employment'], update: ['placement'] },
projects: { read: ['core'] },
leaves: { read: ['core'], create: ['core'], delete: true },
},
});The scopes object is the heart of the type system. Each resource declares its per-action buckets — read, create, update (each a bucket list) and delete (boolean). read drives the return types of list/getById; create/update/delete gate the mutation methods at compile time — calling nucleus.leaves.create(...) is a type error unless you declared leaves.create. Declare it once; TypeScript infers everything, no explicit type parameters needed.
Test vs live: the token is the environment — an
el_test_token reads/writes the anonymized sandbox; anel_live_token hits real data (once your app is published). No SDK config change.
Get your token and init script from the Nucleus dashboard → Apps → your app → Init Script.
Scopes & buckets
Nucleus groups fields into buckets per resource. Your app only receives fields for the buckets you've been approved for.
| Resource | Available buckets |
| ------------------------ | ------------------------------------------------------------------------------------ |
| employees | identity · contact · employment · placement · sensitive¹ · compensation¹ |
| employee-compensation | core · rates¹ · salary¹ · lines¹ |
| employee-bank-accounts | core¹ · account_number¹ |
| organization | core · legal (read-only for apps) |
| projects | core · team · financials · integrations |
| clients | identity · contact · financials · notes |
| partners | identity · contact · notes |
| departments | core |
| projectTypes | core |
| currencies | core |
| genericRates | core · financials¹ |
¹ Requires platform-team approval.
Writing data (mutations)
Resources you've scoped for create / update / delete expose matching methods. They're gated by your declared scope — the method is uncallable unless the action is declared, so you can't even construct a call the server would reject.
// Requires `leaves: { create: [...] }`
const created = await nucleus.leaves.create({
employeeId: 'emp_…',
leaveTypeId: 'lt_…',
startDate: '2026-07-01',
endDate: '2026-07-03',
days: 3,
});
// Requires `leaves: { update: [...] }`
await nucleus.leaves.update('leave_…', { days: 2 });
// Requires `leaves: { delete: true }`
await nucleus.leaves.delete('leave_…');All mutations return the same SingleResult<T> shape as reads ({ data } or { error }) — they never throw. delete returns { data: null }. The server enforces the same per-bucket authorization independently, so an unapproved write fails with INVALID_SCOPE even if the types allowed it.
Data access
Employees
// List — type reflects declared buckets
const result = await nucleus.employees.list({
department: 'Engineering',
page: 1,
pageSize: 50,
});
if (!isError(result)) {
result.data; // Employee[] typed to your scopes
result.meta.total; // number
result.meta.hasMore; // boolean
}
// Single record
const { data: emp } = await nucleus.employees.getById('emp_123');
// With expansion — manager is typed as an Employee object, not just a string ID.
// Needs the `placement` bucket, which is where `managerId` lives.
const { data: emp } = await nucleus.employees.getById('emp_123', {
expand: ['manager'],
});
if (emp.manager) {
emp.manager.email; // ✓ typed
}Personal data from Google Workspace — birthday, home address, gender and the emergency
contact — needs the approval-gated sensitive bucket:
const nucleus = new NucleusClient({
token,
scopes: { employees: { read: ['identity', 'sensitive'] } },
});
const { data: emp } = await nucleus.employees.getById('emp_123');
emp.profile.address?.formatted; // free text from the Admin console; structured parts are often null
emp.profile.emergencyContact?.relationship;Bank accounts
Where employees are paid. Entered by HR through your app (Nucleus never takes them from
Google), kept as history, with exactly one primary account per employee. Both buckets —
core and account_number — need platform approval, and Nucleus audits every call.
// Metadata only: the number is never sent unless you ask, so this is safe to mirror.
const { data: accounts } = await nucleus.employeeBankAccounts.list('emp_123');
accounts[0].accountNumberLast4; // "7890"
// The full number, on demand. Needs the `account_number` bucket; don't store it.
const { data: account } = await nucleus.employeeBankAccounts.get('emp_123', 'acct_1', {
reveal: true,
});
// Payroll: the primary account of every employee in the run, in one call (≤ 500 ids).
const { data: primaries } = await nucleus.employeeBankAccounts.listPrimary(employeeIds, {
reveal: true,
});
await nucleus.employeeBankAccounts.create('emp_123', {
bankName: 'Commercial Bank',
accountHolderName: 'Ada Lovelace',
accountNumber: '0012 3456 7890',
currencyCode: 'LKR',
});
await nucleus.employeeBankAccounts.setPrimary('emp_123', 'acct_2'); // demotes the old oneThe first account is primary. Deleting the primary while another account exists fails
with PRIMARY_IN_USE (promote another first); un-flagging it fails with
PRIMARY_REQUIRED.
Organization
The company profile — what to put on invoices, letters and email footers instead of hardcoding it. Read-only for apps (admins edit it in the Nucleus dashboard).
const { data: org } = await nucleus.organization.get(); // null until it has been filled in
if (org) {
org.legalName ?? org.name;
[org.addressLine1, org.city, org.region, org.postalCode].filter(Boolean).join(', ');
org.taxNumber; // needs the `legal` bucket
}Compensation
Pay terms live on compensation lines — one shape covers every arrangement, and someone with several concurrent lines is just paid the sum of them. Nucleus stores the terms; your app turns them into a payslip.
const { data: lines } = await nucleus.employeeCompensation.listLines('emp_123');
await nucleus.employeeCompensation.createLine('emp_123', {
unit: 'per_overtime_hour',
amount: 15,
projectIds: ['proj_123'], // omit for "any work"
effectiveFrom: '2026-10-01',
});| unit | amount means |
| ------------------- | ----------------------------------------------------------------- |
| per_month | monthly gross — not annual |
| per_hour | hourly rate; projectIds scopes which hours pay it (empty = all) |
| per_unit | rate per unit of work; your app supplies the counts per cycle |
| fixed | a one-time total, within the line's effective window |
| per_overtime_hour | hourly rate for hours beyond your cycle's target — see below |
Lines are independently effective-dated and windows may overlap on purpose, which is how
ramp-up, cool-down and temporary part-time arrangements are expressed. capHours and
capAmount bound what one pay cycle can pay out.
Overtime is deliberately thin here: per_overtime_hour records that someone earns an
overtime rate and what it is, and nothing else. What the cycle's target is, which logged
hours exceed it and what that comes to are all your app's arithmetic — Nucleus has no view
on any of it. Only one overtime line may be in force at a time; end one before starting the
next.
Projects
const result = await nucleus.projects.list({ status: 'active' });
// Expand client and team members in one call
const { data: project } = await nucleus.projects.getById('proj_123', {
expand: ['client', 'projectManager', 'memberships.employee'],
});Team membership & staffing allocation
Membership is the team bucket — it answers "is on this team". How much, and
when, is a separate effective-dated collection, so a staffing change keeps both
the old and the new figure.
await nucleus.projects.addMember('proj_123', { employeeId: 'emp_456', role: 'Engineer' });
// Ramping up: two blocks, and both survive.
await nucleus.projects.addAllocation('proj_123', 'emp_456', {
allocatedHoursPerWeek: 20,
effectiveFrom: '2026-07-01',
effectiveTo: '2026-07-31',
});
await nucleus.projects.addAllocation('proj_123', 'emp_456', {
allocatedHoursPerWeek: 40,
effectiveFrom: '2026-08-01',
// effectiveTo omitted = open-ended
});
const { data: blocks } = await nucleus.projects.listAllocations('proj_123', 'emp_456');Reading a project returns these inline too — a flat allocations array beside
memberships, each entry carrying employeeId to join on. Nucleus stores the
terms; compute utilization yourself against Employee.weeklyCapacity.
Timesheets
This resource is a finalized ledger, not a working surface. Timesheet approval
is domain logic and belongs in your app: keep drafts, submission periods and
approver chains in your own database, and create entries here only once they are
approved. That's why there is no status field — every row you read back is
already final, so nothing downstream has to filter on one.
const { data: entries } = await nucleus.timesheets.list({
employeeId: 'emp_456',
startDate: '2026-07-01',
endDate: '2026-07-31',
});Commit an approved period in one call, and keep the returned ids so a retry resumes instead of duplicating:
const { data } = await nucleus.timesheets.bulkCreate(entries);
// data.created[] — store each id against your local entry
// data.rejected[] — per-row { index, error }; the valid rows still landedIf an approval is withdrawn, delete the rows you created (timesheets.delete).
Clients, Partners, Departments, Currencies, Generic Rates
Same pattern — list(params?) and getById(id, options?) on every resource accessor.
Capabilities
Most scopes are CRUD over a resource. A few are actions the platform performs on your
behalf — sending mail, writing a calendar — and they work differently: they are granted
per app at registration rather than declared in the client's typed scopes config, and
the accessor is always present (the server rejects the call if the capability was not
granted). Nucleus holds the Google credentials, so your app never handles a Google
identity: you address people by Nucleus employeeId.
Email — needs email:send
await nucleus.email.send({ to: '[email protected]', subject: 'Payslip ready', html });The From address is platform-configured per app and cannot be set from the request body.
Google Calendar — needs calendar:read, and calendar:create|update|delete to write
Reading an employee's calendar:
const { data } = await nucleus.calendar.listEvents({
employeeId,
timeMin: '2026-09-01T00:00:00Z',
timeMax: '2026-09-30T23:59:59Z',
});Every write names the identity it acts as, so the organiser is never ambiguous — either the employee whose calendar it is, or the company mailbox for an org-wide event:
// A 1:1, organised by the manager, with a Meet link and a real invitation.
const { data } = await nucleus.calendar.createEvent(
{ employeeId: managerId },
{
summary: 'Quarterly check-in',
start: { dateTime: '2026-09-10T09:00:00+05:30' },
end: { dateTime: '2026-09-10T09:30:00+05:30' },
attendees: [{ employeeId: reportId }],
createMeet: true,
sendUpdates: 'all',
clientReference: `milestone:${cycleId}`,
},
);
data.event.meetLink; // https://meet.google.com/…
data.created; // false if this was a retry of a create that already succeeded
// An all-hands, owned by the company mailbox rather than a person.
await nucleus.calendar.createEvent(
{ organizer: 'company' },
{
summary: 'Town hall',
start: { dateTime: '2026-10-01T15:00:00+05:30' },
end: { dateTime: '2026-10-01T16:00:00+05:30' },
attendees: [{ email: '[email protected]' }],
sendUpdates: 'all',
},
);
// Leave, as an out-of-office block on that person's own calendar.
await nucleus.calendar.createEvent(
{ employeeId },
{
summary: 'Annual leave',
eventType: 'outOfOffice',
outOfOffice: { autoDeclineMode: 'declineAllConflictingInvitations' },
start: { dateTime: '2026-09-14T00:00:00+05:30' },
end: { dateTime: '2026-09-19T00:00:00+05:30' },
clientReference: `leave:${leaveRequestId}`,
},
);
await nucleus.calendar.updateEvent({ employeeId: managerId }, eventId, { location: 'Room 2' });
await nucleus.calendar.deleteEvent({ employeeId: managerId }, eventId, { sendUpdates: 'all' });Four things worth knowing before you ship against this:
- Nothing is emailed unless you ask.
sendUpdatesdefaults tonone. Pass'all'for anything a human is expected to accept. - Retries are safe if you pass
clientReference. Nucleus derives a stable Google event id from it, so a replayed create returns the original event withcreated: falseinstead of double-booking. Key it on the thing being scheduled, not on the attempt. Deletes are idempotent already. updateEventis a patch, exceptattendees, which replaces the whole list ([]clears it).eventTypecannot be changed after creation, so it is not accepted.- A test token cannot reach a real calendar. Writes are redirected to a sandbox
calendar, stamped
[SANDBOX], with attendees stripped intosuppressedAttendeesand invitations forced off. Checksandboxon the response.
Nucleus's own company calendar is a different thing: that is nucleus.calendarEvents
(the calendar-events CRUD resource, date-only), not nucleus.calendar.
Google Chat — needs chat:send
Messages are posted as the company's Chat app. Address a person by employeeId and
Nucleus opens their direct message with the app the first time, then reuses it:
await nucleus.chat.send({
to: { employeeId },
card: {
title: 'Leave approved',
subtitle: '14–18 September',
body: 'Your request was approved by Grace.',
link: { text: 'Open OS', url: 'https://os.typeb.digital/hr/my-leave' },
},
requestId: `leave-${leaveRequestId}-approved`,
});
// Or into a shared space the app belongs to, threaded by a key you choose.
await nucleus.chat.send({
to: { space: 'spaces/AAAA' },
text: 'Two expense claims are waiting for approval.',
thread: { key: `expenses-${weekOf}` },
});Worth knowing:
- This is a transport, not a notification system. No templates, no audiences, no
preferences — your app decides who hears what. Drive it from a durable outbox and use
requestIdso a retry cannot double-notify. - A Chat message reaches a phone. Reserve it for the few kinds that warrant an interruption, and give people a way to opt out; Nucleus holds no preferences.
- Threads are for named spaces only. Google does not thread direct messages, so
threadis ignored for anemployeeIdtarget. - A test token cannot reach a real person. Messages are redirected to a sandbox space
with a banner naming the intended recipient. Check
sandboxandredirectedFrom. CHAT_NOT_AVAILABLE_FOR_EMPLOYEE(422) is final, not transient: the Chat app's visibility list does not cover that person. Read it fromerror.apiCodeand drop the message rather than retrying.
Result type
Every method returns a result object — never throws by default:
import { NucleusClient, isError } from '@typeb-digital/nucleus-sdk';
const result = await nucleus.employees.getById('emp_123');
if (isError(result)) {
// result.error.code: 'FORBIDDEN' | 'NOT_FOUND' | 'INVALID_SCOPE' | 'RATE_LIMITED' | 'NETWORK_ERROR'
console.error(result.error.message);
return;
}
// result.data is fully typed here
console.log(result.data.email);File storage
// Upload — app provides a path-style key; upsert semantics
const { data: file } = await nucleus.files.upload({
key: 'users/123/avatar',
file: buffer, // Buffer
mimeType: 'image/png',
visibility: 'private',
allowedUsers: ['emp_123'], // optional — omit for app-wide access
metadata: { type: 'avatar' },
});
// Resolve a URL to pass to your frontend
// Public → CDN URL (instant, no signing)
// Private → fresh short-lived pre-signed URL
const { data } = await nucleus.files.getUrl('users/123/avatar');
// data.url — hand this to the browser
// Private file scoped to a specific user
const { data } = await nucleus.files.getUrl('users/123/avatar', {
asUser: userAccessToken, // from the client SDK's getSession().accessToken
});
// List files
const result = await nucleus.files.list({ prefix: 'users/123/' });
// Soft delete
await nucleus.files.delete('users/123/avatar');User token verification
Verify a Nucleus user access token forwarded from the browser. Uses RS256 local verification — the JWKS is fetched once and cached; no network round-trip per request.
import { NucleusClient, isError } from '@typeb-digital/nucleus-sdk';
const result = await nucleus.auth.verifyToken(userAccessToken);
if (isError(result)) {
// result.error.code: 'FORBIDDEN' (invalid/expired token) | 'NETWORK_ERROR' (JWKS fetch failed)
return res.status(401).json({ error: 'Unauthorized' });
}
const { employeeId, appId, expiresAt } = result.data;
// employeeId — Nucleus employee ID, use as the user identifier in your backend
// appId — app public ID this token was issued for (e.g. 'app_xxx')
// expiresAt — Unix timestamp secondsSelf-service app info
const { data: app } = await nucleus.apps.me();
// app.name, app.scopes (declared), app.status per-bucketPagination
All list() methods return explicit pagination metadata:
const result = await nucleus.employees.list({ page: 2, pageSize: 25 });
if (!isError(result)) {
result.meta.total; // total records
result.meta.page; // current page
result.meta.pageSize; // records per page
result.meta.hasMore; // boolean shortcut
}Incremental sync
Every list() takes updatedSince, so an app that keeps its own copy of Nucleus data
can pull only what changed instead of re-reading everything:
const result = await nucleus.projects.list({ updatedSince: lastSyncIso, page: 1 });
if (!isError(result)) {
for (const project of result.data) {
if (project.isDeleted) await removeLocalCopy(project.id);
else await upsertLocalCopy(project);
}
}updatedSincereturns deleted records too. Nucleus soft-deletes, and a delete counts as a change, so the record comes back once more withisDeleted: true. Branch on it: a deleted record otherwise looks exactly like a live one, and upserting it brings back something your users just removed. WithoutupdatedSince, a list never returns deleted records.isDeletedandupdatedAtare on every record you can read, whichever buckets you declared. They are not part of a bucket and need no scope.- Results are ordered by
updatedAtascending, so paging is stable while you walk it. - Overlap your watermark.
updatedSinceis inclusive; starting each run a few minutes before the last one began costs a few re-read records and cannot miss a change that landed mid-run. - Inline collections ride their parent. A change to a project's memberships,
allocations or billing lines bumps the project's
updatedAt, and the project comes back carrying the collection as it now stands — replace your copy of it, don't merge.
Expansion
Related records are not included by default. Request them explicitly:
// Depth 1
const { data } = await nucleus.projects.getById('proj_123', {
expand: ['client', 'projectManager'],
});
// Depth 2 (max)
const { data } = await nucleus.projects.getById('proj_123', {
expand: ['projectManager.manager'],
});Valid expand paths per resource are typed — invalid paths or three-segment paths are compile errors.
Two rules decide what an expand returns:
- The expanded record is filtered by your scopes for its resource. Expanding
clientreturns the client buckets you declared, no more. With no scope for that resource at all, the request fails withINVALID_SCOPE. - You can only follow a foreign key you can read.
projectManagerfollowsprojectManagerId, which is in theteambucket;managerfollowsmanagerId, inplacement. Without that bucket the request fails withINVALID_SCOPErather than revealing who it is through the expanded record'sid.
An expanded relation is null when the record it points to no longer exists.
TypeScript
The SDK is fully typed with no any. Bucket scopes drive the return types at compile time:
// scopes.employees = ['identity'] only
const { data: emp } = await nucleus.employees.getById('emp_123');
emp.email; // ✓ string
emp.phone; // ✗ compile error — contact bucket not declared
emp.managerId; // ✗ compile error — placement bucket not declaredConfiguration
new NucleusClient({
token: process.env.NUCLEUS_TOKEN!, // required
baseUrl: 'https://nucleus.example.com', // optional, defaults to platform URL
scopes: { ... }, // required
})Platform data boundary
Understanding these properties lets you make informed decisions about your app's data model:
Nucleus IDs are opaque string references. emp_123, proj_456 etc. are stable string identifiers. Your app stores them as strings and resolves details via the SDK. There are no foreign keys between your database and Nucleus, and no referential integrity is enforced.
A getById result is a snapshot, not a live reference. The underlying Nucleus record may change after you read it — an employee changes department, a project is archived, a client is renamed. Nothing notifies you.
Nucleus is pull-only today. There is no webhook or change-notification mechanism. Your app must request data to see current state.
The consumption strategy is entirely your choice. Both of these are valid:
- Fetch on demand — call the SDK per-request; data is always fresh; more API calls
- Cache or denormalize into your own database — faster reads; you own keeping it fresh
Nucleus does not prescribe which approach to use. If you cache Nucleus data in your own database, you own the freshness of that cache.
Your app owns its own data entirely. Nucleus stores none of it. The Nucleus SDK only reads Nucleus-managed resources (employees, projects, clients, etc.).
License
MIT — Type B Digital
