Roles¶
A profile picks domains for a project. A role maps a job to the slice of the shared content a person needs: which domains, which skills, rules, agents and commands, and how Claude Code should surface each skill. Profiles answer "what does this repository ship"; roles answer "what does a backend engineer, a data analyst or a support agent get on their machine".
ai-rulez never does identity
ai-rulez has no notion of users, groups, authentication or the network. A role is a name. Something else (an
identity-provider integration, a web UI, a wrapper script) decides which person holds which role and runs
ai-rulez generate --user --role <name>. The match hints on a role are inert strings that tool may read
from roles.json; ai-rulez stores and republishes them and never interprets them.
Profiles vs roles¶
Both select a slice of the same content, so it is fair to ask whether profiles are still worth having. They are, because they answer different questions.
| Profile | Role | |
|---|---|---|
| Question | "what does this repository ship?" | "what does this person's job need?" |
| Scope | the project or team | a person (identity decided elsewhere) |
| Selected by | --profile, [profiles], the default profile |
--role |
| Extra knobs | — | skill_mode, delivery, match, pin |
| Compared by | tokens --by-profile |
tokens --by-role |
Reach for a profile when one checkout must emit several project variants — a backend service, a frontend app, a QA bundle — and that choice belongs in the repository. Reach for a role when the same content must be tailored to different people — a backend engineer, an on-call responder, a support agent — and the choice is made at generation time for that person. A profile is about what the artifact contains; a role is about what one holder sees. Many projects keep a profile for the repository's default and layer roles for the per-person variants.
Each axis will meet where it should: domains are the shared vocabulary, profiles group domains for a project
and roles group domains for a person. A profile also selects installed skills and MCP
servers by their profiles field.
Defining roles¶
Roles are a [[roles]] array in config.toml. The optional manifest switch lives in a separate
[role_manifest] table, because TOML cannot use roles as both an array and a table.
[role_manifest]
enabled = true # write .ai-rulez/roles.json on generate
[[roles]]
name = "engineer"
description = "Everyone who writes code"
domains = ["shared", "backend"] # same meaning as a profile's list; "builtin:<name>" works too
[roles.skills]
exclude = ["deploy-*", "backend/release"] # ids or path.Match globs; "domain/id" matches one domain
[roles.skill_mode]
"review-*" = "name-only" # on | name-only | user-invocable-only | off
deploy = "user-invocable-only"
[roles.match]
groups = ["okta:eng", "okta:eng-platform"] # free-form hints for an external identity tool
[[roles]]
name = "release-manager"
extends = "engineer" # one level only
domains = ["release"]
[roles.skill_mode]
deploy = "on" # child wins over the parent's "user-invocable-only"
| Field | Meaning |
|---|---|
name |
Lowercase letters, digits, - and _. What --role takes. Must be unique. |
description |
Free text shown by roles list. |
domains |
Domains the role selects. Root content, globally active builtins and included domains are always kept, exactly as for a profile. |
skills, rules, agents, commands, checks |
include and exclude lists. An entry is an item id or a path.Match glob; an entry containing / is matched against domain/id. An empty include keeps everything the domains provide; exclude always wins. There is no selector for context: context files stay. |
skill_mode |
Skill id or glob to a Claude Code skillOverrides state. |
delivery |
Skill id or glob to static, served or both: how the skill reaches this role's agent (see Delivery). |
pin |
true records the digest of the role's rendered outputs in ai-rulez.lock. Not inherited. |
extends |
The name of one parent role. |
match |
groups: hint strings for an external tool. Not inherited. |
skill_mode precedence¶
For one skill, every matching key competes: an exact id beats a glob, a longer glob beats a shorter one, and ties go to the lexically first pattern. The answer never depends on map order. A skill no key matches keeps the default, which is to render it with no override.
Claude Code's skillOverrides is one flat map keyed by skill id, so two kept skills with the same id in different
domains share one entry. When a role resolves them to different modes (for example backend/deploy = "off" while
frontend/deploy has no mode), validate reports AR971; give both the same mode (a bare deploy key does)
or exclude one of them.
Delivery¶
[roles.delivery] decides, per skill, whether the role's agent gets the skill as a file in the harness skill
tree (static), only on demand from the skills server (served), or both.
The keys follow the skill_mode rules (ids, globs, domain/id, the most specific key wins) and the entries are
inherited through extends with the child winning.
[[roles]]
name = "backend"
domains = ["backend"]
[roles.delivery]
"deploy-*" = "served" # not listed in the backend agent's context, found with find_skill
"backend/runbooks" = "both"
Precedence for one skill: its own delivery frontmatter, then the role, then [domains.<name>] delivery, then
[skills] delivery, then static. Everything that renders or serves a role uses it:
generate --role backendleaves the served skills out of the static trees and adds thedynamic-skillsstub;tokens --role backenddoes not count served skills in the listing and names them (served_skills);ai-rulez mcp --serve-skills --role backendserves those skills andfind_skillis scoped to the role;roles resolve,roles.json(items[].delivery,totals.served_skills,totals.served_tokens,delivery) andcatalog(items[].delivery,items[].role_delivery) report it.
ai-rulez lock pins the served skills of every role, so [lock] enforce covers them.
Inheritance¶
extends is one level deep: a role that extends a role that itself extends another is an error. The child is
merged over the parent as follows.
| Field | Merge |
|---|---|
domains |
union, parent first |
exclude lists |
union |
include lists |
union when both roles set one, otherwise whichever is set |
skill_mode, delivery |
merged, the child wins per key |
description |
the child's, else the parent's |
match |
not inherited: it identifies who holds this role |
Validate() fails hard only on a bad name, a duplicate name, an invalid skill_mode value or an invalid delivery value. Inheritance
problems (unknown parent, cycle, depth greater than one) are logged as warnings there so that
validate can report them as AR972. A role with broken inheritance is left out of
roles.json with a warning.
Roles are overlayable in config.local.toml the same way profiles are: entries merge by name, and an entry with
remove = true drops a shared role. The local overlay schema includes roles.
A role is a content filter, not an access boundary
A role narrows the rules, skills, agents, commands and checks that are rendered or served, and sets skill
modes. It does not narrow hooks, [permissions], [[mcp_servers]] or context files: those are rendered for
every role. Do not use a role to withhold a tool or a credential from someone; the file-based outputs are
plain files the holder can read and edit.
Commands¶
ai-rulez roles list [--format json] # every role with item counts and token estimates
ai-rulez roles show <name> [--format json] # as declared, and with the parent merged in
ai-rulez roles resolve <name> [--format json] # the items the role keeps, with sizes and skill modes
# <name> may be a comma-separated composition (see Composing roles below)
ai-rulez generate --role engineer # project outputs for the role
ai-rulez generate --user --role engineer # user-level outputs (~/.claude, ~/.codex, ...)
ai-rulez generate --check --role engineer # drift check against the role's outputs
ai-rulez tokens --role engineer # token surface of the role
ai-rulez tokens --by-role # one comparison column per role
ai-rulez catalog --format json # items, owners, versions, roles, lock status
--role and --profile are mutually exclusive, and --role cannot be combined with --plugin (plugin bundles
are built from the full content). A role replaces profile selection: the content tree is narrowed by the role's
domains and selectors through the same selection path profiles use, so everything downstream in the same run (outputs, the token
report, the usage index) sees the role's slice. Installed skills scoped to profiles are not filtered by
a role, and the machine-local local/ tree is not narrowed.
Composing roles¶
--role takes a comma-separated list, as do roles show, roles resolve, tokens --role and
mcp --serve-skills --role. A composition is the union of its members: the domains, skills, rules, agents and
commands they keep are combined, and that union is what is rendered, served and counted.
Where two members set skill_mode or delivery for the same skill, the within-role precedence
applies first — a more specific key (an exact id, then a longer glob) wins — and when two keys are equally
specific the role listed last wins. Order therefore matters, and a composition is named after its order:
engineer,oncall and oncall,engineer are different, and reversing the list can change a mode.
A composition is a value, not a [[roles]] entry: it cannot be extended, it need not be declared, and every member
must exist (validate reports one that does not as AR971). A composition is pinnable:
ai-rulez lock --role engineer,oncall records the union's rendered outputs under the canonical name
engineer,oncall, and generate --locked --role engineer,oncall (or lock --check) compares them. Pinning one
member with pin = true does not pin every composition that contains it, and pinning a composition does not pin
its members.
skill_mode becomes Claude Code skillOverrides¶
For every skill the role keeps and gives a mode, generate writes skillOverrides.<skill> in
.claude/settings.json (or ~/.claude/settings.json with --user) through the same per-key ownership as
[claude.settings.managed]: only the listed skill ids are owned, every skill id the role does not
list is left alone, clean takes back exactly what was recorded, and a second run changes nothing. A skill id the
role lists is the role's while the role renders. If you had written a different value for it by hand,
generate --role warns, replaces it, and remembers yours in <config dir>/local/.role-skill-overrides.json
(machine-local, always gitignored); a plain generate, or a role that no longer lists the skill, puts your value
back. This holds for every run that writes the settings: generate, each regeneration of generate --watch --role,
and generate --user; mcp --serve-skills --role only serves skills and never writes .claude/settings.json, so it
changes nothing there. A value you changed after the role wrote it is yours and is left alone. Switching from one role
to another removes the first role's entries and writes the second's. A role's modes win over the same skill in
[claude.settings.managed] skill_overrides.
| Mode | Claude Code behavior |
|---|---|
on |
listed with its description; the default |
name-only |
listed by name only, saving description tokens |
user-invocable-only |
hidden from the model; the user can still run it as /name |
off |
hidden from the model and the user |
skill_mode on other harnesses¶
Only Claude Code has a per-skill settings key. For the other harnesses ai-rulez renders a mode where the vendor
documents an equivalent and says so where it does not. One content tree is rendered for every preset of the run,
so a mode is written into the skill's SKILL.md frontmatter (or, for Codex, agents/openai.yaml) and each harness
reads what it understands. A key you set in the skill's own frontmatter is never overwritten.
| Preset | user-invocable-only |
off |
name-only |
Source (read 2026-10-06) |
|---|---|---|---|---|
claude |
skillOverrides |
skillOverrides |
skillOverrides |
Claude Code skills |
cursor |
disable-model-invocation: true |
no documented setting | no | Cursor skills |
codex |
agents/openai.yaml policy.allow_implicit_invocation: false |
documented ([[skills.config]] enabled = false) but needs an absolute path, not rendered |
no | Codex skills |
copilot |
disable-model-invocation: true |
disable-model-invocation: true and user-invocable: false |
no | VS Code agent skills |
opencode |
no | documented (permission.skill = deny in opencode.json), not rendered |
no | OpenCode skills |
gemini |
no | only the /skills disable command is documented, no settings key |
no | Gemini CLI skills |
| every other preset | not documented | not documented | not documented | not checked |
on is every harness's default. A cell that says "no" or "not documented" means ai-rulez writes nothing for it and
the skill stays listed on that harness for user-invocable-only and name-only.
For off, a skill is hidden only when every configured preset can hide it. Otherwise generate --role follows
skill_mode_fallback:
drop leaves the skill out of the role's render; serve moves it to served delivery, reachable with
find_skill on harnesses that can call MCP. The setting lives in [role_manifest] because TOML cannot use roles as
both an array and a table. Because one tree is rendered, the fallback applies to every harness of the run, Claude Code
included (its skillOverrides entry is then not written for a dropped skill). Each skill and harness that is not
honoured is named in a warning. ai-rulez roles resolve <name> lists them (skill_modes in --format json), and
ai-rulez doctor reports them.
Strict validation¶
generate --role <name> prints the AR971 findings of that role as warnings before it writes anything, so a typo in a
domain or in an exclude entry is not silent.
| Code | Meaning |
|---|---|
AR971 role-reference-unknown |
A role lists a domain that does not exist, or a selector / skill_mode / delivery entry that matches no item (or only matches in a domain the role does not select). |
AR972 role-extends-invalid |
Unknown parent, cycle, or inheritance deeper than one level. |
AR973 role-unreachable-dependency |
An item the role keeps names a skill in its skills: frontmatter that the role drops, or hides from the model with off / user-invocable-only. Prose references are not analysed. |
The roles manifest¶
With [role_manifest] enabled = true, generate writes <config dir>/roles.json. roles list --format json
prints the same document. It is deterministic (no timestamps; roles sorted by name, items by kind, domain and id),
so it is safe to commit, and it is versioned by schema_version. It is built from the shared sources only: roles
declared in config.local.toml and items under local/ never appear in it, so the committed file is the same on every
machine and generate --check does not report it as drifted. Local roles still work with generate --role and
roles list. The JSON schema is
schema/roles-manifest.schema.json.
{
"schema_version": 1,
"tokenizer": "cl100k_base",
"roles": [
{
"name": "engineer",
"description": "Everyone who writes code",
"match": { "groups": ["okta:eng"] },
"domains": ["shared", "backend"],
"skill_modes": { "review-pr": "name-only" },
"delivery": { "deploy-*": "served" },
"items": [
{
"kind": "skill", "id": "review-pr", "domain": "shared",
"path": "domains/shared/skills/review-pr/SKILL.md", "mode": "name-only", "delivery": "static",
"owner": "platform", "version": "1.2.0", "bytes": 2210, "tokens": 540
}
],
"totals": { "items": 1, "bytes": 2210, "tokens": 540, "by_kind": { "skill": 1 }, "served_skills": 0, "served_tokens": 0 }
}
]
}
bytes is the size of the item's source files on disk (a skill counts its resources). tokens is an estimate for
the primary file (SKILL.md, the rule file, ...) and is an approximation, like every figure of ai-rulez tokens.
delivery (skills only) is how the skill reaches the role's agent; served_skills and served_tokens total the
skills that are served only, which cost no listing tokens until load_skill runs.
owner and version come from the item's frontmatter when present.
Integrating an identity tool or UI¶
Everything a tool needs is reachable from --format json output and versioned documents. ai-rulez never calls the
network, and an integration never needs to parse config files.
- Discover the roles. Read
roles.json(committed) or runai-rulez roles list --format json. Each role has itsname, itsdescription, andmatch.groups, the hints you put there for your tool. - Map a person to a role. This is your tool's job. For example, an
aclicommand can read a person's identity-provider groups, find the role whosematch.groupscontains one of them (choosing by your own precedence when several match), and print the role name. ai-rulez does not look atmatch. - Preview.
ai-rulez roles resolve <name> --format jsonlists exactly the items the role keeps, with owner, version, bytes and tokens.ai-rulez tokens --role <name> --format jsonreports the token surface. - Apply. Run
ai-rulez generate --user --role <name> --yes(user level) orai-rulez generate --role <name>(project level). Exit code 0 means the files were written. - Show the whole catalog.
ai-rulez catalog --format jsonlists every item with its owner, version, size, sha256 digest, the roles that keep it, a summary of each role and the lock status. Its schema isschema/catalog.v1.schema.json(--schema-version 2adds load cost, lint and excerpts; see Catalog). - Audit.
ai-rulez lock --diff --format jsonreports what changed between the committed lock and the working tree (schema/lock-diff.schema.json).
Every JSON document carries schema_version; a consumer should refuse a version it does not know. A UI needs no
other interface: lists come from roles.json / catalog, previews from roles resolve, the action is one
generate command.
Roles and the lock file¶
The lock file pins each role declaration as an item (kind = "role"), so adding a role, or
changing what a role includes, shows up in the lock diff and is caught by lock --check. The lock pins the
default rendering; a role's rendered outputs are pinned, as one digest per role, when the role sets pin = true
(or with lock --roles). lock --check [--role <name>] and generate --check --locked --role <name> then
catch a change in what the role renders that leaves the sources alone, for example a new skill_mode entry or a
generator release; see Composing with roles. Roles without a pin are
unchanged, and generate --locked --role <name> still verifies that the sources match the lock. Skills a role delivers as served are
pinned as [[served]] entries (see Served skills and skill sources),
so [lock] enforce holds for a server started with --role. Checks are a role-selectable kind and are pinned like
rules.