Skip to content

[feat] Scope agents, MCP servers and hooks by role, like skills and rules #563

Closed

Description

@SaulMoro

Problem

Roles partition the instructions an agent receives, but not the tools it gets.
manifest/roles.yaml drives skills/<ns>/, and through
activeNamespaces.knowledge it also drives rules/<ns>/ and claudemd/<ns>/.
The rest of the team setup goes out whole: teamai pull copies every file in
agents/, every server in mcp/mcp.yaml and every hook in hooks/hooks.yaml
to every member. There is no opt-in either, because ROLE_RESOURCE_TYPES is
['knowledge', 'skills'], so adding agents: to a role fails validation with
unknown resource type.

Three cases where this costs the team something concrete.

A frontend team wants the Playwright MCP server. Declared in mcp/mcp.yaml, it
reaches the backend, data and PM members too. Their sessions carry its tool
definitions, and each of them gets asked to approve a project .mcp.json they
will never use. The two filters that exist, tools: and requires:, say which
AI tool and which binary on PATH. Neither says who.

agents/visual-regression-reviewer.md, agents/terraform-plan-reviewer.md and
agents/release-notes-writer.md all land in every member's ~/.claude/agents/.
Subagent selection gets worse as that directory grows, and a backend engineer is
offered a reviewer that only works in front of a browser.

A PreToolUse hook on Bash that blocks terraform apply outside a change
window is right for devops and pure latency for everyone else, with a chance of
denying a command it was never meant to see. A PostToolUse hook running
npm run lint:css fails outright for members whose repo has no such script.
sharing.hooks.requireTeamScripts limits what a hook may run, never whose
sessions it runs in.

The two halves cannot agree with each other. A frontend skill that says "drive
the browser with the Playwright MCP" can be scoped to the frontend role. The
server it depends on cannot. Teams then either ship everything to everyone, or
split into one team repo per role and lose the shared common namespace.

Proposed Solution

Two mechanisms, matching the two shapes these resources already have.

agents becomes a namespaced directory. It is a directory of files, like
skills/, so it can follow the same convention:

team-repo/agents/
  teamai-recall.md              # root = everyone, the way rules/ already works
  frontend/visual-regression-reviewer.md
  devops/terraform-plan-reviewer.md
# manifest/roles.yaml
roles:
  - id: frontend
    resources:
      knowledge: [common, frontend]
      skills:    [common, frontend]
      agents:    [common, frontend]

Adding agents to ROLE_RESOURCE_TYPES and PROJECT_RESOURCE_TYPES and reading
it from the existing activeNamespaces union covers the role and the project
dimension in one change.

mcp.yaml and hooks.yaml get an optional roles: field per entry. These are
single files rather than trees, so a field fits better than a namespace:

# mcp/mcp.yaml
servers:
  - name: playwright
    transport: stdio
    command: npx
    args: ['-y', '@playwright/mcp@latest']
    roles: [frontend]          # NEW; omitted = every role, as today

  - name: gpu-analysis
    transport: http
    url: https://example.com/api/mcp
    roles: [devops, data]
# hooks/hooks.yaml
hooks:
  - id: guard-terraform-apply
    event: PreToolUse
    matcher: Bash
    command: 'bash -lc "~/.teamai/team-scripts/guard-tf-apply.sh"'
    roles: [devops]            # NEW

  - id: stylelint-on-write
    event: PostToolUse
    matcher: Write
    command: 'bash -lc "~/.teamai/team-scripts/stylelint.sh" || true'
    roles: [frontend]

roles: is orthogonal to the existing tools:. One says who, the other says
which AI tool, and an entry ships when both match. A projects: field with the
same semantics would work the same way if the union from
resolveActiveNamespaces is passed through.

What the team gets: each member's agent carries the servers, subagents and hooks
of their own job. Sessions spend fewer tokens on MCP tool definitions nobody on
that machine will call, hooks stop failing on machines that were never meant to
run them, and the subagent roster stays short enough to pick from. A role can
own a skill and the tool that skill tells the agent to use.

Alternatives Considered

One team repo per role works today, with --scope user for the org repo and a
project repo using --inherit-user-scope. It splits by directory rather than by
job function, duplicates the common namespace across repos, and the
inheritance channel skips env, hooks and MCP, which are the three this issue is
about.

Gating inside the hook command also works: one hook for everyone, whose script
reads primaryRole from the local config.yaml and exits early. It puts
dispatch logic in every team script, still pays the process spawn on every
matching tool call, and does nothing for MCP or agents.

requires: approximates a machine profile rather than a role, and it changes
behaviour the day someone installs that binary for an unrelated reason.

Tag subscriptions are member-driven opt-in and cover only skills and rules, since
tags.yaml has just those two maps. Who gets which server is an admin decision,
which is what roles already are.

Additional Context

Every new field is optional and its absence keeps today's behaviour. A
root-level agents/*.md still reaches everyone, an entry with no roles: still
ships to every member, and a repo with no manifest/roles.yaml is untouched. The
role manifest already accepts and ignores an unused learnings: key, so there is
precedent for widening the resource-type set without breaking old manifests.

One question worth settling early is revocation. When a member changes role, a
server or hook that no longer matches has to be removed on the next pull, not
just skipped. Ownership tracking exists for both, in managed-mcp.json for
servers and the id marker for hooks, so the reconcile pass can treat "filtered
out by role" like "deleted upstream", for teamai-managed entries only and never
for hand-added ones.

Happy to send a PR if the direction looks right. The agents half looks like the
small one, since it reuses the skills namespace resolution, and it could land on
its own with the roles: filter for MCP and hooks as a follow-up.

Activity

  1. SaulMoro commented on Sep 15, 2026

    @SaulMoro
    CollaboratorAuthor

    Before starting on this I traced the code paths. Three statements in the issue are off, and one problem is already solved.

    The agents half is not the small one. The team agents/ scan is a non-recursive listFiles, and four places call it. Today agents/<ns>/x.md is invisible to every one of them, so each needs the same recursive lookup.

    listFiles(team/agents)            non-recursive
      AgentsHandler.scanTeamForPull   pull, and the count in status
      AgentsHandler.scanLocalForPush  canonical lookup by stem
      AgentsHandler.removeItem        agents/<name>.{yaml,md} only
      collectTeamAgentNames           uninstall
    

    There is no ownership tracking for agents. No manifest and no marker exist, only the agents/.removed tombstone that teamai remove writes. Nothing in pull deletes an agent that stops matching a role, so the revocation pass has to be built. It mirrors the skills cleanup.

    on(pull with role context)
      for each agent in an inactive namespace
        for each tool with an agents dir
          if deployed <stem><ext> equals render(source)
            delete it
          else
            keep it and warn
    

    hooks.yaml has no requires: field. Only mcp.yaml has it. HookDef carries tools: alone.

    Revocation for MCP servers and hooks already works. Both reconcilers rebuild the managed slice from the desired set rather than diffing against upstream. An entry that stops matching tools:, or a future roles:, is removed on the next pull, and hand-added entries stay untouched. There is a test for a server that disappears from mcp.yaml. A roles: filter needs no new removal logic.

    reconcile(tool)
      desired  = teamDefs filtered by tools, requires, policy   (+ roles)
      untouched = existing entries not owned by teamai
      write untouched + desired
      drop owned names missing from desired
    

    Two notes for the design. Unknown keys in mcp.yaml and hooks.yaml are stripped silently, so an older CLI reading a file with roles: installs the entry for everyone. That is today's behaviour rather than a regression, and the docs will say so. Hermes, OpenCode and OpenClaw never receive team hooks, so roles: is inert there, as tools: already is.

    Decisions for the first PR, which covers agents only. agents: becomes an optional key in roles.yaml and projects.yaml. When absent, the member receives root-level agents only, which is what every existing repo receives today because subdirectories are invisible. Namespaced agents deploy flattened by stem like skills, and pull fails on a duplicate stem across active namespaces.

    team-repo/agents/                      ~/.claude/agents/
      teamai-recall.md        everyone       teamai-recall.md
      frontend/vr-reviewer.md frontend       vr-reviewer.md
      devops/tf-reviewer.md   devops         (not deployed for frontend)
    

    roles: for MCP servers and hooks follows in a second PR. It matches on role ids from the local primaryRole and additionalRoles, and behaves like tools:, with a silent skip and an empty list matching nobody.

    While reproducing the cleanup path I found that the tombstone pass in pull only removes .md agent renders, so the Codex and Kiro copies survive on other machines. That exists without roles and is filed as #576.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions