Organizations
Organizations let a school or a team run on Nodaro Cloud as a group: one account owns the organization, people belong to it, and the work happens in workspaces — a class, a team — that members share.
Availability. Organizations are a Nodaro Cloud feature and are switched on per instance. On an instance where they are not enabled, the endpoints below are absent and the
X-Nodaro-Workspaceheader is ignored. Self-hosted builds do not include organizations.
Concepts
| Term | Meaning |
|---|---|
| Organization | The group: a school or a team (kind). Has one owner, any number of admins and members, a unique slug, and settings that apply to every workspace in it. |
| Workspace | Where members work together — a class in a school, a team in a company. Belongs to exactly one organization. Has its own admins and members, and settings that override the organization’s. |
| Implicit admin | Organization owners and admins are admins of every workspace in the organization without being listed in it. Adding them to a workspace explicitly gives them that explicit role instead — which is how an organization admin can be made a plain member of one class. |
| Member status | active or suspended, at the organization level and again at the workspace level. A suspended member keeps their seat but cannot act; suspension at the organization level applies everywhere in it. |
| Organization status | pending (created, awaiting approval), active, suspended (stopped by the platform), deleted (soft-deleted; hidden from its former members). Only an active organization accepts changes. |
| Approval | By default a new organization starts pending and becomes active once the platform approves it. Its owner can see it while it waits; nobody else can, and nothing in it can be changed. |
| Terms | Creating a school requires accepting the organization terms (acceptTerms: true): the owner attests to the authority to enrol students. |
Roles
| Scope | Roles | What the role allows |
|---|---|---|
| Organization | owner |
Everything an admin can, plus transferring ownership and deleting the organization. Exactly one per organization; cannot be suspended, removed or demoted — ownership moves by transfer. |
| Organization | admin |
Manage settings, members (except the owner), and workspaces. An admin everywhere in the organization’s workspaces. |
| Organization | member |
Belongs to the organization; sees and works in the workspaces they are added to. |
| Workspace | admin |
Manage the workspace’s settings and members. |
| Workspace | member |
Works in the workspace. |
Settings
Settings are layered: a workspace override wins over an organization
override, which wins over the default for the organization’s kind. Unset keys
fall through; setting a key to false is a real value, not “unset”.
| Key | Type | Meaning |
|---|---|---|
admin_access |
view | edit |
What admins may do with a member’s workflow. |
default_workflow_visibility |
private | workspace |
The visibility a new workflow gets. |
member_access_to_shared |
view | 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 caps apply. Enforced at reserve time once workspace-paid runs roll out (the payer-selection stage of the organizations feature), together with creditCap on the workspace member. |
personal_space_enabled |
boolean | Whether members keep a personal (non-workspace) space. |
workspace_admins_can_invite |
boolean | Whether workspace admins may invite new people into the organization. |
collaborators_can_invite |
boolean | Whether an editor collaborator may invite further collaborators. |
Organization-only keys: allowed_email_domains (lower-case domains, e.g.
["school.example"]) and vocabulary_overrides (relabel the kind’s
vocabulary, e.g. { "workspace": "Cohort" }). Both are replaced wholesale
when updated, never merged.
Defaults by kind — school: admins edit, workflows private, members view shared work, members cannot create projects, caps on, personal space on, workspace admins can invite, collaborators cannot. team: admins view, workflows visible to the workspace, members edit shared work, members can create projects, caps off, personal space on, both invite settings on.
Identity
GET /v1/me reports the caller’s organizations and workspaces alongside the
profile, each with the caller’s own role and status, plus lastWorkspaceId.
The organization entries also carry the resolved vocabulary for the UI, so
a client never hard-codes “Class” or “Team”. GET /v1/workspaces returns the
same workspace list on its own.
Endpoints
All endpoints require authentication. Bodies are JSON; responses are
{ "data": … }, list responses { "data": [...], "nextCursor": "…" | null }.
Ids are uuids.
Organizations
| Method | Path | Who | Body / query |
|---|---|---|---|
POST |
/v1/orgs |
any signed-in user | { name, kind: "school" \| "team", slug?, acceptTerms?, settings? } — returns 201; status is pending when approval is required. Limited to a few creations per hour per user. |
GET |
/v1/orgs |
— | The caller’s organizations with role and memberStatus. A pending organization is listed for its owner only. |
GET |
/v1/orgs/:id |
member | |
PATCH |
/v1/orgs/:id |
owner, admin | { name?, settings? } — settings merge key by key, then the whole object is re-validated; unknown keys are dropped. |
POST |
/v1/orgs/:id/transfer-ownership |
owner | { userId } — the new owner must be an active admin. Atomic: the old owner becomes an admin in the same step. |
DELETE |
/v1/orgs/:id |
owner | Refused with 409 has_active_workspaces while any workspace is not archived. Soft-deletes; the slug is freed for reuse. |
POST |
/v1/orgs/:id/leave |
member | The owner cannot leave (409 owner_cannot_leave); transfer first. Leaving also removes the caller from every workspace in the organization. |
A slug you supply must be free, or the request fails with 409 name_taken.
Omit it and one is derived from the name ("Sunrise School" → sunrise-school,
sunrise-school-2, …). Slugs are 1–50 lower-case letters, digits and hyphens.
Organization members
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/orgs/:id/members |
owner, admin | ?limit=50&cursor=… (max 200). Each row: userId, role, status, joinedAt, email, displayName, avatarUrl. |
PATCH |
/v1/orgs/:id/members/:userId |
owner, admin | { role?: "admin" \| "member", status?: "active" \| "suspended" } — the owner’s row cannot be changed here. |
DELETE |
/v1/orgs/:id/members/:userId |
owner, admin | Not the owner. Removes the person from every workspace in the organization too. |
Workspaces
| Method | Path | Who | Body / query |
|---|---|---|---|
POST |
/v1/orgs/:id/workspaces |
owner, admin | { name, slug?, description?, settings? } — returns 201. Slugs are unique within the organization. |
GET |
/v1/orgs/:id/workspaces |
member | Members see the workspaces they belong to; owners and admins see all. ?includeArchived=true adds archived ones. |
GET |
/v1/workspaces |
— | Every workspace the caller belongs to, across organizations. |
GET |
/v1/workspaces/:id |
workspace member | |
PATCH |
/v1/workspaces/:id |
workspace admin | { name?, description?, settings? } |
POST |
/v1/workspaces/:id/archive |
owner, admin | Archived workspaces stay readable; writes into them are refused with 403 workspace_archived. |
POST |
/v1/workspaces/:id/unarchive |
owner, admin |
Workspace members
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/workspaces/:id/members |
workspace member | ?limit&cursor. Members see userId, role, displayName, avatarUrl, addedAt; workspace admins also see status and creditCap. Email addresses are not on this roster. |
POST |
/v1/workspaces/:id/members |
workspace admin | { userId, role: "admin" \| "member" } — the person must already be an active member of the organization (400 not_org_member). |
PATCH |
/v1/workspaces/:id/members/:userId |
workspace admin | { role?, status?, creditCap?: number \| null, resetSpend?: boolean }. creditCap is stored now and enforced once budgets are enabled; resetSpend is accepted and currently has no effect. |
DELETE |
/v1/workspaces/:id/members/:userId |
workspace admin | Removes the workspace row only; organization membership stays. |
Invitations
An invitation names one email address. The link carries an unguessable token that exists only in the email — the API stores its hash — and the person who follows it sees what they were invited to before signing in.
| Method | Path | Who | Body / query |
|---|---|---|---|
POST |
/v1/orgs/:id/invitations |
owner, admin — and workspace admins when workspace_admins_can_invite is on, into their own workspace only |
{ emails: string[], orgRole?: "admin" | "member", workspaceId?, workspaceRole? }. Up to 200 addresses per call, lower-cased and de-duplicated. Returns 201 with one row per address: { email, status: "sent" | "link_only" | "failed", link? }. link is present whenever the address was not emailed, so the inviter can pass it on. |
GET |
/v1/orgs/:id/invitations |
the same people | ?status=open|accepted|revoked|expired&workspaceId&limit&cursor. Never returns a token. |
DELETE |
/v1/invitations/:id |
the same people | Revokes; the link stops working. Refused on an invitation that was already accepted. |
POST |
/v1/invitations/:id/resend |
the same people | Issues a new token and a new expiry, and sends it again — the previous link stops working. |
GET |
/v1/invitations/by-token/:token |
public | What the invitee needs to decide: { orgName, kind, vocabulary, inviterName, workspaceName, email (masked), expiresAt, state }. Rate-limited per IP. |
POST |
/v1/invitations/:token/accept |
the signed-in invitee | The account’s email must match the invitation’s (400 email_mismatch). Joining is atomic; a second acceptance is refused. |
Invitations expire after 14 days, an organization may create 500 per day, and re-inviting an address that already has an open invitation rotates that invitation onto a fresh token rather than creating a second one.
An invitation to somebody who is already a member is still consumed, but it does not change their existing role or lift a suspension.
Email delivery is optional. An install with RESEND_API_KEY and
EMAIL_FROM set sends the invitation; without them every address comes back
as link_only with its link, and the flow works exactly the same from there.
Join codes
A join code is eight characters, read aloud in a room. Because it is short it
is bounded: it admits a plain member of one workspace, it never lifts a
suspension, and it obeys the organization’s allowed_email_domains.
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/workspaces/:id/join-code |
workspace admin | { code, enabled, rotatedAt, rotatedBy }, or null when no code has been minted. |
POST |
/v1/workspaces/:id/join-code |
workspace admin | { action: "rotate" | "enable" | "disable" }. Enabling a workspace that never had a code mints one; rotating replaces it and the previous code stops working immediately. |
POST |
/v1/workspaces/join |
anyone signed in | { code }. Accepts the spoken forms — BCDF-GHJK, lower case, and the letters people mishear (O for zero, I and L for one) are folded. Limited to 10 attempts per minute per account and 30 per minute per IP. |
Workflow collaborators
A grant gives one person access to one workflow. It is a floor: it can
only add access, never take it away, so a viewer grant handed to someone
who could already edit the workflow changes nothing.
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/workflows/:id/collaborators |
anyone who may view the workflow | Rows of { userId, name, avatarUrl, role, createdAt }. Never an email address — this list is readable by someone granted a look at this one workflow and belonging to nothing else. |
POST |
/v1/workflows/:id/collaborators |
see below | { userId } or { email } — exactly one — plus role: "viewer" \| "editor". Returns 201. Limited to 20 additions per minute per account. |
PATCH |
/v1/workflows/:id/collaborators/:userId |
same as POST |
{ role }. |
DELETE |
/v1/workflows/:id/collaborators/:userId |
same as POST, or yourself |
Leaving is always yours to do: nobody has to ask permission to stop having access. |
Who may share. The creator always; a workspace admin where admin_access
is edit; and anyone else who may edit the workflow where the workspace’s
collaborators_can_invite is on — off in a school by default, on in a team.
Deliberately not “anyone who may edit”: changing a piece of work and
deciding who else sees it are different powers.
Adding by email matches any address with an account, whatever
organization it belongs to — a class often needs to include someone who is
not in it yet. The address is matched case-insensitively. 404 means no
account; nothing else is disclosed about it.
A non-member of the workspace is capped at view, editor grant or not:
one person sharing the class’s work cannot hand an outsider the ability to
change it. Running is stricter still and needs active membership, so an
outside collaborator can edit the canvas and never spend the class’s credits.
An archived workspace refuses POST and PATCH with 403
workspace_archived — a frozen workspace takes no new sharing. Removing
somebody still works.
You cannot grant to the workflow’s creator (they own it) or to yourself
(you already have access, and a grant would outlive the role that allowed
you to write it). Both answer 400.
A grant takes effect the moment it is written — there is no acceptance step. The recipient finds the workflow under Shared with me (below), and it also turns up in their search, the same as anything else they can open.
Work shared with me
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/workflows/shared-with-me |
anyone signed in | Workflows the caller holds a grant on, each with the grantedRole that reaches it. Newest first, up to 200. |
Only work that is not in a workspace you belong to. Anything shared with the whole workspace already appears in that workspace’s own lists, and listing it twice would make “shared with me” the less true of the two labels.
Reaching one workflow
Every by-id workflow route asks one question — what may this person do with this workflow — and enforces the answer:
| Route | Needs |
|---|---|
GET /v1/workflows/:id |
view. The response carries the caller’s own access, so an editor opening a workflow they may only read gets a read-only canvas without asking twice. |
GET /v1/workflows/:id/access |
view. Just the answer — { access, workspaceId, visibility, canChangeVisibility } and never the graph, for a client that already has the workflow and only needs to know what it may do with it. |
GET /v1/workflows/:id/export |
view. Bundled assets (?assets=true) stay scoped to the CALLER: being allowed to read a workflow is not being allowed to walk out with the characters and locations behind it, so a shared export comes back with the graph and without them. |
GET /v1/workflows/:id/interface |
view |
PATCH /v1/workflows/:id |
edit |
POST /v1/workflows/:parentId/sub-workflows |
edit on the parent |
POST /v1/workflow-triggers |
the same bar as running — a trigger IS a run, just an unattended one. Every fire re-checks that its owner may still run the workflow, and a trigger whose owner has lost access deactivates itself. |
POST /v1/workflows/:id/run |
edit and active membership when the workflow belongs to a workspace |
POST /v1/apps/publish, POST /v1/templates/publish |
own and authorship. Publishing is a disclosure decision, not an edit — and own alone does not mean “the creator”, so both are required. |
PATCH /v1/workflows/:id { settings } |
edit, except when the write would change settings.studio.shared — the opt-in that makes a workflow readable by GET /v1/public/workflows/:id with no auth. That is an audience decision and takes the same authority as visibility. |
DELETE /v1/workflows/:id |
creator, workspace admin, or platform admin — never a collaborator, whatever their grant says |
What a refusal looks like. No access at all answers 404 — a
workflow you cannot reach is indistinguishable from one that does not exist,
and any other answer would confirm to a stranger that an id is real. Only
when you can already see it and need more does the answer become 403.
Changing visibility (PATCH /v1/workflows/:id { visibility }) is the
creator’s or a workspace admin’s, not an editor’s — flipping a private
workflow to workspace publishes someone else’s work to the class. On a
workflow that is not in a workspace there is nothing to be visible to, and
the request answers 400 not_workspace_scoped.
Deleting somebody else’s work is recorded. The row policies admit only
the creator; a workspace admin can delete a member’s workflow through the
API and nowhere else, and the audit entry
(workflow.deleted_by_admin) is written before the deletion — if it
cannot be written, the deletion does not happen (503 audit_unavailable).
Deleting your own work is unchanged and is not audited. A workflow that is
in no workspace has no organization to record an entry under, so nobody but
its creator can delete it at all.
GET /v1/workflows/:id/access answers three permissions separately —
canChangeVisibility, canShare, canRun — because they are three
different rules. A workspace can let ordinary editors invite collaborators
while reserving the class-wide visibility switch for admins, and canRun
can be false while the caller may still edit. Never infer one from another.
Audit
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/orgs/:id/audit |
owner, org admin | ?cursor&limit. Newest first. Each entry is { id, workspaceId, action, targetType, targetId, details, createdAt, actor }; actor is null for anything the system did on nobody’s behalf. |
Readable while the organization is suspended, unlike every other management endpoint — the record of what happened is exactly what someone needs when things have gone wrong.
action is an open vocabulary: new actions are added as the product
grows, and the schema promises only that the field holds a string. Render the
ones you recognise and fall back to the raw value for the rest — a client that
switched exhaustively over it would break on the first new one.
Platform administration
On instances that hold new organizations for review, a platform administrator approves them. These endpoints answer 403, not 404, to everyone else: they are a platform surface, not a member one, and there is nothing to conceal about their existence.
| Method | Path | Who | Body / query |
|---|---|---|---|
GET |
/v1/admin/orgs |
platform admin | ?status&cursor&limit. Unfiltered returns every live organization; a soft-deleted one appears only when status=deleted is asked for explicitly. Each row carries the owner, the member count and the workspace count. |
GET |
/v1/admin/orgs/:id |
platform admin | One organization, same shape. |
PATCH |
/v1/admin/orgs/:id |
platform admin | { status: "active" | "suspended" }. Approving a pending organization and restoring a suspended one are the same call. |
The surface stops there on purpose. A platform administrator decides whether an organization may operate on the instance; renaming it, reading its members and changing its settings belong to the people who run it.
Budgets and credits
An organization pays for its members’ work in three moves, each one narrower than the last:
- The pool. The organization buys prepaid credit packs (the same packs and prices as personal top-ups) into one org-wide pool. Purchases go through Stripe Checkout against the organization’s own Stripe customer — never a member’s card on file.
- Allocations. An owner moves credits from the pool into a workspace’s budget. Allocation is a move, not a copy: the pool goes down by exactly what the workspace gains. Credits can be reclaimed back to the pool, but only down to what the workspace has already reserved or spent.
- Member caps (optional). Inside a workspace, an admin can cap what a single member may spend. Caps bind only where the workspace’s settings enable them (the school preset does; the team preset does not), and only for explicit members — an organization admin acting in the workspace is never capped.
Work done inside a workspace is paid by the workspace, whatever the member’s
own balance says: the member’s personal credits are untouched, and the two
balances are shown side by side. (Workspace-paid runs are live; the budget
console and payer-aware run gate arrive with the next update. Rollout-gated:
instances enable organizations explicitly.) Headroom at every level is
allocated − reserved − spent; a run that would exceed it is refused with a
stable error code (budget_exceeded, member_cap_exceeded) rather than
started.
| Route | Who | Does |
|---|---|---|
GET /v1/orgs/:id/credits |
owner / org admin | pool, lifetime purchased, and every workspace’s allocation with reserved/spent |
POST /v1/orgs/:id/credits/checkout { packId } |
owner | Stripe Checkout URL for one prepaid pack |
POST /v1/orgs/:id/workspaces/:wsId/allocate { delta } |
owner | move credits pool ⇄ workspace; returns the new headroom |
GET /v1/workspaces/:id/budget |
member | own spend, cap and headroom; admins also get per-member rows |
PATCH /v1/workspaces/:id/members/:userId { creditCap, resetSpend } |
workspace admin | cap a member / zero their counted spend |
GET /v1/orgs/:id/usage |
owner / org admin | credits by workspace, member, model or day for an inclusive date range; groupBy=none pages the runs; format=csv exports |
GET /v1/workspaces/:id/usage |
member (own runs) / workspace admin (everyone) | the same, scoped to one workspace |
Refunds and disputes claw credits back from the organization’s pool, proportionally to the refunded amount and floored at zero — a pool that already spent what was refunded records the shortfall instead of going negative. Deleting an organization requires reclaiming every workspace allocation first.
Automations spend the workspace’s money. A webhook trigger’s URL is a
bearer capability: whoever holds it can start the run, and once workspace
payment is live, a run of a workspace-homed workflow bills the workspace’s
budget. Treat trigger URLs like credentials — anyone you share one with can
spend against the class. Every automated fire (webhook, schedule, Telegram)
re-checks at fire time that the trigger’s creator may still run the
workflow; a creator who lost access (grant revoked, membership suspended,
workspace archived) stops the automation, and the workflow’s run history
shows one failed entry with the code run_requires_authenticated_member
saying why. (Rollout-gated: workspace-paid runs may lag this document.)
Usage reports
Two read-only endpoints answer “who spent how much on what, when” over the workspace-paid runs, for an inclusive date range:
fromandtoare inclusive calendar dates (YYYY-MM-DD);tzis an IANA zone name (defaultUTC) and day buckets follow it. The range is at most 366 days and defaults to the last 30.- Group by
workspace(the organization report only),member,modelorday.groupBy=nonereturns the individual runs, newest first, cursor-paged; the cursor comes back asnextCursorand is passed in again ascursor. format=csvstreams the same report as a CSV attachment.
Each row carries three credit numbers. credits is what a run has cost so far —
the settled amount where the run has finished, the held reservation while it is
still running. settledCredits and inFlightCredits split that sum, so an
admin sees what is final and what is still pending. A run still holding its
reservation is listed as in flight; it settles or is refunded when the job
finishes, usually within the reconcile window, and a row that stays in flight
for longer is a support case.
A metered run that cost more than the workspace had left is charged up to the
headroom; the rest is absorbed by the platform and listed on its own line,
never against a member. The report totals metered overruns as
platformAbsorbedCredits, so chargedToBudget = settledCredits −
platformAbsorbedCredits is the metered settlement that reached the budget. An
approved-app markup the budget could not cover is absorbed too, but it has no
run in this report; it is totalled separately as appMarkupAbsorbedCredits and
is not folded into chargedToBudget — so chargedToBudget is not, on its own,
workspace_budgets.spent. Totals and the platform-absorbed lines cover the
WHOLE window; only the grouped rows can be truncated.
A plain workspace member sees their OWN usage: the workspace report is narrowed to them, and asking to group by member is refused. A workspace admin (explicit, or an organization admin acting in the workspace) sees everyone and may filter to one member. Organization-level usage is owner and organization admins only.
A report with more than 5000 groups comes back with truncated: true — narrow
the window. Only the grouped rows are cut; the totals and the platform-absorbed
lines still cover the whole window.
CSV. UTF-8, RFC 4180, CRLF line endings, no byte-order mark. A cell that
begins with =, +, - or @ is quoted with a leading apostrophe so a
spreadsheet cannot execute it. Exports are limited to ten per minute per user,
and each export is recorded in the audit log before a single byte is streamed —
if that record cannot be written the export is refused rather than left
untracked.
A known limit. A member who deletes their account takes their run history with them; reports show a gap for their past activity. Their workflows and projects stay with the class, and the platform-absorbed line is unaffected. Resetting a member’s counted spend does not change the report — it zeroes the cap counter, not the history.
(Rollout-gated: usage reports may lag this document — an instance that has not
yet received them answers 404 for both routes, and 503 billing_unavailable
once the routes exist but the reporting functions are not yet applied.)
Errors
Errors use the standard envelope { "error": { "code", "message" } }; dispatch
on code.
| Status | Code | When |
|---|---|---|
| 400 | validation_error |
A field fails validation, a cursor is malformed, the change is not allowed for that row (for example, editing the owner’s membership), or a usage report is asked for with a bad date, an unknown time zone, a range over 366 days, or a grouping the scope does not allow. |
| 400 | terms_required |
Creating a school without acceptTerms: true. |
| 400 | not_org_member |
Adding someone to a workspace who is not an active member of its organization. |
| 400 | token_workspace_mismatch |
A workspace-bound API token used with a header naming another workspace. |
| 401 | unauthorized |
No valid credentials. |
| 403 | insufficient_role |
You are a member, but below the role the action needs. |
| 403 | member_suspended |
Your membership is suspended. |
| 403 | org_not_active |
The organization is pending or suspended and the action changes something (or you are not its owner and it is pending). |
| 403 | workspace_archived |
A write into an archived workspace. |
| 403 | not_a_member |
The X-Nodaro-Workspace header names a workspace you cannot select. |
| 404 | not_found |
No such organization, workspace or member — or one you are not a member of. An id route never reveals whether something you cannot see exists. |
| 409 | name_taken |
The slug you supplied is in use. |
| 409 | already_a_member |
The person is already in the workspace. |
| 409 | owner_cannot_leave |
Transfer ownership before leaving. |
| 409 | has_active_workspaces |
Archive every workspace before deleting the organization. |
| 429 | rate_limit_exceeded |
Too many organizations created recently, too many join attempts, or too many CSV usage exports (ten per minute per user). |
| 503 | billing_unavailable |
Usage reporting is not yet available on this instance (its reporting functions have not been applied). |
| 503 | audit_unavailable |
A CSV usage export could not be recorded in the audit log, so it was refused; try again. |
| 400 | join_code_invalid |
No such code, the code is disabled, or its workspace is archived — one answer for all three, so a code cannot be used to learn what exists. |
| 400 | invitation_expired |
The invitation is past its 14 days. |
| 400 | invitation_revoked |
The invitation was revoked. |
| 400 | invitation_accepted |
The invitation was already accepted. |
| 400 | email_mismatch |
The signed-in account’s email is not the invited one. |
| 403 | domain_not_allowed |
The organization only admits listed email domains. |
| 404 | invitation_not_found |
No invitation for that token — including one whose organization is not active. |
| 429 | bulk_invite_cap_exceeded |
The organization has reached its daily invitation limit. |
A first organization, end to end
# 1. Create a school (pending until approved)
curl -X POST https://app.nodaro.ai/v1/orgs \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Sunrise School","kind":"school","acceptTerms":true}'
# → 201 { "data": { "id": "…", "slug": "sunrise-school", "status": "pending", "role": "owner", … } }
# 2. Once active: open a class
curl -X POST https://app.nodaro.ai/v1/orgs/$ORG/workspaces \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Class 1"}'
# → 201 { "data": { "id": "…", "slug": "class-1", "role": "admin", … } }
# 3. Put a member of the organization into the class
curl -X POST https://app.nodaro.ai/v1/workspaces/$WS/members \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"userId":"…","role":"member"}'
# 4. Work inside the class
curl https://app.nodaro.ai/v1/workflows \
-H "Authorization: Bearer $TOKEN" -H "X-Nodaro-Workspace: $WS"
# 5. Invite a teacher into the class
curl -X POST https://app.nodaro.ai/v1/orgs/$ORG/invitations -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"emails":["teacher@school.example"],"workspaceId":"'$WS'","workspaceRole":"admin"}'
# → 201 { "data": [{ "email": "teacher@school.example", "status": "sent" }] }
# 6. Or hand the class a code instead
curl -X POST https://app.nodaro.ai/v1/workspaces/$WS/join-code -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"action":"enable"}'
# → 200 { "data": { "code": "BCDFGHJK", "enabled": true, ... } }
From the SDK, the CLI, and MCP
The same endpoints, three ways.
SDK — client.organizations and
client.workspaces. Choose where a request acts with
createClient({ workspaceId }) or client.withWorkspace(id), which returns a
NEW client rather than mutating a shared one, so two concurrent operations
cannot race over which workspace they are in:
const classroom = nodaro.withWorkspace(workspaceId)
await classroom.workflows.run(workflowId) // lands in the class
await nodaro.workflows.run(workflowId) // lands in the personal space
client.me() carries the organizations block. Keep its three states apart:
the fields absent means the instance has no organizations, present and
empty means the account belongs to none, and organizationsUnavailable
means the lookup failed — in which case keep whatever selection you had rather
than concluding the person was removed from everything.
client.organizations.usage / usageRows / usageCsv and the matching
client.workspaces methods return a usage report, its individual runs, or the
same report as CSV text, for a date range.
CLI — nodaro org and nodaro workspace. Three ways to say
which workspace a command acts in, each beating the one below:
--workspace <id> for one command, NODARO_WORKSPACE for a shell or a CI
job, and nodaro workspace use <id> saved on the profile.
nodaro workspace current reports which of the three decided.
nodaro org usage and nodaro workspace usage print the report as a table,
--json as JSON, and --csv writes the CSV to stdout.
MCP — list_workspaces and select_workspace. An MCP
client has no switcher and no header it controls, so the same two questions
become tools. The selection is remembered across sessions and re-validated
at every one: a preference is written once and read for months, and
membership can end in between. There is no usage tool yet — usage is read
through the SDK, the CLI, or the API.
Notes for integrators
Invitations may come back as links. POST /v1/orgs/:id/invitations
returns one row per address, and a row whose status is not sent carries a
link instead — an install with no mail provider, or a delivery that failed.
Surface it. The invitation exists either way, and without the link nobody can
reach it.
Third-party app tokens. workspaces:read and workspaces:write exist
and are required by the MCP workspace tools; request them if your app uses
those. The organization REST endpoints are not scope-gated yet and are
reachable with a first-party session — that will change, and further scopes
will be added alongside the checks that enforce them, so do not build on a
token continuing to reach these endpoints without asking for anything.
A pending organization grants nothing. Where approval is required, a newly
created organization is pending until a platform administrator approves it.
Say so to whoever created it, or waiting is indistinguishable from broken.
Model policy and assignments are documented as they become available.