Per-message model and effort: put @haiku or @max anywhere in a prompt and that one turn runs on it

Pick the model and effort for one message without leaving the prompt box.
explain this stack trace @haiku
@opus @max find the race condition
quick one @low
The tags are removed from the message, that turn runs on what they name (tool loops included), and the next message goes back to the session's settings. Subagents keep their own model.
No tags, no change: the mod does nothing until a message carries a tag. While a tagged turn runs, the status line shows what it runs on, like ▸ haiku · low (this turn).
| Tag | Model |
|---|---|
@opus | claude-opus-5-5 |
@sonnet | claude-sonnet-5-5 |
@haiku | claude-haiku-5-5 |
@fable | claude-fable-5-1 |
@claude-… | any full model id, as written |
::anything | any model string, as written |
Add [1m] for the 1M context variant: @opus[1m].
| Effort tag | |
|---|---|
@low @medium @high @xhigh @max | reasoning effort for this message |
Model and effort tags combine: @haiku @low. A model that takes no effort setting ignores the effort tag.
@ only counts as a tag for model names and near misses, so @src/app.ts and @agent-reviewer stay normal mentions.@hiaku or @hihg opens a picker with the closest match first. Dismiss it and the message is not sent.▸ This message runs on haiku (…), low effort) and the status line confirm it./plugin install model-tag --marketplace yyosifov/prompt-mods
hooks/models.ts: mods send the model string to the API as written. PRs welcome when new models ship.Option+P (needs Option-as-Meta in your terminal), or Ctrl+S to stash the draft, run /model, and it comes back.hooks/register.ts 121 lines1import type { EngineInterface, Register } from 'claude-code';
2
3import { EFFORT_PICKER, type Effort, isEffort } from './efforts';
4import { PICKER_OPTIONS, resolveModel, shortName } from './models';
5import { closestName, parseTags } from './parse';
6
7// What one tagged message asks for; a field left out keeps the session's.
8type Choice = { model?: { name: string; id: string }; effort?: Effort };
9
10async function pick($: EngineInterface, word: string, kind: 'model' | 'effort'): Promise<string | null> {
11 const guess = closestName(word)?.name;
12 const all = kind === 'model' ? PICKER_OPTIONS : EFFORT_PICKER;
13 const options = guess !== undefined && all.includes(guess) ? [guess, ...all.filter((o) => o !== guess)] : all;
14 try {
15 const answer = await $.ui.ask(`No ${kind} called "${word}". Which one should this message use?`, {
16 header: kind === 'model' ? 'Model' : 'Effort',
17 options,
18 });
19 return answer.toLowerCase();
20 } catch {
21 return null;
22 }
23}
24
25const label = (model: string, effort: string | number | undefined): string => (effort === undefined ? model : `${model} · ${effort}`);
26
27export const register: Register = (on) => {
28 const pending: Choice[] = [];
29 const byTurn = new Map<string, Choice>();
30
31 // A reload must not leave a line from an earlier load pinned.
32 on('session.start', ($, e, next) => {
33 $.ui.status(undefined);
34 return next(e);
35 });
36
37 on('prompt.submit', async ($, e, next) => {
38 const tags = parseTags(e.text);
39 if (tags === null) {
40 return next(e);
41 }
42
43 if (tags.text === '') {
44 return { drop: 'A model or effort tag needs a message to go with it' };
45 }
46
47 const choice: Choice = {};
48 if (tags.model !== undefined) {
49 let name = tags.model;
50 let id = resolveModel(name);
51 if (id === null) {
52 name = (await pick($, tags.model, 'model')) ?? '';
53 id = resolveModel(name);
54 }
55 if (id === null) {
56 return { drop: `No model called "${tags.model}"; the message was not sent` };
57 }
58 choice.model = { name, id };
59 }
60 if (tags.effort !== undefined) {
61 let effort: string | null = tags.effort;
62
63 if (!isEffort(effort)) {
64 effort = await pick($, tags.effort, 'effort');
65 }
66 if (effort === null || !isEffort(effort)) {
67 return { drop: `No effort called "${tags.effort}"; the message was not sent` };
68 }
69 choice.effort = effort;
70 }
71
72 if (e.turnId !== undefined) {
73 // Delivered into the running turn: its remaining steps take the choice.
74 byTurn.set(e.turnId, choice);
75 } else {
76 pending.push(choice);
77 }
78
79 const what = [choice.model && `${choice.model.name} (${choice.model.id})`, choice.effort && `${choice.effort} effort`];
80 $.ui.log(`▸ This message runs on ${what.filter(Boolean).join(', ')}`);
81
82 return next({ ...e, text: tags.text });
83 });
84
85 on('turn.start', ($, e, next) => {
86 const choice = pending.shift();
87 if (choice !== undefined) {
88 byTurn.set(e.turnId, choice);
89 }
90
91 return next(e);
92 });
93
94 on('turn.step', async function* ($, e, next) {
95 // Subagents keep the model they were given.
96 const isSubagent = e.agentId !== undefined;
97 if (isSubagent) {
98 return yield* next(e);
99 }
100
101 const choice = byTurn.get(e.turnId);
102 if (choice === undefined) {
103 return yield* next(e);
104 }
105
106 const model = choice.model?.id ?? e.model;
107 const effort = choice.effort ?? e.effort;
108 $.ui.status(`▸ ${label(shortName(model), effort)} (this turn)`);
109
110 return yield* next({ ...e, model, ...(effort === undefined ? {} : { effort }) });
111 });
112
113 on('turn.complete', ($, e, next) => {
114 if (e.agentId === undefined && byTurn.delete(e.turnId)) {
115 $.ui.status(undefined);
116 }
117
118 return next(e);
119 });
120};
121hooks/efforts.ts 9 lines1export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
2
3export type Effort = (typeof EFFORTS)[number];
4
5// AskUserQuestion takes four options; `xhigh` is still reachable under Other.
6export const EFFORT_PICKER = ['low', 'medium', 'high', 'max'];
7
8export const isEffort = (word: string): word is Effort => (EFFORTS as readonly string[]).includes(word);
9hooks/models.ts 30 lines1// `turn.step` sends the model string to the API as written, so short names
2// are mapped to full ids here. Update when new models ship.
3export const MODELS: Readonly<Record<string, string>> = {
4 opus: 'claude-opus-5-5',
5 sonnet: 'claude-sonnet-5-5',
6 haiku: 'claude-haiku-5-5',
7 fable: 'claude-fable-5-1',
8};
9
10export const PICKER_OPTIONS = Object.keys(MODELS);
11
12const LONG_CONTEXT = '[1m]';
13
14export const resolveModel = (name: string): string | null => {
15 const isLong = name.endsWith(LONG_CONTEXT);
16 const base = isLong ? name.slice(0, -LONG_CONTEXT.length) : name;
17 const id = MODELS[base] ?? (base.startsWith('claude-') ? base : null);
18
19 return id === null ? null : id + (isLong ? LONG_CONTEXT : '');
20};
21
22export const shortName = (id: string): string => {
23 const isLong = id.endsWith(LONG_CONTEXT);
24 const base = isLong ? id.slice(0, -LONG_CONTEXT.length) : id;
25 const known = Object.entries(MODELS).find(([, full]) => full === base)?.[0];
26 const name = known ?? base.replace(/^claude-/, '').replace(/-\d{8}$/, '');
27
28 return name + (isLong ? LONG_CONTEXT : '');
29};
30hooks/parse.ts 96 lines1import { EFFORTS } from './efforts';
2import { MODELS } from './models';
3
4// A tag stands on its own, at the start of the text or after whitespace, so
5// `Foo::bar()` and `me@opus.dev` are left alone.
6// `@haiku`, `@low`: model and effort names and near misses (`@hiaku`), so
7// `@src/app.ts` and `@agent-x` stay mentions
8// `::anything`: any model string, passed through as written
9const AT_TAG = /(^|\s)@([a-z][\w.-]*(?:\[1m\])?)(?=\s|$)/gi;
10const COLON_TAG = /(^|\s)::([a-z][\w.\-[\]]*)(?=\s|$)/gi;
11const MODEL_NAMES = Object.keys(MODELS);
12const MAX_TYPOS = 2;
13// Shorter words must match exactly: `@me` is too far from anything to guess.
14const MIN_FUZZY_LENGTH = 4;
15
16export type Kind = 'model' | 'effort';
17
18export type Tags = { model?: string; effort?: string; text: string };
19
20const distance = (a: string, b: string): number => {
21 let row = Array.from({ length: b.length + 1 }, (_, j) => j);
22 for (let i = 1; i <= a.length; i++) {
23 const next = [i];
24 for (let j = 1; j <= b.length; j++) {
25 next[j] = Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1));
26 }
27 row = next;
28 }
29
30 return row[b.length] ?? 0;
31};
32
33export const closestName = (word: string): { name: string; kind: Kind } | null => {
34 const base = word.toLowerCase().replace(/\[1m\]$/, '');
35 const limit = base.length >= MIN_FUZZY_LENGTH ? MAX_TYPOS : 0;
36 let best: { name: string; kind: Kind } | null = null;
37 let bestDistance = limit + 1;
38 const candidates: [string, Kind][] = [
39 ...MODEL_NAMES.map((name): [string, Kind] => [name, 'model']),
40 ...EFFORTS.map((name): [string, Kind] => [name, 'effort']),
41 ];
42 for (const [name, kind] of candidates) {
43 const nameDistance = distance(base, name);
44 if (nameDistance < bestDistance) {
45 best = { name, kind };
46 bestDistance = nameDistance;
47 }
48 }
49
50 return best;
51};
52
53const kindOf = (word: string): Kind | null => (/^claude-/i.test(word) ? 'model' : (closestName(word)?.kind ?? null));
54
55export const parseTags = (text: string): Tags | null => {
56 const found: { start: number; end: number; kind: Kind; word: string }[] = [];
57 for (const match of text.matchAll(AT_TAG)) {
58 const word = match[2] ?? '';
59 const kind = kindOf(word);
60 if (kind !== null) {
61 const start = (match.index ?? 0) + (match[1] ?? '').length;
62 found.push({ start, end: start + word.length + 1, kind, word });
63 }
64 }
65 for (const match of text.matchAll(COLON_TAG)) {
66 const word = match[2] ?? '';
67 const start = (match.index ?? 0) + (match[1] ?? '').length;
68 found.push({ start, end: start + word.length + 2, kind: 'model', word });
69 }
70 if (found.length === 0) {
71 return null;
72 }
73
74 let stripped = text;
75 for (const tag of [...found].sort((a, b) => b.start - a.start)) {
76 stripped = `${stripped.slice(0, tag.start).trimEnd()} ${stripped.slice(tag.end).trimStart()}`;
77 }
78
79 const first = (kind: Kind): string | undefined =>
80 found
81 .filter((t) => t.kind === kind)
82 .sort((a, b) => a.start - b.start)[0]
83 ?.word.toLowerCase();
84 const tags: Tags = { text: stripped.trim() };
85 const model = first('model');
86 const effort = first('effort');
87 if (model !== undefined) {
88 tags.model = model;
89 }
90 if (effort !== undefined) {
91 tags.effort = effort;
92 }
93
94 return tags;
95};
96