From zombie-brains
Reference for designing Zombie Brains permission sets (roles) — which knob to turn for brain access, tool grants, connector gates, dataset access, variable scoping, and assigning a role to an agent vs a human vs an API key. Use whenever the user asks to set up a new agent's access, restrict a teammate, scope a secret, attach a dataset, or change who can do what. Pairs with zombie-create-agent (novice wizard) and zombie-build-tools (env.* gates).
How this skill is triggered — by the user, by Claude, or both
Slash command
/zombie-brains:zombie-build-permissionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Zombie Brains is role-based. A `permission_set` (the user-facing name; the DB table is `roles`) bundles together every grant that defines what a principal can touch — which **brains** (`role_brain_scopes`), which **native MCP tools** (`tool_permissions` JSONB), which **custom serverless tools** (`role_tools`), which **connectors** (`role_connectors`), which **datasets** (`role_datasets`), which...
Zombie Brains is role-based. A permission_set (the user-facing name; the DB table is roles) bundles together every grant that defines what a principal can touch — which brains (role_brain_scopes), which native MCP tools (tool_permissions JSONB), which custom serverless tools (role_tools), which connectors (role_connectors), which datasets (role_datasets), which skills (role_skills) — plus general_powers (type-level create/delete grants) and a bag of encrypted variables. Permission sets are then assigned to either humans (member_roles), agents (agent_roles), or API keys (api_key_roles). An agent can wear several roles (union semantics); a human can wear many; an API key inherits whichever role(s) you bind to it.
The permission set is the only path that flows brain/tool/skill/connector/dataset access through to an agent. The legacy direct-assignment tables still exist for historical reads but create_agent and update_agent will not write them — pass permission_set_id (or permission_set_ids) instead.
| Resource | Table | Access levels | Granularity |
|---|---|---|---|
| Brains | role_brain_scopes | per-feature scopes JSON + admin flag | per-brain row, include_children cascade flag |
| Native MCP tools | roles.tool_permissions (JSONB on the role itself) | granted or not — boolean | per-tool name (search_memory: true) |
| Custom serverless tools | role_tools | 'use' (only level today) | per-tool_id |
| Connectors | role_connectors | view / use / admin | per-connector_id |
| Datasets | role_datasets | read / write / admin | per-dataset_id |
| Skills | role_skills | 'use' (only level today) | per-skill_id |
When the role-resolver walks an agent's grants at every call, it unions across all assigned roles. Highest level wins when the same resource appears at multiple levels.
read < write < admin. Read gates query/get/count. Write gates CRUD. Admin gates schema + grants.{ "memories": {"read": true, "write": false},
"documents": {"read": true, "write": false},
"skills": {"read": true},
"core_knowledge": {"read": true, "write": false},
"admin": false }
In practice you pass access: "read" | "write" | "admin" and the server derives the canonical map. Pass scopes: {...} only when you want partial control (e.g. documents.write but not memories.write).tool_permissions (JSONB on the role) is for native MCP tools — the platform's own catalog (search_memory, add_memory, manage, load_brain, read_document, …). Default-on tools are auto-included if tool_permissions is undefined at create time. Unknown names are rejected loud (typos = hard error, not silent off).role_tools is for custom tools you authored — V8 serverless tools and MCP relay tools (Apify, Gmail, Fathom, HubSpot, etc.). Custom tools default to OFF unless explicitly attached to a role.Rule of thumb: if the tool ships with the server, it's in tool_permissions. If you wrote it (or attached an MCP relay), it's in role_tools. Call manage(action: 'list_tool_catalog') for the live native-tool name list — don't guess.
general_powers lets you grant CRUD on a kind of thing without naming specific IDs:
{
"brains": { "create": true, "delete_own": true, "delete_any": false },
"tools": { "create": true, "delete_own": true },
"connectors": { "create": true },
"skills": { "create": true },
"sources": { "create": true },
"datasets": { "create": true },
"agents": { "create": false, "delete_own": false },
"channels": { "create": false },
"humans": { "manage": false }
}
Use this to say "this role can create new brains" without naming any brain ID up front. Unioned across all assigned roles at resolve time. Without general_powers.brains.create, an agent assigned this role cannot call manage(create_brain) even if you handed it manage in tool_permissions.
When a tool / agent / channel adapter resolves a variable name (e.g. env.ANTHROPIC_API_KEY inside a V8 tool), Zombie Brains walks this chain in order and most-specific wins:
| # | Scope | Lives on | Who sees it |
|---|---|---|---|
| 1 | org | the agent owner's user row (users.variables_encrypted) | every agent and role the owner owns |
| 2 | permission_set | the role row (roles.variables_encrypted) | every agent + member wearing this role |
| 3 | brain | the brain row (projects.variables_encrypted) | every tool/agent operating in that brain |
| 4 | agent | the agent row (agents.variables_encrypted) | only that agent |
Resolution order in loadAgentConfig: org → permission_set → brain → agent, each Object.assign-ed on top. So agent overrides brain overrides permission_set overrides org. All four bags are encrypted at rest. manage(list_variables) returns names only — values never come back over the wire.
Changing a variable takes effect on the next tool call (live re-resolve, no agent restart). Credentials rotate transparently.
manage(set_variable_direct, {var_name, var_value, var_scope}) — pass the value inline. Synchronous, immediate. Reserve for non-sensitive values (config, URLs, prefs). The AI sees the value.manage(create_variable_link, {var_name, var_scope}) — generates a one-time secure URL (10-minute TTL). User opens it in their browser and pastes the value there. Best UX for credentials — the AI never touches the secret. Use for API keys, tokens, refresh secrets.manage(create_variable_bundle_link, {bundle_variables: [...], oauth: {...}}) — multi-variable + optional OAuth combination, atomically. The link writes several variables + walks the OAuth dance + persists the resulting tokens in one transaction. The primary path for "wire up a third-party connector" flows.var_scope accepts 'org', 'permission_set:<uuid>', 'brain:<uuid>' (implicit when brain_id is set), or 'agent:<uuid>'. Defaults to 'org' when omitted.
# Create
manage(action: 'create_permission_set', {
name: 'Cold Outbound Sales Bot',
description: 'Sales agent operating in the Cold Outbound brain.',
brain_scopes: [{ brain_id: '<cold-outbound-brain>', access: 'write', include_children: true }],
tool_permissions: { search_memory: true, add_memory: true, load_brain: true,
manage: false },
tool_ids: ['<custom-tool-uuid-1>', '<custom-tool-uuid-2>'],
connector_ids: ['<instantly-connector>', '<hubspot-connector>'],
skill_ids: ['<sales-voice-skill>'],
general_powers: { brains: { create: false }, tools: { create: false } },
variables: [
{ name: 'INSTANTLY_API_KEY', value: '...' },
{ name: 'HUBSPOT_PRIVATE_APP_TOKEN', value: '...' }
]
})
# → returns permission_set_id
# Read
manage(action: 'list_permission_sets') # all roles you own
manage(action: 'list_permission_set_members', {permission_set_id}) # agents + humans wearing it
# Update — replace-semantics on the array fields (brain_scopes / connector_ids / tool_ids /
# skill_ids), set-semantics on the JSON fields (tool_permissions / general_powers / variables).
manage(action: 'update_permission_set', {permission_set_id, tool_permissions: {...}, ...})
# Assign to an AGENT
manage(action: 'add_agent_to_permission_set', {permission_set_id, agent_id})
# or, REPLACE-style on the agent itself:
manage(action: 'update_agent', {agent_id, permission_set_ids: ['<role-a>', '<role-b>']})
# Assign to a HUMAN — uses change_access (the legacy name, kept for compatibility):
manage(action: 'change_access', {brain_id, member_id, permission_set_id})
# Unassign
manage(action: 'remove_agent_from_permission_set', {permission_set_id, agent_id})
manage(action: 'remove_member_from_permission_set', {permission_set_id, target_user_id})
# Delete (cascades to all grant tables + agent_roles / member_roles)
manage(action: 'delete_permission_set', {permission_set_id})
Datasets sit outside the role-creation form because dataset-admin is a separate axis: only a dataset's admin can hand it to another role.
manage(action: 'attach_dataset_to_role', {
role_id: '<permission_set_id>',
dataset_id: '<dataset_id>',
access: 'read' | 'write' | 'admin'
})
manage(action: 'detach_dataset_from_role', {role_id, dataset_id})
Caller must be admin on both the role and the dataset. Upsert semantics: re-attaching with a different level changes the level. Multi-attach across roles unions to highest-wins at resolve time.
api_key_roles is the same shape as agent_roles / member_roles. When you mint an API key, pass permission_set_id (or a list) — the key inherits the role's exact grant set. This is how service accounts work: no human attached, no agent attached, just a key with a role.
One bundled create_agent (the wizard path) that creates the role inline, or two calls if you want to reuse an existing role:
manage(create_permission_set, {
name: 'iPad Support Bot',
brain_scopes: [{ brain_id: '<ipad-support-brain>', access: 'write' }],
tool_permissions: {search_memory: true, add_memory: true, read_document: true},
variables: [{ name: 'ANTHROPIC_API_KEY', value: '...' }]
})
manage(create_agent, {name: 'iPad Support', permission_set_id: '<from-above>'})
manage(create_permission_set, {
name: 'Analyst — Customer Brain (read-only)',
brain_scopes: [{ brain_id: '<customer-brain>', access: 'read' }],
tool_permissions: {search_memory: true, load_brain: true, read_document: true,
add_memory: false, manage: false}
})
manage(change_access, {brain_id: '<customer-brain>', member_id: '<user-id>',
permission_set_id: '<role-from-above>'})
No connector_ids, no tool_ids, no general_powers. The user can read but cannot write a memory, cannot manage anything, cannot create brains.
Either enumerate brains, or use parent + include_children:
brain_scopes: [
{ brain_id: '<sales-brain-a>', access: 'write' },
{ brain_id: '<sales-brain-b>', access: 'write' }
]
# OR the cascade form (covers descendants created later):
brain_scopes: [{ brain_id: '<parent-sales-brain>', access: 'write', include_children: true }]
include_children is load-bearing — without it, brains created under the parent later won't be covered. Evaluated live on every resolve via projects.parent_brain_id.
manage(create_api_key, {name: 'Zapier Webhook Key', permission_set_id: '<role>'})
The key inherits the role's grants. Rotating the role rotates the key's access.
When registering an MCP relay, oauth_scope_target: 'permission_set:<id>' parks the OAuth tokens on the role instead of the individual user — every agent/member with the role uses the same upstream credential. Same trick for register_mcp_relay templates.
manage is destructive. Granting manage: true to an agent gives it delete_brain, delete_permission_set, update_agent, etc. Don't hand it out casually. Most agents need none of manage.agent_roles row, it gets ZERO custom tools and ZERO brains. The primary-owner short-circuit grants god-mode for permission checks but does not synthesize a role — the MCP surface stays empty.env.zombie.search_memory(...) from a V8 tool requires tool_permissions.search_memory = true on the agent's role. The V8 dispatcher re-checks the gate per call. Same for every other env.zombie.<name>.include_children defaults to false. A scope on a parent brain does NOT cover descendants without the flag. The role editor exposes this as a "+ kids" toggle.loadAgentConfig (every inbound dispatch) re-resolves the chain. Conversely: do not cache credentials inside a V8 tool — rotations won't propagate.resolveAccountOwnerId. In multi-user workspaces, permission walks resolve to the agent's owner's user ID, not the agent ID. Variables stored at "org" scope on an invited member's user row are invisible to agents owned by the workspace primary owner.update_permission_set REPLACES brain_scopes / connector_ids / tool_ids / skill_ids (pass the full set you want), but SETS tool_permissions / general_powers / variables as full-document writes. To remove a single brain scope, send the full remaining brain_scopes array minus the unwanted entry.delete_permission_set cascades through agent_roles / member_roles / api_key_roles. Agents that lose their last role drop to zero grants — they keep running but stop seeing tools / brains. Always check list_permission_set_members first.This is the #1 footgun for authors who manage multiple workspaces. Role grants are filtered to the agent's own workspace at loadAgentConfig time. Cross-workspace grant rows still exist in the database — they just silently disappear from the agent's runtime view. No teaching error fires. No warning surfaces. The agent's brainAccess / connectorAccess / toolAccess / skillAccess just come back missing the cross-workspace entries.
brainAccess — every role_brain_scopes.brain_id is checked against project_members for role='owner' matching the agent's workspace primary owner. Brains owned by a different workspace are dropped.connectorAccess — connectors.owner_user_id must equal the agent's workspace owner. Foreign-workspace connectors are dropped.toolAccess — agent_tools.owner_user_id must match. Foreign-workspace custom tools are dropped.skillAccess — skills are workspace-bound transitively via brain_skills.brain_id (the brain must be in the agent's workspace). Global skills (brain_id IS NULL) are workspace-agnostic and pass through unchanged.A consultant manages workspaces A and B. From inside workspace A's manage (the tree-walk lets them see B), they build a permission set that lists brain_id: <B-brain> in brain_scopes. The role_brain_scopes row writes successfully. They assign the role to an agent in workspace A and ship it.
At runtime, the agent in A loads its config. The B-brain scope gets filtered out before brainAccess is built. The agent has no idea brain B was ever supposed to be in its world — search_memory returns empty, load_brain doesn't list it, read_document against B's docs fails with "not found." No error mentions the workspace boundary.
Workspace nesting is a management primitive (humans walking trees via manage), not a runtime cross-reference primitive. An agent's identity is anchored to one workspace, and its tool/brain surface is built strictly from that workspace's resources. Cross-workspace runtime sharing would break tenancy isolation.
(a) Transfer the resource into the agent's workspace:
manage(action: 'transfer_brain', {brain_id: '<B-brain>', target_workspace_id: '<A>'})
# or, for tools / connectors:
manage(action: 'transfer', {resource_type: 'tool'|'connector', resource_id, target_workspace_id})
After transfer, the existing role_brain_scopes row resolves cleanly and brainAccess picks it up on the next load.
(b) Run the agent in the resource's workspace. Move the agent (or rebuild it) under workspace B. The role's B-brain grant then resolves in-bounds.
When an author says "the agent doesn't see brain X / tool Y / connector Z":
manage(action: 'describe_access', {agent_id}) — shows the agent's workspace tree and resolved grants.manage(action: 'list_brains') from the agent's owner context — does brain X appear? If not, X is in a foreign workspace.projects.owner_user_id for brain X vs the agent's workspace primary owner. Mismatch = silent filter is doing its job.Global skills are the only resource type that crosses workspace boundaries by design — that's why platform-shipped skills work everywhere. Everything else is hard-walled.
env.* resolves variables and re-checks tool_permissions per call.attach_dataset_to_role plugs into the env.dataset.* surface.npx claudepluginhub zombie-brains/zombie-brains --plugin zombie-brainsProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Searches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.