Organizations and workspaces
Manage Nodaro organizations and workspaces from TypeScript. Create schools and teams, invite members, hand out join codes, and read credit usage reports.
Available on Nodaro Cloud
client.organizations manages organizations, a school or a team, with their members, invitations, audit log and usage reports. client.workspaces manages the workspaces inside them, such as a class or a team project, with their members and join codes. The methods call the Workspaces REST API. See Workspaces for the concepts.
Organizations are a Nodaro Cloud feature, switched on per instance. On an instance without them, every method here throws NotFoundError.
Belonging and acting
Belonging to a workspace and acting in it are different things. These two resources manage who belongs where. To act in a workspace, so that lists read from it and new work lands in it, create a client with client.withWorkspace(workspaceId) or pass workspaceId to createClient:
const classroom = client.withWorkspace(workspaceId)
await classroom.workflows.run(workflowId) // runs in the class
await client.workflows.run(workflowId) // runs in your personal spaceclient.me() returns the organizations and workspaces you belong to, and lastWorkspaceId.
The server decides every permission. Whether a caller may invite, remove or rename is its answer, sent as an error. Do not guess it in your client, because a setting can change it.
Methods
client.organizations
organizations.list()
Lists the organizations you belong to (GET /v1/orgs).
list(): Promise<{ data: OrganizationView[] }>const { data: orgs } = await client.organizations.list()organizations.get(id)
Reads one organization (GET /v1/orgs/:id).
get(id: string): Promise<{ data: OrganizationView }>Prop
Type
const { data: org } = await client.organizations.get(orgId)
console.log(org.status) // pending, active, suspended or deletedorganizations.create(input)
Creates an organization, with you as its owner (POST /v1/orgs). A new organization usually starts as pending and becomes active once Nodaro approves it. While it is pending, only you can see it and nothing in it can change, so tell the user it is waiting for approval.
create(input: { name: string; kind: "school" | "team"; slug?: string; acceptTerms?: boolean; settings?: OrgSettings }): Promise<{ data: OrganizationView }>Prop
Type
const { data: school } = await client.organizations.create({
name: "Sunrise School",
kind: "school",
acceptTerms: true,
})A school without acceptTerms: true fails with 400 terms_required.
organizations.update(id, input)
Renames an organization or changes its settings (PATCH /v1/orgs/:id).
update(id: string, input: { name?: string; settings?: OrgSettings }): Promise<{ data: OrganizationView }>Prop
Type
await client.organizations.update(orgId, {
settings: { default_workflow_visibility: "workspace", allowed_email_domains: ["school.example"] },
})Settings are layered: a workspace setting wins over the organization's, which wins over the default for its kind. Setting a key to false is a real value, not "unset".
| Key | Type | Meaning |
|---|---|---|
admin_access | view or edit | What admins may do with a member's workflow |
default_workflow_visibility | private or workspace | The visibility of a new workflow |
member_access_to_shared | view or edit | What members may do with a workflow shared to the workspace |
members_can_create_projects | boolean | Whether members may create projects in the workspace |
member_caps_enabled | boolean | Whether per-member credit limits apply |
personal_space_enabled | boolean | Whether members keep a personal space |
workspace_admins_can_invite | boolean | Whether workspace admins may invite new people |
collaborators_can_invite | boolean | Whether an editor collaborator may invite more collaborators |
allowed_email_domains | string list | Organization only: the email domains that may join |
vocabulary_overrides | object | Organization only: new labels for the kind's words, such as { "workspace": "Cohort" } |
The two organization-only keys are replaced as a whole when you update them.
organizations.delete(id)
Deletes an organization (DELETE /v1/orgs/:id). It is hidden from its former members, but nothing is destroyed. Archive every workspace first: otherwise the call fails with 409 has_active_workspaces.
delete(id: string): Promise<{ data: { id: string; status: string } }>Prop
Type
await client.organizations.delete(orgId)organizations.transferOwnership(id, userId)
Makes another member the owner (POST /v1/orgs/:id/transfer-ownership). You become an admin.
transferOwnership(id: string, userId: string): Promise<{ data: { orgId: string; ownerUserId: string } }>Prop
Type
await client.organizations.transferOwnership(orgId, newOwnerId)organizations.leave(id)
Leaves an organization (POST /v1/orgs/:id/leave). The owner cannot leave: transfer ownership first, or the call fails with 409 owner_cannot_leave.
leave(id: string): Promise<{ data: { orgId: string; left: boolean } }>Prop
Type
await client.organizations.leave(orgId)Organization members
List, change and remove the members of an organization (/v1/orgs/:id/members). A member is active or suspended; a suspended member keeps the seat but cannot act. The owner cannot be suspended, removed or demoted.
listMembers(orgId: string, opts?: { cursor?: string; limit?: number }): Promise<OrgPage<OrgMemberView>>
updateMember(orgId: string, userId: string, input: { role?: "admin" | "member"; status?: "active" | "suspended" }): Promise<{ data: OrgMemberView }>
removeMember(orgId: string, userId: string): Promise<{ data: { removed: boolean } }>Prop
Type
const { data: members, nextCursor } = await client.organizations.listMembers(orgId)
await client.organizations.updateMember(orgId, userId, { role: "admin" })organizations.invite(orgId, input)
Invites people by email (POST /v1/orgs/:id/invitations). It returns one row per address.
invite(orgId: string, input: {
emails: string[]
orgRole?: "admin" | "member"
workspaceId?: string
workspaceRole?: "admin" | "member"
}): Promise<{ data: InvitationDelivery[] }>Prop
Type
const { data: rows } = await client.organizations.invite(orgId, {
emails: ["teacher@school.example"],
workspaceId,
workspaceRole: "admin",
})
for (const row of rows) {
if (row.status !== "sent") showInviteLink(row.email, row.link) // not emailed: share the link yourself
}Surface the link. A row whose status is not sent carries a link instead: the install has no mail provider, or the delivery failed. The invitation exists either way, and without the link nobody can reach it. An invitation expires after 14 days. The organization's daily invitation limit answers with RateLimitedError.
Manage invitations
List, revoke and resend invitations.
listInvitations(orgId: string, opts?: { status?: "open" | "accepted" | "revoked" | "expired"; workspaceId?: string; cursor?: string; limit?: number }): Promise<OrgPage<InvitationView>>
revokeInvitation(id: string): Promise<{ data: { id: string; revoked: boolean } }>
resendInvitation(id: string): Promise<{ data: InvitationDelivery & { id: string } }>Prop
Type
const { data: open } = await client.organizations.listInvitations(orgId, { status: "open" })
await client.organizations.resendInvitation(open[0].id)Preview and accept an invitation
previewInvitation() is public: it works while the invited person is still signed out, and it returns the address masked. acceptInvitation() needs a signed-in caller whose email matches the invitation.
previewInvitation(token: string): Promise<{ data: InvitationPreview }>
acceptInvitation(token: string): Promise<{ data: { orgId: string; workspaceId: string | null } }>Prop
Type
const { data: preview } = await client.organizations.previewInvitation(token)
const { data: joined } = await signedInClient.organizations.acceptInvitation(token)Accepting fails with 400 invitation_expired, invitation_revoked, invitation_accepted or email_mismatch, and with a ForbiddenError when the organization admits only listed email domains.
organizations.audit(orgId, opts?)
Reads the organization's audit log, newest first (GET /v1/orgs/:id/audit). It stays readable while the organization is suspended.
audit(orgId: string, opts?: { cursor?: string; limit?: number }): Promise<OrgPage<OrgAuditEntry>>Prop
Type
const { data: entries } = await client.organizations.audit(orgId, { limit: 50 })
for (const e of entries) console.log(e.createdAt, e.action, e.actor?.displayName ?? "system")Each entry has id, workspaceId, action, targetType, targetId, details, createdAt and actor, which is null for actions the system took. action is an open list: show the actions you know and the raw string for the rest. Code that handles only a fixed list breaks on the first new action.
Organization usage
Credit usage reports for a date range, for the owner and the organization admins (GET /v1/orgs/:id/usage). usage() groups the report, usageRows() returns the runs behind it, newest first, a page at a time, and usageCsv() returns the report or the rows as CSV text.
usage(orgId: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: "workspace" | "member" | "model" | "day"; workspaceId?: string; userId?: string }): Promise<{ data: UsageReport }>
usageRows(orgId: string, opts?: { from?: string; to?: string; tz?: string; workspaceId?: string; userId?: string; cursor?: string; limit?: number }): Promise<OrgPage<UsageLogEntry>>
usageCsv(orgId: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: string; workspaceId?: string; userId?: string }): Promise<string>Prop
Type
const { data: report } = await client.organizations.usage(orgId, {
from: "2026-09-01",
to: "2026-09-30",
tz: "Europe/Rome",
groupBy: "workspace",
})
const csv = await client.organizations.usageCsv(orgId, { from: "2026-09-01", to: "2026-09-30" })Each row reports three credit figures. credits is what the runs have cost so far: the settled amount for finished runs and the held reservation for the others. settledCredits and inFlightCredits split it. The totals cover the whole range, even when a grouping is truncated.
platformAbsorbedCreditsis the excess of a metered run that went over the workspace's remaining budget, which the platform absorbs.chargedToBudgetequalssettledCreditsminusplatformAbsorbedCredits: the settled credits that reached the budget.appMarkupAbsorbedCreditsis an app markup the budget could not cover. It has no run in the report, so it is not part ofchargedToBudget.
CSV exports are limited to ten per minute per user.
client.workspaces
workspaces.list()
Lists the workspaces you belong to, and your lastWorkspaceId (GET /v1/workspaces). It returns summaries, the same list client.me() carries.
list(): Promise<{ data: WorkspaceSummary[]; lastWorkspaceId: string | null }>const { data: workspaces, lastWorkspaceId } = await client.workspaces.list()workspaces.listForOrg(orgId, opts?)
Lists the workspaces of one organization (GET /v1/orgs/:id/workspaces).
listForOrg(orgId: string, opts?: { includeArchived?: boolean }): Promise<{ data: WorkspaceView[] }>Prop
Type
const { data: classes } = await client.workspaces.listForOrg(orgId)workspaces.get(id)
Reads one workspace in full (GET /v1/workspaces/:id).
get(id: string): Promise<{ data: WorkspaceView }>Prop
Type
const { data: workspace } = await client.workspaces.get(workspaceId)workspaces.create(orgId, input)
Creates a workspace in an organization (POST /v1/orgs/:id/workspaces). The organization must be active.
create(orgId: string, input: { name: string; slug?: string; description?: string; settings?: WorkspaceSettings }): Promise<{ data: WorkspaceView }>Prop
Type
const { data: classOne } = await client.workspaces.create(orgId, { name: "Class 1" })workspaces.update(id, input)
Changes a workspace (PATCH /v1/workspaces/:id).
update(id: string, input: { name?: string; description?: string | null; settings?: WorkspaceSettings }): Promise<{ data: WorkspaceView }>Prop
Type
await client.workspaces.update(workspaceId, { settings: { members_can_create_projects: true } })workspaces.setArchived(id, archived)
Archives or unarchives a workspace (POST /v1/workspaces/:id/archive or /unarchive). Archiving can be undone and destroys nothing: the workspace stops accepting new work and stays fully readable. A write into an archived workspace fails with a ForbiddenError.
setArchived(id: string, archived: boolean): Promise<{ data: WorkspaceView }>Prop
Type
await client.workspaces.setArchived(workspaceId, true)Workspace members
List, add, change and remove the members of a workspace (/v1/workspaces/:id/members). A person must already belong to the organization to be added; to bring in someone new, invite them. Organization owners and admins are admins of every workspace without being listed.
listMembers(id: string, opts?: { cursor?: string; limit?: number }): Promise<OrgPage<WorkspaceMemberView>>
addMember(id: string, input: { userId: string; role: "admin" | "member" }): Promise<{ data: WorkspaceMemberView }>
updateMember(id: string, userId: string, input: { role?: "admin" | "member"; status?: "active" | "suspended"; creditCap?: number | null }): Promise<{ data: WorkspaceMemberView }>
removeMember(id: string, userId: string): Promise<{ data: { removed: boolean } }>Prop
Type
await client.workspaces.addMember(workspaceId, { userId, role: "member" })
await client.workspaces.updateMember(workspaceId, userId, { creditCap: 500 })Adding someone who is not an active member of the organization fails with 400 not_org_member, and adding someone twice fails with 409 already_a_member.
Join codes
A join code lets people join a workspace without an invitation. Only workspace admins can read or change it.
getJoinCode(id: string): Promise<{ data: JoinCodeView | null }>
actOnJoinCode(id: string, action: "rotate" | "enable" | "disable"): Promise<{ data: JoinCodeView }>Prop
Type
const { data: code } = await client.workspaces.actOnJoinCode(workspaceId, "enable")
console.log(code.code) // for example BCDFGHJKgetJoinCode() returns null when no code has been made yet.
workspaces.join(code)
Joins a workspace with its join code (POST /v1/workspaces/join). It works whatever workspace your client acts in, so a stale selection never blocks it.
join(code: string): Promise<{ data: { orgId: string; workspaceId: string } }>Prop
Type
const { data: joined } = await client.workspaces.join("BCDFGHJK")An unknown or disabled code, or a code of an archived workspace, fails with 400 join_code_invalid: one answer for all three, so a code cannot reveal what exists. Too many attempts answer with RateLimitedError.
Workspace usage
The same credit reports as the organization's, for one workspace (GET /v1/workspaces/:id/usage). A member sees their own runs; an admin sees everyone's and may filter by userId.
usage(id: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: "member" | "model" | "day"; userId?: string }): Promise<{ data: UsageReport }>
usageRows(id: string, opts?: { from?: string; to?: string; tz?: string; userId?: string; cursor?: string; limit?: number }): Promise<OrgPage<UsageLogEntry>>
usageCsv(id: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: string; userId?: string }): Promise<string>Prop
Type
const { data: byModel } = await client.workspaces.usage(workspaceId, { groupBy: "model" })Errors
The server decides every permission and answers with an error. In the SDK, a 403 becomes ForbiddenError, a 404 NotFoundError and a 429 RateLimitedError; read their message for the reason. Other statuses arrive as NodaroError with the server's code.
| Status | Code | When |
|---|---|---|
| 400 | validation_error | A field is invalid, a cursor is malformed, or a usage report has a bad date, time zone or range |
| 400 | terms_required | A school was created without acceptTerms: true |
| 400 | not_org_member | The person added to a workspace is not an active member of its organization |
| 400 | token_workspace_mismatch | A token bound to one workspace was used with another workspace |
| 400 | join_code_invalid | The join code does not exist, is disabled, or belongs to an archived workspace |
| 400 | invitation_expired, invitation_revoked, invitation_accepted, email_mismatch | The invitation cannot be accepted |
| 403 | The role is too low, the member is suspended, the organization is not active, the workspace is archived, or the email domain is not allowed | |
| 404 | No such organization, workspace, member or invitation, or one you are not a member of | |
| 409 | name_taken | The slug is in use |
| 409 | already_a_member | The person is already in the workspace |
| 409 | owner_cannot_leave | The owner tried to leave |
| 409 | has_active_workspaces | The organization still has workspaces that are not archived |
| 429 | Too many organizations created, join attempts, CSV exports or invitations | |
| 503 | billing_unavailable | Usage reports are not available on this instance yet |
| 503 | audit_unavailable | A CSV export could not be recorded in the audit log. Try again. |
Frequently asked questions
Related
Workspaces
Client
Workspaces and organizations
Workflows and projects
Last updated on
Pickers, presets and prompts
Read every picker's valid options, fill pickers from a scene description, load node presets, and improve prompts with the Prompt Wizard from TypeScript.
OAuth and developer apps
Register and manage your Nodaro OAuth apps with client.developerApps, and exchange codes, revoke tokens and read consent-screen data with client.oauth.