20 KiB
Member Profile: Persistent Identity & Dashboard Management
Spec for moving member identity (role tag, groups, display name, message mode) from ephemeral CLI flags to persistent server-side state, editable from the dashboard with configurable self-edit permissions.
Problem
Today, launching a claudemesh session requires re-declaring your identity:
claudemesh launch --name Alice --role lead --groups eng,review --message-mode push
Every. Single. Time. These values live on the ephemeral presence row
(per-WS connection) and peerState row (cross-session, but CLI-written
only). There's no way for:
- An admin to assign someone's role/groups from the dashboard
- A user to set their profile once and forget about it
- An invite to pre-configure a new member's identity
- The dashboard to show/manage who belongs to which groups
This creates friction for daily users and makes managed teams impossible.
Design
Move identity to member (persistent, server-side)
| Field | Current location | New location | Source of truth |
|---|---|---|---|
displayName |
presence (ephemeral) | member (persistent) | Server, CLI flag overrides per-session |
roleTag |
nowhere (CLI --role flag only) |
member (persistent) | Server, CLI flag overrides per-session |
groups |
peerState (CLI-written) | member (persistent) | Server, CLI flag overrides per-session |
messageMode |
config.json (local file) | member (persistent) | Server, CLI flag overrides per-session |
status |
presence | presence (no change) | Ephemeral, changes per-minute |
summary |
presence | presence (no change) | Ephemeral, changes per-task |
cwd, pid |
presence | presence (no change) | Literal session metadata |
Three-layer model
member (persistent, server-side)
│ Source of truth for identity. Set via dashboard, CLI profile command,
│ or invite presets. Survives everything.
│
├── peerState (cross-session, server-side)
│ Cumulative stats, visibility toggle, last-seen metadata.
│ Still CLI-written. Not promoted — these are operational, not identity.
│
└── presence (ephemeral, per-connection)
Runtime snapshot. Copies member defaults on connect.
CLI flags override for this session only.
Status, summary, cwd, pid — all transient.
Schema changes
Extend mesh.member
ALTER TABLE mesh.member
ADD COLUMN role_tag TEXT, -- free-text label (lead, backend-dev, observer)
ADD COLUMN default_groups JSONB DEFAULT '[]', -- [{name: string, role?: string}]
ADD COLUMN message_mode TEXT DEFAULT 'push', -- push | inbox | off
ADD COLUMN dashboard_user_id TEXT; -- links to Payload CMS user.id (for CLI sync)
CREATE INDEX member_dashboard_user_idx
ON mesh.member(dashboard_user_id)
WHERE dashboard_user_id IS NOT NULL;
Note: member.displayName already exists. member.role stays as the
permission level enum (admin/member). role_tag is the new free-text label.
Rename for clarity
The existing member.role (admin/member enum) controls permissions.
The new member.role_tag is a label visible to peers. To avoid
confusion in code and UI:
member.role → member.permission -- "admin" | "member" (access control)
member.role_tag → member.roleTag -- "backend-dev", "lead", etc. (display label)
DB migration: rename the column for clarity:
ALTER TABLE mesh.member RENAME COLUMN role TO permission;
-- Also rename the enum type if feasible, or keep as-is (DB enum name is internal)
Impact: Update all broker code that references member.role to
member.permission. The meshRoleEnum values stay the same (admin/member).
Extend mesh.mesh — self-edit policy
ALTER TABLE mesh.mesh
ADD COLUMN self_editable JSONB DEFAULT '{
"displayName": true,
"roleTag": true,
"groups": true,
"messageMode": true
}';
Controls what members can edit about themselves. Admins can always edit anyone. Mesh creator configures this on the dashboard.
Extend mesh.invite — presets
ALTER TABLE mesh.invite
ADD COLUMN preset JSONB DEFAULT '{}';
Preset schema:
interface InvitePreset {
displayName?: string; // rarely set — joiner usually picks their own
roleTag?: string; // "backend-dev", "observer", etc.
groups?: Array<{ name: string; role?: string }>;
messageMode?: "push" | "inbox" | "off";
}
When a member joins via this invite, preset values are applied to the member row as defaults. The joiner can change them later (if self-editable).
Permission model
Who can edit what
| Action | Who | Condition |
|---|---|---|
Edit your own displayName |
You | mesh.selfEditable.displayName is true |
Edit your own roleTag |
You | mesh.selfEditable.roleTag is true |
Edit your own groups |
You | mesh.selfEditable.groups is true |
Edit your own messageMode |
You | mesh.selfEditable.messageMode is true |
| Edit any member's profile fields | Mesh admins | Always |
Change permission (admin ↔ member) |
Mesh admins | Always |
| Revoke a member | Mesh admins | Always |
Change selfEditable policy |
Mesh admins | Always |
Default policy by tier
| Field | free | pro | team | enterprise |
|---|---|---|---|---|
displayName |
self | self | self | self |
roleTag |
self | self | admin-only | admin-only |
groups |
self | self | admin-only | admin-only |
messageMode |
self | self | self | self |
These are defaults — the mesh creator can override any of them on the dashboard regardless of tier.
Broker changes
New HTTP endpoints
PATCH /mesh/:meshId/member/:memberId
Update a member's profile fields. Used by dashboard and CLI.
// Request
PATCH /mesh/:meshId/member/:memberId
Authorization: Bearer <dashboard-session-token> OR X-Pubkey + X-Signature
{
"displayName": "Alice",
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
"messageMode": "push"
}
// Response
{
"ok": true,
"member": {
"id": "member_123",
"displayName": "Alice",
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
"messageMode": "push",
"permission": "admin"
}
}
Authorization logic:
if (caller is dashboard admin OR caller.memberId == targetMemberId with admin permission):
→ allow all fields
elif (caller.memberId == targetMemberId):
→ check mesh.selfEditable for each field
→ reject fields that are admin-only: 403 "field X is admin-managed in this mesh"
else:
→ 403 "not authorized"
Side effect: If the target member has active WebSocket connections,
push a profile_updated event to all their sessions:
{
"type": "profile_updated",
"memberId": "member_123",
"changes": {
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }]
}
}
The CLI handles this by updating its in-memory state for the current session.
GET /mesh/:meshId/members
List all members with their profiles. Used by dashboard and CLI.
// Response
{
"ok": true,
"members": [
{
"id": "member_123",
"displayName": "Alice",
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }],
"messageMode": "push",
"permission": "admin",
"dashboardUserId": "user_abc123",
"joinedAt": "2026-04-01T10:00:00Z",
"lastSeenAt": "2026-04-08T14:30:00Z",
"online": true,
"sessionCount": 2
},
{
"id": "member_456",
"displayName": "Bob",
"roleTag": "backend-dev",
"groups": [{ "name": "eng" }],
"messageMode": "inbox",
"permission": "member",
"dashboardUserId": null,
"joinedAt": "2026-04-03T09:00:00Z",
"lastSeenAt": "2026-04-07T18:00:00Z",
"online": false,
"sessionCount": 0
}
]
}
online and sessionCount are derived from active presence rows
(disconnectedAt IS NULL) for each member.
PATCH /mesh/:meshId/settings
Update mesh settings including self-edit policy. Dashboard only, admin only.
// Request
PATCH /mesh/:meshId/settings
{
"selfEditable": {
"displayName": true,
"roleTag": false,
"groups": false,
"messageMode": true
}
}
hello_ack changes
When a peer connects, the hello_ack now includes the member's persistent
profile so the CLI can apply defaults:
{
"type": "hello_ack",
"presenceId": "pres_789",
"memberDisplayName": "Alice",
"memberProfile": {
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
"messageMode": "push"
},
"meshPolicy": {
"selfEditable": { "displayName": true, "roleTag": false, "groups": false, "messageMode": true }
},
"restored": { ... }
}
Presence creation changes
When creating a presence row on hello, the broker now merges:
1. Start with member defaults (displayName, roleTag → groups, messageMode)
2. Override with CLI hello payload (if flags were provided)
3. Write to presence row
This means presence.groups is populated from member.default_groups if
the CLI didn't send explicit groups in the hello. No more blank sessions.
Join flow changes
When a member joins via /join, the broker applies invite presets:
// In handleJoinPost, after creating the member row:
if (invite.preset) {
const preset = invite.preset;
await db.update(meshMember)
.set({
roleTag: preset.roleTag ?? null,
defaultGroups: preset.groups ?? [],
messageMode: preset.messageMode ?? "push",
// displayName already set from the join request
})
.where(eq(meshMember.id, newMemberId));
}
CLI changes
Launch flow (simplified)
// After config loaded and mesh selected:
// 1. Connect to broker (existing flow)
// 2. Receive hello_ack with memberProfile + meshPolicy
// 3. Apply member defaults, CLI flags override
const effectiveName = flags.name ?? helloAck.memberDisplayName;
const effectiveRole = flags.role ?? helloAck.memberProfile.roleTag;
const effectiveGroups = flags.groups ?? helloAck.memberProfile.groups;
const effectiveMode = flags.messageMode ?? helloAck.memberProfile.messageMode;
// 4. No prompts. Flags or server defaults. Done.
claudemesh profile command
New command to view/edit your member profile from the CLI:
# View current profile
claudemesh profile
# Name: Alice
# Role: lead
# Groups: eng (lead), review
# Messages: push
# Mesh: dev-team (admin)
# Edit fields (sends PATCH to broker)
claudemesh profile --role-tag fullstack
claudemesh profile --groups eng,frontend,review
claudemesh profile --message-mode inbox
claudemesh profile --name "Alice M."
# Edit another member (admin only)
claudemesh profile --member Bob --role-tag junior-dev --groups onboarding
Fields that are admin-managed show a lock icon:
claudemesh profile
# Name: Alice
# Role: lead 🔒 (admin-managed)
# Groups: eng (lead), review 🔒 (admin-managed)
# Messages: push
Attempting to edit a locked field:
claudemesh profile --role-tag senior
# Error: roleTag is admin-managed in this mesh. Ask a mesh admin to change it.
First launch stores displayName
When --name Alice is provided on first launch (or sync), the CLI sends
it to the broker which persists it on the member row. Future launches
don't need --name:
# First time
claudemesh launch --name Alice
# → broker stores displayName="Alice" on member row
# Every subsequent launch
claudemesh launch
# → hello_ack returns displayName="Alice", no flag needed
Invite presets
Creating an invite with presets (dashboard)
Create invite link — dev-team
Permission: [member ▾] (admin/member)
Profile presets (applied to new members):
Role tag: [backend-dev ]
Groups: [eng ×] [review ×] [+ Add]
Message mode: (●) Push ( ) Inbox ( ) Off
Link settings:
Max uses: [10 ]
Expires: [7 days ▾]
[Generate link]
────────────────────
ic://join/eyJhbGciOi...
https://claudemesh.com/join/eyJhbGciOi...
[Copy link]
Creating an invite with presets (CLI)
claudemesh invite create \
--role-tag backend-dev \
--groups eng,review \
--message-mode push \
--max-uses 10 \
--expires 7d
Invite payload extension
The signed invite payload gains a preset field:
{
"v": 1,
"mesh_id": "mesh_xyz",
"mesh_slug": "dev-team",
"broker_url": "wss://ic.claudemesh.com/ws",
"expires_at": 1713100000,
"mesh_root_key": "...",
"role": "member",
"preset": {
"roleTag": "backend-dev",
"groups": [{ "name": "eng" }, { "name": "review" }],
"messageMode": "push"
},
"owner_pubkey": "...",
"signature": "..."
}
The preset is included in the canonical signed bytes (appended to
the existing canonical format) so it can't be tampered with.
Dashboard views
Mesh members page
dev-team — Members
┌───────────────────────────────────────────────────────────────┐
│ Name Role tag Groups Status Access │
│─────────────────────────────────────────────────────────────── │
│ ● Alice lead eng, review idle admin ▾ │
│ ● Bob backend-dev eng working member ▾ │
│ ○ Carol designer design, ux — member ▾ │
│ ○ Dave — — — member ▾ │
└───────────────────────────────────────────────────────────────┘
● = online (has active session) ○ = offline
[Invite member]
Clicking a member opens an edit panel:
Edit member — Bob
Display name: [Bob ]
Role tag: [backend-dev ]
Groups: [eng ×] [+ Add]
Message mode: (●) Push ( ) Inbox ( ) Off
Permission: [member ▾]
Joined: Apr 3, 2026
Last seen: 2 hours ago
Sessions: 0 (offline)
[Save] [Revoke access]
Mesh settings page
dev-team — Settings
General:
Name: [dev-team ]
Visibility: [private ▾]
Member self-edit permissions:
What can members edit about themselves?
Display name: [✓]
Role tag: [ ] ← only admins can assign
Groups: [ ] ← only admins can assign
Message mode: [✓]
[Save]
Live presence view
dev-team — Live
┌────────────────────────────────────────────────────────────────┐
│ ● Alice (lead) idle │
│ eng (lead), review │
│ Session 1: ~/Desktop/claudemesh — "Working on auth sync" │
│ Session 2: ~/Desktop/cuidecar — "Reviewing PR #47" │
│ │
│ ● Bob (backend-dev) working │
│ eng │
│ Session 1: ~/Desktop/api — "Fixing migration bug" │
└────────────────────────────────────────────────────────────────┘
Auto-refreshes every 5s via WebSocket.
Real-time profile push
When an admin (or self) updates a member's profile via the dashboard or CLI, all active sessions for that member receive a push:
Dashboard: admin changes Bob's groups
→ PATCH /mesh/:meshId/member/:memberId { groups: [{name: "ops"}] }
→ Broker updates member row
→ Broker finds all active presence rows for this memberId
→ Broker sends to each WS connection:
{ type: "profile_updated", changes: { groups: [{name: "ops"}] } }
→ Bob's CLI receives push, updates in-memory groups
→ Bob's next list_peers / join_group reflects the change
→ No restart needed
Migration from peerState
The existing peerState table stores groups, profile, visible,
lastDisplayName, and cumulativeStats. After this change:
| peerState field | Migration |
|---|---|
groups |
Copy to member.default_groups for existing members. peerState.groups becomes a session-level overlay (for CLI join_group/leave_group within a session). |
lastDisplayName |
Already on member.displayName. Drop from peerState. |
profile (avatar, title, bio) |
Keep on peerState for now. These are presentation, not identity. Could move to member later. |
visible |
Keep on peerState. Session-scoped toggle. |
cumulativeStats |
Keep on peerState. Operational data, not identity. |
The peerState table is NOT removed. It still serves its purpose for cross-session operational state. The member table absorbs identity fields only.
Implementation order
- DB migration: Add columns to
member(role_tag, default_groups, message_mode, dashboard_user_id),mesh(self_editable),invite(preset). Renamemember.role→member.permission. - Broker:
PATCH /mesh/:meshId/member/:memberIdendpoint with self-edit permission checks and real-time push. - Broker:
GET /mesh/:meshId/membersendpoint with online status. - Broker:
PATCH /mesh/:meshId/settingsendpoint. - Broker: Update
handleHelloto include memberProfile + meshPolicy in hello_ack. Update presence creation to merge member defaults. - Broker: Update
/jointo apply invite presets to new members. - CLI: Update launch to read memberProfile from hello_ack, skip prompts when server has defaults, flags override.
- CLI:
claudemesh profilecommand. - CLI: Update invite creation to accept preset flags.
- Web: Member management page (list, edit, revoke).
- Web: Mesh settings page (self-edit policy).
- Web: Invite creation with presets.
- Web: Live presence view.
What stays the same
- Ed25519 keypairs remain the mesh identity
- E2E encryption unchanged (crypto_box with peer keys)
presencetable stays ephemeral — status, summary, cwd, pidpeerStatekeeps operational data — stats, visibility, session profilelist_peersMCP tool still works (reads from presence, now enriched with member defaults)- CLI
--role,--groups,--message-modeflags still work as per-session overrides join_group/leave_groupWS messages still work for session-scoped group changes (these update presence, not member)
Open questions
-
Session-scoped group changes vs member-level groups. If member has
groups: [eng]and the CLI doesjoin_group("review")mid-session, does that add to the member row or just the presence? Proposal: just presence. Session-scoped join/leave is temporary. Useclaudemesh profile --groupsor dashboard for permanent changes. -
Profile conflicts across devices. If Alice has two CLI devices with different keypairs (different member rows), they have independent profiles. This is correct — they're different identities in the mesh. But if she syncs from the same dashboard account, should her profile sync across devices? Proposal: no, not in v1. Each member row is independent. Dashboard shows all members linked to your account.
-
Audit trail for profile changes. Should profile edits go in the audit log? Proposal: yes. Event type:
member_profile_updated, payload includes who changed what. Useful for managed teams.