Stackable Claude Code persona profiles covering register, working conduct, and content policy, with variants, ordered composition, and persistent activation.

Modular Agent Style and Qualities.
Masq is a Claude Code plugin for reusable, stackable persona profiles. A profile can supply a voice, tone, domain posture, workflow preference, or contextual behavior. Profiles can be activated independently, assigned a variant, reordered, combined, and turned off without editing the plugin runtime.
Masq is quiet by default. No profile is active until one is explicitly enabled.
The name is clipped from the courtly theatrical tradition of costumes, roles, spectacle, and deliberate performance. It fits the first Renfaire profile while remaining broad enough for technical, editorial, workflow, and domain personas.
A single global personality toggle cannot express combinations such as:
dean:default + audience:expert + conduct:strict
Masq keeps an ordered stack. Every profile contributes its non-conflicting traits. A later profile wins only a direct style conflict. Contextual profiles remain dormant outside their declared scope.
In the example above, Dean sets the register: point first, prose over structure. Audience sets how much gets glossed for an expert reader without touching the register. Conduct changes what a completion report must contain, and may never widen a permission or skip a check. Each profile owns one axis, so the three compose without fighting.
On Claude Code 2.1.287 or later, /masq status, /masq set plain dean, /masq move plain last, and /masq temp set caveman run immediately without a model turn. They use the same command core and state as the ordinary hooks. The band above the prompt shows the last captured stack. Changes apply at the next prompt hook; an already running response keeps its existing context. Preview remains /masq:persona preview because it needs a model response.
The native controls are optional. Disabling mods leaves the commands below and the existing context hooks available. Mod reload resets the display; status reads persistent state again. Clear/resume use the ordinary session hooks' existing temporary-state rules. A failed or timed-out mutation is never retried automatically: inspect status first.
From this checkout, use Node 18+ with explicit store, project and session identity:
node src/cli.js --data-dir /absolute/masq-data --cwd /absolute/project --session-id SESSION --harness codex --json -- set plain dean
node src/cli.js --data-dir /absolute/masq-data --cwd /absolute/project --session-id SESSION --harness codex -- status
On Windows, use absolute Windows paths and quote paths with spaces. Omit --json for text. package.json also exposes the masq executable for local package use. This command manages profiles; integrating their context into another harness still requires that harness's supported adapter.
claude-code preserves the existing store layout. Other harnesses use <data-dir>/harnesses/<harness>; --shared-store explicitly shares persistent preferences and presets while temporary sessions retain a harness prefix. Status is read-only. The CLI does not mark a selection as already injected.
The Claude mod resolves the loaded plugin's exact registry identity for its data directory (masq-inline for a development checkout). It refuses ambiguous cached installations. MASQ_DATA_DIR and CLAUDE_PLUGIN_DATA remain explicit overrides. Ordinary CLI --data-dir always wins over inherited defaults.
The controls were tested on Windows with Claude Code 2.1.296 using native event and rendering tests plus an interactive terminal. Desktop drawing is covered by the event harness; a live Desktop installation and macOS launch remain separate qualification checks. Run npm test and, when Claude is installed, npm run test:mods.
Claude Code namespaces plugin skills with the plugin name:
/masq:persona status
/masq:persona list
/masq:persona doctor
/masq:persona preview [profile[:variant] ...]
/masq:persona on <profile[:variant]> [...]
/masq:persona off <profile> [...]
/masq:persona toggle <profile[:variant]> [...]
/masq:persona set <profile[:variant]> [...]
/masq:persona move <profile> first|last
/masq:persona clear
/masq:persona help
Scope and preset commands:
/masq:persona global <status|on|off|toggle|set|move|clear> [...]
/masq:persona project <status|on|off|toggle|set|move|clear|unset> [...]
/masq:persona temp <status|on|off|toggle|set|move|clear> [...]
/masq:persona preset list
/masq:persona preset export <name> [effective|global|project|temp]
/masq:persona preset import <name> [active|global|project|temp]
/masq:persona preset delete <name>
Shorthand activation:
/masq:persona renfaire
/masq:persona renfaire:full
Examples:
/masq:persona on dean
/masq:persona on audience:expert
/masq:persona set dean:default audience:expert conduct:strict
/masq:persona move conduct last
/masq:persona off dean
/masq:persona clear
/masq:persona project set plain:strict caveman:lite
/masq:persona temp on renfaire:courtly
/masq:persona preset export concise effective
/masq:persona preset import concise project
/masq:persona preview renfaire:pageant caveman:lite
preview renders a standardized release-status sample through either the current effective stack or an explicitly supplied ordered combination. It is a one-turn comparison and does not change saved or temporary persona state.
Simple natural-language controls are also recognized:
turn on the medieval persona
turn off the caveman profile
show the active persona stack
clear all personas
The Afterdark compatibility command remains available:
/masq:afterdark
/masq:afterdark flirty
/masq:afterdark suggestive
/masq:afterdark direct
/masq:afterdark off
An overcommitted Medieval Times and renaissance-fair performer who has remained in character several hours past closing.
renfaire:courtly
renfaire:full
renfaire:pageant
The default is renfaire:pageant.
Clear human-facing prose: concrete subjects and verbs, statuses explained rather than left bare, exact technical names preserved, and no invented project shorthand.
plain:light
plain:default
plain:strict
The default is plain:default.
Token-conscious terse prose adapted from the MIT-licensed Caveman skill. Caveman removes filler and repetition while preserving technical substance, exact literals, negations, and clarity around consequential actions.
caveman:lite
caveman:full
caveman:ultra
caveman:wenyan-lite
caveman:wenyan-full
caveman:wenyan-ultra
The default is caveman:full. See THIRD_PARTY_NOTICES.md for attribution and license terms.
A senior-engineer register: casual but competent, point first, prose over structure, no bolted-on summary. Carries channel calibration, so chat, commit messages, and reports each land at their own level.
dean:light
dean:default
The default is dean:default. dean:light leaves genuinely structured content structured; dean:default also collapses bullets back into prose where the items are reasoning rather than a checklist. The working half of this persona is the separate conduct profile below.
Sets the reader's assumed expertise, and only that: what gets glossed on first use, how far a procedure is spelled out, what gets restated. It sets no register, so it pairs with whichever voice profile you want.
audience:novice
audience:peer
audience:expert
The default is audience:peer. An override runs through every variant: where assuming knowledge would leave a reader unable to act on something consequential, the explanation stays regardless of level. expert means writing for peers, not writing to make someone feel stupid.
The first profile on the conduct axis. Scope discipline, batched questions instead of permission theater, results that were probed rather than read off a dashboard, residuals named instead of swept into "done," and notes that record the why.
conduct:light
conduct:default
conduct:strict
The default is conduct:default. conduct:light applies the reporting habits only; conduct:strict requires an explicit residuals line on every completion report and a named probe behind every status claim.
A conduct profile is bounded more tightly than a presentation profile: it may change effort, sequencing, and what a report contains, and it may never grant tool authority, widen a permission, lower a confirmation requirement, skip a safety check, or alter a factual claim.
Removes the patterns that make prose read as machine-generated: negation-contrast padding, rhetorical-question transitions, throat-clearing openers, filler vocabulary, cadence triplets, and endings that restate their heading. Negations that state a real limit, hedges that reflect real uncertainty, and any quoted or reproduced text survive untouched.
de-tell:light
de-tell:default
de-tell:strict
The default is de-tell:default. It supplies no register of its own, so it composes under any voice profile and strips only filler that no active profile asked for. Its document-side counterpart is the de-tell pass in tali.
A contextual adult intimate-message profile with controlled levels of directness.
afterdark:flirty
afterdark:suggestive
afterdark:direct
Afterdark remains non-graphic, consent-aware, and dormant outside adult intimate-message drafting or revision.
Masq treats profiles as ordered overlays, not new authorities or identities. Each profile declares a kind that bounds what it may change:
| Kind | Changes | Never |
|---|---|---|
presentation | wording, register, structure, compression | what the response asserts, or detail the reader needs to act |
conduct | effort, sequencing, report contents | permissions, confirmations, safety checks, facts |
policy | required checks, clarifications, refusals, content limits | loosens anything, it may only tighten |
Precedence runs within a kind. A later presentation profile owns the surface register, a later conduct profile wins a direct conduct conflict, and policy requirements sit outside stack precedence with the strictest one applying.
Masq resolves one effective stack from three scopes:
Unqualified mutation commands edit the project override when one exists and otherwise edit the global stack. project unset removes the override and restores global fallback. project clear deliberately creates an empty override. Temporary profiles expire at SessionEnd and remain isolated from other Claude Code sessions.
All state remains inside Claude Code's private plugin-data directory:
${CLAUDE_PLUGIN_DATA}/state.json
${CLAUDE_PLUGIN_DATA}/projects/<project-hash>.json
${CLAUDE_PLUGIN_DATA}/sessions/<session-hash>.json
${CLAUDE_PLUGIN_DATA}/presets.json
Global and project stacks survive new sessions, resume, /clear, and context compaction. Temporary stacks persist while the session remains live and through compaction; Claude Code's SessionEnd hook removes them on exit, /clear, or a session switch. Named presets store canonical stacks and never read or write an arbitrary path.
Run /masq:persona doctor to inspect the catalog, manifest hooks, data directory, current scopes, effective stack, and preset count. Doctor is read-only.
Seed an empty installation with a default stack:
$env:MASQ_DEFAULT_STACK = "dean:default,conduct:default"
claude --plugin-dir .
Reset the saved stack at each true startup:
$env:MASQ_RESET_ON_START = "1"
When both variables are set, startup clears the prior global stack and then loads the defaults. Project overrides remain explicit and unchanged.
Profiles are discovered automatically from profiles/*.md. No registry or hook edit is required.
npm run new-profile -- goblin-accountant "Goblin Accountant"
npm test
See docs/PROFILE_FORMAT.md for the profile contract and docs/ARCHITECTURE.md for runtime behavior.
Node 18 or newer is required. There are no runtime npm dependencies.
npm test
claude --plugin-dir .
claude plugin marketplace add mdn87/masq
claude plugin install masq@masq
claude plugin update masq@masq
Claude Code pins a plugin's directory once per session. Since 0.7.0 the masq hooks run through src/hooks/launch.js, which checks installed_plugins.json on every hook event and delegates to the installed copy when it differs from the pinned one. New profiles and hook fixes therefore apply on the next prompt in every open session; only a new slash command in SKILL.md waits for a restart. /masq:persona doctor prints a Launcher: line showing which copy is running. The launcher leaves claude --plugin-dir . checkouts alone; MASQ_LIVE_ROOT=<path> points a checkout at another copy on purpose and MASQ_NO_RELAUNCH=1 turns delegation off.
masq/
├── .claude-plugin/ # Claude Code plugin and marketplace manifests
├── .github/workflows/ci.yml # Node test matrix
├── profiles/ # Auto-discovered persona profiles
├── skills/ # Persona management and compatibility commands
├── src/hooks/ # Persistent state and prompt-context hooks
├── src/rules/ # Global composition contract
├── scripts/ # Validation and profile scaffolding
├── tests/ # Hook and persistence tests
├── docs/ # Architecture, profile format, roadmap, ADRs
├── AGENTS.md # Repository instructions for coding agents
└── CLAUDE.md # Claude Code maintainer contexthooks/register.js 84 lines1let report = 'Masq: run /masq status to inspect the active stack';
2let band = report;
3let generation = 0;
4let busy = false;
5
6function reset($) {
7 generation += 1;
8 busy = false;
9 report = 'Masq: run /masq status to inspect the active stack';
10 band = report;
11 $.ui.invalidate('ui.render');
12}
13
14async function runCommand($, args) {
15 if (busy) return 'Masq: the previous command is still running; inspect status before trying again.';
16 busy = true;
17 const epoch = generation;
18 let started = false;
19 const failure = 'Masq: session identity unavailable; no command was run.';
20 try {
21 const root = $.plugin.root;
22 const cwd = await $.session.cwd();
23 const session = await $.session.id();
24 if (!cwd || !session) throw new Error('session identity unavailable');
25 started = true;
26 const result = await $.process.run(['node', root + '/src/cli.js',
27 '--claude-plugin-root', root, '--cwd', cwd, '--session-id', session,
28 '--harness', 'claude-code', '--json', '--', ...args.trim().split(/\s+/).filter(Boolean)],
29 { timeoutMs: 5000 });
30 const output = JSON.parse(result.stdout);
31 if (output.schema_version !== 1 || typeof output.text !== 'string') throw new Error('invalid command response');
32 if (epoch !== generation) return 'Masq: the session changed; inspect status in the current session.';
33 report = output.text;
34 band = typeof output.status_text === 'string' ? output.status_text : report;
35 $.ui.invalidate('ui.render');
36 return report;
37 } catch (_) {
38 const message = started ? 'Masq: command result unavailable. A change may have been saved; run /masq status before retrying.' : failure;
39 if (epoch === generation) {
40 report = message;
41 band = message;
42 $.ui.invalidate('ui.render');
43 }
44 return message;
45 } finally {
46 if (epoch === generation) busy = false;
47 }
48}
49
50export function register(on) {
51 on('session.start', async ($, e, next) => {
52 reset($);
53 await $.command.register({ name: 'masq', immediate: true,
54 description: 'Inspect or change persona selection, order and scope without a model turn',
55 argumentHint: '[status|list|set|move|global|project|temp|preset] [...]' });
56 $.clock.after(0, () => runCommand($, 'status'));
57 return next(e);
58 });
59 on('classic.SessionStart', async ($, e, next) => {
60 const result = await next(e);
61 reset($);
62 $.clock.after(0, () => runCommand($, 'status'));
63 return result;
64 });
65 on('classic.CwdChanged', async ($, e, next) => {
66 const result = await next(e);
67 reset($);
68 $.clock.after(0, () => runCommand($, 'status'));
69 return result;
70 });
71 on('session.end', async ($, e, next) => { reset($); return next(e); });
72 on('command.run', { command: 'masq' }, async ($, e) => ({ text: await runCommand($, e.args || 'status') }));
73 on('turn.complete', async ($, e, next) => {
74 if (e.agentId === undefined) $.clock.after(0, () => runCommand($, 'status'));
75 return next(e);
76 });
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 const previous = await next(e);
79 const { Box, Text } = $.ui.resolve(e);
80 return Box({ flexDirection: 'column', children: [previous,
81 Text({ dimColor: true, children: [band.split('\n').slice(0, 2).join(' | ')] })].filter(Boolean) });
82 });
83}
84