Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch
TypeScript SDK

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 space

client.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

MethodWhat it does
organizations.list()List the organizations you belong to
organizations.get(id)Read one organization
organizations.create(input)Create a school or a team
organizations.update(id, input)Rename it or change its settings
organizations.delete(id)Delete an organization
organizations.transferOwnership(id, userId)Make another member the owner
organizations.leave(id)Leave an organization
organizations.listMembers(), updateMember(), removeMember()Manage members
organizations.invite(orgId, input)Invite people by email
organizations.listInvitations(), revokeInvitation(), resendInvitation()Manage sent invitations
organizations.previewInvitation(), acceptInvitation()Show and accept an invitation
organizations.audit(orgId, opts?)Read the audit log
organizations.usage(), usageRows(), usageCsv()Read credit usage
workspaces.list()List the workspaces you belong to
workspaces.listForOrg(orgId, opts?)List an organization's workspaces
workspaces.get(id)Read one workspace
workspaces.create(orgId, input)Create a workspace
workspaces.update(id, input)Change a workspace
workspaces.setArchived(id, archived)Archive or unarchive a workspace
workspaces.listMembers(), addMember(), updateMember(), removeMember()Manage workspace members
workspaces.getJoinCode(), actOnJoinCode()Manage the join code
workspaces.join(code)Join a workspace with a code
workspaces.usage(), usageRows(), usageCsv()Read a workspace's credit usage

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 deleted

organizations.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".

KeyTypeMeaning
admin_accessview or editWhat admins may do with a member's workflow
default_workflow_visibilityprivate or workspaceThe visibility of a new workflow
member_access_to_sharedview or editWhat members may do with a workflow shared to the workspace
members_can_create_projectsbooleanWhether members may create projects in the workspace
member_caps_enabledbooleanWhether per-member credit limits apply
personal_space_enabledbooleanWhether members keep a personal space
workspace_admins_can_invitebooleanWhether workspace admins may invite new people
collaborators_can_invitebooleanWhether an editor collaborator may invite more collaborators
allowed_email_domainsstring listOrganization only: the email domains that may join
vocabulary_overridesobjectOrganization 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.

  • platformAbsorbedCredits is the excess of a metered run that went over the workspace's remaining budget, which the platform absorbs.
  • chargedToBudget equals settledCredits minus platformAbsorbedCredits: the settled credits that reached the budget.
  • appMarkupAbsorbedCredits is an app markup the budget could not cover. It has no run in the report, so it is not part of chargedToBudget.

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 BCDFGHJK

getJoinCode() 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.

StatusCodeWhen
400validation_errorA field is invalid, a cursor is malformed, or a usage report has a bad date, time zone or range
400terms_requiredA school was created without acceptTerms: true
400not_org_memberThe person added to a workspace is not an active member of its organization
400token_workspace_mismatchA token bound to one workspace was used with another workspace
400join_code_invalidThe join code does not exist, is disabled, or belongs to an archived workspace
400invitation_expired, invitation_revoked, invitation_accepted, email_mismatchThe invitation cannot be accepted
403The role is too low, the member is suspended, the organization is not active, the workspace is archived, or the email domain is not allowed
404No such organization, workspace, member or invitation, or one you are not a member of
409name_takenThe slug is in use
409already_a_memberThe person is already in the workspace
409owner_cannot_leaveThe owner tried to leave
409has_active_workspacesThe organization still has workspaces that are not archived
429Too many organizations created, join attempts, CSV exports or invitations
503billing_unavailableUsage reports are not available on this instance yet
503audit_unavailableA CSV export could not be recorded in the audit log. Try again.

Frequently asked questions

Last updated on

On this page