Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch
REST API

Workspaces and organizations

Act in an organization's workspace from the Nodaro API with X-Nodaro-Workspace, bind tokens to a workspace, and manage members, invitations, sharing and usage.

Available on Nodaro Cloud

Workspaces are where an organization's members work together: a class in a school, a team in a company. An API request acts in one workspace when you send its id in the X-Nodaro-Workspace header, and the organization endpoints manage everything around it: organizations, members, invitations, join codes, sharing, budgets and usage.

Organizations are a Nodaro Cloud feature and are switched on per instance. Where they are not enabled, the endpoints below do not exist and the header is ignored. Self-hosted builds do not include organizations. Read Workspaces for the concepts.

Act in a workspace

X-Nodaro-Workspace: 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34

The header does two things, and only these two: it decides which workspace a list returns and where a create lands. It never grants access. Reading, updating, deleting or running something you name by id follows that object's own workspace. A forgotten header cannot hide your work, and a forged one cannot reach anyone else's.

Without the header you work in your personal space, as an account without an organization always does.

curl -s https://app.nodaro.ai/v1/workflows \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "X-Nodaro-Workspace: 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34"
// Every request of this client acts in the workspace:
const classClient = createClient({
  baseUrl: 'https://app.nodaro.ai',
  auth: new StaticTokenAuth(process.env.NODARO_API_KEY!),
  workspaceId: '6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34',
})

// Or derive a second client from an existing one:
const classroom = client.withWorkspace(workspaceId)
await classroom.workflows.run(workflowId) // lands in the class
await client.workflows.run(workflowId)    // lands in the personal space
nodaro --workspace 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34 workflows list   # this command only
export NODARO_WORKSPACE=6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34            # this shell or CI job
nodaro workspace use 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34                # saved on the profile
nodaro workspace current                                                # which one applies, and why

client.withWorkspace returns a new client instead of changing the old one, so two operations running at the same time can never race over which workspace they are in. Pass null for the personal space. In the CLI, each of the three ways beats the one below it, and nodaro workspace use checks the workspace before saving it.

Send the header only for a workspace you belong to:

StatusCodeWhen
400validation_errorThe value is not a UUID.
403not_a_memberYou are not a member of that workspace, it does not exist, or its organization is not active.
403member_suspendedYour membership is suspended.

Two places never refuse a stale selection, so it can never lock you out: GET /v1/me and GET /v1/workspaces, and accepting an invitation. There, a workspace you can no longer select is treated as if you sent no header. Those are the endpoints that tell you which workspaces you may select, so clear a cached selection when they stop listing it.

Bind a token to a workspace

An API token can be bound to one workspace. It then acts as if it sent the header on every request, and an explicit header naming a different workspace answers 400 token_workspace_mismatch.

# Bind
curl -X PATCH https://app.nodaro.ai/v1/api-tokens/$TOKEN_ID \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId": "6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34"}'

# Unbind
curl -X PATCH https://app.nodaro.ai/v1/api-tokens/$TOKEN_ID \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId": null}'

Token management needs your signed-in session's JWT: an API token or an OAuth token gets 403 forbidden. You can bind a token only to a workspace you could select with the header, and unbinding is always allowed. Listing your tokens returns the binding as workspaceId.

Where a create lands

  • Inside a workspace, a create lands in the workspace, never in your personal space. A create that names no project goes to the workspace's project. When the workspace has no project yet, the create answers 409 workspace_has_no_default_project: name a project and it succeeds.
  • A project you name must belong to the workspace you act in. A project of another workspace, or of your personal space, answers 404 Project not found. That is the same answer as a project that does not exist, so the header cannot be used to discover what exists.
  • Creating a project may be limited to admins. Members then get 403 project_create_not_allowed.
  • An organization can close the personal space. Its members then create only inside a workspace, and a create without the header answers 403 personal_space_disabled. Accounts without an organization are never affected.

Archived workspaces are read-only

Archiving a workspace keeps everything in it readable and stops new work from being added. Lists behave as before. Every create, whether a project, a workflow, an import or a sub-workflow, answers 409 workspace_archived, and so does moving work into the workspace. Moving work out stays allowed, because rescuing it is the reason to open an archived workspace. Other writes into it, such as sharing a workflow, answer 403 workspace_archived. Match on the code, not on the status.

Who you are: GET /v1/me

GET /v1/me returns your profile. On an instance with organizations it also carries organizations, workspaces and lastWorkspaceId, each entry with your own role and status. Organization entries include the resolved vocabulary, such as the word the organization uses for a workspace, so a client never hard-codes "Class" or "Team". GET /v1/workspaces returns the same workspace list on its own.

Keep the three states of the organization fields apart:

What you seeWhat it meansWhat to do
The fields are absentThis instance has no organizations.Never show a workspace switcher.
Present and emptyThe account belongs to no organization.Offer to create or join one.
organizationsUnavailable: trueThe lookup failed.Keep the selection you had.

Organization endpoints

Every endpoint needs authentication. Bodies are JSON, responses are { "data": … }, lists are { "data": [ … ], "nextCursor": … }, and ids are UUIDs.

Organizations

MethodPathWhoBody or query
POST/v1/orgsAnyone signed in{ name, kind, slug?, acceptTerms?, settings? }, where kind is school or team. Returns 201.
GET/v1/orgsAnyoneYour organizations, with your role and memberStatus.
GET/v1/orgs/:idMember
PATCH/v1/orgs/:idOwner, admin{ name?, settings? }. Settings merge key by key, and unknown keys are dropped.
POST/v1/orgs/:id/transfer-ownershipOwner{ userId }. The new owner must be an active admin, and the old owner becomes an admin in the same step.
DELETE/v1/orgs/:idOwnerRefused with 409 has_active_workspaces while any workspace is not archived. The organization is soft-deleted and its slug freed.
POST/v1/orgs/:id/leaveMemberThe owner cannot leave (409 owner_cannot_leave): transfer first. Leaving removes you from every workspace of the organization.
  • Approval. A new organization usually starts pending and becomes active once the platform approves it. Its owner sees it while it waits, nobody else does, and nothing in it can change. Tell whoever created it, or the wait looks like a failure.
  • Schools need terms. Creating a school requires acceptTerms: true, by which the owner confirms the authority to enroll students. Without it, the create answers 400 terms_required.
  • Slugs are 1 to 50 lower-case letters, digits and hyphens. A slug you send must be free (409 name_taken). Without one, it is derived from the name: "Sunrise School" becomes sunrise-school, then sunrise-school-2.
  • Creating organizations is limited to a few per hour for each user.

Members

MethodPathWhoBody or query
GET/v1/orgs/:id/membersOwner, admin?limit=50&cursor=…, up to 200. Rows: userId, role, status, joinedAt, email, displayName, avatarUrl.
PATCH/v1/orgs/:id/members/:userIdOwner, admin{ role?, status? }: admin or member, active or suspended. Not the owner's row.
DELETE/v1/orgs/:id/members/:userIdOwner, adminNot the owner. Also removes the person from every workspace of the organization.

Workspaces

MethodPathWhoBody or query
POST/v1/orgs/:id/workspacesOwner, admin{ name, slug?, description?, settings? }. Returns 201. Slugs are unique within the organization.
GET/v1/orgs/:id/workspacesMemberMembers see their workspaces, owners and admins see all. ?includeArchived=true adds archived ones.
GET/v1/workspacesAnyoneEvery workspace you belong to, across organizations.
GET/v1/workspaces/:idWorkspace member
PATCH/v1/workspaces/:idWorkspace admin{ name?, description?, settings? }
POST/v1/workspaces/:id/archiveOwner, adminArchive. Reversible, and nothing is destroyed.
POST/v1/workspaces/:id/unarchiveOwner, adminUnarchive.

Workspace members

MethodPathWhoBody or query
GET/v1/workspaces/:id/membersWorkspace member?limit&cursor. Members see userId, role, displayName, avatarUrl and addedAt. Admins also see status and creditCap. The roster has no email addresses.
POST/v1/workspaces/:id/membersWorkspace admin{ userId, role }, with role admin or member. The person must already be an active member of the organization (400 not_org_member): to bring in someone new, invite them.
PATCH/v1/workspaces/:id/members/:userIdWorkspace admin{ role?, status?, creditCap? }. creditCap is the member's spending cap, or null for none.
DELETE/v1/workspaces/:id/members/:userIdWorkspace adminRemoves the person from the workspace only. They stay in the organization.

Organization owners and admins are admins of every workspace in the organization without being listed. Adding one of them to a workspace explicitly gives them that explicit role instead, which is how an organization admin can be a plain member of one class.

Invitations

An invitation names one email address. Its link carries a token that exists only in the email, and the invitee sees what they are invited to before signing in.

MethodPathWhoBody or query
POST/v1/orgs/:id/invitationsOwner, admin, and workspace admins into their own workspace when the organization allows it{ emails, orgRole?, workspaceId?, workspaceRole? }. Up to 200 addresses, lower-cased and de-duplicated. Returns 201 with one row per address: { email, status, link? }.
GET/v1/orgs/:id/invitationsThe same people?status= (open, accepted, revoked or expired), workspaceId, limit and cursor. Never returns a token.
DELETE/v1/invitations/:idThe same peopleRevoke. The link stops working. Refused on an accepted invitation.
POST/v1/invitations/:id/resendThe same peopleIssues a new token and expiry and sends it again. The previous link stops working.
GET/v1/invitations/by-token/:tokenPublicWhat the invitee needs to decide: orgName, kind, vocabulary, inviterName, workspaceName, the email address masked, expiresAt and state. Limited per IP address.
POST/v1/invitations/:token/acceptThe signed-in inviteeThe account's email must match the invitation (400 email_mismatch). A second acceptance is refused.
  • Each address gets its own row. status is sent, link_only or failed, and link is present whenever the address was not emailed. An install without a mail provider returns every address as link_only. Show those links: the invitation exists either way, and without the link nobody can reach it.
  • Invitations expire after 14 days, and an organization may send 500 a day (429 bulk_invite_cap_exceeded).
  • Inviting an address again moves its open invitation to a fresh token instead of creating a second one.
  • Inviting an existing member uses up the invitation but does not change their role or lift a suspension.

Join codes

A join code is eight characters, short enough to read aloud in a room. It admits a plain member into one workspace, never lifts a suspension, and respects the organization's allowed email domains.

MethodPathWhoBody or query
GET/v1/workspaces/:id/join-codeWorkspace admin{ code, enabled, rotatedAt, rotatedBy }, or null when none was created.
POST/v1/workspaces/:id/join-codeWorkspace admin{ action }: rotate, enable or disable. Enabling a workspace that never had a code creates one. Rotating replaces it, and the old code stops working at once.
POST/v1/workspaces/joinAnyone signed in{ code }. Spoken forms work: BCDF-GHJK, lower case, and commonly misheard letters such as O for zero. Limited to 10 attempts per minute for each account and 30 for each IP address.

Sharing one workflow

A grant gives one person access to one workflow. It can only add access, never remove it.

MethodPathWhoBody or query
GET/v1/workflows/:id/collaboratorsAnyone who may view the workflowRows of { userId, name, avatarUrl, role, createdAt }, never an email address.
POST/v1/workflows/:id/collaboratorsSee below{ userId } or { email }, exactly one, plus role: viewer or editor. Returns 201. Limited to 20 additions per minute.
PATCH/v1/workflows/:id/collaborators/:userIdSame as POST{ role }
DELETE/v1/workflows/:id/collaborators/:userIdSame as POST, or yourselfAnyone may remove their own access.
GET/v1/workflows/shared-with-meAnyone signed inWorkflows you hold a grant on, outside your own workspaces, each with its grantedRole. Newest first, up to 200.
GET/v1/workflows/:id/accessAnyone who may view it{ access, workspaceId, visibility, canChangeVisibility, canShare, canRun }, never the graph.
  • Who may share: the creator; a workspace admin where admins may edit members' work; and anyone who may edit the workflow where the workspace lets collaborators invite.
  • Adding by email matches any account's address, whatever its organization. 404 means there is no account, and nothing more is disclosed.
  • Someone outside the workspace is capped at viewing, even with an editor grant. Running needs active membership, so an outside collaborator can never spend the workspace's credits.
  • You cannot grant to the workflow's creator or to yourself (400). A grant works at once, with no acceptance step.
  • canChangeVisibility, canShare and canRun are three separate rules. Never infer one from another.

A workflow you cannot reach at all answers 404, the same as one that does not exist. Only when you can already see it and need more does the answer become 403. Running a workflow that belongs to a workspace needs edit access and active membership.

Audit log

MethodPathWhoBody or query
GET/v1/orgs/:id/auditOwner, admin?cursor&limit, newest first. Each entry: { id, workspaceId, action, targetType, targetId, details, createdAt, actor }. actor is null for actions the system took.

The audit log stays readable while the organization is suspended. action is an open list that grows with the product: show the actions you know, and fall back to the raw value for the rest.

Roles and settings

ScopeRoleCan
OrganizationownerEverything an admin can, plus transfer ownership and delete the organization. There is exactly one. The owner cannot be suspended, removed or demoted.
OrganizationadminManage settings, members other than the owner, and workspaces. Admin of every workspace.
OrganizationmemberBelong to the organization and work in the workspaces they are added to.
WorkspaceadminManage the workspace's settings and members.
WorkspacememberWork in the workspace.

Settings are layered: a workspace's setting wins over the organization's, which wins over the default for the organization's kind. A key that is not set falls through, and false is a real value.

KeyValuesControls
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 with the workspace.
members_can_create_projectsbooleanWhether members may create projects.
member_caps_enabledbooleanWhether per-member credit caps apply.
personal_space_enabledbooleanWhether members keep a personal space.
workspace_admins_can_invitebooleanWhether workspace admins may invite new people into the organization.
collaborators_can_invitebooleanWhether an editor collaborator may invite more collaborators.

The organization alone also has allowed_email_domains, such as ["school.example"], and vocabulary_overrides, such as { "workspace": "Cohort" }. Both are replaced whole when updated.

A school starts with: admins edit, workflows private, members view shared work, members cannot create projects, caps on, personal space on, workspace admins can invite, collaborators cannot. A team starts with: admins view, workflows visible to the workspace, members edit shared work, members can create projects, caps off, personal space on, and both invite settings on.

Budgets and usage

An organization pays for its members' work in three steps, each narrower than the last:

  1. The pool. The organization buys prepaid credit packs into one pool for the whole organization, through its own checkout.
  2. Allocations. The owner moves credits from the pool into a workspace's budget. The pool goes down by exactly what the workspace gains. Credits can go back to the pool, but only down to what the workspace has already reserved or spent.
  3. Member caps. Inside a workspace, an admin can cap what one member may spend. Caps apply only where the workspace's settings turn them on, and never to an organization admin working in the workspace.

Work done inside a workspace is paid by the workspace, and the member's own credits are not touched. Headroom at every level is allocated minus reserved minus spent, and a run that would exceed it is refused with 402 budget_exceeded or 402 member_cap_exceeded before it starts.

RouteWhoDoes
GET /v1/orgs/:id/creditsOwner, org adminThe pool, the lifetime purchases, and each workspace's allocation with its reserved and spent credits.
POST /v1/orgs/:id/credits/checkoutOwnerA checkout URL for one prepaid pack. Body { packId }.
POST /v1/orgs/:id/workspaces/:wsId/allocateOwnerMove credits between the pool and a workspace. Body { delta }. Returns the new headroom.
GET /v1/workspaces/:id/budgetMemberYour spend, cap and headroom. Admins also get a row for each member.
GET /v1/orgs/:id/usageOwner, org adminA usage report for the organization.
GET /v1/workspaces/:id/usageMember, workspace adminThe same for one workspace.

Refunds and disputes take credits back from the organization's pool in proportion, never below zero. Deleting an organization requires taking back every workspace allocation first.

Automations spend the workspace's credits. A Webhook Trigger URL is a bearer credential: whoever holds it can start a run, and a run of a workflow in a workspace is paid by that workspace. Every automated run, from a webhook, a schedule or Telegram, checks first that the trigger's creator may still run the workflow. When they no longer can, the automation stops and the run history shows one failed entry with the code run_requires_authenticated_member.

Usage reports

The two usage routes answer who spent how much on what, over a date range:

QueryMeaning
from, toInclusive dates, YYYY-MM-DD. The range is at most 366 days and defaults to the last 30.
tzAn IANA time zone for the day buckets. The default is UTC.
groupByworkspace (organization only), member, model, day, or none for the individual runs, newest first, paged with cursor.
workspaceId, userIdNarrow the report.
formatcsv returns the report as CSV.
  • Each row has three credit figures. credits is what the runs cost so far: the settled amount for finished runs and the held reservation for runs in flight. settledCredits and inFlightCredits split that sum.
  • A metered run that cost more than the workspace had left is charged up to the headroom, and the platform absorbs the rest. The totals report it as platformAbsorbedCredits, and settledCredits minus platformAbsorbedCredits is what reached the budget. An approved-app markup the budget could not cover is absorbed too and reported apart as appMarkupAbsorbedCredits.
  • A report with more than 5,000 groups returns truncated: true. Only the grouped rows are cut: the totals cover the whole range.
  • A plain member sees only their own runs, and grouping by member is refused for them. A workspace admin sees everyone and may filter by userId. The organization report is for owners and organization admins.
  • CSV exports are UTF-8, RFC 4180, with CRLF line endings and no byte-order mark. A cell that starts with =, +, - or @ is quoted with a leading apostrophe so a spreadsheet cannot run it. Exports are limited to 10 per minute for each user and are recorded in the audit log.
  • When a member deletes their account, their run history goes with it, and the report shows a gap. Their workflows and projects stay with the workspace.

An instance without usage reports answers 404, and 503 billing_unavailable while reporting is not yet available.

Errors

StatusCodeWhen
400validation_errorA field is invalid, a cursor is malformed, the change is not allowed for that row, or a usage report has a bad date, time zone, range or grouping.
400terms_requiredCreating a school without acceptTerms: true.
400not_org_memberAdding someone to a workspace who is not an active member of its organization.
400token_workspace_mismatchA token bound to one workspace, with a header naming another.
400join_code_invalidNo such code, the code is disabled, or its workspace is archived. One answer for all three.
400invitation_expired, invitation_revoked, invitation_acceptedThe invitation is past its 14 days, revoked, or already accepted.
400email_mismatchThe signed-in account's email is not the invited one.
401unauthorizedNo valid credentials.
403insufficient_roleYou are a member, but your role is too low for the action.
403member_suspendedYour membership is suspended.
403org_not_activeThe organization is pending or suspended, and the action changes something.
403workspace_archivedA write into an archived workspace.
403not_a_memberThe header names a workspace you cannot select.
403domain_not_allowedThe organization admits only listed email domains.
404not_foundNo such organization, workspace or member, or one you are not a member of.
404invitation_not_foundNo invitation for that token, including one whose organization is not active.
409name_takenThe slug you sent is in use.
409already_a_memberThe person is already in the workspace.
409owner_cannot_leaveTransfer ownership before leaving.
409has_active_workspacesArchive every workspace before deleting the organization.
429rate_limit_exceededToo many organizations created, join attempts, or CSV exports.
429bulk_invite_cap_exceededThe organization reached its daily invitation limit.
503billing_unavailableUsage reporting is not available on this instance yet.
503audit_unavailableA CSV export could not be recorded in the audit log, so it was refused. Try again.

From the SDK, the CLI and MCP

  • SDK: client.organizations and client.workspaces cover these endpoints, including usage, usageRows and usageCsv for reports. client.me() carries the organization fields.
  • CLI: nodaro org and nodaro workspace, for example nodaro org invite <orgId> --email ada@school.example --workspace <id> and nodaro org usage <orgId> --from 2026-09-01 --to 2026-09-30 --group-by member --csv > september.csv. The invite command prints one line per address, with the link for any address that was not emailed.
  • MCP: the list_workspaces and select_workspace tools. The selection is remembered across sessions and checked again at each one. An OAuth app that uses these tools needs the workspaces:read and workspaces:write scopes.

Read The CLI and The SDK for the full command and method lists.

Frequently asked questions

Last updated on

On this page