Peer session inbox and /send, a status band above the prompt, and per-prompt model triage (Haiku, Sonnet or Opus).

A Claude Code mod (function hooks plugin).
<img src="assets/switchboard-demo.gif" alt="Switchboard demo" width="100%">
Claude Code only runs plugin code from a repo once a person has opted in, so how it loads depends on the session:
.claude/skills/switchboard once you accept the workspace trust dialog. If you trusted the folder after the session started, run /reload-plugins.claude -p and the Agent SDK: there is no trust dialog, so it does not auto-load. Set CLAUDE_CODE_PLUGIN_DIRS to the folder's absolute path (in the cloud environment's environment variables, or the env block of ~/.claude/settings.json), e.g. CLAUDE_CODE_PLUGIN_DIRS=/home/user/product-stack/.claude/skills/switchboard, or pass --plugin-dir .claude/skills/switchboard./plugin install switchboard --marketplace stunt-double/product-stack
Repo settings (enabledPlugins, extraKnownMarketplaces in .claude/settings.json) can't load it: Claude Code ignores plugins enabled only by repo-authored settings.
/inbox opens them in a pane; /send <to> <message> sends one by the name ListAgents shows.deep → claude-opus-5-5) and a button for unread messages; the status line shows the tier.claude -p and the Agent SDK) is classified, by the local laya-mlx MCP server (laya, from .mcp.json, ~10ms, free) when it is connected and confident (margin 0.1 or more), else by Haiku, as quick, standard, deep, ultra or copywriting, and that turn's main-loop requests go to Haiku 5.5 (low effort), Opus (medium or high effort), Fable 5.1 (max for ultra, medium for copywriting). Every route has a 1M context window, so a quick turn late in a long conversation never overflows a smaller model and forces a compaction (Haiku 4.5, the old quick route, had 200K; Haiku 5.5 has 1M). /triage on|off, /triage quick|standard|deep|ultra|copywriting to pin the main loop, /triage auto to unpin.general-purpose with no model given is routed by its task at spawn. Workflow agents (ultracode included) never pass agent.spawn, so their opening task is captured at session.append and they are routed on their first request. A model chosen explicitly (Agent({ model }), agent(..., { model }), a custom agent type's own model), a fork and a teammate are left alone. The band and /triage show how many subagents went to each tier.Switching models between turns drops the prompt cache, so pin a tier for long sessions where that cost matters. Routes live in ROUTES in hooks/register.tsx.
Check it with claude plugin validate .claude/skills/switchboard and claude plugin test .claude/skills/switchboard.
hooks/register.tsx 343 lines1import type { EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3
4import type { SwitchboardMessage, SwitchboardTier } from '../types';
5
6const isTriageOn = atom({ plugin: 'switchboard', key: 'isTriageOn' } as const, true);
7const pinned = atom({ plugin: 'switchboard', key: 'pinned' } as const, null);
8const tier = atom({ plugin: 'switchboard', key: 'tier' } as const, null);
9const inbox = atom({ plugin: 'switchboard', key: 'inbox' } as const, []);
10const unread = atom({ plugin: 'switchboard', key: 'unread' } as const, 0);
11const subagents = atom({ plugin: 'switchboard', key: 'subagents' } as const, {
12 quick: 0,
13 standard: 0,
14 deep: 0,
15});
16// ponytail: one entry per workflow agent for the session; fine at workflow scale.
17const agentTiers = atom({ plugin: 'switchboard', key: 'agentTiers' } as const, {});
18
19const INBOX = 'switchboard-inbox';
20const TIERS: readonly SwitchboardTier[] = ['quick', 'standard', 'deep', 'ultra', 'copywriting'];
21
22// Cheapest model that does the job; Opus kept for hard reasoning. Every route has a 1M window:
23// a turn routed to a 200K model (Haiku 4.5) overflows a long conversation and forces compaction.
24export const ROUTES: Record<SwitchboardTier, { model: string; effort: 'low' | 'medium' | 'high' | 'max' }> = {
25 quick: { model: 'claude-haiku-5-5', effort: 'low' },
26 standard: { model: 'claude-opus-5-5', effort: 'medium' },
27 deep: { model: 'claude-opus-5-5', effort: 'high' },
28 ultra: { model: 'claude-fable-5-1', effort: 'max' },
29 copywriting: { model: 'claude-fable-5-1', effort: 'medium' },
30};
31
32const HINTS: Record<SwitchboardTier, string> = {
33 quick: 'a lookup, a one-line answer, a rename or a tiny edit',
34 standard: 'an ordinary coding task in a few files',
35 deep: 'architecture, debugging, a migration, multi-step or risky work',
36 ultra: 'full-system redesign, highest-complexity multi-step work, maximum reasoning needed',
37 copywriting: 'marketing copy, product writing, messaging, tone and voice',
38};
39const LABELS = TIERS.map((t) => `${t}: ${HINTS[t]}`);
40
41// The repo's local laya-mlx MCP server (.mcp.json); below this margin it is unsure (see deciding-with-laya).
42const LAYA_SERVER = 'laya';
43const LAYA_MIN_CONFIDENCE = 0.1;
44
45export const tierOf = (label: string | undefined): SwitchboardTier | null =>
46 TIERS.find((t) => label?.startsWith(t)) ?? null;
47
48// Agent types whose model nobody chose: a custom agent's own `model` is respected.
49const TRIAGED_TYPES = new Set(['general-purpose']);
50
51// An engine fork (compaction, memory) opens on the whole transcript; a workflow agent on its task.
52const MAX_OPENING_MESSAGES = 3;
53
54const INJECTED_DOORS = new Set([
55 'attachment',
56 'hook-context',
57 'note',
58 'delivery',
59 'compaction',
60 'notice',
61]);
62
63const PEER_KINDS = new Set(['peer', 'coordinator', 'peer-send-message']);
64
65// A person's own prompt: typed at the terminal, sent from the Claude app (Remote Control), or `claude -p` / the Agent SDK.
66const PERSON_KINDS = new Set(['composer', 'bridge', 'sdk']);
67
68// Local laya-mlx first (~10ms, free); Haiku when laya is not connected, errors or is unsure.
69async function layaTier($: EngineInterface, text: string): Promise<SwitchboardTier | null> {
70 try {
71 const res = await $.mcp.call(LAYA_SERVER, 'choose', {
72 state: text.slice(0, 2000),
73 question: 'How demanding is this request?',
74 options: HINTS,
75 });
76 const raw = res.content.find((b) => b.type === 'text')?.text;
77 if (res.isError || raw === undefined) return null;
78 const answer = JSON.parse(raw) as { choice?: string; confidence?: number };
79 return (answer.confidence ?? 0) >= LAYA_MIN_CONFIDENCE ? tierOf(answer.choice) : null;
80 } catch {
81 return null;
82 }
83}
84
85const classify = async ($: EngineInterface, text: string): Promise<SwitchboardTier | null> =>
86 (await layaTier($, text)) ??
87 $.model
88 .classify(text.slice(0, 4000), LABELS, { model: 'haiku' })
89 .then(tierOf)
90 .catch(() => null);
91
92async function count($: EngineInterface, picked: SwitchboardTier) {
93 await update($, subagents, (n) => ({ ...n, [picked]: n[picked] + 1 }));
94}
95
96// The session's own model, read off the main loop before triage rewrites it; kept across reloads.
97const sessionModel = atom({ plugin: 'switchboard', key: 'sessionModel' } as const, null);
98// A subagent's opening task, captured at session.append until its first request.
99const openings = new Map<string, string>();
100// In-flight decisions, so concurrent steps of one agent classify once.
101const deciding = new Map<string, Promise<SwitchboardTier | null>>();
102
103type FirstStep = { agentId: string; model: string; index: number; messageCount: number };
104
105// Workflow agents never pass agent.spawn, so they are routed on their first request.
106async function decideWorkflowAgent(
107 $: EngineInterface,
108 step: FirstStep
109): Promise<SwitchboardTier | null> {
110 const known = (await read($, agentTiers))[step.agentId];
111 if (known !== undefined) return known;
112
113 const task = openings.get(step.agentId) ?? '';
114 openings.delete(step.agentId);
115 // No opening seen (a reload in between, an engine loop): leave it, and decide nothing for good.
116 if (task === '') return null;
117 const inherited = await read($, sessionModel);
118
119 const isWorkflowAgent =
120 step.index === 0 &&
121 step.messageCount <= MAX_OPENING_MESSAGES &&
122 // A model other than the session's was chosen explicitly (`agent(..., { model })`).
123 step.model === inherited &&
124 // Listed agents came through agent.spawn, which already decided.
125 !(await $.agent.list()).some((a) => a.id === step.agentId);
126
127 let picked: SwitchboardTier | null = null;
128 if (isWorkflowAgent && (await read($, isTriageOn))) {
129 picked = await classify($, task);
130 if (picked !== null) await count($, picked);
131 }
132 await update($, agentTiers, (all) => ({ ...all, [step.agentId]: picked }));
133 return picked;
134}
135
136export const register: Register = (on) => {
137 on('session.start', async ($, e, next) => {
138 await $.command.register({
139 name: 'triage',
140 description: 'Model triage: on, off, auto, or pin quick|standard|deep',
141 argumentHint: '[on|off|auto|quick|standard|deep]',
142 });
143 await $.command.register({
144 name: 'inbox',
145 description: 'Show messages from other sessions and agents',
146 });
147 await $.command.register({
148 name: 'send',
149 description: 'Send a message to another session or agent',
150 argumentHint: '<to> <message>',
151 });
152
153 return next(e);
154 });
155
156 // Model selection triage: classify each typed prompt, then route the turn's requests.
157 on('prompt.submit', async ($, e, next) => {
158 if (!PERSON_KINDS.has(e.origin.kind) || e.text.startsWith('/')) return next(e);
159
160 const pin = await read($, pinned);
161 let picked: SwitchboardTier | null = pin;
162 if (pin === null && (await read($, isTriageOn))) {
163 picked = await classify($, e.text);
164 }
165 await update($, tier, () => picked);
166 $.ui.status(picked ? `triage: ${picked}` : undefined);
167
168 return next(e);
169 });
170
171 // Agent tool subagents: route a generic subagent nobody picked a model for.
172 on('agent.spawn', async ($, e, next) => {
173 if (
174 e.fork ||
175 e.isTeammate ||
176 e.model !== undefined ||
177 !TRIAGED_TYPES.has(e.subagentType) ||
178 !(await read($, isTriageOn))
179 )
180 return next(e);
181
182 const picked = await classify($, `${e.description}\n\n${e.prompt}`);
183 if (picked === null) return next(e);
184 await count($, picked);
185 return next({ ...e, model: ROUTES[picked].model });
186 });
187
188 // Keep the agent's own task: its first user row as typed, not what the engine or a hook injects.
189 on('session.append', ($, e, next) => {
190 const id = e.agentId;
191 if (
192 id !== undefined &&
193 e.message.role === 'user' &&
194 !e.message.isMeta &&
195 !INJECTED_DOORS.has(e.door) &&
196 !openings.has(id) &&
197 !deciding.has(id)
198 ) {
199 const text = e.message.content
200 .flatMap((block) => (block.type === 'text' ? [block.text] : []))
201 .join('\n');
202 if (text) openings.set(id, text.slice(0, 8000));
203 }
204 return next(e);
205 });
206
207 on('turn.step', async function* ($, e, next) {
208 if (e.agentId === undefined) {
209 if ((await read($, sessionModel)) !== e.model) await update($, sessionModel, () => e.model);
210 const picked = await read($, tier);
211 if (picked === null) return yield* next(e);
212
213 const { model, effort } = ROUTES[picked];
214 return yield* next({ ...e, model, effort });
215 }
216
217 const agentId = e.agentId;
218 let decision = deciding.get(agentId);
219 if (decision === undefined) {
220 decision = decideWorkflowAgent($, {
221 agentId,
222 model: e.model,
223 index: e.index,
224 messageCount: e.messageCount,
225 });
226 deciding.set(agentId, decision);
227 }
228 const picked = await decision;
229 if (picked === null) return yield* next(e);
230
231 // A subagent keeps the effort it was given.
232 return yield* next({ ...e, model: ROUTES[picked].model });
233 });
234
235 // Agent to agent comms: keep an inbox of peer deliveries, and send with /send.
236 on('session.receive', async ($, e, next) => {
237 if (PEER_KINDS.has(e.origin.kind)) {
238 const from = 'teammate' in e.origin ? e.origin.teammate : e.origin.kind;
239 const message: SwitchboardMessage = {
240 from,
241 text: e.text,
242 at: await $.clock.now(),
243 };
244 await update($, inbox, (list) => [...list, message].slice(-50));
245 await update($, unread, (n) => n + 1);
246 $.ui.toast(`Message from ${from}`);
247 }
248
249 return next(e);
250 });
251
252 on('command.run', { command: 'send' }, async ($, e) => {
253 const [to, ...words] = e.args.trim().split(/\s+/);
254 const text = words.join(' ');
255 if (!to || !text)
256 return {
257 text: 'Usage: /send <to> <message> (names as ListAgents lists them)',
258 };
259
260 const sent = await $.session.send({ to, text });
261 return {
262 text: sent.isDelivered ? `Sent to ${to}.` : `Not sent: ${sent.reason}`,
263 };
264 });
265
266 on('command.run', { command: 'inbox' }, async ($) => {
267 await update($, unread, () => 0);
268 await $.ui.open({ id: INBOX, title: 'Inbox' });
269 return { text: 'Inbox opened.' };
270 });
271
272 on('command.run', { command: 'triage' }, async ($, e) => {
273 const arg = e.args.trim();
274 if (arg === 'on' || arg === 'off') await update($, isTriageOn, () => arg === 'on');
275 else if (arg === 'auto') await update($, pinned, () => null);
276 else if ((TIERS as readonly string[]).includes(arg))
277 await update($, pinned, () => arg as SwitchboardTier);
278 else if (arg !== '') return { text: 'Usage: /triage [on|off|auto|quick|standard|deep]' };
279
280 if (!(await read($, isTriageOn)) && (await read($, pinned)) === null) {
281 await update($, tier, () => null);
282 $.ui.status(undefined);
283 }
284 const pin = await read($, pinned);
285 const routed = await read($, subagents);
286 const tally = TIERS.filter((t) => routed[t] > 0).map((t) => `${routed[t]} ${t}`);
287 return {
288 text: `Triage ${(await read($, isTriageOn)) ? 'on' : 'off'}${pin ? `, pinned to ${pin} (${ROUTES[pin].model})` : ''}.${tally.length ? ` Subagents routed: ${tally.join(', ')}.` : ''}`,
289 };
290 });
291
292 // UI: a band above the prompt with the route and unread messages.
293 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294 const picked = await read($, tier);
295 const count = await read($, unread);
296 const routed = await read($, subagents);
297 const tally = TIERS.filter((t) => routed[t] > 0).map((t) => `${routed[t]} ${t}`);
298 if (e.props.hasSurvey || (picked === null && count === 0 && tally.length === 0)) return next(e);
299
300 const { Box, Button, Text } = $.ui.resolve(e);
301 return (
302 <Box gap={2}>
303 {picked !== null && (
304 <Text dimColor>
305 {picked} → {ROUTES[picked].model}
306 {(await read($, pinned)) ? ' (pinned)' : ''}
307 </Text>
308 )}
309 {tally.length > 0 && <Text dimColor>subagents: {tally.join(' · ')}</Text>}
310 {count > 0 && (
311 <Button
312 key="inbox"
313 label={`${count} new message${count === 1 ? '' : 's'}`}
314 onPress={async () => {
315 await update($, unread, () => 0);
316 await $.ui.open({ id: INBOX, title: 'Inbox' });
317 }}
318 />
319 )}
320 </Box>
321 );
322 });
323
324 on('ui.render', { component: 'Pane', requestId: INBOX }, async ($, e) => {
325 const { Box, Text } = $.ui.resolve(e);
326 const list = await read($, inbox);
327 return (
328 <Box flexDirection="column">
329 {list.length === 0 && <Text dimColor>No messages yet.</Text>}
330 {list
331 .slice()
332 .reverse()
333 .map((m) => (
334 <Box flexDirection="column" marginBottom={1}>
335 <Text bold>{m.from}</Text>
336 <Text>{m.text}</Text>
337 </Box>
338 ))}
339 </Box>
340 );
341 });
342};
343types/index.d.ts 18 lines1export type SwitchboardTier = 'quick' | 'standard' | 'deep' | 'ultra' | 'copywriting';
2export type SwitchboardMessage = { from: string; text: string; at: number };
3
4declare module 'claude-code' {
5 interface PluginState {
6 switchboard: {
7 isTriageOn: boolean;
8 pinned: SwitchboardTier | null;
9 tier: SwitchboardTier | null;
10 inbox: SwitchboardMessage[];
11 unread: number;
12 subagents: Record<SwitchboardTier, number>;
13 agentTiers: Record<string, SwitchboardTier | null>;
14 sessionModel: string | null;
15 };
16 }
17}
18