Tokens and grants
Every call carries one JWT. The token fixes the caller (sub), the org, the namespaces it may
read and the roles it holds. The rest of the identity tuple (agent, user_id,
session_id) travels per call. Rows in loom.namespace_grants add per-namespace roles on top
of the token. This guide holds the procedures;
identities and namespaces explains the model and
security the verification.
Mint a token
| Path | Who | Produces |
|---|---|---|
loom mint-token |
an operator holding the signing key | any token, for any subject (the CLI signs locally): the way to mint in another principal's name |
POST /v1/admin/token.mint |
an org_admin token |
a service-account token delegated from the caller's, inside the caller's own org, saved to the caller's token list; body fields name, namespaces, roles, ns_roles, consumer, ttl_seconds, saves_to |
POST /v1/me/service_accounts.create |
any signed-in person (Settings → Tokens, loom token create, agent.account.create_token) |
a token delegated from the person's, saved to their token list: everything they belong to (*) or only the namespaces listed (one token for every use) |
loom login, loom.login() |
a person, on any browser | an hour-long user token and a rotating refresh token, stored in ~/.loom/credentials (below) |
POST /v1/auth/login, OAuth |
a person | a user token for their own subject |
POST /v1/auth/self-signup |
anyone, when LOOM_SELF_SIGNUP=open |
a writer token on the namespace the caller claims |
POST /v1/auth/agent-signup (loom init --agent) |
a bot, when LOOM_SELF_SIGNUP=open |
a service-account writer token on agent-<slug> plus a claim code that works once (headless bots) |
loom mint-token --subject my-bot --org acme --namespaces support --roles writer --ttl-days 30 --save
# /home/you/.loom/token
loom ships with the server package and the Docker image, not with the SDK; in a container
run docker exec <server-container> loom mint-token .... Minting needs the signing key,
LOOM_JWT_SECRET. A fresh clone gets a development secret from .env.example.
loom mint-token and loom doctor warn while it is in use, and a deployment with auth on
refuses to start with it (except the make dev stack).
| Flag | Default | Meaning |
|---|---|---|
--subject |
(required) | The principal (sub), recorded on every ledger row. |
--org |
(required) | The org. * is a cross-org wildcard. |
--namespaces |
none | Readable namespaces, space- or comma-separated; * reads every namespace of the org. Exclusive with --namespace-role. |
--roles |
writer |
viewer, writer and/or admin, applied to every namespace the token names. |
--namespace-role NS=ROLE |
none | Repeatable. Makes a scoped token: exactly these namespaces, in this order (the read priority), each with its own role (ns_roles claim). |
--ttl-days N |
none | Lifetime. Without it the token has no exp and lives until revoked; use a TTL for anything that leaves your machine, and re-mint on a schedule. |
--read-scope |
none | namespace, own_user or own_session: the widest read the token may make (read scope). |
--read-user-id USER_ID |
none | Pin every read to one end user; a request naming another user is refused. |
--traffic-class |
none | production, interactive or bench: the rate buckets, ClickHouse priority, LLM-tier policy and cost column the token's calls use. Omitted, the namespace row decides. |
--consumer NAME |
none | Bind the consumer tag: every call is attributed to NAME whatever its body says. |
--service-account |
on | Mark the token as a service account (sa: true). |
--user-token |
off | Mint a user token instead. |
--save [PATH] |
off | Write the token to PATH (default ~/.loom/token, mode 0600, ~/.loom created 0700) and print the path. The SDKs, the CLI importers and the plugin hooks read that file. |
--json |
off | Print a JSON summary (with jti) instead of the bare token. |
Without --save stdout is the bare token, so TOKEN=$(loom mint-token …) works. The
CLI reference lists every flag as the parser defines it.
Sign in from a terminal or a notebook
People who never open the dashboard sign in with the OAuth device grant. It works the same with the password form and with single sign-on, and on a machine with no browser (an SSH box, a remote notebook kernel):
$ loom login --server https://loom.example.com
To sign in, open https://loom.example.com/oauth/device and enter the code BCDF-GHJK
$ loom whoami
$ loom logout
Open the URL on any device, sign in, check that the code matches, and approve.
In Python the same flow is loom.login("https://loom.example.com"). With
only the SDK installed (pip install clickhouse-loom), the commands are
python -m loom login, python -m loom whoami and so on. The loom command
comes with a checkout of the server (pip install -e .), and it needs the
SDK beside it for these.
The session is stored in ~/.loom/credentials (mode 0600): a one-hour
access token and a refresh token that rotates on every use. An Agent given
no api_key and no LOOM_API_KEY, talking to that server (base_url= or
LOOM_BASE_URL), uses it and refreshes as it goes. If a
spent refresh token is ever presented again, the server ends the whole
session. loom logout revokes it on the server. --profile NAME (or
LOOM_PROFILE) keeps sessions for several servers side by side; a profile's
token is only ever sent to the server it signed in to.
CI and services have no one to approve a code. Give them a token that reads
only the namespaces they need instead, made under Settings → Tokens (Only what you
choose), with loom token create --namespace …, or by an operator. Pass it as
LOOM_API_KEY, which takes precedence over a stored session:
loom mint-token --service-account --subject sa:ci --org acme \
--namespace-role support=writer --ttl-days 30
Read the claims
| Claim | Meaning |
|---|---|
sub |
The principal. Service-account subjects created from the dashboard start with sa:. |
org |
The org; * reaches every org. |
namespaces |
The readable set, in order; ["*"] reads every namespace of the org. |
roles |
The flat role list, applied to every readable namespace. |
ns_roles |
Scoped tokens only: namespace → role. Authoritative: it replaces roles and the grant lookup for those namespaces. |
ns_write |
Optional writable subset of namespaces, set through mint_token(write_namespaces=…) in server/auth/jwt.py; no CLI flag. Absent = writes follow reads. |
read_scope, read_user_id |
The read ceiling and the pinned end user. |
traffic_class, consumer |
Bound traffic class and consumer tag. |
sa |
true for a service account, false for a user token. |
jti |
The token's id: stamped on the audit and exposure ledgers as token_id, and the handle loom revoke-token --jti takes. Not a secret. |
parents, parent_subjects, parent_iats, creator |
Delegated tokens only: the ids, subs and iats of every ancestor token (parents also names a third-party MCP client's session), and the person whose live grants the token is rechecked against (delegated tokens). |
exp |
Expiry, when minted with a TTL. |
iss, aud, iat |
LOOM_JWT_ISSUER (loom), LOOM_JWT_AUDIENCE (loom-platform), issued-at. |
The JWT header's kid names the signing key. GET /v1/whoami shows what the server
verified for a token: the first thing to check when a call is refused.
Warning: wildcards are wide.
org="*"ornamespaces=["*"]reads everything below it. Mint them only for operator tooling. They never count for a restricted namespace.
Signing keys
LOOM_JWT_SECRET is the root secret. It signs and verifies as kid default (HS256) and
seals every stored credential and every other signing key (Security).
The other keys live in loom.signing_keys, added by loom signing-key rotate
(rotate). Every process re-reads them every
LOOM_JWT_KEYRING_REFRESH_SECONDS and records that interval in loom.keyring_readers;
the newest active key signs. A PEM LOOM_JWT_SECRET seals, and signs RS256 as kid
default.
| Setting | Meaning |
|---|---|
LOOM_JWT_SECRET |
The root secret: kid default, and the key that seals stored credentials and keyring keys. |
LOOM_JWT_KEYRING_REFRESH_SECONDS |
Seconds between re-reads of loom.signing_keys (default 30, at least 1). rotate waits out twice the slowest process's. |
LOOM_JWT_PUBLIC_KEYS_JSON |
{kid: PEM public key} of a gateway whose RS256 tokens this deployment accepts; a token without a kid is looked up as default. |
LOOM_JWT_TRUSTED_ISSUERS |
Extra accepted iss values, for a gateway minting Loom-shaped tokens. |
LOOM_JWT_LEEWAY_SECONDS |
Clock-skew tolerance on iat / exp (default 30). |
/v1/.well-known/jwks.json serves the public keys of the keyring's RS256 keys.
Verification accepts only HS256 and RS256; alg=none is rejected. The
configuration reference lists every auth setting.
Choose roles
Roles are ordered; a higher role satisfies every check for a lower one. Each op declares its
required_role (GET /v1/operations, and the operations reference).
| Role | Grants |
|---|---|
viewer |
Read. |
writer |
Read and write memory, knowledge and the other data planes. |
admin |
Administer the namespace: grants, configuration, policy, restore into it. Not org-wide visibility. |
org_admin |
Org-wide views (for example cost.summary without a namespace). A recognised gate with no in-product assignment path. |
platform_admin |
Cross-org operator views. Conferred at login to the handles in LOOM_PLATFORM_ADMINS, and to its emails only when an IdP verified them; to every token of a bot registered --platform-admin; minted only for a service account, with an expiry. |
Grants carry only viewer, writer or admin, and so do tokens, except a
platform_admin one: a bot's (Bots and CI) or
loom mint-token --platform-admin --subject sa:<name> --ttl-days <n>, which refuses a token
that never expires. The role spans every org.
Namespace access
Membership is the read boundary. A token that can read a namespace reads every row in
it, whichever agent wrote it; agent is authorship, and passing it on a read only narrows.
A call on (org, namespace) is allowed when:
- the namespace is not restricted (otherwise see restricted namespaces);
- for a scoped token (
ns_roles): its role for that namespace reaches the op's role, and a write also needs the namespace inns_writewhen the token has one. Nothing else widens it, except that a viewer grant lets it read a code namespace; - for a flat token: the token covers the namespace (
namespacesfor reads,ns_writefor writes) and a role inrolesreaches the op's role, or a live grant on the namespace does.
A scoped token called without org and namespace in the body answers
400 IDENTITY_INCOMPLETE: its authority is per namespace. The exceptions are ops that read
no namespace (namespace.browse, grants.pending_for_me, code.repos, …) and ops
addressed by a row id: those pass on the token's best namespace role, inside the token's
own org only. A scoped token's namespaces order is the search order across tiers.
A token can read a shared namespace and write only its own:
namespaces = ["team-shared", "agent-private"]
ns_write = ["agent-private"]
Read scope
read_scope bounds how far one read reaches inside a namespace. memory.search,
memory.search_trace, memory.ask, memory.recall, context.pack and profile.render
take it in the body; a token's read_scope claim is a ceiling the body can narrow but not
widen.
read_scope |
A read returns |
|---|---|
namespace (default) |
every row of the namespace |
own_user |
rows written under the user_id the request names |
own_session |
rows written under the session_id the request names |
Mint own_user (with --read-user-id where one token serves one end user) for an agent
that serves many users through one credential, so one user's prompt cannot pull another
user's memories:
loom mint-token --subject support-bot --org acme --namespaces support --roles writer \
--read-scope own_user --ttl-days 30 --save
A scoped read without the user_id / session_id it binds to is 400
IDENTITY_INCOMPLETE, never silently widened. A token scoped below the namespace binds
every op's reads, not only the ones that take read_scope in the body: user_id="*" is
refused, a pinned token's write naming another user is refused, another user's
memory_id reads as not found, and an op that cannot bound its
answer to the user (the entity graph, conversation reads, exports, admin ops) is 403
AUTH_INSUFFICIENT (read scope inside a namespace).
Give and take away grants
loom.namespace_grants holds one row per (org, namespace, subject): a role on one
namespace above what the token carries. Grants add; where the subject holds one, a
token's claim goes no further than it.
| Op | Role | Does |
|---|---|---|
grants.create |
admin | Give subject a role. Below the subject's current role it answers 409 GRANT_WOULD_DOWNGRADE unless replace: true. |
grants.revoke |
admin | Remove it; on a restricted namespace the grants of tokens the subject minted there go too. |
grants.list |
viewer | The namespace's grants. |
grants.approve |
admin | Activate a pending join request. Below the subject's current grant it answers 409: an approval never asks to lower one. |
grants.pending_for_me |
viewer | Requests waiting on the caller's approval. |
curl -s "$LOOM_BASE_URL/v1/grants.create" -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"org":"acme","namespace":"team-shared","subject":"sa:triage","role":"viewer"}'
- A claim goes no further than the grant. Where the subject holds a grant row on a
namespace the token names, of any status, the claim is capped at that grant: a
grants.revoke, areplace: truelowering,namespace.leave,loom claim-releaseor a restore ends every older token's claim there at once in the server process that made it and withinLOOM_ACL_WATERMARK_POLL_SECONDS(1 s) in every other process, on that server or another, and a pending or rejected request caps it at no access. A scoped token keeps its own role there, capped at the grant. A namespace an operator named at mint that was never granted to the subject keeps its claim; revoke the token for that. - A grant under an email reaches the account only once the email is verified. An SSO
account's email is verified (the identity provider vouched for it), and so is an email
linked to a password account (
loom sso adopt,loom sso link, a password link, or its owner proving the password from an SSO sign-in). A self-signup email is not: whoever typed the address first does not hold the grants written under it, which still cap that account's claims. Grant a self-signup account under its login handle, the namespace it signed up with. Agrants.revokeof an address no verified account holds, and that never held a row of its own on the namespace, takes the rows stored under each unverified account naming it, and the grants there of the tokens those accounts made. When the address held a row of its own there, its revoke also takes the grants of the tokens those accounts made there, unless one still holds an active grant of its own. - Registering is owning.
namespace.registermakes the caller the namespace'sadmin. With self-signup (LOOM_IDENTITY_PROVIDER=trust-network) the first principal to claim a name owns it;loom claim-release --org <org> --namespace <ns>revokes its grants, clears the owner and deactivates it. The name stays reserved (its memories are kept, and whoever claimed it next would own them); erasing the namespace removes the data but not the reservation. - Restoring into a namespace needs its admin.
namespace.restoreinto an existing namespace, active or soft-deleted, needs an admin of it. A collection name (col-…, or@me) is refused, asnamespace.registerrefuses one. - Service accounts inherit. An
sa:subject with no grant of its own on a namespace uses its creator's grant there, so a token minted from the dashboard follows the person who made it, including into namespaces they join later. Restricted namespaces are the exception. Tokens minted since delegation landed do this through theircreatorclaim (delegated tokens). - Pending grants do nothing until approved.
List every grant
grants.list answers one row per person, newest grant first: a login handle and the
verified email tied to it fold into one row, the other spelling in also. A person with an
active grant reads active at their highest active role, with any request of theirs in
pending_role. A page holds up to limit rows (default 100, at most 500). When more people
follow, the answer carries truncated: true and a next_cursor; send it back as cursor,
with the same org and namespace, until next_cursor is null. A person is folded before
the page is cut, so no page splits one. The cursor names the namespace it was issued for
under a MAC keyed by LOOM_JWT_SECRET: on another namespace, edited, or after the secret is
rotated, it is refused with 422. agent.grants.list in the Python SDK walks every page, up
to max_pages.
- A cursor is a place in the list, not a snapshot. A grant written during a walk (a new
grant, a role change, an approval) moves to the front of the list, which the walk has
passed, and a revoked one drops out. A
grants.revoketakes the person's row under every verified spelling, so no older spelling brings them back; anamespace.leavedrops only the caller's row under their exact subject, so one under their verified email can. A tie between a login and an email made between pages can hide the person it joins, not even listed inalso, and a tie removed can list them twice. Everyone else comes back exactly once; a walk started after the change sees it. - A failed read fails the call. The answer is 503
BACKEND_UNAVAILABLEwhen ClickHouse is unreachable and 504BACKEND_TIMEOUTpast the read deadline, never a list with people unfolded. Retry with the same cursor: it holds only a position. - A server without paging cannot say the list is whole. An answer without
truncated, or one that namescursorinignored_args, comes from a server that does not pagegrants.list; a client walking the pages stops there with an error, as the SDK does. - Every page reads the whole namespace. A page groups all of the namespace's live grants and scans the org's account directory for their ties, so a late page costs what the first does.
One token for every use

Settings → Tokens → New token, loom token create and agent.account.create_token
make the same token for an AI tool, an agent, a script or a CI job. One answer holds the
token, once, with the setup of every supported MCP client (clients) and where it saves
(saves_to); the same token also works as LOOM_API_KEY. Two choices shape it:
- What it reads.
namespaces: "*"(the default) reads every namespace and repository the person belongs to, now and after they join more, at their own role in each; it takes norole, so for a read-only token list the namespaces withviewer. A namespace named before*is where it saves, when it may write there; restricted namespaces it names are read too, each with a grant of the token's own capped at the person's own grant there (a namespace they hold none on is refused). A restricted repository brings its bound restricted tracker record, read-only. A list without*reads exactly those, in that order (the read priority), each at the role given there, never above the person's grant. - Where it saves. A call that names no namespace lands, for a
*token (or anadmin/token.minttoken whosenamespacesstart with one), in the namespace named before*when it is a memory namespace it may write (a collection there is a 422); otherwise it follows the namespace the person works in on the dashboard, as their own sign-in does, except that a collection picked there is skipped for the next memory namespace it may write. A list token saves where the mint recorded it, in the token itself:saves_to, a memory namespace of the list it may write (anything else, such as a repository, a collection or a namespace it only reads, is a 422 naming it), else the first such namespace in the list's order, else nowhere by default: a call names where it saves (with no write role it only reads). So a token can search a shared namespace first and save to another. Switching namespace on the dashboard never moves it. If the token may no longer write there, a call lands in the first memory namespace of its list it may write (never a collection), then, to read, in the recorded one or the first namespace of its list it may read, a collection only while it cannot write there; never in the person's dashboard pick.saves_togoes with a list only (*takes the namespace before it), andloom token create --saves-tosends whichever form the token takes. A token minted from a list token saves where its parent does when it may write there.service_accounts.createanswers with the namespace (saves_to);admin/token.mintanswers the namespace the token names for its saves while it may write there, "" otherwise.
On the dashboard, Everything you belong to reads no collection: a * claim names none,
and a delegated token reads a collection only when its claim lists it. Only what you
choose lists three groups: Namespaces, Collections and Repositories
(read-only). A collection is offered by its title and the collections it sits in
(Library › Papers, its col-… name in its tooltip), and only where the person holds a
grant on it directly, the grant the mint accepts; one read through a parent, a group or
everyone is not offered. Saves memories to never lists a collection.
Blank names become token-<day>-<hex>. Every token the person made is listed with what it
reads and, when it records one, where it saves, and revoking it there ends it in every
tool at once; a collection there is named by its title.
Delegated tokens
service_accounts.create (the dashboard's New token form, loom token create, the SDKs) and
admin/token.mint mint a token from the caller's own, through one policy (server/auth/delegation.py). The child gets
what was asked for, cut to what the caller's token allows and to the caller's live grants:
- the caller's org only (an org-wide
*token names one); viewer,writeroradminonly;org_adminandplatform_adminare never delegated;- on each named namespace, at most the person's live grant; a namespace without one is
refused (403 naming it). A per-namespace child of a sign-in token (or of a flat
loom mint-tokentoken) takes that grant, as the gate admits the token there on its grant alone. A flat child, a child of a delegated or per-namespace token, and a child on a restricted namespace also stay inside the caller's token role there; *only from a sign-in token or a delegated*token, and it means "wherever the person holds a grant", at the caller's token role; from a narrower token it becomes that token's own namespace list, at the lowest role the narrower token holds on it (a role per namespace needs a list). A namespace named beside*without a role does not lower that one role: the person's live grant caps every call there anyway;- the caller's
read_scope,read_user_idandns_write; a per-namespace child asking forwriteroradminon a namespace outside the caller'sns_writeis refused, and the inheritedns_writebinds the child's writes whatever itsns_rolessay; - a service-account token under its own
sa:subject, saved to the caller's token list (loom.service_accounts), where it is revoked on its own; - an expiry:
ttl_days/ttl_seconds, 90 days when unset,0for never, and never past the caller's own token'sexp(a longer TTL is clipped, and a token that expires cannot make one that never does), except from a sign-in session's hour-long token (loom login, dashboard SSO), whose children last what was asked; - a name: unique in its org while its token lives (
409 TOKEN_NAME_TAKEN, which does not say whose it is), free again once the token is revoked or has expired; names startingagent ·are agents' own, and a name holds no:; parents(every ancestor's token id, and the sign-in session of a third-party MCP client that minted it),parent_subjects(every ancestor'ssub),parent_iats(every ancestor'siat) andcreator(the person at the root).
On every request the gate rechecks a delegated token: the person's live grant must still
reach the role, inside the token's own claim (the token subject's own grants do not widen
it), and the person's account must still be active (a creator who owns namespaces, none of
them active any more, or who held grants and has none live, as after a claim release, stops
every token they delegated). An op with no namespace that writes needs the person to hold
that role on some namespace of the org. Revoking any ancestor's jti or sub (the
dashboard revokes a service account by its sub) or the person's sub kills the token, its
children and theirs. Tokens minted from a loom login or dashboard SSO session outlive its
logout and the end of the session for any other reason; they are revoked by subject or one
by one. Tokens a third-party MCP client's session minted end with that session. A revoke
with an issued-before cutoff judges each ancestor by its own iat and the person by the
root's, so a child minted after the cutoff from an older token dies with it; a delegated
token without aligned parent_iats is refused. loom mint-token signs with the key and is
not a delegation.
Let people join a namespace
A namespace's directory row (namespace.register) says who may join it:
| Field | Values |
|---|---|
visibility |
private (default) or public |
join_policy |
open, approval, closed, or inherit (unset) |
requires_approval |
true / false |
inherit resolves to open for a public namespace that does not require approval, and to
approval otherwise. namespace.join takes a role (default writer; ask for viewer to
be admitted at once on an open namespace) and decides:
| Policy | viewer join |
writer / admin join |
|---|---|---|
open |
active at once | pending |
approval |
pending | pending |
closed |
refused | refused |
namespace.leave, namespace.cancel_request, namespace.browse and namespace.list_mine
complete the self-service surface.
Code namespaces (kind=code, one per indexed repository and branch) have readers
(viewer) and maintainers (admin). visibility=public makes one joinable, not readable:
reading needs a grant or a token that names it.
LOOM_CODE_ORG_READABLE=true(or"org_readable": trueon an entry ofLOOM_CODE_REPOS_JSON) lets every authenticated principal of the org read every code namespace with the token it holds.- Refreshing (
writer) and administering (admin) always come from grants. - An unowned code namespace is adopted with
POST /v1/admin/namespace.adopt(orloom namespaces-adopt); a restricted one never is (below).
Restricted namespaces (classification=restricted)
Everything above widens access. classification narrows it: a namespace whose
loom.namespaces.classification is restricted, or whose LOOM_CODE_REPOS_JSON entry says
"classification": "restricted" (either input restricts), switches every default off. A
read or write passes only when all hold:
- the token belongs to the namespace's org (
org="*"does not count); - the token names the namespace literally, in
namespacesorns_roles(namespaces=["*"]does not count); - the token's own role there reaches the op's role;
- a live direct grant for the token's own subject reaches it (a service account does not inherit its creator's grant).
No org-readable rule, visibility=public, org_admin or platform_admin bypasses this. A
refusal is the generic "cannot access" message, so it does not reveal whether the namespace
exists. If the classification cannot be read, access is denied.
- Search fan-out, context packs and org-wide rollups skip the restricted namespaces the
caller cannot read, and
code.reposhides them. - Each request admitted to the restricted namespace it names writes a
namespace.restricted_accessaudit row before the answer (LOOM_RESTRICTED_AUDIT_INLINE=falsewrites it beside the request instead, which a killed process can lose). A delegated mint (POST /v1/admin/token.mint,POST /v1/me/service_accounts.create) writes one for each restricted namespace whose direct grant it writes for the new token, with the minter as actor and the token's subject asgranted_to. An inbox watch writes one for the inbox's owner namespace, and the MCP primer (auditopmcp.primer) for the default namespace whose profile it renders. A read an op handler admits by id, on the row's own namespace, writes none. Code search traces keep only the query digest. - A restricted namespace cannot be made public; its directory row changes only for a caller
who passes the four checks at
admin. POST /v1/admin/token.mint, like every delegated mint, names a restricted namespace only for a caller holding a grant there at or above the requested role (passns_rolesfor per-namespace roles).POST /v1/admin/namespace.adoptandPOST /v1/admin/claim_releaseanswerNAMESPACE_NOT_FOUNDon a restricted namespace the restricted gate refuses the caller at admin (a token naming it and a direct admin grant), so an org admin cannot adopt a restricted namespace that has no admin. Adopting a name with no directory row, or one whose classification cannot be read, gets the same answer.LOOM_RESTRICTED_SERVE=falserefuses every restricted read while ingest continues.
Warning:
loom mint-tokensigns locally and is not checked against grants. Whoever holds the signing key can mint any token; the direct-grant check at request time is what still protects a restricted namespace.
To give a reader access, do both: a direct grant on the subject, and a token that names the namespace.
# as an admin of the namespace
curl -s "$LOOM_BASE_URL/v1/grants.create" -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"org":"acme","namespace":"incidents-private","subject":"sa:triage","role":"viewer"}'
# as the operator
loom mint-token --subject sa:triage --org acme --namespaces team-shared,incidents-private \
--roles viewer --ttl-days 30
A person who holds a direct grant can also add the namespace to a token minted from the
dashboard: the mint names it in the claim and writes a direct grant for the token's own
sa: subject (viewer on a repository index, else the token's role capped at writer).
That grant is revoked with the token, and when the person's own grant is revoked.
Add history policy
Enforcement is off unless LOOM_POLICY_MODE turns it on. policy.set, policy.delete and
policy.decisions are served over HTTP and the SDKs, not over MCP.
A policy rule restricts what a caller may do based on its own recent history in
loom.agent_events, evaluated at call time after the role check. Rules are per
(org, namespace) and can only take permission away.
effect: "forbid"denies when the condition holds (count_within 1h memory.set gte 200is a durable, cross-replica per-agent rate limit).effect: "require"denies when it does not hold (formerly within 1h memory.getbefore amemory.delete;args_equal: {"memory_key": "$args.memory_key"}requires the same key, andout_<field>names what an earlier call returned).
Leaves are formerly, count_within, not_since and breadth_within (how many distinct
memory keys the verified subject was shown in the window, as a value or a fraction of
the namespace), combined with all, any and not. Each leaf reads only the caller's own
events, keyed by agent (default), session or user; windows are <n>s|m|h|d, capped by
LOOM_POLICY_MAX_WINDOW_SECONDS (default one day).
LOOM_POLICY_MODE |
Effect |
|---|---|
off (default) |
No rule is evaluated and nothing is recorded, whatever a rule's own mode says. |
shadow |
Each decision is recorded in loom.policy_decisions; nothing is blocked. |
enforce |
A denial answers 403 POLICY_FORBIDDEN with decision, determining_rules and errors. |
Under shadow or enforce, a rule's own mode (inherit, shadow, enforce) overrides
the deployment's. Under enforcement, a rule that cannot be evaluated denies a write and allows
a read. Start in shadow, read policy.decisions, then enforce.
curl -s "$LOOM_BASE_URL/v1/policy.set" -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{
"org": "acme", "namespace": "team", "policy_id": "get-before-delete", "effect": "require",
"actions": ["memory.delete"],
"condition": {"op": "formerly", "within": "1h", "operation": "memory.get",
"args_equal": {"memory_key": "$args.memory_key"}}}'
policy.check evaluates a hypothetical call without blocking (any role); policy.list,
policy.delete and policy.decisions complete the surface. policy.set and
policy.delete need admin, are audited, and report widening when a change loosens a
rule (which also writes a policy.widened audit row).
Pick an identity mode, gateway or IdP
LOOM_IDENTITY_PROVIDER (single-user, trust-network or gateway) picks how people get
tokens; unset, it is inferred from LOOM_AUTH_REQUIRED and LOOM_SELF_SIGNUP at boot. The
modes are described in
identities and namespaces and their settings in
deployment.
People through an IdP. Single sign-on has the setup.
LOOM_OAUTH_ENABLED=true serves an OAuth 2.1 authorization
server for MCP one-click connect (/.well-known discovery, /oauth/{register,authorize,token};
LOOM_OAUTH_ISSUER is the public base URL). LOOM_OAUTH_LOGIN_PROVIDER=oidc sends the
sign-in to an external IdP (LOOM_OIDC_ISSUER, LOOM_OIDC_CLIENT_ID,
LOOM_OIDC_CLIENT_SECRET); Loom maps the verified email to an account and still mints its
own token. LOOM_OIDC_JIT_PROVISION=true creates a personal namespace in LOOM_OIDC_JIT_ORG
for a first sign-in with no account (LOOM_OIDC_JIT_EMAIL_DOMAINS restricts it); off, such
a sign-in is refused. A person who has an active account in that org, whatever its name, is
signed in to it and never given another. JIT never rebuilds an existing name: a released,
deleted or another account's one is skipped for the next free name, <name>-2 and on,
then a random suffix. Two first sign-ins of one person at the same moment are kept to one
account on one server; on two servers, with every predictable name taken, each may pick a
different random name, and an operator releases the extra one.
To offboard a JIT user, revoke their subject (loom revoke-token --subject <namespace>)
and delete their account: JIT refuses while any inactive account in the org still naming
their email is revoked outright. Deleting alone is not offboarding (its owner can do it),
and removing the domain from LOOM_OIDC_JIT_EMAIL_DOMAINS stops only new accounts from it,
not the sign-in of an account that exists; removing the person at the IdP stops both. A
revocation of a subject has no undo and lasts ten years: to let an offboarded person back
in, loom claim-release the revoked account, which clears its contact so it names no one.
That is also what a release does to an offboarded account tidied up later: it lets the
person sign up again, under a new subject.
An aggregator acting for its users. A subject listed in LOOM_TRUSTED_CALLERS may send
X-Loom-On-Behalf-Of: <subject>: the call runs as that subject and every ledger row records
the real caller in impersonated_by. A subject spelled like a grant principal (user:,
group:, agent:, everyone, in any case, bare or after a /) is refused: end users are
signed in by their own issuer. A subject revoked outright (loom revoke-token --subject) or
deprovisioned through SCIM is not acted for (403), nor is a bot that loom bot disable has
disabled, or one of its agents (sa:<bot>, sa:<bot>:<agent>): a disable leaves the
agents' grants and ends the bot's tokens with a cutoff. Revoking a bot outright refuses
acting for its agents too. Any other sa: subject (a dashboard token's, an agent sign-up
token's, or one loom mint-token minted) is never acted for: it is a token's own subject,
and its grants must not outlive the token. A token minted under a subject without sa:
(loom mint-token --subject support-bot) reads to the server like a person's, so a trusted
caller may act for it until loom revoke-token --subject revokes it outright. Mint a token
that holds direct grants under an sa: name that no bot holds and that has no : in it,
since a bot's subject and its agents' (sa:<bot>:<agent>) are acted for while the bot is
active. A cutoff (--issued-before) ends a subject's own tokens but not a trusted caller's
calls for a subject it still acts for (a person, an active bot or one of its agents). Empty
disables the header. With self-signup open, act for email subjects (a sign-up name cannot
spell one) or grant each handle in LOOM_SELF_SIGNUP_ORG before acting for it: a handle
nothing reserves there is open to sign-up, and acting for it from a service account or
another org grants nothing there.
Act for a service's end users
A service in front of Loom, whose users have no Loom account, signs a short-lived RS256
token per request for the user it serves. Register it in
LOOM_JWT_END_USER_ISSUERS_JSON, under its issuer: the orgs it may sign for (exact names
or prefix*, never *) and where its keys come from, a jwks_url (https, fetched at
boot and every LOOM_JWT_END_USER_JWKS_REFRESH_SECONDS) or keys by kid:
LOOM_JWT_END_USER_ISSUERS_JSON='{"chat-app": {"orgs": ["acme", "team-*"], "jwks_url": "https://chat.example.com/.well-known/jwks.json"}}'
# or, with the public keys inline:
LOOM_JWT_END_USER_ISSUERS_JSON='{"chat-app": {"orgs": ["acme"], "keys": {"chat-1": "-----BEGIN PUBLIC KEY-----\n…"}}}'
The service then signs, for each call, a token with kid in its header, iss its
issuer, aud Loom's audience (LOOM_JWT_AUDIENCE), sub: user:<id>, org, a fresh
jti, iat and an exp at most LOOM_JWT_END_USER_MAX_TTL_SECONDS (5 min) later, and
agent: agent:<id> when the call runs inside one of its agents:
curl -s "$LOOM_BASE_URL/v1/knowledge.search" -H "Authorization: Bearer $END_USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"org": "acme", "namespace": "@me", "query": "renewal clause"}'
- The request runs as
user:u-42alone, in the token's org: an org member with no claim, reads pinned to that user's rows, reaching what grants giveuser:u-42, the groups that list it, the agent the token names, andeveryone. A token with any other claim (namespaces, roles, a read scope), an org its issuer does not list, nojtior a longer life is refused, and the key verifies its own issuer's end-user tokens and nothing else. Until an issuer's JWKS is first fetched, its tokens are answered 503 (retry). MCP,/v1/me/*and the admin routes refuse an end-user token. - Grants name these principals as subjects:
grants.createwithsubject: "user:u-42","group:finance","agent:helper"or"everyone". Groups, agents andeveryonehold grants on collections only. - Groups are pushed with a token of the org's admin to SCIM
/scim/v2/Groups(create, replace, patch members, delete); a member is a user id. A removed member loses the group's grants withinLOOM_ACL_WATERMARK_POLL_SECONDS. - Ids are lowercase letters, digits and
._@-.namespace: "@me"is the user's personal collection: only they reach it, and it takes no grant. - Of the ops that name no namespace, an end user may call
collection.create,collection.list,namespace.list_mine,grants.pending_for_meandmentor.guide, but no directory self-service (namespace.register,namespace.join,namespace.browse). - An agent's files: a collection granted to
agent:<id>, searched withknowledge.searchandnamespaces: ["<that collection>", "@me"], so the search reads those alone. A search that names neithernamespacesnornamespace_scopereads itsnamespacealone (this) for an end user;namespace_scope: "all_mine"widens it to every collection the user reaches. A document'sagent,session_idandsource_classcome back on each hit. - To cut off one user, revoke their tokens and grants and take them out of their groups,
or erase them (
erasure.requestwithscope_type: user_id, andpurge: trueto take the rows off disk). An end user's tokens are revoked by subject under their org,loom revoke-token --subject <org>/user:<id>, sinceuser:<id>names one person per org. Collections, their tree and the read cost are in Sharing knowledge.
Revoke and rotate
One token or one subject. loom revoke-token writes loom.revoked_tokens (it needs the
configured ClickHouse, LOOM_CH_*). Verification checks a token's jti and its sub
against the set.
loom revoke-token --jti <jti> # exactly one token
loom revoke-token --subject my-bot --reason "laptop lost" # every token for my-bot, in every org
loom revoke-token --subject my-bot --issued-before <ISO-8601> # only tokens issued before that instant
loom revoke-token --jti <jti> --expires-at <token exp, ISO-8601> # let the row lapse with the token
loom revoke-token --list # unexpired revocations
--issued-before keeps a token re-minted after a leak working. Without --expires-at the
row is kept for ten years. Revoking a service account from the dashboard
(/v1/me/service_accounts.revoke) revokes its subject the same way, along with the grants
the token held.
A --subject revocation with neither --issued-before nor --expires-at is for good: it
also marks revoked, in their token lists, the service accounts and MCP tokens the subject
made, directly or through its tokens, so their names come free, and it reports how many of
those tokens and of the subject's sign-in sessions it ends. Run again, it writes nothing and
says the subject is already revoked: the first revocation's reason and author stay. A
platform admin does the same from anywhere with POST /v1/admin/revoke_subject
(Deprovisioning without SCIM), and reads
the revocations with GET /v1/admin/revocations, narrowed to one subject with
?subject=<subject>.
Every process that verifies tokens keeps its own copy of the set, loaded at start-up and
refreshed every LOOM_JWT_REVOCATION_CACHE_TTL_SECONDS (default 30 s), so a revocation
reaches every replica within that window. Until a process has loaded the set once it
refuses tokens with 503 AUTH_UNAVAILABLE instead of admitting a revoked one; after that, a
ClickHouse outage keeps the last good copy.
Every token of a key. To rotate the signing key:
loom signing-key rotate [--algorithm HS256|RS256]writes a new key to ClickHouse. It starts signing--activate-inseconds later (300 by default). It refuses a value under twice the slowestLOOM_JWT_KEYRING_REFRESH_SECONDSa process recorded in the last two hours (this host's own when none did), so every process verifies the key before any token carries it. Old tokens still verify by their headerkid. No restart.- Re-mint the tokens you want to keep.
loom signing-key retire <kid>stops the old key verifying, within one refresh interval.defaultcan be retired too, for example in an RS256-only deployment; a retire that would leave no signing key is refused, and a second retire while one runs anywhere in the deployment is refused with "retry in a moment".
loom signing-key list shows the keys. Every replica reads the same table, so a rotation
needs no step on them.
Run the keyring commands with the servers' LOOM_JWT_SECRET. The keyring's default
row records an HMAC of that root secret, written by the workers leader or the first
keyring command; rotate, retire and loom mint-token refuse to run where
it does not match (a key sealed or a token signed there would not open on the servers),
and the keyring commands refuse when the keyring cannot be read. /v1/health reports a
server that never read the keyring (keyring_not_loaded), one whose root differs
(keyring_root_mismatch), keys it cannot unseal (signing_keys_unsealable) and a
keyring that leaves it nothing to sign with (no_signing_key; sign-ins answer 503
NO_SIGNING_KEY); loom doctor's signing_keyring line names the kid that signs.
Replacing LOOM_JWT_SECRET itself re-keys everything: tokens signed as default, every
keyring key and every stored credential sealed with the old value stop opening. Then run
loom signing-key record-root with the new value, so the keyring commands accept it, and
loom deployment publish --yes, so the replicas on the new value are ready.