Skip to content

Per-user agents & comms

Two things in Subspace are scoped to a single user with no shortcuts around it: an agent run, which acts with its owner’s exact access, and a communications account (mail, calendar, IM), which no one but its owner, not even an admin, can read. Everything else in a claimed instance follows page ACLs; comms are the one place the model is different by design.

Every agent run carries ops.agent_runs.owner_user_id. The run’s every tool call, page read, and page edit is evaluated against that owner’s live permissions, not the permissions of whoever’s near the run, an admin watching a shared task queue can’t see into a run’s private pages just by opening its panel.

Ownership is set once, at the entry point that starts the run:

Entry point Owner
agents.invoke (tRPC, interactive) The calling session’s user
Scheduled runs (schedule: in the definition) The agent definition page’s owner
agents.spawn (a child run started by a parent run) The parent run’s owner, inherited, not the definition page’s owner
Legacy / system runs NULL owner, full access, the pre-multi-user behavior

The authorize seam sits under the approval gate

Section titled “The authorize seam sits under the approval gate”

Every gated tool already has an approval gate. Per-user scoping adds a check that runs before that gate: ToolSpec.authorize(args, ctx) evaluates the call against the owner’s ACLs at the moment the tool would run, not at invoke time, so a grant revoked mid-run is enforced on the very next tool call in that run.

  • Denied: the authorize check returns a denial string. That string becomes the tool result directly. No approval row is created, no one is asked to approve a call that was never going to be allowed.
  • Permitted: the call proceeds to the normal gate exactly as before, unchanged. A tool that requires approval still requires approval; authorize only narrows what’s reachable at all.
Tool family Rule
kb.* page resolution Owner-readable-only; an unreadable page resolves as “no such page”, the same NOT_FOUND shape a human gets.
kb.search, kb.grep, kb.read (link-walk), node.read Filtered to owner-readable results.
kb.edit, kb.append, kb.remove, node.edit, node.create Require write access, checked both at authorize time and again inside the write transaction (applyKbCommandAs); landed commands carry kb_commands.user_id set to the owner.
memory.propose Requires write on the memory surface.
os.exec, code.* dispatch Require the run owner to be an admin. Non-admin owners can’t reach a host shell or coding-agent dispatch through an agent any more than they could directly.

An agent isn’t just scoped at the tool-call boundary, it’s assembled from only what its owner can read. At startRun, memory RAG retrieval, the injected skills block plus any skill.load call, bound mcp/ pages, and bound code/ functions are all resolved against the owner’s readable set. agents.list and agents.invoke apply the same filter to the definitions themselves: a definition page the caller can’t read doesn’t show up in the slash menu, and an unreadable definition on a scheduled or spawned run falls back to the built-in default agent constant rather than silently running with someone else’s private prompt.

Approval prompts and human-question cards stamp ops.tasks.user_id to the run owner, so the task queue shows each member only their own agent’s cards; admins additionally see every card, including the NULL-owner system cards from legacy runs. agents.decide and ops.tasks.resolve are owner-or-admin, anyone else gets NOT_FOUND. The run panel, the run tree, step logs, and the run:<id> WebSocket topic that streams a run live all follow the same owner-or-admin rule.

This is the one deliberate exception to admin visibility in the whole system. Everywhere else, an admin can see what they need to administer the instance; mail, calendar, and IM are different, because they’re not instance state, they’re someone’s actual inbox.

comms.mail_accounts, comms.im_accounts, and comms.cal_calendars all carry a user_id column, and every mail, calendar, and IM tRPC surface filters strictly to the caller (user_id IS NOT DISTINCT FROM $caller). Someone else’s thread doesn’t error, it reads as though it doesn’t exist, the same NOT_FOUND-shaped behavior as an unreadable page. Rows created before claim (user_id IS NULL) go to the claiming admin as part of the claim backfill; on a still-unclaimed instance, NULL rows are simply what everyone sees, matching legacy single-user behavior.

/oauth/gmail/start mints a one-shot state row in ops.oauth_states (purpose gmail-connect) that carries the connecting user’s id through the OAuth round trip. The callback stamps that user as the account owner: it adopts an unowned row if one matches, but never reassigns an already-owned account to someone else. Calendar rows follow the connected account’s email address automatically. Settings → Connected accounts lists only your own accounts, with connect and disconnect (disconnect drops the account row and its encrypted OAuth tokens).

mail.send, both as a tRPC procedure and as the agent tool, resolves only the caller’s (or the run owner’s) accounts; if there isn’t one, the call errors instead of silently using someone else’s. Mark-read and archive are owner-scoped the same way. A mail rule agent run acts as the account owner, not as whoever authored the rule, so a shared rule triggers with each account holder’s own access. Mail vector search hits are filtered by account owner, matching how page search filters by ACL. IM accounts key on (app, user), so the same WhatsApp connector can serve several people’s independent sessions; env-configured API connectors (as opposed to browser-automated ones) sync into the first admin’s accounts once the instance is claimed.

The Chrome extension’s automation host drains ops.browser_jobs, and every job row carries the user_id of whoever’s action enqueued it. A paired device pulls only its own user’s jobs; admins additionally drain the system’s NULL-user sweep jobs (scheduled polls, not tied to a person). Messages fetched by a job land in that job’s owner’s IM account, and a “logged out, please reconnect” card routes to the job owner’s task queue, not to whichever admin happens to be online.

How this composes with focus, triage, and notifications

Section titled “How this composes with focus, triage, and notifications”

Per-user scoping isn’t a separate subsystem bolted onto the ops spine, it’s the same filter applied consistently. Triage and the inbox show each user their own captured items; the task queue shows each user their own cards (admins see everything, including system cards); not-now locks are scoped per user’s own feed quotas; and notifications deliver to the device and user that owns the underlying event. An agent acting through mail or the task queue on your behalf shows up exactly where a card you filed yourself would, because it’s using your user_id throughout, not a privileged one.