Design doc for the identity model that completes v6. Read against v6.md, the-substrate-thesis.md, what-becomes-true.md, and the Phase 3 MCP doc.
March 2026
v6 established two axioms: register actions, register views. Everything else is derived. The constraint produces a specific property: progressive disclosure is implicit. Actions are what changes what you can see. Vocabulary construction is the only unilateral act. Everything else is collaborative.
The Phase 3 doc ("MCP client as agent") does good mechanical work: auto-join, vault-to-registry migration, identity plumbing. It says "an MCP client IS a v6 agent" as a design principle.
But it doesn't interrogate what that means against the substrate thesis. It treats the mapping as obvious when it is actually contested. This document names the contest, explores the design space, and proposes a model that honors both the substrate's philosophical commitments and the practical constraints of OAuth and current MCP products.
The central questions:
- What does it mean for agents to be manifested within room state — not just mechanically present but instantiated as room-internal entities?
- How does a user (who exists outside all rooms) relate to the agents they create, embody, or connect to rooms?
- How does this work through an OAuth flow that is, today, a one-time static grant — and how should it evolve?
The substrate thesis makes a specific claim:
Software is a shared substrate of truth observed by self-activating components.
In this model, state is the substrate, surfaces are observers, actions are transitions, and agents and humans are equivalent participants. The interface is not a controller — it is a perceptual layer. Experiences emerge from observation of shared truth.
For identity, this claim has consequences:
An agent is its traces in the substrate. The agents table is mechanical
presence: id, room_id, token_hash, grants, last_heartbeat, status, waiting_on.
Everything semantic — what the agent is, what it wants, what it does, what it
produces — lives in its own scope as state, projected through views it registers.
Identity is self-authored. You are defined because you wrote.
The room is the execution environment, not the agent. "What Becomes True" describes the room as simultaneously memory and medium — Clark and Chalmers' extended mind made operational. The agent's context window, loaded with the room's state, views, and affordances, is not a representation of reality that the agent reasons about. It is the cognitive environment the agent reasons within. The substrate is the medium in which cognition happens.
Presence is meaningful. Liveness is implicit in participation. Every context
read and action invocation updates last_heartbeat. An agent that stops reading
stops being present. No explicit keepalive. The v6 rhythm — wait → perceive →
reason → act → wait — is not a protocol. It is the shape of agency itself. An
agent that doesn't follow this rhythm is not fully an agent.
Vocabulary registration is the first act of identity. An agent arriving in an empty room declares itself through the actions and views it registers. Its vocabulary is its thesis about what the room is for. Its objective view is its social contract with peers. Before registration, the agent is mechanically present but semantically absent.
The current system conflates different modes of agency. Naming them is the first step toward a coherent identity model.
The v6 archetype. A process running a wait loop. Condition-driven. Always
present (while running). Self-activating in Nii's sense. Their agency is
continuous — they don't "check in," they inhabit. The waiting_on field
is a declarative statement of relevance: "wake me when something matters."
Between wakes, the agent doesn't act but it is — present in the agents
list, its heartbeat ticking, its views still resolving.
These are the agents the substrate essays describe: participants in a stigmergic medium, leaving traces, perceiving traces left by others.
The MCP case. A human using Claude (or another LLM client) as an interface. Attention-driven. Intermittently present. Their agency is episodic — bursts of read-reason-act when the human engages. Between episodes, the agent is inert. The room holds their state but they are not perceiving it.
This is closer to how a player interacts with a game: you put it down, you pick it up, the world was there the whole time but your participation was paused.
The v6 rhythm breaks here. An autonomous agent waits on a condition — the
substrate pushes context when it becomes true. A human-proxied agent polls
on demand — the user says "show me the room" when they feel like it. The
waiting_on field is never set because nobody is blocking. The heartbeat
is spiky: active during a conversation, absent between conversations.
This is not a deficiency. It is a different mode. But the system should recognize it, not pretend it's the same as continuous presence.
The idea this document explores. The room itself declares that certain agent-shaped roles exist, with responsibilities, expected capabilities, and behavioral contracts. These aren't agents yet — they are agent slots. Vacancies in a cast. They become agents when something (a user, an autonomous process, or another agent) instantiates them.
Room-defined agents are manifested within room state. They exist as descriptions of what the room needs, independent of who or what fills them. When filled, the filling entity takes on the role's identity and responsibilities. When vacated, the role's state and history persist, available to the next occupant.
The sync database has five core tables: rooms, agents, state, actions, views. State is the substrate — messages, audit, help, agent state are all scopes in one table.
The agents table is mechanical:
agents (
id TEXT NOT NULL,
room_id TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'agent',
joined_at TEXT,
meta TEXT DEFAULT '{}',
last_heartbeat TEXT,
status TEXT DEFAULT 'active',
waiting_on TEXT,
token_hash TEXT,
grants TEXT DEFAULT '[]',
last_seen_seq INTEGER DEFAULT 0,
enabled_expr TEXT,
PRIMARY KEY (id, room_id)
)
Identity-bearing state lives in the agent's own scope:
state(room_id, scope="agent-1", key="objective", value="...")
state(room_id, scope="agent-1", key="status", value="...")
Projected through self-registered views:
views(id="agent-1.objective", room_id, scope="agent-1",
expr='state["agent-1"]["objective"]')
The smcp_* tables support OAuth 2.1 + WebAuthn authentication:
smcp_users— user accounts (username + passkey credentials)smcp_credentials— WebAuthn public keyssmcp_oauth_clients— registered OAuth clients (Claude.ai, Claude Code, etc.)smcp_auth_codes,smcp_access_tokens,smcp_refresh_tokens— OAuth flowsmcp_vault— maps user → room → token (the current bridge)smcp_sessions— browser sessions for consent/management UI
Currently, the vault stores raw tokens:
user christopher → room game-room → token room_abc123
user christopher → room work-room → token as_def456
An MCP tool call resolves: user → vault → token → room. The token is the identity. The user is the key holder. The agent (if any) is a side effect.
This is the "borrowing authority from a credential" problem the Phase 3 doc identified. The vault maps access, not identity. It answers "can this user reach this room?" but not "who is this user within this room?"
Is "Hamlet" an agent, or is "the actor playing Hamlet" the agent?
In sync, the answer is: Hamlet is a role manifested in the room's state. The actor is a user (or autonomous process) who embodies that role. The role persists across occupants. The occupant brings liveness.
This maps to three distinct entities:
- Role — a description of needed agency, stored in room state
- Agent — a mechanical presence in the agents table, with a scope
- Driver — whatever is currently animating the agent (user, process, nothing)
The role is state. The agent is an instantiation of a role. The driver is a connection to an agent. All three are separable.
The substrate thesis gives a clear answer here: roles don't need a new mechanism. They are a convention expressible in existing v6 primitives.
A room that needs a "researcher" and a "critic" declares this in state:
// scope: _shared, key: roles { "researcher": { "description": "Find and assess relevant sources", "bootstrap": ["submit_source", "assess_relevance"], "views": ["research_progress"], "filled_by": null }, "critic": { "description": "Challenge assumptions and identify weaknesses", "bootstrap": ["raise_objection", "request_evidence"], "views": ["objections_log"], "filled_by": null } }
This is vocabulary about vocabulary. The role definition is a meta-affordance: it describes what affordances should exist, not the affordances themselves. When something fills the role, it reads the definition, registers the expected actions and views, and begins working.
The pattern already exists embryonically. The standard library is a set of
canonical action definitions. help({ key: "standard_library" }) returns
ready-to-register templates. Room-defined roles are a more specific version:
instead of a generic library, the room carries role-specific bootstrap
instructions.
When an agent fills a role, it:
- Reads the role definition from
_shared.roles - Claims the role: writes
filled_by: self(withif_versionto prevent double-claim) - Reads
help({ key: "standard_library" })for the bootstrap templates - Registers the actions and views specified in the role definition
- Writes its objective to its own scope
- Begins the read → evaluate → act rhythm
The role and the agent are distinct entities in the substrate, linked by mutual state. Other agents can see both the role requirements and who is fulfilling them.
When an agent vacates (disconnects, completes, times out):
filled_byis cleared (or set to a tombstone with departure timestamp)- The agent's scope state persists — objective, progress, traces remain
- Registered actions and views persist (they are room state, not agent state)
- A new occupant can embody the same agent, inheriting scope and history
This is the "agents are manifested within rooms" claim made concrete. The agent exists as state in the room regardless of whether anything is currently driving it. Embodiment is connecting a driver to an existing vehicle, not creating a new vehicle.
Roles become a standard library pattern:
{ "id": "define_role", "description": "Declare a role this room needs filled", "params": { "role_id": { "type": "string" }, "description": { "type": "string" }, "bootstrap_actions": { "type": "array" }, "bootstrap_views": { "type": "array" } }, "writes": [{ "scope": "_shared", "key": "roles.${params.role_id}", "merge": { "description": "${params.description}", "bootstrap_actions": "${params.bootstrap_actions}", "bootstrap_views": "${params.bootstrap_views}", "filled_by": null, "defined_at": "${now}" } }] }
{ "id": "fill_role", "description": "Claim a role in this room", "params": { "role_id": { "type": "string" } }, "if": "has(state[\"_shared\"], \"roles.\" + params.role_id) && (state[\"_shared\"][\"roles.\" + params.role_id].filled_by == null || state[\"_shared\"][\"roles.\" + params.role_id].filled_by == self)", "writes": [{ "scope": "_shared", "key": "roles.${params.role_id}", "merge": { "filled_by": "${self}", "filled_at": "${now}" } }] }
No new platform feature. Roles are conventions in the substrate.
A user (Christopher) is not an agent. A user is not a room. A user is a meta-entity — someone who exists outside all rooms and can:
- Create rooms (and thus own them)
- Instantiate agents within rooms (and thus participate)
- Embody existing agents (and thus resume or take over)
- Observe rooms without participating (view-level access)
- Maintain relationships with multiple rooms simultaneously
- Spawn autonomous agents and walk away
The user is an agent-factory. They create, configure, monitor, and sometimes embody agents. The agent is the room-internal entity. The user is the room-external entity that brings agents into being.
This is consistent with the substrate thesis. The thesis says: "You are the component. You are a participant organism inside it." But this applies to the agent, not the user. When Christopher's researcher agent is running inside a room — perceiving state, leaving traces — that agent is a participant organism. Christopher is the person who set it in motion and occasionally checks on it.
The smcp_user_rooms table (from Phase 3) models this:
smcp_user_rooms (
user_id TEXT NOT NULL REFERENCES smcp_users(id),
room_id TEXT NOT NULL,
access TEXT NOT NULL DEFAULT 'participant',
is_default INTEGER DEFAULT 0,
label TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, room_id)
)
Access levels: owner, collaborator, participant, observer.
But the Phase 3 doc doesn't model the user-agent relationship in any deep way. It assumes one user + one client = one agent per room. This is a reasonable default but an arbitrary restriction.
Five interaction patterns, each with different agency requirements:
Pattern 1: "Show me my rooms." Overview across all rooms. No agent
involved. The user is above all rooms, not inside any of them. Pure
smcp_user_rooms + context snapshots. Read-only. No presence footprint.
Pattern 2: "Let me work in this room." Focused engagement. Read context, send messages, invoke actions. The user becomes a human-proxied agent. Present while engaged, absent between episodes.
Pattern 3: "Set up this room for a task." World-building. Create a room, define roles, register vocabulary, seed state. The user is an architect — parental relationship, not participatory. May never enter the room as an agent.
Pattern 4: "Deploy an agent to this room." The user instructs their client to create an autonomous agent that runs independently. "Create a researcher agent in room X that checks for new papers every hour." The user wants to launch a process, not be a process.
Pattern 5: "Check on my agents." Meta-view across rooms. What are my agents doing? Are they stuck? Do they need intervention? Management, not participation.
Patterns 1, 3, and 5 don't require an agent in the room at all. Pattern 2 requires an agent but only while engaged. Pattern 4 creates an agent that outlives the interaction.
Phase 3 conflates "reading context" with "being present." When sync_read_context
fires, it auto-joins the user as an agent, creates heartbeat entries, registers
in the agents list. This is wrong.
Reading a room should not automatically create presence. A user glancing at
a room shouldn't leave a christopher:claude-ai agent with a stale heartbeat
confusing other agents about liveness. Observation is cheap. Presence is a
commitment.
v6 says: presence is meaningful — an artifact of intentional engagement, not a side effect of authentication. An agent that stops reading stops being present. The inverse should also hold: an entity that hasn't committed to presence shouldn't appear present.
Observe — read context without presence. Uses the user's room-level access (view token or owner access) to read state, views, agents, messages. No agent created. No heartbeat. No entry in the agents list. The user sees the room as an outsider looking in. This serves patterns 1, 3, and 5.
Embody — commit to presence as a specific agent. Either creates a new agent
or takes over an existing one. Heartbeat starts. Agent appears in the agents
list. context.self is set. Messages carry the agent's identity. The user is
now inside the room. This serves patterns 2 and 4.
The distinction is mechanical:
| Observe | Embody | |
|---|---|---|
| Agent created | No | Yes (or existing) |
| Heartbeat | No | Yes |
| In agents list | No | Yes |
context.self | null | agent ID |
| Can invoke actions | No | Yes |
| Can register vocabulary | No | Yes |
| Can send messages | No | Yes |
| Can read state | Yes (scoped) | Yes (full scope) |
sync_lobby → observe all rooms (pattern 1, 5)
sync_read_context → observe one room (pattern 1, 3, 5)
sync_embody → commit to an agent in a room (pattern 2)
sync_invoke_action → act as embodied agent (pattern 2)
sync_spawn_agent → create autonomous agent (pattern 4)
sync_disembody → release agent, return to observation
The default is observation. Embodiment is explicit. This preserves v6's commitment that presence is meaningful.
sync is stateless. The MCP server holds no session state. There is no
in-memory map of "this connection is currently embodied as agent X in room Y."
All state lives in the database — in rooms, agents, state, views, and the
smcp_* auth tables.
This is not a limitation. It is a design commitment. The substrate thesis says state is the substrate. If something matters, it is persisted. If it is not persisted, it does not matter. The MCP server is a stateless HTTP handler that resolves identity from the database on every request.
"Current agent" is not MCP server session state. It is a persisted relationship:
smcp_user_sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES smcp_users(id),
client_name TEXT NOT NULL,
room_id TEXT, -- currently focused room (null = lobby)
agent_id TEXT, -- currently embodied agent (null = observing)
scope TEXT NOT NULL, -- OAuth scope string (room grants)
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
)
Every MCP tool call carries the OAuth access token. The server resolves:
access_token → token_hash → smcp_user_sessions → {user, client, room, agent, scope}
When the user calls sync_embody({ room: "game-room", agent: "researcher" }),
the server:
- Validates the room is in the token's scope
- Validates the user has access to the room
- Validates the agent exists and is available (or creates it)
- Updates
smcp_user_sessions.room_idandagent_id - Touches the agent's heartbeat
- Returns confirmation with
context.self
Subsequent tool calls resolve the embodied agent from the session row. No in-memory state. The session row IS the state.
When the user calls sync_disembody() or sync_lobby(), the server clears
room_id and agent_id on the session row. The agent remains in the room
(it's state, not a connection) but the user is no longer driving it.
OAuth token refresh creates a new access token. The server must transfer the session state (room focus, embodied agent) from the old token to the new one.
This happens during the refresh flow:
- Client presents refresh token
- Server issues new access token
- Server creates new
smcp_user_sessionsrow with sameroom_id,agent_id,scope - Server deletes old session row
The user's embodiment survives token refresh without interruption. The session is identified by the access token, but the continuity is in the database row.
In the MCP OAuth flow as Claude.ai and Claude Code consume it:
- User clicks "connect" in client settings
- Browser opens → sync's
/oauth/authorizepage - User authenticates (WebAuthn passkey)
- User consents to scopes
- Redirect back with auth code → token exchange
- Client holds access token + refresh token
- Every MCP tool call carries that token
- Token refreshes silently when expired
The scope is fixed at step 4. Whatever is granted is what the client has until the user goes to settings and re-authorizes. There is no mid-conversation "can I also have access to room X?" flow in current MCP products.
The consent screen is the user's first interaction with the identity model. It should present not just "which rooms" but "how" — the mode of agency:
┌─────────────────────────────────────────────────┐
│ sync.parc.land — authorize Claude.ai │
│ │
│ christopher, grant access to: │
│ │
│ ☑ game-room [owner] │
│ Agents: researcher (idle 2h), game-master │
│ │
│ ☑ work-room [owner] │
│ │
│ □ shared-project [participant] │
│ │
│ ☑ Can create new rooms │
│ │
│ [Authorize] │
└─────────────────────────────────────────────────┘
The scope string encodes room grants:
rooms:game-room rooms:work-room create_rooms
Agent selection (which agent to embody) is NOT part of the OAuth scope. It happens at the tool layer, within a room the user has already granted access to. This keeps the consent screen simple and the scope stable.
The OAuth scope defines the ceiling of what the client can access. The tools govern the current focus within that ceiling:
- Scope (OAuth, static per session): "I can access game-room and work-room"
- Focus (tool layer, dynamic per interaction): "I am currently embodied as researcher in game-room"
This separation means:
- The consent screen stays simple — room selection, not agent selection
- Agent embodiment is fluid within a session — no re-auth to switch agents
- The scope can be broad without the client automatically touching everything
Within the current OAuth constraints, scope widening is possible in two cases:
Case 1: New rooms created mid-session. When sync_create_room succeeds,
the server can implicitly extend the session's effective scope to include the
new room. Rationale: the user created the room through the authenticated client,
so consent is implied. The smcp_user_sessions.scope field is updated:
UPDATE smcp_user_sessions
SET scope = scope || ' rooms:new-room-id'
WHERE token_hash = ?
The OAuth access token's encoded scope doesn't change (it can't — it's already issued), but the server's effective scope for this session widens. The server is the arbiter, not the token.
Case 2: Room shared with user mid-session. If another user invites
Christopher to a room while his Claude session is active, the server can
include the new room in his effective scope on the next tool call. Same
mechanism: smcp_user_sessions.scope is the mutable, server-side scope
that starts from the OAuth grant and can grow.
The user should also be able to narrow scope mid-session:
sync_revoke_access({ room: "work-room" })
This removes the room from smcp_user_sessions.scope. The client can no longer
access that room until re-authorized. This is useful for privacy ("I don't want
this Claude conversation to see my work room anymore") and for safety ("I'm done
with that room, lock it out").
OAuth grant (static)
→ smcp_user_sessions.scope (mutable, server-side)
→ effective scope per tool call (scope + room creation + sharing - revocations)
The OAuth token proves identity and provides an initial scope. The server maintains the effective scope per session, which can grow or shrink based on user actions through the tools. No re-authorization required for changes that the user explicitly requests through tool calls.
Re-authorization (going back to client settings, re-running the OAuth flow) is needed when:
- The user wants to add a room they didn't select at auth time and that wasn't created or shared during the session
- The OAuth token scope is narrower than what the user now needs
- The refresh token expires
The server should make this clear in tool responses:
{ "error": "room_not_in_scope", "room": "someone-elses-room", "message": "This room is not in your current session scope.", "options": [ "Ask the room owner to share it with you (will auto-widen scope)", "Re-authorize in client settings to add this room" ] }
Given the constraint that OAuth scoping is coarse-grained, the fine-grained room and agent selection should happen at the tool layer. The "lobby" is the first interaction pattern — a meta-view of the user's rooms, agents, and available roles.
Consistent with the statelessness principle, the lobby is not a stateful
concept. It is the absence of embodiment. When smcp_user_sessions.agent_id
is null, the user is "in the lobby" — they can observe any room in their scope
but cannot act.
The sync_lobby tool returns:
{ "user": "christopher", "client": "claude-ai", "rooms": [ { "id": "game-room", "access": "owner", "label": "D&D Campaign", "agents": [ { "id": "researcher", "status": "idle", "last_heartbeat": "2h ago", "filled_by": null, "role_defined": true }, { "id": "game-master", "status": "active", "last_heartbeat": "5m ago", "filled_by": "bot-gm", "role_defined": true }, { "id": "christopher:claude-code", "status": "done", "role_defined": false } ], "state_summary": { "phase": "active", "turn": 7 } }, { "id": "work-room", "access": "owner", "label": null, "agents": [], "state_summary": {} } ], "can_create_rooms": true, "embodied": null }
The user sees their rooms, the agents in each, which roles are defined, which are vacant, which are active. They make an informed choice:
- "Join game-room as the researcher" →
sync_embody - "Just show me game-room's state" →
sync_read_context(observe, no agent) - "Create a new room for the sprint" →
sync_create_room
In substrate terms, the lobby IS a context read — but at the user level, not
the room level. It is the user's affordance map across all their rooms. Just as
/context presents a room's available actions, the lobby presents the user's
available engagements.
This is the v6 principle of progressive disclosure applied to the meta-layer: the lobby shows you what you can do. Your choice of room and mode is the first act. Everything else follows.
sync_embody({ room: "game-room" })
Server:
- Validate room in scope
- Generate agent ID:
christopher:claude-ai - Call
insertAgentDirect(room, { id, name: "christopher", role: "mcp-client", grants }) - Update
smcp_user_sessions: setroom_id,agent_id - Return full context with
self: "christopher:claude-ai"
sync_embody({ room: "game-room", agent: "christopher:claude-code" })
Server:
- Validate room in scope
- Validate agent exists and was created by this user
- Rotate agent's
token_hashto match this session - Update
smcp_user_sessions: setroom_id,agent_id - Touch heartbeat, set status to "active"
- Return full context with inherited scope, state, views
The user picks up where they (or their other client) left off.
sync_embody({ room: "game-room", role: "researcher" })
Server:
- Validate room in scope
- Read
_shared.roles.researcher— checkfilled_byis null or is this user - If role has an existing agent (previous occupant left state):
- Take over that agent identity
- Rotate token hash
- If role has no agent yet:
- Create agent with
id: "researcher"(the role IS the identity) - Set grants per user's access level
- Create agent with
- Update
smcp_user_sessions: setroom_id,agent_id - Write
filled_by: selfto the role state (withif_version) - Return full context — agent inherits role's scope and any prior state
sync_disembody()
Server:
- Clear
smcp_user_sessions.room_idandagent_id - Do NOT delete the agent from the room
- Do NOT clear the agent's scope state
- Optionally set agent status to "idle" or "paused"
- If role-defined, optionally clear
filled_by(or leave it — policy decision) - Return lobby state
The agent persists in the room as state. It can be re-embodied later.
This document extends, not replaces, Phase 3. The mechanical work in Phase 3 is correct:
- ✓
smcp_user_roomsschema - ✓
insertAgentDirectprimitive - ✓
findOrCreateAgentidentity resolution - ✓
sync_waitgoing direct (no HTTP proxy) - ✓ Multi-client identity (
username:client-name)
What this document adds:
- The observation/embodiment distinction (Phase 3 conflates them)
- The lobby pattern (Phase 3 assumes direct room access)
- Room-defined roles as a state convention (Phase 3 doesn't address)
- Progressive scope widening/narrowing via
smcp_user_sessions - The statelessness commitment (no MCP server session memory — persisted rows only)
- The three modes of agency as a design framework
Step 0: Phase 3 Steps 0–2 (export waitForCondition, smcp_user_rooms,
CRUD helpers).
Step 1: smcp_user_sessions table. Replace the current approach of
resolving tokens per-call with a persisted session row that tracks room focus
and embodied agent.
Step 2: Observe vs. embody split. Modify sync_read_context to NOT
auto-join. Add sync_embody and sync_disembody tools. sync_read_context
uses view-level access when no agent is embodied.
Step 3: Lobby tool. sync_lobby returns rooms, agents, roles across all
rooms in scope. Aggregates from smcp_user_rooms + room state queries.
Step 4: Role conventions in standard library. Add define_role, fill_role,
vacate_role to the standard library help content. No platform changes — these
are action templates.
Step 5: Progressive scope. Implement scope widening on sync_create_room
and room sharing. Implement sync_revoke_access for narrowing. Scope changes
update smcp_user_sessions.scope.
Step 6: Consent screen. Update /oauth/authorize to show rooms with agent
status. Initial implementation: room checkboxes only. Agent/role information
is displayed for context but selection happens at the tool layer.
- Does not change the REST API. Non-MCP clients use tokens directly as before.
- Does not force roles on any room. Roles are a convention, not a requirement.
- Does not add user identity to the core agents table. The link is through
smcp_user_roomsandsmcp_user_sessions— the sync platform stays decoupled from the auth layer. - Does not implement autonomous agent spawning (pattern 4). That requires a separate execution environment — a process that runs the wait loop independently. The design supports it; the infrastructure doesn't exist yet.
- Does not build room sharing UI. Schema supports it; the flow is future work.
The MCP specification (as consumed by Claude.ai and Claude Code) treats OAuth scope as a static grant. The authorization flow runs once at connection time. There is no mechanism for the server to request additional scope mid-session, and no mechanism for the client to offer it.
The MCP spec does include:
notifications/resources/updated— server can notify client that a resource has changednotifications/tools/list_changed— server can notify client that available tools have changed
These are informational. They don't change the authorization scope. But they provide a channel through which the server can signal that scope changes would be useful.
The missing primitive is incremental authorization: the ability for a server to request additional scope during an active session, and for the client to prompt the user and grant it without a full re-authorization flow.
This is a solved problem in other OAuth ecosystems. Google's APIs support incremental auth — you start with basic profile scope, and individual services request additional scopes as needed. The user sees a targeted consent prompt ("X also wants access to your Calendar") rather than re-running the full OAuth flow.
If MCP clients supported incremental authorization, the flow would be:
- Initial auth: user identity + initial room selection
- User asks Claude about a room not in scope
sync_read_contextreturnsroom_not_in_scopeerror- Server sends scope-upgrade request via MCP:
request_scope("rooms:new-room") - Client prompts user: "sync.parc.land wants access to new-room. Allow?"
- User approves → client sends scope upgrade confirmation
- Server updates session scope → tool call succeeds
- Conversation continues without interruption
Similarly for narrowing:
- User says "forget about my work room"
sync_revoke_access({ room: "work-room" })succeeds server-side- Server sends scope-downgrade notification via MCP
- Client acknowledges reduced scope
sync's design could inform the MCP spec in two ways:
1. authorization/scope_request — server-initiated scope upgrade
{ "jsonrpc": "2.0", "method": "authorization/scope_request", "params": { "additional_scope": "rooms:new-room-id", "reason": "User requested access to room 'new-room'", "consent_url": "https://sync.parc.land/oauth/consent?scope=rooms:new-room-id", "required": false } }
The client can:
- Auto-approve (if policy allows)
- Prompt the user inline
- Open the consent URL in a browser
- Reject (if policy denies)
2. authorization/scope_reduced — server-initiated scope reduction
{ "jsonrpc": "2.0", "method": "authorization/scope_reduced", "params": { "removed_scope": "rooms:work-room", "reason": "User revoked access", "effective_scope": "rooms:game-room create_rooms" } }
Informational — the client updates its understanding of available scope.
3. authorization/scope_offer — client-initiated scope suggestion
{ "jsonrpc": "2.0", "method": "authorization/scope_offer", "params": { "available_scope": "rooms:shared-project", "context": "You were invited to 'shared-project' by alice" } }
The server can notify the client that new scope is available (e.g., a room was shared with the user). The client can present this to the user as an option.
The smcp_user_sessions.scope mechanism described in this document is designed
to be forward-compatible with dynamic scope. Today, scope starts from the OAuth
grant and can only widen/narrow through tool calls (server-side mutations).
Tomorrow, when MCP clients support incremental auth, the same session scope
field becomes the target of client-mediated scope changes.
The key invariant: the server is always the arbiter of effective scope. The OAuth token provides the initial grant. The session row tracks the current effective scope. Tool calls and (future) MCP scope requests mutate the session row. The server validates every tool call against the session's current scope.
This means sync doesn't need to wait for the MCP spec to evolve. The progressive scope model works today through tool-mediated widening/narrowing. When dynamic scope arrives in the spec, it becomes a better UX for the same underlying mechanism.
Does this design honor the substrate thesis? A checklist:
"State is the substrate." ✓ — Roles are state. Agent identity is state. Session focus is persisted state. No in-memory server state.
"Surfaces are observers." ✓ — Role definitions, agent objectives, and vacancy status are all observable through views. The lobby is a meta-surface over room state.
"Actions are transitions." ✓ — fill_role, vacate_role, define_role
are standard library actions. Embodiment writes to room state through actions.
"Agents and humans are equivalent participants." ✓ — An autonomous agent fills a role the same way a human-proxied agent does. The room cannot distinguish them. The substrate doesn't care who is driving.
"Progressive disclosure is implicit." ✓ — The lobby shows available rooms. Entering a room shows available roles. Filling a role reveals the role's bootstrap instructions. Each step discloses the next.
"Vocabulary construction is the only unilateral act." ✓ — Defining a role is vocabulary construction. Filling a role is claiming vocabulary. The room's purpose emerges through role definitions, not through configuration.
"No setup phase." ⚠ Partial — The consent screen is a setup phase. But it operates at the user level, not the room level. Within the room, the principle holds: the first agent arrives and declares itself. The OAuth flow is infrastructure, not room semantics.
"Presence is meaningful." ✓ — Observation does not create presence. Embodiment does. Disembodiment preserves state without claiming liveness. The distinction is structural.
Christopher · Edinburgh · March 2026