Noma Cloud Guide
Noma Cloud is the hosted workspace for shared Noma documents: research papers, books, documentation spaces, live rendered artifacts, permissioned editing, hybrid retrieval with exact citations, knowledge health, scoped agents, proofed patch review, connected sources, offline recovery, published reader sites, and a queryable SQLite-backed API.
What Noma Cloud is for
Use Noma Cloud when a .noma document should move beyond a local HTML file:
- collaborate on a paper, research memo, book, or documentation space
- keep multiple pages inside one workspace
- give viewers and editors different access
- share stable page, artifact, and published-site links
- let an agent patch a named block without rewriting the whole source
- discuss and approve changes at a stable block or saved document version
- manage delivery work with projects, issues, boards, backlog, and sprints in the same space
- expose a permission-aware query API for future Codex plugins and automation
The source remains plain Noma. The cloud layer adds persistence, users, permissions, share links, site membership, rendered artifacts, and the database index around that source.
Technical preview boundaries
The v0.17 Cloud release is a self-hosted technical preview, not a managed multi-tenant SaaS or a completed enterprise integration suite:
- Ask Noma uses deterministic local hybrid retrieval and extractive source
summaries. It does not call a hosted LLM or claim generative-answer quality.
- Connector endpoints persist permission, URL, hash, timestamp, and tombstone
lineage. OAuth clients, provider polling, and background synchronization workers are deployment-specific follow-up work.
- Recipe runs produce reviewable plans with a
proof_proposal_onlypolicy.
There is no built-in scheduler or autonomous worker loop.
- OIDC/SAML and SCIM routes are trusted-proxy and provisioning contracts. The
reverse proxy or identity gateway performs protocol validation before sending a shared-secret assertion to Noma.
- Realtime collaboration is an ordered, pollable operation feed with atomic
hash preconditions. It is not a WebSocket presence, cursor, or CRDT system.
- Retention and legal-hold cleanup currently cover platform metadata records.
Canonical documents, immutable revisions, and SQLite backup retention remain explicit operator responsibilities.
These boundaries keep launch claims aligned with the implementation while the retrieval, proof, permission, and source-portability contracts are evaluated with real teams.

Run it locally
From a checkout:
npm install
npm run build:cloud
PORT=3000 npm start
open http://localhost:3000/cloud.html
The server stores runtime state in SQLite. By default it writes under the local cloud data directory configured by the server. In production, set a stable data directory or mount /data/noma so users, documents, sites, permissions, share links, and the block index survive container replacement.
Check the server:
curl http://localhost:3000/healthz
curl http://localhost:3000/api/status
Deploy with EZKeel from this repo:
npm run deploy:ezkeel:dry-run
npm run deploy:ezkeel
The EZKeel deployment uses Dockerfile, ezkeel.yaml, npm run build:cloud, node dist/cloud-server.js, and /data/noma as the storage root.
Attachment blobs live under <storage root>/blobs by default. To keep them in object storage instead, set NOMA_CLOUD_BLOB_STORE=s3 (see S3-compatible attachment storage).
Protect the cloud app
Production deployments must provide a global token gate for the cloud app and cloud APIs plus a separate invitation code for user registration:
NOMA_CLOUD_ACCESS_TOKEN_FILE=/data/noma/access-token \
NOMA_CLOUD_INVITATION_CODE_FILE=/data/noma/invitation-code \
NOMA_CLOUD_SSO_TRUST_SECRET=<reverse-proxy-to-Noma-shared-secret> \
npm start
Noma Cloud refuses to start with NODE_ENV=production when either secret is missing. A deliberately public deployment can opt out explicitly with NOMA_CLOUD_ALLOW_OPEN_ACCESS=1 and/or NOMA_CLOUD_ALLOW_OPEN_REGISTRATION=1. API requests are rate-limited by client address; deployments behind a trusted reverse proxy should set NOMA_CLOUD_TRUST_PROXY=1. NOMA_CLOUD_RATE_LIMIT_MAX, NOMA_CLOUD_AUTH_RATE_LIMIT_MAX, and NOMA_CLOUD_RATE_LIMIT_WINDOW_MS tune the defaults. Every request that carries ?access= -- including app-shell and static paths, not only /api/* -- counts against the stricter auth limiter, so the gate token cannot be brute-forced through /cloud?access=<guess>.
Set NOMA_CLOUD_ADMIN_USER_IDS (comma-separated user IDs) in production. With NODE_ENV=production and no allowlist, the server still starts but every /api/enterprise route returns 403 with code: "admin_not_configured" (fail closed). Outside production the first user ever registered is the bootstrap workspace admin.
Open the login page:
https://noma-cloud.apps.ezkeel.com/login.html
The login form validates the cloud access token, sets an HttpOnly cookie, and can exchange an existing Noma user token or personal access token for a browser session. New user registration is blocked unless the registration form includes the invitation code.
Browser sessions and CSRF
The web app never stores a raw token in localStorage. Login (POST /api/auth/session with userToken), registration (POST /api/auth/register), trusted SSO (POST /api/auth/sso), and OpenID Connect (GET /api/auth/oidc/callback) create a server-side session and set two cookies:
noma_session--HttpOnly; SameSite=Lax; Path=/; Max-Age=30 days, plus
Secure everywhere except localhost/127.0.0.1 over plain HTTP. Only the SHA-256 of the cookie is stored, with user, scopes, created, last-seen, expiry, user agent, and client address.
noma_csrf-- a readable double-submit token. Every cookie-authenticated
POST, PUT, PATCH, or DELETE must echo it in the X-Noma-CSRF header; the server compares it with the hash bound to the session and otherwise returns 403 with code: "csrf_required". GET /api/auth/session also returns the token (and re-issues one when the cookie was lost).
Bearer tokens (Authorization: Bearer ... or X-Noma-User-Token) keep working for API clients and agents and need no CSRF header; when both are present the bearer token wins. A browser that still has a token from an earlier build exchanges it for a session on first load and deletes it from storage.
GET /api/auth/session current principal, scopes, CSRF token
GET /api/auth/sessions own active sessions (?limit=&offset=)
DELETE /api/auth/sessions/<id> revoke one of your sessions
POST /api/auth/logout revoke the current session, clear cookies
OpenID Connect login
Noma Cloud is a native OpenID Connect relying party. Point it at any standards-compliant IdP (Okta, Entra ID, Google Workspace, Auth0, Keycloak, Authentik, ...) and the login page and the app's top bar show a Sign in with <label> button. There are no extra dependencies: discovery, JWKS, PKCE and ID-token checks use node:crypto and fetch.
NOMA_CLOUD_OIDC_ISSUER=https://id.example.com \
NOMA_CLOUD_OIDC_CLIENT_ID=noma-cloud \
NOMA_CLOUD_OIDC_CLIENT_SECRET_FILE=/data/noma/oidc-client-secret \
NOMA_CLOUD_OIDC_REDIRECT_URL=https://wiki.example.com/api/auth/oidc/callback \
NOMA_CLOUD_OIDC_LABEL="Example ID" \
npm start
| Variable | Meaning |
|---|---|
NOMA_CLOUD_OIDC_ISSUER | Issuer URL; discovery is read from <issuer>/.well-known/openid-configuration and its issuer must match |
NOMA_CLOUD_OIDC_CLIENT_ID | Client ID registered at the IdP |
NOMA_CLOUD_OIDC_CLIENT_SECRET / _FILE | Client secret, inline or from a file (set one, not both) |
NOMA_CLOUD_OIDC_REDIRECT_URL | Registered callback; defaults to NOMA_CLOUD_PUBLIC_URL + /api/auth/oidc/callback |
NOMA_CLOUD_OIDC_SCOPES | Space- or comma-separated scopes (default openid email profile; openid is always added) |
NOMA_CLOUD_OIDC_ALLOWED_DOMAINS | Comma-separated email domains; when set, only a verified email in one of them may sign in |
NOMA_CLOUD_OIDC_AUTO_PROVISION | 1 creates a Noma user on first login (default off: unknown identities get 403) |
NOMA_CLOUD_OIDC_LINK_BY_EMAIL | 1 links a first login to an existing user by verified email (default off, because Noma profile emails are self-asserted) |
NOMA_CLOUD_OIDC_REQUIRED_GROUP | Group that must appear in the groups claim |
NOMA_CLOUD_OIDC_GROUPS_CLAIM | Claim that carries groups (default groups) |
NOMA_CLOUD_OIDC_LABEL | Button label (default SSO) |
NOMA_CLOUD_OIDC_TOKEN_AUTH_METHOD | client_secret_basic or client_secret_post; default picks from discovery, preferring basic |
Setting any NOMA_CLOUD_OIDC_* variable without issuer, client ID, client secret, and a redirect URL stops the server at startup. The issuer, redirect URL, and discovered endpoints must be https (plain http only on loopback hosts).
GET /api/auth/providers sign-in methods for the login screens
GET /api/auth/oidc/start?returnTo=/... sets the flow cookie, redirects to the IdP
GET /api/auth/oidc/callback IdP redirect target; opens the session
The flow. /start creates state, nonce, and a PKCE verifier (S256) and seals them, with the return path, into noma_oidc_flow: an AES-256-GCM encrypted cookie (HttpOnly; SameSite=Lax; Path=/api/auth/oidc; Max-Age=600, Secure off loopback). /callback clears that cookie, checks state in constant time, exchanges the code at the token endpoint with the verifier, and verifies the ID token: only RS256/384/512, PS256/384/512, and ES256/384/512 signatures from the IdP JWKS are accepted (alg: none and HS* are refused), then iss, aud (and azp when there are several audiences), exp, iat, nbf with 60 seconds of clock skew, and nonce. Discovery and the JWKS are cached for an hour; a token signed with an unknown kid refetches the JWKS at most once a minute, so key rotation needs no restart. IdP calls time out after 10 seconds and responses are size-capped. The callback counts against the auth rate limiter like every /api/auth/* route.
Account mapping. The first match wins:
- an existing binding of this issuer +
subto a Noma user; - an active SCIM identity whose
externalIdequalssub; - with
NOMA_CLOUD_OIDC_LINK_BY_EMAIL=1, exactly one user whose email
equals the ID token's email, only when email_verified is true and that user is not already bound to a different subject at this issuer (turn this on to migrate token users, and only where you trust who set those emails);
- a new user (name from
name,preferred_username, or the email), only
with NOMA_CLOUD_OIDC_AUTO_PROVISION=1.
The match is stored as an (issuer, subject) binding, so later logins keep working when the email changes. The domain allowlist and required group are checked on every login. A user whose SCIM identities are all inactive is refused as deprovisioned. On success the callback opens the same noma_session + noma_csrf cookie session as every other login (source oidc, listed under GET /api/auth/sessions) and redirects to returnTo. Only same-origin paths are honoured; absolute, protocol-relative, backslash, and /api/ targets fall back to /cloud.html. An OIDC session also passes the cloud access gate, so SSO users never need the gate token. Failures render a short error page with the HTTP status (400 state/flow, 401 token, 403 policy, 502 IdP).
Enforced SSO. When workspace policy enforces SSO (/api/enterprise, sso.enforced), token login and self-registration return 403, and OIDC login satisfies the policy just like the trusted-header route. Enforcement can be switched on once either OIDC or NOMA_CLOUD_SSO_TRUST_SECRET is configured. With sso.provider: "oidc" and an sso.issuer, the configured issuer must match. With SCIM also enabled, first-login provisioning is off and every OIDC login needs an active SCIM identity for the user.
SAML is not native. Put a SAML-capable proxy (for example an IdP gateway or mod_auth_mellon) in front of Noma Cloud and use the trusted-header route: the proxy authenticates the user and calls POST /api/auth/sso with X-Noma-SSO-Trust-Secret and the SCIM externalId.
Personal access tokens
Scripts and agents should use named personal access tokens instead of the legacy per-user token. A token is shown once at creation, starts with noma_pat_, and is stored only as a hash with a short preview.
POST /api/tokens {"name", "scopes": ["read"|"write"|"admin"], "expiresInDays"?: 1-365}
GET /api/tokens own tokens with preview, scopes, expiry, lastUsedAt, active
DELETE /api/tokens/<id> revoke; sessions opened with the token end too
POST /api/users/me/rotate-token replace the legacy token; other sessions are revoked
read is implied by every token. A token without write gets 403 (code: "insufficient_scope") on every mutating method except the read-only POST /api/db/query; /api/enterprise additionally needs admin. A token can only mint tokens within its own scopes, and rotating the legacy token needs admin. Expired or revoked tokens return 401 with token_expired or token_revoked. Signing in with a personal access token opens a session with the same scopes that ends no later than the token. Each user may hold 50 active tokens. The legacy token returned at registration still works with full scope, but it is deprecated for automation.
In the app, the header Security button opens a dialog with both lists. It shows your own tokens with name, preview, scopes, creation date, expiry, and last use, and a Revoke button for each. A form creates a token with a name, scopes from those your session holds (read is always on), and an expiry of 7, 30, 90, or 365 days, or none. The new secret is shown once with a Copy token button and is cleared when the dialog closes. The same dialog lists your signed-in browser sessions with how they started, when, last activity, user agent, and IP. The current browser is marked and cannot be revoked there (use Sign Out). Other sessions can be revoked one at a time or all at once with Sign out other sessions.
API clients must send the gate token separately from the normal Noma user token:
curl -H "X-Noma-Cloud-Access-Token: $NOMA_CLOUD_ACCESS_TOKEN" \
-H "Authorization: Bearer $NOMA_TOKEN" \
https://noma-cloud.apps.ezkeel.com/api/status
When the gate is enabled, cloud.html, the cloud editor assets, and /api/* return 401 without the access token. Browser visits to cloud.html redirect to login.html. Published document and site artifacts still require their own share tokens.
First workspace
After login, Noma Cloud uses a browser-stored Noma user token as the editing identity for API calls and UI permissions.
- Pass the deployment gate in
login.htmlwhen the server has a global access
token.
- In the Cloud header, enter a name and invitation code, then choose
Register. On deliberately open development deployments the invitation field can stay empty.
- To resume an existing identity, paste its user token in Token and choose
Log In. Sign Out clears that browser session without deleting data.
- Use Copy User ID when another owner needs to invite you.
- Use Security to create personal access tokens for your own API calls or
plugin development, and to review or revoke signed-in sessions.
- Registration creates a starter workspace and paper page automatically.
Choose New Space when you need another research, book, or docs space.
- Use New Page to add another paper section, chapter, or reference page,
then click pages in the left rail to switch documents.
The default first page is paper-oriented: abstract, research question, claim, evidence, methods, review table, findings, citation, bibliography, and review task. It is only a starter. Replace it with the structure your team needs.
Find, favorite, import, and recover content
The left rail provides workspace navigation beyond the page tree:
| Control | Behavior |
|---|---|
| Search | Hybrid lexical, semantic (local hash vector or a configured embedding model), typed-block, graph, trust, and freshness search over visible source blocks. Results preserve the exact source span, version hash, and access decision and open the matching page and source line. |
| Page template | Starts a new page from a built-in template (blank, meeting-notes, decision-record, project-overview, technical-spec, research-paper), a workspace template, or a template of the current space. Blueprints with variables open a form first. Manage lists every template and lets you edit or delete the ones you manage. |
| Draft with AI | Drafts a new page in the current space from a title and instructions. The draft becomes a page proposal under Agent Review. See generative-ai-on-the-trust-loop. |
| Import | Uploads .noma, .md, .markdown, or plain text into the current space. Markdown intake pins stable heading IDs. |
| Confluence | Imports a whole Confluence space into the current space. See wiki-macros-templates-import-export. |
| Favorite | Adds the current page to a per-user Favorites list. Spaces can be favorited from their context menu. |
| Recent | Tracks the pages and spaces opened by the current user. |
| Trash | Lists pages and spaces moved out of active navigation and restores them without losing source or document history. |
Search uses SQLite FTS5 plus deterministic local embeddings, typed metadata, wiki/trust edges, verification, and freshness. It always intersects results with the caller's page or space permissions. Trashed content is excluded from active lists, search, published spaces, and database queries. Moving a page to trash preserves its space membership so restoration returns it to the same workspace and folder.
Agents and integrations use the same lifecycle APIs:
GET /api/search?q=<text>&site=<optional-site-id>&limit=25
GET /api/knowledge/search?q=<text>&site=<optional-site-id>&limit=25
GET /api/navigation
POST /api/navigation/recent
PUT /api/navigation/favorites
DELETE /api/navigation/favorites
GET /api/templates
GET /api/trash
POST /api/trash/document/<id>
POST /api/trash/document/<id>/restore
POST /api/trash/site/<id>
POST /api/trash/site/<id>/restore
When creating a page through /api/documents or /api/sites/<site-id>/documents, send templateId instead of source, or send format: "markdown" with Markdown source for server-side intake.
Wiki macros, templates, import, and export
Wiki macros
Pages can pull in live content with wiki macros. They are directives, so the source stays reviewable and agents can patch around them by block ID:
| Macro | What it shows |
|---|---|
::include{page="Title or id" block="block-id"} | One block of another page (or of this page, without page=). Without block= it includes the whole page. |
::include{page="..." excerpt} | The other page's ::excerpt. |
::excerpt | Marks this page's summary. The page tree and search results show it as summary. |
::children{depth=2 sort="title"} | Child pages from the space tree, with links and summaries. sort is position, title, or updated. |
::issue{key="ENG-12"} | A live card for a tracker issue: status, assignee, summary. |
::issues{project="ENG" status="in_progress"} | A table of matching issues. |
::page-properties / ::page-properties-report{label="adr"} | A key/value table, and a report of the properties of every page with that label in the space. |
Macros resolve when a page is rendered (/d/<id>, /s/<id>, /api/documents/<id>/html and /llm, exports) and in the editor preview. Every lookup is checked against the viewer. A page the viewer cannot open renders a "You do not have access" placeholder and nothing from it. A trashed or unknown page renders "not found". Includes nest at most three levels, and an include cycle renders a placeholder instead of recursing. At most 200 lookups run per render.
The editor preview renders unsaved source in the browser, so it asks the server in one batch:
POST /api/macros/resolve
{ "documentId": "<page id>", "requests": [
{ "kind": "include", "page": "Handbook", "block": "policy" },
{ "kind": "children", "documentId": "<page id>", "depth": 1, "sort": "title" },
{ "kind": "issue", "key": "ENG-12" },
{ "kind": "issues", "project": "ENG", "status": "todo", "limit": 20 },
{ "kind": "page-properties-report", "label": "adr" } ] }
The response has one result per request (status is ok, missing, forbidden, or unavailable). Included blocks come back as AST nodes with their IDs and line positions removed. At most 50 requests are answered per call, and every documentId / fromDocumentId must be a page the caller can view.
Templates and blueprints
Besides the built-in templates, a workspace can hold workspace templates (managed by workspace admins) and space templates (managed by editors of that space). A template is .noma source with {{variable}} placeholders. It can use title, title_id, space, date, and author without declaring them. Any other variable must be declared with a name, a label, an optional default, and required. A template that uses an undeclared placeholder is rejected.
GET /api/templates?site=<optional-site-id> built-ins + workspace + that space's templates
GET /api/templates/<id>
POST /api/templates { scope: "workspace"|"site", siteId?, name, description?, category?, source | fromDocumentId, variables? }
PUT /api/templates/<id>
DELETE /api/templates/<id>
fromDocumentId saves an existing page as a template ("Save as template" in the page toolbar). The page's first heading becomes # {{title}} {id="{{title_id}}"}, so each new page gets its own title and ID. To create a page from a blueprint, send templateId and variables to POST /api/sites/<id>/documents. Space templates can only be used inside their own space. Missing required variables return 400 with a missing list.
In the app, Manage next to the page template picker lists built-in, workspace, and space templates. Templates you can manage (workspace templates for workspace owners, space templates for that space's editors) have Edit and Delete buttons. Edit changes the name, description, category, source, and declared variables (name, label, default, required). Server validation errors, such as an undeclared placeholder, are shown in the dialog. Built-in templates are read-only.
Values are filled in so they cannot change the page's structure. Values become one line. Inside attributes, quotes and braces are neutralised. In YAML frontmatter, values are quoted. If a value would turn a line into a heading, list, directive, fence, or attribute block, a zero-width space is placed in front of it so it stays plain text.
Import from Confluence
POST /api/import/confluence imports a whole Confluence space into an existing Noma space. The caller needs editor access to that space. The request returns 202 with a job, and the import runs in the background:
POST /api/import/confluence { siteId, deployment: "cloud", baseUrl, email, apiToken, spaceKey, overwrite? }
POST /api/import/confluence { siteId, deployment: "datacenter", baseUrl, pat, spaceKey, overwrite? }
POST /api/import/confluence?site=<id>&overwrite=false (body: XML space export .zip, or entities.xml)
POST /api/import/confluence { siteId, archiveBase64 } | { siteId, entitiesXml } | { siteId, bundle }
GET /api/import/jobs/<job-id>
- Live import reads every current page of the space through the Confluence
Cloud v2 REST API (email + API token) or the Data Center REST API (personal access token), following pagination. Credentials are used for that one job and never stored or returned. The target must be https and resolve to a public address. Each request resolves the host once, checks every answer against the private-address guard, and pins the connection to the checked address, so a DNS answer that changes after the check (DNS rebinding) cannot reach an internal host. TLS still verifies the certificate for the hostname. Redirects are refused. Set NOMA_CLOUD_IMPORT_ALLOW_PRIVATE_HOSTS=1 only for a self-hosted Data Center on a private network.
- XML space export accepts the ZIP from *Space settings → Export space →
XML*, or its entities.xml. Only current pages are imported: historical versions, drafts, and deleted pages are skipped. XML entities are never expanded. An HTML export is rejected with a hint to export XML instead. Uploads are capped by NOMA_CLOUD_IMPORT_MAX_BYTES (default 50 MB).
- JSON bundle (
format: "noma-confluence-bundle") takes pages withid,
title, parentId, storage (storage-format XHTML), labels, and optional attachments: [{ filename, dataBase64, id?, mediaType? }].
Storage format becomes .noma: headings (shifted one level under the page title, with explicit IDs), paragraphs, lists, task lists, tables, code and noformat blocks, info/note/warning/tip/panel callouts, expand sections, status lozenges, page links (as [[wikilinks]]), images (as ::figure), and the excerpt, include, excerpt-include, children, Jira-issue, page-properties, and page-properties-report macros (as the Noma macros above). Other macros are kept as readable text in a ::confluence_macro block and counted in the job's loss report.
Attachments are copied into Noma Cloud as page attachments:
| Source | Where the bytes come from |
|---|---|
| Live import | /rest/api/content/<pageId>/child/attachment (paginated), downloaded from each file's _links.download with the job's credentials and the same pinned, redirect-refusing transport as page fetches |
| XML export ZIP | attachments/<pageId>/<attachmentId>/<version> inside the archive, matched through the Attachment objects in entities.xml (current versions only) |
entities.xml alone | none; references keep their links and the result says to upload the whole ZIP |
| JSON bundle | base64 attachments on each page |
Copied files go through the same checks as uploads: the per-file limit (NOMA_CLOUD_MAX_ATTACHMENT_BYTES), magic-byte sniffing with executables refused, and the space's attachment quota (NOMA_CLOUD_ATTACHMENT_QUOTA_BYTES). One import reads at most NOMA_CLOUD_IMPORT_MAX_BYTES of attachment bytes. Images (ri:attachment in ac:image) become ::figure{src="att:<id>"} and attachment links become [label](att:<id>), so they resolve to signed URLs like any other page attachment. Files that cannot be copied keep their original Confluence download URL (or attachments/<pageId>/<file> for file exports). The job result's attachments block reports referenced, copied, reused, skipped, and bytesCopied, plus skippedDetails with each skipped file and why (too large, over the import budget or quota, executable, download failed, not in the source). Re-importing is idempotent for attachments too: a file already on the page with the same name and content hash is reused, not stored again, and blobs are content-addressed.
Each page keeps its Confluence ID, space, URL, author, dates, and version in frontmatter (source: confluence). The parent/child hierarchy becomes the space's page tree, and Confluence labels become page labels. Re-importing is idempotent: pages are matched by Confluence page ID. Unchanged pages are left alone, and changed pages get a new revision. A page edited in Noma since the last import is skipped unless overwrite: true. The job status reports total, processed, created, updated, unchanged, skipped, failed, attachmentsCopied, and attachmentsSkipped counts, with a per-page result list. Only the job's creator or an editor of the space can read it. Only one import per space runs at a time. Jobs interrupted by a server restart are marked failed.
Import from Notion
POST /api/import/notion imports a Notion workspace (or any part of it) into an existing Noma space, with the same job model as the Confluence import: editor access to the space, a 202 job, GET /api/import/jobs/<job-id> polling, and one import per space at a time.
POST /api/import/notion?site=<id>&overwrite=false (body: Notion "Markdown & CSV" export .zip)
POST /api/import/notion { siteId, archiveBase64, overwrite? }
POST /api/import/notion { siteId, bundle, overwrite? }
- Export ZIP is the file from Settings → Export → Markdown & CSV (include
subpages and files). Pages are Title <id>.md, child pages live in the folder Title <id>/, and databases are Name <id>.csv (the _all.csv variant wins when both exist) with their row pages in Name <id>/. Large exports that wrap Part-N.zip files are unpacked one level deep. The upload is capped by NOMA_CLOUD_IMPORT_MAX_BYTES, the archive by entry count and inflated size, and the import by 2,000 pages. Entries with absolute or .. paths are skipped and listed in the result.
- JSON bundle (
format: "noma-notion-bundle", optional) is for pipelines
that read the Notion API block tree themselves: { workspace?, pages: [{ id,
title, parentId?, markdown, properties?, url?, database?: { columns, rows }
}] }. markdown uses Notion's export dialect. Links to notion.so URLs or bare page IDs become wikilinks. Bundles carry no files.
Each page becomes one .noma document with source: notion frontmatter (the Notion ID and URL). Folders become the page tree. Links to other imported pages (relative .md/.csv paths or notion.so URLs) become [[Title]] wikilinks. Images and files in the export are stored as page attachments. A standalone image becomes ::figure{src="att:<file>"} and other files become [name](att:<file>) links. Attachment size and space quota limits apply, and a file that is too large or executable is skipped and listed in the result. Notion callouts (<aside>) become ::callout blocks. A database becomes a page with a readable pipe table (row titles link to their row pages) plus a ::dataset{format="csv"} copy that agents and plots can use. A row page's properties (the Key: value lines under its title) become a ::page-properties block, so ::page-properties-report can list them. A Tags or Labels property becomes page labels. Literal :: lines and [[...]] text from Notion are neutralised with a zero-width space so they stay plain text.
The job's loss report counts what did not carry over exactly: raw HTML (html), links to pages or files missing from the export (broken-link, missing-file), titles that cannot be a wikilink target (unlinkable-title), duplicate titles, and oversized files. Re-importing matches pages by Notion ID. It follows the same unchanged / updated / skipped-unless-overwrite rules as the Confluence import. Attachments whose name and bytes are unchanged are kept, and changed files replace the old attachment. Wikilinks resolve by title, so two imported pages with the same title link to whichever the space finds first.
The CLI converts the same exports offline: noma ingest export.zip --from notion --out wiki/ writes one .noma file per page in a folder tree, files under <page>.files/, and a notion-import-report.json with the loss report.
Export
GET /api/documents/<id>/export?to= downloads one page as pdf, docx, markdown, html, noma, llm, or json. Macros are resolved for the viewer. HTML and PDF render with escape hatches and external assets off. PDF export needs Puppeteer and its Chrome build on the server. Without them it returns 501 with code: "pdf_unavailable". At most two PDF exports run at once.
GET /api/sites/<id>/export?to=site-zip downloads the space as a static HTML site: an index.html page tree plus one page per document, with child-page links rewritten to the exported files. ?to=noma-zip downloads the .noma sources, a manifest.json (IDs, titles, parents, folders, labels, hashes), and a book.noma.yml manifest, so noma render book.noma.yml --to site works offline. The page toolbar's Export menu offers both.
Page tree, labels, watching, and version diffs
Spaces organise pages as a tree, the way wiki users expect:
| Control | Behavior |
|---|---|
| Page tree | Pages nest under parent pages. The rail indents children beneath their parent; the page header shows breadcrumbs back to the space. Right-click a page for Add child page or Move under page.... |
| Labels | Lowercase, hyphenated tags on a page (how-to, onboarding). Editors add and remove them from the page header; everyone with access can list pages by label. |
| Watch | Creators and editors automatically watch the pages they touch. Watchers of a page, or of its whole space, get a page_updated notification whenever someone else changes it. |
| Diff | Each saved version in History has a Diff button that shows the line diff against the previous version plus the stable block IDs that were added, removed, or changed. |
| Delete forever | Owners can permanently purge a page or space that is already in the trash, unless a legal hold covers it. |
The tree is stored per space as a pageParents map (child page → parent page). Parents must be pages in the same space and cycles are rejected. When a parent page is trashed its children move up to the nearest visible ancestor; restoring the parent puts them back.
GET /api/sites/<site-id>/tree
GET /api/sites/<site-id>/documents/<page-id>/breadcrumbs
PUT /api/sites/<site-id>/documents/<page-id>/parent {"parentId": "<id>|null", "position": 0}
POST /api/sites/<site-id>/documents {"title": "...", "parentId": "<id>"}
GET /api/documents/<id>/labels
PUT /api/documents/<id>/labels {"labels": ["how-to", "onboarding"]}
POST /api/documents/<id>/labels {"label": "how-to"}
DELETE /api/documents/<id>/labels/<label>
GET /api/labels?site=<optional-site-id>
GET /api/labels/<label>?site=<optional-site-id>
GET /api/documents/<id>/watch
PUT /api/documents/<id>/watch
DELETE /api/documents/<id>/watch
PUT /api/sites/<site-id>/watch
GET /api/documents/<id>/revisions/<n>/diff?against=<m>
DELETE /api/trash/<document|site>/<id>
Search filters and query syntax
Both search endpoints accept a small query language inside q, and the same filters as query parameters. Filters narrow the permitted corpus before ranking, so results never widen beyond the caller's access.
| Filter | In q | Parameter | Matches |
|---|---|---|---|
| Label | label:how-to | label=how-to | Pages carrying the label; repeat for AND. |
| Author | author:@ada, author:me | author=<name or user ID> | Pages created or updated by the user, including any saved revision. |
| Space | space:ENG | space=<key, ID, slug, or title> | Pages in that space. |
| Updated | after:2026-01-01, before:2026-07-01 | updatedAfter=, updatedBefore= | Last update on or after / strictly before the instant. |
| Type | type:page, type:claim, type:section | type= | page keeps the best block per page; anything else matches a block type or directive name. |
| Phrase | "exact phrase" | The words must appear together, in order. |
A query made only of filters (for example label:how-to space:ENG) lists the matching pages, most recently updated first. Unknown key:value tokens are searched as plain text. /api/knowledge/search returns mode: "filter" for filter-only queries and echoes the interpreted filters as filters so clients can render chips. The Cloud search panel has type, date, label, and author dropdowns plus removable chips for filters typed into the box.
GET /api/search?q=deploy+label:how-to+author:@ada+"blue green"
GET /api/knowledge/search?q=rollback&space=ENG&type=decision&updatedAfter=2026-06-01
Mentions
Type @ in a comment box or in the source editor to open the mention picker. It searches only people who share at least one space with you, so the picker never exposes the whole user directory. Choosing someone inserts the stable source form @{user-id}; comments and the paper preview display it as @Name, while the source keeps the ID so renames never break a mention.
- A mention in a comment notifies the mentioned user if they can open the page.
- A mention in page source notifies on save, but only for mentions that were
not already in the previous version, and only for users who can open the page. Mentions inside fenced code blocks are ignored.
- Comment responses carry a
mentionsarray of{id, name}for display.
GET /api/users?q=<name prefix>&document=<optional page id>&limit=10
GET /api/users?ids=<id,id,...>&document=<optional page id>
q returns {id, name} for co-members of your spaces (plus yourself); document= narrows the list to people who can open that page. ids= resolves display names for mentions and returns only people you share a space with, or people who can open the given page.
Space keys, home pages, and archiving
Every new space gets a unique uppercase key (2–10 letters or digits, starting with a letter). Pass key when creating a space, or Noma derives one from the title (Engineering Handbook → EH, then EH2). Keys are unique across the workspace; a clash returns 409 space_key_taken. Only space owners can change a key. Search accepts keys in space:ENG.
| Setting | Behavior |
|---|---|
description | Up to 2,000 characters, shown under the title on the published space. |
icon | An emoji or up to 8 plain characters shown beside the title. |
homeDocumentId | A page in the space. The app opens it when you enter the space, and /s/<id> renders it first. |
| Archive | Owners archive a space to make it read-only. Archived spaces are hidden from GET /api/sites unless ?archived=include (or only) and their pages are left out of search unless the query says archived:include or names the space. Every write to the space or to a page that lives only in archived spaces returns 409 space_archived; reading, watching, and unarchiving still work. |
The Space settings panel edits these fields; the Spaces rail has an Archived toggle to list archived spaces.
POST /api/sites {"title": "Engineering", "key": "ENG", "description": "...", "icon": "🛠️", "documentIds": []}
PUT /api/sites/<site-id> {"key": "BUILD", "description": "...", "icon": null, "homeDocumentId": "<page-id>"}
GET /api/sites?archived=exclude|include|only
POST /api/sites/<site-id>/archive
POST /api/sites/<site-id>/unarchive
GET /s/<site-id>
Page analytics
The app sends a view beacon when a page opens, and opening a rendered page at /d/<id> counts as a view too. Views are deduplicated per viewer per page for 30 minutes. Share-link views are anonymous: they are keyed by a hash of the link and client address, and never linked to a user. Views older than about 400 days are pruned.
The page header shows N views (last 30 days). Clicking it shows views per day and, for page editors and owners only, who viewed the page. The rail lists the most viewed pages of the current space.
POST /api/documents/<id>/views
GET /api/documents/<id>/analytics?days=30
GET /api/sites/<site-id>/documents/<page-id>/analytics?days=30
GET /api/sites/<site-id>/popular?days=30&limit=10
analytics returns totalViews, uniqueViewers (signed-in people), anonymousViews, and viewsByDay. It includes viewers (name, view count, last view) only when the caller is a signed-in editor or owner of the page. popular ranks the space's non-trashed pages by views, then unique viewers.
Reorder the page tree
Editors can drag a page in the rail and drop it on another page: the top edge drops it before that page, the bottom edge after it, and the middle makes it a child. The same moves are available without a mouse:
| Move | Keyboard (focused page) | Context menu |
|---|---|---|
| Up among siblings | Alt+↑ | Move up |
| Down among siblings | Alt+↓ | Move down |
| Indent under the page above | Alt+→ | Indent |
| Outdent next to its parent | Alt+← | Outdent |
All of these call PUT /api/sites/<site-id>/documents/<page-id>/parent with parentId and position (the index among the new siblings). A position at or past the end appends the page after the last sibling and its subpages.
Inline tasks
A top-level list item that starts with a checkbox is a tracked task:
- [ ] Draft the launch plan @{user-id} due:2026-10-01
- [x] Book the room
- The first mention is the assignee;
due:YYYY-MM-DDis the due date. - When a person saves a page (create, or
PUTthe page), every checkbox item
without an ID gets a stable marker in the source, for example - {#task-k3v9x2ab} [ ] Draft the launch plan …. The marker never changes afterwards, so the task keeps its identity when its text is edited or moved. Agent patches are never rewritten; their new tasks get IDs on the next human save.
- Every save re-indexes the page's tasks. People newly assigned a task get a
task_assigned notification if they can open the page.
- Completing a task flips only that list item's checkbox through a block-level
replace_body patch. Pass the task's blockHash as baseHash so a task that changed since you loaded it returns 409 task_changed instead of being overwritten. Editors can complete any task; a viewer can complete tasks assigned to them.
The rail's My tasks list shows your open tasks across spaces, overdue ones highlighted, with a checkbox to complete them. In the preview, task checkboxes are clickable once the page is saved.
GET /api/tasks?assignee=me|any|<user-id>&status=open|done|all&site=<id>&document=<id>&limit=100
GET /api/documents/<id>/tasks
GET /api/documents/<id>/tasks/<task-id>
POST /api/documents/<id>/tasks/<task-id> {"done": true, "baseHash": "<blockHash>"}
Task responses include title (text without the mention or due marker), status, assigneeId, dueDate, overdue, completedAt, completedBy, and blockHash. Tasks on trashed pages, and on pages that live only in archived spaces, are left out of /api/tasks.
Outbound webhooks
Space owners can send space events to other systems. Each webhook has a target URL, a list of events, a format, and a signing secret.
| Event | Sent when |
|---|---|
page.created | A page is created in the space. |
page.updated | A page's source changes, by a person or an applied agent patch. |
page.deleted | A page is moved to trash. |
comment.created | Someone comments on or replies to a page. |
label.changed | A page's labels change (labels, added, removed). |
task.completed | An inline task is checked off (task). |
Deliveries are JSON with id, event, createdAt, space, page, actor, and event data. Each request carries x-noma-event, x-noma-delivery, x-noma-timestamp, and x-noma-signature: sha256=<hex>, the HMAC-SHA256 of <timestamp>.<raw body> with the webhook secret. The secret is returned only when the webhook is created; pass your own secret (16–256 characters) or let Noma generate one. With format: "slack" the body is {"text": "..."} for a Slack incoming webhook.
Deliveries are queued in SQLite and sent by an in-process timer (NOMA_CLOUD_QUEUE_INTERVAL_MS, default 5000; 0 disables it) or by one run of npx tsx apps/worker/cloud-queue.ts. Any 2xx response is success. Other responses and network errors retry after 30s, 1m, 2m, and so on, up to 6 hours apart, for 8 attempts in total; after that the delivery is marked failed. Retries reuse the delivery ID so receivers can deduplicate.
Pages under a view restriction (their own or an inherited one) never emit webhook events, so restricted titles and IDs do not leave the workspace. Search filters, the people directory, popular pages, My tasks, mention and task notifications, and digests all apply the same view restrictions as the page itself.
SSRF protection. Webhook URLs must be http or https without credentials. URLs pointing at loopback, private (RFC 1918), link-local (including cloud metadata), CGNAT, multicast, or unique-local IPv6 addresses are rejected, and every delivery re-checks the resolved addresses before connecting, so DNS rebinding cannot reach internal hosts. Set NOMA_CLOUD_ALLOW_PRIVATE_WEBHOOKS=1 only for on-premises targets.
GET /api/sites/<site-id>/webhooks
POST /api/sites/<site-id>/webhooks {"url": "https://...", "events": ["page.updated"], "format": "json", "secret": "optional"}
GET /api/sites/<site-id>/webhooks/<webhook-id>
DELETE /api/sites/<site-id>/webhooks/<webhook-id>
GET /api/sites/<site-id>/webhooks/<webhook-id>/deliveries?limit=50
The Space settings panel has a Webhooks section for owners.
Email, notification preferences, and digests
Each person chooses, per notification type (mention, comment, approval_requested, approval_updated, page_updated, task_assigned), whether it is shown in app only (the default), in app and by email, or off (not recorded at all). A daily or weekly digest emails a summary of notifications that are still unread; nothing is sent when everything is read. The first digest arrives one full period after it is turned on.
Email needs an address on the profile. It is visible only to its owner: GET /api/users and the DB API never return it.
GET /api/users/me
PUT /api/users/me {"email": "ada@example.com", "name": "Ada"} (email null clears it)
GET /api/users/me/preferences
PUT /api/users/me/preferences {"channels": {"mention": "email", "comment": "off"}, "digest": "daily|weekly|off"}
Mail goes through a persisted outbox drained by the same background queue as webhooks. Transient failures retry after 1, 2, 4, and 8 minutes; SMTP 5xx replies fail immediately.
| Setting | Behavior |
|---|---|
NOMA_CLOUD_SMTP_URL | smtp://user:pass@host:587 uses STARTTLS when the server offers it (?starttls=required to insist, ?starttls=never to skip). smtps://host:465 uses implicit TLS. Credentials are sent with AUTH PLAIN or LOGIN, and only over TLS. ?insecure=1 skips certificate checks and allows AUTH without TLS; use it for local testing only. |
NOMA_CLOUD_MAIL_TRANSPORT=log | Development transport: writes each message as a JSON line to NOMA_CLOUD_MAIL_LOG, or to stdout. It is also used when no SMTP URL is set. |
NOMA_CLOUD_MAIL_FROM | Sender, default Noma Cloud <noreply@localhost>. |
NOMA_CLOUD_PUBLIC_URL | Base URL for links in emails. |
The Notifications panel has a Notification settings section for the email address, per-type channels, and the digest frequency.
Attachments and images
Pages can carry files: screenshots, PDFs, spreadsheets, and exports. Upload from the Attachments panel in the inspector, or drop or paste files straight into the source editor. Images are inserted at the cursor as a figure, and other files as a link:
::figure{src="att:<attachment-id>" alt="Growth chart"}
::
Read the [spec](att:<attachment-id>) or refer to a file by name: [spec](att:spec.pdf).
The reference stays in the .noma source, so pages still round-trip through source with stable IDs. When Noma Cloud renders a page (the preview, /d/<id>, /api/documents/<id>/html, and the published /s/<id> site), it maps each att: reference to a short-lived signed URL. That URL is bound to the viewer and is checked again on every request. An att: reference resolves only against the same page's attachments, so one page cannot embed another page's files. A reference that does not resolve renders as a placeholder.
| Behavior | Detail |
|---|---|
| Storage | Content-addressed blobs keyed by SHA-256, laid out as blobs/ab/cd/<sha256>. Identical uploads share one blob. Uploads stream to a local temp file while they are hashed and checked, then move into the blob store. The default store is the local disk under <storage root>/blobs. Set NOMA_CLOUD_BLOB_STORE=s3 to keep blobs in S3 or an S3-compatible service instead (see below). |
| Limits | 25 MB per file (NOMA_CLOUD_MAX_ATTACHMENT_BYTES). 1 GB of live attachments per space, or per uploader for pages outside a space (NOMA_CLOUD_ATTACHMENT_QUOTA_BYTES). Over the limit, the server returns 413 with code attachment_too_large or attachment_quota_exceeded. |
| Content type | Taken from the file's magic bytes, not the declared type. Executables (PE, ELF, Mach-O, shebang scripts, and executable extensions) are rejected with 415. Filenames lose directory parts and control characters. |
| Serving | x-content-type-options: nosniff, CSP sandbox, and an ETag. PNG, JPEG, GIF, WebP, and PDF are served inline. Everything else downloads as an attachment. SVG, HTML, and XML are always sent as application/octet-stream. |
| Permissions | Viewers of a page can list and download its attachments. This includes page share links and space share links that reach the page. Editors upload and delete. Page restrictions apply to attachments too. |
| Lifecycle | Deleting an attachment hides it. Purging the page from trash removes its attachment rows and any blob that no other page still references. |
| Search and backup | Filenames are indexed as attachment rows in /api/search. /api/backup/export embeds attachments, base64 encoded and hash-checked, up to 50 MB (pass includeAttachments: false to skip them). Import restores them for pages the importer can edit. |
POST /api/documents/<id>/attachments raw body (content-type + x-filename) or multipart/form-data
GET /api/documents/<id>/attachments
DELETE /api/documents/<id>/attachments/<attachment-id>
GET /api/attachments/<attachment-id> bearer/share access, or ?exp=&p=&sig= signed URL
An upload is either the raw file bytes, with content-type and a URL-encoded x-filename header, or a multipart/form-data body. A multipart body needs exactly one file part named file. Optional filename or name text fields set the stored name. Without them, the file part's own filename is used. The file part's content-type is the declared type. Other fields are ignored. The parser streams the file part into staging and never buffers the whole body. The same size limit, quota, and magic-byte checks apply to both upload styles.
curl -H "Authorization: Bearer $NOMA_CLOUD_TOKEN" \
-F "file=@chart.png;type=image/png" -F "filename=Q3 chart.png" \
https://wiki.example.com/api/documents/<id>/attachments
| Multipart error | Status | Code |
|---|---|---|
No part named file | 400 | attachment_multipart_missing_file |
Missing or invalid boundary, a body that does not use it, no closing boundary, bad part headers, a second file part, more than 16 parts, or a text field over 4 KB | 400 | attachment_multipart_malformed |
File part larger than NOMA_CLOUD_MAX_ATTACHMENT_BYTES | 413 | attachment_too_large |
S3-compatible attachment storage
NOMA_CLOUD_BLOB_STORE=s3 stores attachment blobs in AWS S3 or any S3-compatible service, such as MinIO, Cloudflare R2, or Hetzner Object Storage. The driver has no dependencies. It signs requests with AWS Signature V4 using node:crypto and sends them with the built-in fetch.
- Upload. Each upload is still staged on local disk first, so it can be
hashed, sniffed, and checked against the quota. Commit sends a HeadObject and skips the upload when the blob already exists. Otherwise it streams a PutObject from the staged file.
- Integrity. The signed
x-amz-content-sha256header is the blob's
SHA-256, the same value as its key. The service rejects any body that does not match it.
- Download. Downloads stream from
GetObject. Deleting a blob during
garbage collection sends DeleteObject.
- Keys. Object keys are
<prefix>blobs/ab/cd/<sha256>.
| Variable | Meaning |
|---|---|
NOMA_CLOUD_BLOB_STORE | local (default) or s3. |
NOMA_CLOUD_S3_BUCKET | Bucket name. Required for s3. |
NOMA_CLOUD_S3_REGION | Signing region, falling back to AWS_REGION or AWS_DEFAULT_REGION. Required for AWS. With a custom endpoint it defaults to us-east-1. Use auto for R2. |
NOMA_CLOUD_S3_ENDPOINT | Custom endpoint, for example http://minio:9000, https://<account>.r2.cloudflarestorage.com, or https://fsn1.your-objectstorage.com. Falls back to AWS_ENDPOINT_URL_S3 or AWS_ENDPOINT_URL. When unset, the AWS endpoint for the region is used. |
NOMA_CLOUD_S3_FORCE_PATH_STYLE | 1 for path-style URLs (<endpoint>/<bucket>/<key>) and 0 for virtual-hosted URLs (<bucket>.<host>/<key>). Defaults to path-style with a custom endpoint and virtual-hosted on AWS. |
NOMA_CLOUD_S3_PREFIX | Key prefix, for example noma/prod/, to share one bucket between deployments. |
NOMA_CLOUD_S3_ACCESS_KEY_ID, NOMA_CLOUD_S3_SECRET_ACCESS_KEY | Credentials. Either one can be read from a file with the _FILE suffix. Falls back to AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. |
NOMA_CLOUD_S3_SESSION_TOKEN | Optional session token for temporary credentials (also _FILE, or AWS_SESSION_TOKEN). |
NOMA_CLOUD_S3_SSE | Server-side encryption on upload: AES256, aws:kms, or none (default: the bucket's own setting). |
NOMA_CLOUD_S3_KMS_KEY_ID | KMS key ID or ARN for aws:kms. When it is unset, the bucket's default key is used. |
NOMA_CLOUD_S3_TIMEOUT_MS, NOMA_CLOUD_S3_MAX_ATTEMPTS | Per-attempt timeout (default 30000) and total attempts (default 3). Only 5xx, 429, throttling codes such as SlowDown, timeouts, and network errors are retried, with jittered exponential backoff. |
NOMA_CLOUD_BLOB_STORE=s3 \
NOMA_CLOUD_S3_BUCKET=noma-attachments \
NOMA_CLOUD_S3_REGION=eu-central-1 \
NOMA_CLOUD_S3_PREFIX=prod/ \
NOMA_CLOUD_S3_ACCESS_KEY_ID_FILE=/run/secrets/s3-key-id \
NOMA_CLOUD_S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3-secret \
NOMA_CLOUD_S3_SSE=aws:kms \
npm start
If s3 is selected but the bucket, region, or credentials are missing or invalid, the server fails at startup. The error names the variable but never includes a secret value. Errors from S3 report only the operation, the HTTP status, and the S3 error code.
The IAM principal needs s3:GetObject, s3:PutObject, and s3:DeleteObject on <bucket>/<prefix>*. It also needs kms:GenerateDataKey and kms:Decrypt on the key when SSE-KMS is used. Also grant s3:ListBucket on the bucket. S3 answers a HeadObject for a missing key with 403 instead of 404 when the caller lacks it. Without it, every upload is sent even when the blob already exists.
With the AWS/EU reference template (infra/aws-eu-reference.json), set NOMA_CLOUD_S3_BUCKET to its AssetsBucketName output and NOMA_CLOUD_S3_KMS_KEY_ID to its KmsKeyArn output. That bucket already enforces TLS and default SSE-KMS.
Only static credentials are read from the environment. For instance-role or web-identity credentials, embed Noma Cloud and pass blobStore: new S3BlobStore({ credentials: async () => ... }) to createNomaCloudServer. Existing local blobs are not migrated automatically. Copy the blobs/ tree into the bucket under the same prefix before you switch.
Page restrictions
Space permissions decide who can reach a page. Restrictions can then narrow that group for a single page, like Confluence's view and edit restrictions. Open them from the lock badge in the page header, or from Restrictions... in the page's context menu.
| Restriction | Effect |
|---|---|
| View | Only the listed users and groups can see the page. The page's own owners and workspace admins can always see it. Everyone else loses the page from every channel: direct reads, space listings, the page tree, breadcrumbs, wiki and backlinks, search, labels, recents and favorites, trash, activity, the DB API, Ask Noma and knowledge exports, agents, notifications, attachments, and the published /s/<id> site. |
| Inherited view | View restrictions apply to every page below the restricted page, in any space's page tree. A child page is visible only if every restricted ancestor allows the viewer. |
| Edit | Only the listed users and groups (plus page owners and admins) can edit. Everyone else who has access sees the page as a viewer. Edit restrictions apply to the page itself and are not inherited. |
Restrictions never grant access. They only narrow grants that already exist. Share links, whether on the page or on its space, count as anonymous: they cannot open a view-restricted page, and an editor link on an edit-restricted page only gives view access. Agent grants are capped by the agent owner's access after restrictions. Signed attachment URLs stop working as soon as the viewer loses access.
A space update from someone who cannot see a restricted page keeps that page where it is. Only the page's direct owners, owners of a space that contains it, and workspace admins can view or change its restrictions. A space owner can therefore always unlock a page, even one hidden from them.
GET /api/documents/<id>/restrictions
PUT /api/documents/<id>/restrictions {"view": {"users": ["<user-id>"], "groups": ["<group-id>"]}, "edit": {"users": [], "groups": []}}
The response lists named principals, the restricted ancestors in inherited, and canManage. Tree nodes from GET /api/sites/<id>/tree carry restrictions: {view, edit, inheritedView} for the rail's lock icons.
Ask Noma, trust, and knowledge health
The Ask Noma inspector is retrieval-first rather than a generic chat box. An answer is returned only when the caller can access sufficiently relevant evidence. Every citation includes the document ID, stable block ID, exact source span, current document hash, content type, trust/freshness metadata, provenance, relevance score, and the access decision made for that query.
When evidence is weak, Noma returns insufficient_evidence with no invented citations. When canonical sources disagree, the answer keeps the conflicting claims visible instead of averaging them silently.
Trust metadata can be attached to any stable block:
| Field | Meaning |
|---|---|
ownerId | Human accountable for the knowledge |
verifiedBy, verifiedAt | Who checked the source and when |
reviewBy | Date after which the block is stale |
supersedes | Older block or external source replaced by this block |
canonicalFor | Concepts for which the block is authoritative |
sourceOf | Upstream source URLs or stable source identifiers |
provenance | Structured import, review, or generation lineage |
The knowledge health queue detects stale/review-due blocks, pages without resolved links, broken wiki targets, semantic duplicate candidates, contradictory canonical claims, missing owners, and unanswered questions. LLM Wiki mode adds suggested links, missing concept pages, canonical concepts, typed relationships, and proof-first merge drafts.
POST /api/ask
GET /api/knowledge/search
GET /api/knowledge/llm
GET /api/knowledge/health
GET /api/knowledge/wiki
POST /api/knowledge/reindex
GET|PUT /api/knowledge/trust/<document-id>/<block-id>
POST /api/knowledge/evaluations
Evaluation fixtures declare required and forbidden sources plus abstention, latency, and cost expectations. Each run records source recall, forbidden hits, citation coverage, permission leakage, stale-source use, abstention correctness, latency, and estimated cost.
Semantic retrieval and embeddings
Knowledge search scores every visible block on lexical overlap, semantic similarity, typed-block match, graph links, verification, and freshness. The semantic part is pluggable. By default it uses a deterministic 96-dimension local hash vector: offline, no text leaves the server, and results are reproducible. Configure a real embedding model to match meaning rather than shared words ("outage playbook" finding a "failover after a disaster" block).
| Variable | Meaning |
|---|---|
NOMA_CLOUD_EMBEDDINGS | local (default, hash vector), openai (any OpenAI-compatible /v1/embeddings API: OpenAI, gateways, Ollama, local servers), or voyage (Voyage AI) |
NOMA_CLOUD_EMBEDDINGS_MODEL | Model ID (defaults text-embedding-3-small / voyage-3.5) |
NOMA_CLOUD_EMBEDDINGS_URL | Base URL or full /embeddings endpoint (for example http://localhost:11434 for Ollama) |
NOMA_CLOUD_EMBEDDINGS_API_KEY, NOMA_CLOUD_EMBEDDINGS_API_KEY_FILE | Bearer key; Voyage also reads VOYAGE_API_KEY, OpenAI OPENAI_API_KEY. A keyless openai URL is allowed for local servers |
NOMA_CLOUD_EMBEDDINGS_DIMENSIONS | Optional vector length, sent to models that support shortening and checked on every response |
NOMA_CLOUD_EMBEDDINGS_TIMEOUT_MS, NOMA_CLOUD_EMBEDDINGS_MAX_RETRIES, NOMA_CLOUD_EMBEDDINGS_BATCH_SIZE | Per-request timeout (default 30000), retries on 408/409/429/5xx and network errors (default 2), and inputs per request (256 OpenAI, 128 Voyage) |
NOMA_CLOUD_EMBEDDINGS_QUERY_TIMEOUT_MS | Deadline for embedding a search query before falling back (default 2000) |
NOMA_CLOUD_EMBEDDINGS_ZERO_RETENTION | Operator attestation that the embedding account has zero data retention |
Indexing stays synchronous and always stores the hash vector. Provider vectors are computed in the background by the queue tick (and by POST
/api/knowledge/reindex, which reports an embeddings backfill summary). They are cached in SQLite by provider, model, and SHA-256 of the block text, so re-indexing unchanged text never re-embeds it. At query time the query is embedded with the same provider under a short deadline. Blocks with a cached vector of the same length are scored against it. Every other block, or every block when the provider is slow, down, or blocked by policy, falls back to lexical plus hash scoring. Vectors of different models or lengths are never compared.
Search, Ask Noma, and the agent gateway report the mode that served each query:
"retrieval": {
"semantic": "voyage:voyage-3.5",
"provider": "voyage:voyage-3.5",
"coverage": { "embedded": 412, "total": 415 }
}
semantic is local-hash whenever the provider did not score the query, and fallback then says why: not_embedded (the backfill has not covered these blocks yet), provider_unavailable (errors put the provider on a one-minute cooldown), query_timeout, policy_model_not_allowed, or policy_zero_retention_required.
A remote embedding provider follows the same enterprise policy as the language model. Once an admin saves a policy, its modelAllowlist must list the embedding model (voyage-3.5 or voyage:voyage-3.5), and requireZeroRetentionModels requires the zero-retention attestation. Until then, no workspace text is sent to the provider, for either backfill or queries.
Agents as teammates
People hand work to an agent the way they hand it to a colleague:
- Mention it in a comment.
@{agent-id} please re-check the TAMopens an
assignment for that agent, anchored to the comment thread.
- Assign it a page task.
- [ ] Refresh the restart steps @{agent-id}
opens an assignment for the task. Checking the task off closes it as done.
The mention picker lists the agents that can work on the current page next to people, marked 🤖. An agent is assignable on a page only while it is active, holds a page or space grant that covers the page, and its owner can still open the page. The agent's owner gets a task_assigned notification, and spaces can subscribe to the agent.assigned webhook event to wake an external agent.
The agent (or its owner's MCP client) works the assignment through the gateway:
assignmentslists its inbox, with the request, the page hash, and the
comment thread.
replyposts a threaded comment as the agent. It needs thecomment
capability. The comment is stored under the owner's account with an agentId, and the UI shows it as 🤖 Agent (agent of Owner).
proposalwith anassignmentIdopens a proofed patch proposal and links
it to the assignment. The edit still needs another person's approval before it is applied.
update_assignmentreportsin_progress,done, ordeclinedwith a
note. Closing an assignment notifies the person who asked.
Comments written by agents never open new assignments, so agents cannot hand work back and forth in a loop. Reopening an agent's task reopens its assignment. If the agent or its owner loses access to the page, the inbox keeps the assignment but marks it accessRevoked and leaves out the request, the page hash, and the thread.
GET /api/documents/<id>/agents agents assignable on the page
GET /api/documents/<id>/agent-assignments assignments on the page
GET /api/agents/<agent-id>/assignments?status=active|open|in_progress|done|declined|all
POST /api/agents/<agent-id>/assignments/<assignment-id>/reply {"body": "..."}
POST /api/agents/<agent-id>/assignments/<assignment-id>/status {"status": "done", "note": "...", "proposalId": "..."}
Connected and self-maintaining knowledge
Connector record contracts support GitHub, Slack, Google Drive, Jira, Linear, and filesystem sources. Every recorded source retains upstream permissions, modified time, source URL, content hash, predecessor lineage, synchronization time, and deletion tombstone rather than erasing its history.
Six built-in proposal templates cover stale-document review, meeting-to-decision, issue-to-runbook, research refresh, onboarding answers, and release maintenance. Manual, schedule, event, and webhook trigger intent is explicit; an external scheduler or worker invokes those routes. A recipe run produces a plan and proof_proposal_only mutation policy; it never writes around the agent review contract.
Semantic collections query typed blocks across permitted pages: open decisions, claims missing evidence, risks by owner, stale citations, and agent changes awaiting review. Analytics count no-result queries, generated and rejected answers, citation opens, and completed tasks only inside the caller's accessible document scope.
GET|POST /api/connectors
GET|POST /api/connectors/<connector-id>/sources
GET|POST /api/recipes
GET|POST /api/recipes/<recipe-id>/runs
GET /api/collections
GET|POST /api/analytics
Generative AI on the trust loop
Noma Cloud can call a language model, but model output never edits a page directly. Generated answers are checked against the retrieved blocks, and generated edits become proofed patch proposals that need a different collaborator to approve before a hash-checked apply.
Configure a provider with environment variables. Without one, every AI feature falls back to the extractive behaviour above and reports ai_unavailable with a reason (not_configured, model_not_allowed, zero_retention_required, user_budget_exhausted, agent_budget_exhausted, provider_error, or refused).
| Variable | Meaning |
|---|---|
ANTHROPIC_API_KEY | Enables the Claude Messages API provider |
NOMA_CLOUD_LLM_MODEL | Model ID (default claude-opus-5) |
NOMA_CLOUD_LLM_PROVIDER | anthropic, fake (deterministic offline model), or none |
NOMA_CLOUD_LLM_TIMEOUT_MS, NOMA_CLOUD_LLM_MAX_RETRIES | Per-attempt timeout and retries on 408/409/429/5xx and network errors |
NOMA_CLOUD_LLM_ZERO_RETENTION | Operator attestation that the provider account has zero data retention |
NOMA_CLOUD_LLM_FALLBACKS | off disables server-side refusal fallbacks (on by default) |
NOMA_CLOUD_LLM_EFFORT | Optional output_config.effort (low to max) |
NOMA_CLOUD_AI_USER_BUDGET_USD | Per-user spend cap over a rolling 30 days (default 10) |
NOMA_CLOUD_AI_AGENT_BUDGET_USD | Budget of each user's system AI agent (default 25) |
NOMA_CLOUD_AI_ALLOW_PRIVATE_SOURCES | Lets refresh fetch private or loopback URLs (on-premises only) |
Every call is charged to an agent identity: the caller's system agent noma-ai-<user-id> (listed under Scoped agents) or a user-owned agent passed as agentId. The call is refused before it is sent when the worst-case cost would exceed the user's or the agent's remaining budget. Each call is recorded as an agent run and in GET /api/ai/usage. Enterprise policy is checked on every call: once a workspace admin saves a policy, modelAllowlist must list the model (including a model that served a fallback), and requireZeroRetentionModels requires the zero-retention attestation. An untouched default policy allows the operator-configured model.
- Generative Ask.
POST /api/askwithmode: "generative"runs the same
permission-scoped retrieval, sends only the retrieved blocks to the model as quoted data with their doc:block@hash references, and keeps only citations that match a retrieved block and version. The answer abstains when retrieval is weak, when the model abstains, or when citations fail validation. Unverifiable citations are removed and listed in generation.invalidCitations. Conflict detection is unchanged, and the UI renders the answer as escaped text.
- Summarize.
POST /api/documents/<id>/ai/summarizereturns a summary.
With insert: true it creates a proposal that adds or replaces an ai-summary block.
- Draft changes.
POST /api/documents/<id>/ai/draftwith an
instruction. The model returns patch operations. Each operation is validated against schemas/patch-op.schema.json (rename_id is never allowed, at most 30 operations) and proofed before a proposal is stored.
- Refresh from sources.
POST /api/documents/<id>/ai/refreshwith
sourceUrls, sourceDocumentIds, or connectorSourceIds (up to five). URLs are fetched with DNS-pinned private-address blocking, a size limit, and a timeout. The proposal summary names the cited sources, and the proof record keeps each source's content hash.
- Draft a page.
POST /api/sites/<id>/ai/draft-pagewithtitle,
instruction, and an optional parentId stores a page proposal. The page is created only after another editor approves it and someone applies it.
GET /api/ai/status
GET /api/ai/usage
POST /api/ask {"mode": "generative"}
POST /api/documents/<id>/ai/summarize {"insert": true}
POST /api/documents/<id>/ai/draft {"instruction": "..."}
POST /api/documents/<id>/ai/refresh {"sourceUrls": ["https://..."]}
POST /api/sites/<id>/ai/draft-page {"title": "...", "instruction": "..."}
GET /api/sites/<id>/ai/page-proposals[/<proposal-id>]
POST /api/sites/<id>/ai/page-proposals/<proposal-id>/review {"decision": "approved"}
POST /api/sites/<id>/ai/page-proposals/<proposal-id>/apply
The requesting user is recorded as the proposer, so they cannot approve their own AI draft. The proof record carries the system agent, model, instruction, and sources. In the app, the page header AI menu offers Summarize, Draft changes, and Refresh from sources. Results open in the Agent Review panel. The Ask panel's Generate answer toggle turns citations into links to the cited blocks.
Draft with AI in the space rail asks for a title and instructions. It can also place the page under the page that is open. The dialog shows the drafted source and adds the proposal to Agent Review → AI page proposals for the current space. There, the proposer can withdraw it, and another editor can approve or reject it. After approval, an editor chooses Create page, which applies it and opens the new page. When AI is unavailable (no provider, policy, or budget), the dialog says why and does not send the request. A request that fails with ai_unavailable is reported the same way.
Stale-knowledge maintenance
Each space can run a maintenance sweep. The sweep turns knowledge-health findings into tracked items: blocks past reviewBy or with low freshness, conflicting claims, and broken wiki links. Items resolve when the finding disappears. When the space opts into AI refresh, the sweep also drafts refresh proposals for stale pages that record sources. Sources are trust sourceOf URLs and connector sources linked to the page. Drafts are limited by maxProposalsPerRun, and a page that already has a pending draft is skipped.
Scheduled sweeps run as the editor who saved the settings, so they see only what that person can see and spend that person's AI budget. Manual runs use the caller and are limited to one per minute per space. An in-process timer (NOMA_CLOUD_MAINTENANCE_TICK_MS, default 15 minutes, 0 disables it) sweeps up to five due spaces per tick. Deployments that prefer cron can run npx tsx apps/worker/cloud-maintenance.ts with the same NOMA_CLOUD_* storage variables.
GET|PUT /api/sites/<id>/maintenance {"enabled", "aiRefresh", "intervalHours", "maxProposalsPerRun"}
POST /api/sites/<id>/maintenance/run
GET /api/sites/<id>/maintenance/items?status=open|resolved
Git-native spaces
A space can live in a Git repository as a directory of .noma files. The server publishes a manifest with page IDs, tree-derived paths, labels, and revision hashes:
GET /api/sites/<id>/sync-manifest
The CLI uses a personal user token (NOMA_CLOUD_TOKEN) against a server (NOMA_CLOUD_URL):
noma cloud export-space --site <space-id> --out docs/space
noma cloud sync --site <space-id> --dir docs/space [--pull-only|--push-only] [--dry-run] [--json]
Paths follow the page tree: a page is <slug>.noma, and its children sit in the <slug>/ directory. The sync keys cloudId, cloudHash (the revision the file was last synced with), cloudParent, and cloudLabels are added to the file's frontmatter. They are removed again before upload, so the server source is byte-identical.
The sync compares each file with cloudHash. Server-only changes are pulled. Local-only changes are pushed with expectedHash. New files without cloudId become pages under the page their directory maps to. When a page changed on both sides, the local file is kept, the server version is written next to it as <file>.noma.conflict, and the command exits with status 1. Resolve the conflict, set cloudHash to the server hash, and sync again.
A scheduled GitHub Action keeps a repository and a space in step:
name: Sync Noma space
on:
schedule: [{ cron: "*/30 * * * *" }]
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npx -y @ferax564/noma-cli cloud sync --site "$SPACE_ID" --dir docs/space
env:
NOMA_CLOUD_URL: ${{ vars.NOMA_CLOUD_URL }}
NOMA_CLOUD_TOKEN: ${{ secrets.NOMA_CLOUD_TOKEN }}
SPACE_ID: ${{ vars.NOMA_SPACE_ID }}
- run: |
git config user.name "noma-sync"
git config user.email "noma-sync@users.noreply.github.com"
git add docs/space
git diff --cached --quiet || git commit -m "docs: sync Noma space"
git push
Portable backup, offline recovery, and realtime humans
POST /api/backup/export produces a deterministic .noma bundle sorted by document ID, with per-file hashes, one bundle digest, and optional repository, branch, and pull-request-review metadata. Import verifies every source hash, reports corrupt bundles and concurrent edits, and applies creates/updates only when the conflict plan is clean. Canonical content remains reconstructable plain .noma source. Applying an import validates the whole bundle first (hashes, conflicts, free IDs for creates, editor access for updates) and then writes every document in one SQLite transaction, so a failure leaves nothing half-imported. The plan only describes documents you can read; if a create targets an ID that is already taken, or an update targets a page you cannot edit, the import fails with one uniform 409 (code:
"backup_ids_unavailable") that names no IDs. Bundles hold at most 1,000 documents (413, code: "backup_too_large").
Noma Cloud is an installable PWA. The service worker caches the application shell, while each edit stores a full local draft containing base hash, base source, draft source, title, user, document ID, and timestamp. A draft whose base still matches is restored automatically. If the server changed, the UI offers explicit recover, three-way merge, or discard choices and renders conflict markers rather than overwriting either author.
Server-side offline drafts are capped at 200 per user (429, code:
"offline_draft_quota_exceeded") and 1 MB of base plus draft source (413, code: "offline_draft_too_large"). Knowledge analytics accept 120 events per user per minute (429, code: "analytics_rate_limited"), and each user's events are kept for 90 days, up to 10,000.
Realtime human operations use the same stable IDs, proof engine, expected hash, immutable document revisions, and ordered operation sequence as normal writes. The feed is pollable with after=<sequence> and limit (default 500). Realtime operations reject agent actors; asynchronous proofed proposals remain the agent default.
POST /api/backup/export
POST /api/backup/import
GET|POST /api/offline/drafts
POST /api/offline/drafts/<draft-id>/merge
GET|POST /api/realtime/documents/<document-id>/operations
Bounded work and pagination
Knowledge endpoints (Ask Noma, knowledge search, health, LLM Wiki, semantic collections, agent inbox, analytics, backup export) read at most the 2,000 most recently updated pages the caller can access. Parsed and indexed blocks are cached in memory by document ID and content hash, and at most 250 changed pages are re-parsed per request; later requests index the rest. Platform listings take ?limit=&offset= and echo both in the response: GET /api/agents, GET /api/agents/<id>/runs, GET /api/offline/drafts, GET /api/enterprise/scim, GET /api/enterprise/legal-holds, GET /api/tokens, and GET /api/auth/sessions.
Enterprise policy
Workspace-owner enterprise policy configures SSO enforcement -- native OpenID Connect (see openid-connect-login) or trusted-proxy OIDC/SAML login -- SCIM identity records, platform-metadata retention days and legal hold, declared data residency, connector allowlists, model allowlists, zero-retention model requirements, and audit export. Connector and agent creation enforce the active allowlists immediately. Agent completion cannot exceed its remaining spend budget. Retention cleanup excludes records protected by an active legal hold.
GET|PUT /api/enterprise
POST /api/auth/sso
GET /api/auth/oidc/start
GET /api/auth/oidc/callback
GET|POST /api/enterprise/scim
GET|POST /api/enterprise/legal-holds
GET /api/enterprise/audit
POST /api/enterprise/retention
Collaborate, notify, and approve
The inspector keeps review context beside the source instead of scattering it across email and a separate ticket system:
| Panel | Behavior |
|---|---|
| Comments | Creates threads anchored to an optional stable block ID/alias and line. Replies retain the parent thread; authors and editors can resolve or reopen them. |
| Notifications | Shows mentions, comment replies, approval requests, and approval decisions for the signed-in user. |
| Approvals | Binds a reviewer decision to the current document hash. An approval for an older version cannot be accepted or applied as if it covered new content. |
| Activity | Lists permission-scoped document and space events, including comments, approvals, sharing changes, trash/restore, and agent patch reviews. |
| Groups | Creates managed user groups and grants a group viewer/editor access to a page or space. Space grants inherit to every page and update dynamically as membership changes. |
Mention another collaborator with the stable syntax @{user-id}. Mentions are delivered only when that user can access the document. A group grant never copies hidden direct permissions to every member: search, navigation, API reads, and edit checks resolve current membership at request time.
Core collaboration routes are available in both standalone document form and under /api/sites/<site-id>/documents/<document-id>:
GET|POST /api/documents/<id>/comments
POST /api/documents/<id>/comments/<comment-id>/resolve
GET|POST /api/documents/<id>/approvals
PATCH /api/documents/<id>/approvals/<approval-id>
GET /api/notifications
POST /api/notifications/read-all
GET /api/activity?document=<id>&site=<id>
GET|POST /api/groups
POST /api/groups/<id>/members
GET|POST /api/documents/<id>/group-collaborators
Manage work beside knowledge
The Work inspector gives each space an integrated Jira-style project. This keeps delivery context attached to the specs, decisions, research, and runbooks that define the work.
Projects support:
- unique keys such as
NOM, producing stable issue keys likeNOM-42 - task, story, bug, and epic issue types
- backlog, to-do, in-progress, in-review, and done workflow states with checked transitions
- priorities, assignees, labels, estimates, due dates, and parent issues
- query filters over text, status, type, priority, assignee, label, and sprint
- board columns, a no-sprint backlog, planned/active/closed sprints, and one active sprint per project
- unfinished-work carry-over when a sprint closes
- issue comments, related/blocks/duplicates links, and immutable issue change history
Project access comes from its space, including group grants. Viewers can browse and comment; editors can create and transition issues, manage sprints, and add links. The bounded API is suitable for agents and integrations:
GET|POST /api/projects
GET|PATCH /api/projects/<project-id-or-key>
GET|POST /api/projects/<id>/issues
GET|PATCH /api/projects/<id>/issues/<issue-id-or-key>
GET|POST /api/projects/<id>/issues/<issue>/comments
GET|POST /api/projects/<id>/issues/<issue>/links
GET /api/projects/<id>/issues/<issue>/history
GET /api/projects/<id>/board
GET /api/projects/<id>/backlog
GET|POST /api/projects/<id>/sprints
GET|PATCH /api/projects/<id>/sprints/<sprint-id>
Edit a page
The center of the app has two panes:
| Pane | Purpose |
|---|---|
| Noma Source | The editable .noma source. This is the source of truth and the patch target for agents. |
| Paper Preview | A sandboxed rendered artifact. It updates as you type and uses the same renderer as the CLI. |
Use the view switch in the page header when the workspace feels too dense:
| Mode | Use it for |
|---|---|
| Visual | Edit the page as formatted blocks, with live co-editing. The default for new users; each user's last choice is remembered. |
| Source | Give the .noma editor the full writing canvas. |
| Split | Keep source and rendered preview side by side. |
| Preview | Hide the source pane and side panels so the paper/artifact becomes the main surface. |
In Preview mode, owners and editors can click rendered headings, paragraphs, list items, and quotes to edit them directly. Those edits sync back to the matching source lines and mark the page unsaved. Semantic blocks, tables, citations, figures, and agent patches remain source-first so structured metadata is not rewritten accidentally. Use Panels when you need the workspace rail, share controls, diagnostics, or outline again.
Mouse editing is available in Preview mode:
| Action | Result |
|---|---|
| Click a heading, paragraph, list item, or quote | Select it and edit the rendered text in place. |
| Click + Section on the selected-block toolbar | Insert a new section after the selected section or block. |
| Click + Text on the selected-block toolbar | Insert a new paragraph after the selected block. |
| Drag the paper edge | Resize the preview paper width for reading and screenshots. |
| Drag the divider in Split mode | Resize the source and preview panes. |
Visual editing and live co-editing
Visual mode is a block editor over the same .noma source. Each Noma block maps to one editor block: headings keep their stable IDs and aliases, lists keep {#id} item markers and [ ] task state, tables stay pipe tables, and directives become cards with an Attributes editor. Callouts, claims, evidence, decisions, and figures get styled cards. ::math and ::diagram{kind="mermaid"} are edited as code. toc, children, and include show as chips. Blocks the editor cannot represent, such as datasets, plots, and controls, are shown as raw Noma source and round-trip unchanged.
Saving writes source, not editor state. Blocks you did not touch are copied from the saved source byte for byte, so a visual edit changes only the lines of the blocks you edited. Renaming a heading keeps its old ID as an explicit {id="…"}. New claims, decisions, figures, and risks get a deterministic ID such as decision-1. Switch to Source at any time to see the exact text.
| Input | Result |
|---|---|
/ | Block menu: headings, lists, task list, table, code, quote, callout, warning, decision, claim, evidence, figure, math, mermaid, table of contents, child pages, include, slide deck, slide, speaker notes, grid of cards, card, HTML widget, canvas, divider, raw Noma. Exact matches rank first |
## , - , 1. , [ ] , > , three backticks, --- | Markdown shortcuts |
**bold**, *em*, backtick code, [[id]] | Inline shortcuts |
| Select text | Floating toolbar: bold, italic, code, link, block link, heading level; table row and column buttons inside tables |
| Paste HTML or Markdown | Converted through the Markdown ingest pipeline; scripts, styles, and unsafe links are dropped |
| Ctrl/Cmd+S, Ctrl/Cmd+B/I/E/K, Shift+Enter, Tab in tables | Save, marks, link, line break, next cell |
When the page has no unsaved source draft and the server is reachable, Visual mode joins the page's live room. Everyone editing the page sees the others' changes as they type, their cursors with names and colours, and their avatars in the page header. If the socket cannot connect, Visual mode keeps working on your device and you save as usual. Typing in the raw Source pane pauses live editing until that draft is saved, because a source draft is saved the classic way with a hash check.
Decks and widgets.
/deckinserts a::deckwith a title slide and a content slide./slideadds a slide after the one you are in. Outside a deck, it starts a new deck./notesadds speaker notes at the end of the current slide./widgetinserts a sandboxed::htmlblock with a working example./canvasinserts a::canvasembed pointing at a page attachment (att:board.json).- New decks, slides, widgets, and canvases get stable IDs (
deck-1,slide-3,widget-1,canvas-1) that are not already used on the page, so presenter deep links and agent patches can target them.
Slide strip. A page with a ::deck shows a filmstrip of its slides above the editor, drawn through the same canvas model as the PowerPoint export. The Slides button shows or hides it on any page; a page without a deck shows the slides that Present would build from its sections.
- Click a slide to jump to it in the source, the visual editor, and the preview. The slide at the source cursor is highlighted.
- Drag a slide, or focus it and press Alt+← / Alt+→, to reorder. Hide marks a slide
hiddenfor presenting; + Slide adds one at the end of the deck. - Every strip action is an ordinary patch (
move_block,update_attribute,add_block) applied to the page source, so it saves, diffs, and goes through proposals like any other edit. Reordering is available when every child of the deck is a::slidewith an id.
Components. Each space can name a Component kit page in Space settings (owners only). The ::component definitions on that page are available to every page in the space. Type / and the component name, for example /pricing, to insert a use. The use comes with its required props and named slots filled in as placeholders, plus a fresh stable ID. Pages render, present, export, and validate with the kit. Viewers who cannot open the kit page still see its components rendered. The editor receives only the definitions, never the rest of the kit page. Changing a definition updates every page that uses it.
Style picker. Every directive card has a Style button next to Attributes. It opens chips for the style-token groups (tone, surface, emphasis, size, align, spacing, span, layout, media). When the page's spaces define aliases, they appear first under space. Clicking a chip writes it into the block's class= attribute right away. Tone, surface, size, align, spacing, and span allow one token each, so picking tone-info replaces tone-accent. Words that are not tokens show as dashed unknown chips you can click to remove.
Live edits become normal page history. The server writes a checkpoint revision at most once per 30 seconds of activity, and again when the last editor leaves. Changes made outside the room, such as an applied agent patch, an API PUT, or a restored version, are merged into the live document block by block. People editing live keep their changes, and nobody overwrites the agent.
| Route | Behaviour |
|---|---|
GET /api/collab/documents/<id> | Room status for viewers and above: live, connected clients (name, colour, role), pendingUpdates, and the source hash. |
WebSocket /api/collab/documents/<id> | Live room. The first frame is {"type":"hello","clientId":<yjs id>,"token"?,"share"?}, or a bearer or share header. Viewers and viewer links are read-only. Editors write. The access-gate cookie is honoured. Cross-origin sockets are refused. |
Every update is stored in SQLite before it is acknowledged, applied, and broadcast. A checkpoint compacts the stored updates. Access is checked on connect, after every change to the page record (for example, a collaborator is removed or a share link is revoked), and on a timer. Revoked users and trashed pages are disconnected. The server replaces each cursor's name and colour with the identity it knows, so nobody can pose as someone else.
Present a page
Any page can be presented. Click Present in the page header, or the Present link on a published /d/<id> page, to open /d/<id>/present in a new tab.
- A page with a
::deckshows that deck's slides, layouts, and speaker notes. - Any other page becomes a title slide (the top heading and its intro) plus one
slide per section, so a runbook, handbook, or decision record presents without extra work.
- Use the arrow keys, Space, PageUp/PageDown, Home/End, the bar buttons, or a
swipe to move between slides. N toggles the speaker-notes panel, O opens the overview grid, and F switches to fullscreen.
- The URL hash follows the current slide (
#setup), so links open on a slide. - Share links work:
/d/<id>/present?share=<token>. Sandboxed::html
widgets, attachments, and the space's style tokens render as they do on the page.
- The presenter shows the last saved version. If the editor has unsaved
changes, Noma asks before it opens.
The CLI renders the same presenter with noma render page.noma --to slides.
Canvas round trip.
GET /api/documents/<id>/export?to=paperdomdownloads the page as a PaperDOM canvas, with the space kit's components expanded.- After text edits on the canvas,
POST /api/documents/<id>/paperdom-syncwith{ "canvas": … }(editors only) returns{ ops, changes, skipped, documentHash }. It does not write. - Post those
opsto/api/documents/<id>/patch-proposalsto create an ordinary proofed proposal. Another person approves it, and then it is applied with a hash check.
Layout changes stay on the canvas. Anything that cannot be mapped back to text is listed in skipped with a reason.
PowerPoint. Export… → PowerPoint (.pptx) (export?to=pptx) writes the page's deck, or one slide per section, through the same canvas model. Speaker notes, hidden slides, transitions, tables, and charts are native. The x-noma-fidelity response header lists anything that was approximated or left out, and the editor shows it next to the download.
Canvas embeds. Upload a PaperDOM canvas .json as a page attachment, then reference it with ::canvas{id="board" src="att:board.json" page="flow"}. Published pages, the presenter, the space site, and every export draw it as static SVG; the editor preview fetches it once and redraws. Only the page's own attachments resolve, and canvas JSON above 2 MB is not drawn.
Link pages like Obsidian
Noma Cloud supports page-oriented wikilinks for LLM wiki workflows:
| Syntax | Meaning |
|---|---|
[[Literature Review]] | Link to a page by title. |
[[Literature Review|review]] | Link to a page by title with a shorter label. |
[[Literature Review#Methods]] | Link to a page plus a heading target. |
[[claim-main]] | Keep using a stable Noma block ID link inside a page. |
In the preview, click a resolved page link to open that page. Click a missing page link as an editor to create a new wiki page with a summary, notes, related links, and an agent maintenance task. The Wiki inspector shows outgoing links, backlinks, and missing pages for the current page.
The server exposes the same graph through the API:
curl -H "X-Noma-Cloud-Access-Token: $NOMA_CLOUD_ACCESS_TOKEN" \
-H "Authorization: Bearer $NOMA_TOKEN" \
https://noma-cloud.apps.ezkeel.com/api/sites/<site-id>/wiki
The response includes pages, links, backlinks, and missing so a Codex plugin can query the wiki graph without scraping rendered HTML.
Use stable IDs on headings and semantic blocks:
# Research Paper Draft {id="research-paper-draft"}
::claim{id="claim-main" confidence=0.72}
The core claim goes here.
::
::evidence{id="evidence-primary" for="claim-main" source="source-primary"}
The strongest evidence goes here.
::
Click Save when the diagnostics are acceptable. Every save includes the hash of the version you loaded. If another human or agent saved first, Noma keeps your draft intact and reports a conflict instead of overwriting their work. Use Reload to discard the draft and open the latest saved page.
The History panel lists every saved content version. Owners and editors can restore an older version; restoration creates a new version, so the versions between the old state and the restored state remain available for comparison and audit. Permission and share-link changes do not create noisy content versions.
The cloud server stores the source, title, hash, immutable revision history, diagnostics, and block index in SQLite.

Scientific-paper workflow
For papers and technical research, keep each reviewable idea in an addressable block:
| Need | Noma structure |
|---|---|
| Abstract | ::abstract{id="abstract" status="draft"} |
| Main claim | ::claim{id="claim-main" confidence=0.72} |
| Evidence | ::evidence{id="evidence-primary" for="claim-main" source="source-id"} |
| Counterpoint | ::counterevidence{for="claim-main" source="source-id"} |
| Method note | normal heading plus prose, or a typed directive if the method needs metadata |
| Table | pipe table or ::table{id="..." header} |
| Figure | ::figure{id="..." src="..." alt="..." caption="..."} |
| Equation | inline math or ::math{id="..."} |
| Citation | ::citation{id="source-id" url="..." accessed="YYYY-MM-DD"} |
| References | ::bibliography{id="references"} |
| Review task | ::agent_task{id="task-source-check" scope="paper-review"} |
| Reviewer note | ::comment{id="comment-..." parent="claim-main" author="..."} |
| Proposed edit | ::change_request{id="cr-..." target="claim-main" action="replace" from="..." to="..."} |
This shape matters because collaborators and agents can target exactly claim-main, evidence-primary, or review-checklist instead of editing the whole document.
For journal or committee handoff, use the CLI from the saved source:
noma render paper.noma --to html --strict --out paper.html
noma render paper.noma --to pdf --out paper.pdf
noma render paper.noma --to docx --out paper.docx
noma docx-review-sync paper.noma reviewed-paper.docx --out paper.reviewed.noma --report review-sync.json
Cloud is the shared workspace. The CLI remains the strongest release path for PDF, DOCX, strict publishing, CI, and source-controlled review.
Proofed agent review linked to work
The Agent Review panel accepts one patch op or an array of patch ops in JSON. Preview in Draft runs a local parse/validation preview without saving. Propose for Review sends the operations to the server, where Noma creates a safety proof against the current saved document hash. If a Work issue is selected, the proposal and every later review/apply event are linked into that issue's immutable history.
The proof records pre/post hashes, patch result, diagnostics, stable IDs, source-preservation metrics, a compact diff, and a sandboxed post-patch artifact preview. A different editor must approve the proposal. Apply re-runs the proof against the current source and rejects the proposal if a human or agent changed the document after it was proposed. Self-approval, failed proofs, unapproved apply attempts, and stale versions are blocked.
Example:
[
{
"op": "replace_body",
"id": "claim-main",
"content": "The revised central claim goes here."
}
]
Common ops:
| Op | Use it for |
|---|---|
replace_body | Rewrite a directive body without touching attrs or neighbors. |
update_heading | Rename a heading while preserving its stable ID. |
update_attribute | Change metadata such as confidence, status, owner, or accessed. |
add_comment | Add a targeted review note after the reviewed block. |
resolve_comment | Mark a comment resolved without deleting history. |
update_table_cell | Patch one table cell by row and column/header. |
insert_table_row | Add one row to an ID-bearing table. |
rename_id | Rename a block ID and retarget references. |
Use Copy LLM to copy deterministic LLM context for the current page. That context strips unsafe escape hatch bodies and keeps block IDs visible so an agent can propose a focused patch transaction.
The same review gate is available to API clients:
GET|POST /api/documents/<id>/patch-proposals
GET /api/documents/<id>/patch-proposals/<proposal-id>
POST /api/documents/<id>/patch-proposals/<proposal-id>/review
POST /api/documents/<id>/patch-proposals/<proposal-id>/apply
Create proposals with { "ops": [...], "issueId": "optional-issue-id" }. Review with { "decision": "approved" } or "rejected". The document and linked issue both receive auditable proposed, approved/rejected, and applied events.
Published sites and artifacts
Use Artifact when you want one page as a reader artifact. Use Published when the workspace should become a multi-page reader site with page navigation.

These routes are generated from the same saved source:
| Route | Purpose |
|---|---|
/cloud.html?site=<id> | Editable workspace shell. |
/cloud.html?doc=<id> | Editable or readonly single-page shell. |
/d/<id> | Rendered single-page artifact. |
/s/<id> | Rendered workspace site. |
/api/documents/<id>/html | Rendered HTML for one document (inline, macros resolved). |
/api/documents/<id>/llm | LLM context for one document, with included content and provenance comments. |
/api/documents/<id>/json | JSON AST for one document. |
/api/documents/<id>/export?to=<format> | Download one document as pdf, docx, markdown, html, noma, llm, or json. |
/api/sites/<id>/export?to=<format> | Download a whole space as a static HTML site (site-zip) or as .noma sources (noma-zip). |
Query the DB API
The DB API is intentionally not raw SQL. It is a bounded JSON query surface for future plugins and agent tools.
Read the available resources:
curl -H "authorization: Bearer $NOMA_TOKEN" \
http://localhost:3000/api/db/schema
Query blocks:
curl -X POST \
-H "authorization: Bearer $NOMA_TOKEN" \
-H "content-type: application/json" \
-d '{"resource":"blocks","q":"claim","limit":10}' \
http://localhost:3000/api/db/query
Query documents in a workspace:
curl -X POST \
-H "authorization: Bearer $NOMA_TOKEN" \
-H "content-type: application/json" \
-d '{"resource":"documents","siteId":"site-id","limit":20}' \
http://localhost:3000/api/db/query
Resources:
| Resource | What it returns |
|---|---|
documents | Documents visible to the authenticated user. |
sites | Workspaces visible to the authenticated user. |
blocks | Indexed headings and directive blocks from visible documents. |
users | Public user lookup for collaboration workflows. |
Every result is filtered by the caller's Noma Cloud permissions. Tokens copied from share links are for document/site access, not database inspection.

Mobile and tablet use
On small screens the app stacks the top bar, workspace rail, editor, preview, and inspector vertically. This is useful for review and light editing. Long authoring sessions are still better on desktop because the source and preview can remain side by side.

Safety model
Noma Cloud follows the same safety posture as the workbench:
- the preview runs in a sandboxed iframe
- raw
::htmland::svgblocks with anidrender as sandboxed widget iframes. Published pages run their scripts in an opaque origin with no network access. The editor preview shows the saved widget with scripts inert.::scriptis always blocked - external figure, math, diagram, and Plotly loads are disabled in preview
- permissions are checked on document, site, export, share, collaborator, and DB endpoints
- group grants are resolved dynamically and participate in the same permission checks
- approvals and agent patch proposals are bound to immutable document hashes
- agent patches are re-proofed after independent review and immediately before apply
- share links are role-scoped and token-based
- DB queries are resource-bounded and permission-aware, not arbitrary SQL
- request bodies have size limits
- server-side render paths escape user source before artifact output
- rendered links, buttons, datasets, and citations drop
javascript:,data:, and other script-capable URL schemes - published artifacts are served with a CSP
sandbox, so page scripts run on an opaque origin - browser sessions live in an HttpOnly
noma_sessioncookie; no raw token is kept inlocalStorage, and cookie-authenticated writes require theX-Noma-CSRFheader - personal access tokens are hashed at rest, scoped (
read,write,admin), expiring, and revocable; revoking one ends the sessions opened with it - token previews are visible only to their owner (
/api/users,/api/db/query) - workspace-wide enterprise settings require a workspace admin: the IDs in
NOMA_CLOUD_ADMIN_USER_IDS; production fails closed without it, and development falls back to the first user ever registered ?access=gate-token checks are rate limited on every path- backup imports are validated in full and written in one transaction without revealing which inaccessible IDs exist
- legacy JSON records are moved out of the data directory to
legacy-imported-<timestamp>/after their one-time SQLite import - adding an existing page to a space requires owner access on the page
- agent grants never exceed the agent owner's current access
- hosted patch proofs never read
::dataset{src=...}files from the server
For repository changes, run:
npx tsc --noEmit
npm test
npm run build:site
For browser acceptance, verify owner, editor, viewer, page edit, site edit, group inheritance, comments, mentions, notifications, approvals, project/issue workflows, sprint carry-over, issue-linked patch review, share links, published site, artifact export, DB schema/query, diagnostics, mobile layout, and XSS payload handling.
Troubleshooting
| Problem | Check |
|---|---|
| Save is disabled | Your current role is viewer, the server is busy, or no page is selected. |
| New Page is disabled | You need an editor or owner role on the workspace. |
| Invite is disabled | Only owners can invite collaborators. |
| Published page is old | Save the page before copying/opening a published link. |
| DB query returns empty results | Confirm the bearer token belongs to a user with access to the workspace or document. |
403 csrf_required from a script | Send a bearer token instead of the browser cookie, or echo the noma_csrf cookie in X-Noma-CSRF. |
403 insufficient_scope | The personal access token lacks write (mutations) or admin (/api/enterprise); create one with the needed scopes. |
403 admin_not_configured | Set NOMA_CLOUD_ADMIN_USER_IDS on the production server and restart it. |
| A patch fails | Use a smaller patch op, verify the target ID in the outline, and fix validation errors before saving. |
| A collaborator cannot edit | Invite their user ID as editor or create an editor share link. |
Noma Cloud is the collaboration layer. The .noma source, renderer outputs, block IDs, validator, proof/patch ops, and CLI remain the durable product contract underneath it.
For the market evidence and the next agent-human knowledge roadmap -- including block-native RAG, Ask Noma, LLM Wiki maintenance, scoped agent identities, knowledge health, connectors, and offline/realtime sequencing -- see Agent-Human Knowledge Platform Research and PLAN.md §26.

Comment editing, reactions, and quoted text
Comments are threads anchored to the page, a block, or an exact quote.
editedAt; the UI shows edited. Mentions added by an edit notify.deleted: true.resolvedAt.anchor: {blockId, quote, prefix, suffix}and the preview highlights the quoted text.The quote must appear in the block's rendered text when the comment is created. When later edits remove it, the comment is kept and returned with
outdated: true; the UI strikes the quote through and stops highlighting it.The same routes exist under
/api/sites/<site-id>/documents/<page-id>/comments.