On every prompt, asks Jev which skills fit, shows them ranked above the prompt, and passes them to the model as suggestions.

A Claude Code mod that suggests the right skills for each prompt. On every prompt it asks Jev whether each skill fits, shows the ones over the threshold ranked above the prompt, and passes them to the model as a suggestion. It never orders a skill loaded: the model decides.
Jev suggests · tdd 0.96 · animate 0.95 (240 ms)
/clear forgets them.Set in /plugin → Installed → skill-router → Configure options.
| Option | Default | Meaning |
|---|---|---|
| Threshold | 0.8 | Probability (above 0, up to 1) a skill needs to be suggested; anything else falls back to 0.8 |
| Max suggestions | 3 | Skills over the threshold to show and suggest, highest first |
| Timeout (ms) | 1500 | How long to wait for Jev before sending the prompt without skills |
Needs the jev mod and a TypeSafe key.
/plugin install jev --marketplace gecm0/jev-mod
/plugin install skill-router --marketplace gecm0/skill-router-mod
Every routed prompt sends to api.typesafe.ai: your prompt (first 4,000 characters) and each skill's name and one-line description. Never a skill's full content. With about 90 skills that is roughly 7,000 input tokens and 200 to 450 ms per prompt.
claude --plugin-dir ../jev-mod --plugin-dir .
claude plugin validate .
claude plugin test .hooks/register.tsx 110 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { SkillRouterLast } from '../types'
5import { candidatesOf, instructionOf, questionsOf, rank, shouldRoute, stateOf, thresholdOf } from './route'
6
7const last = atom({ plugin: 'skill-router', key: 'last' } as const, null as SkillRouterLast | null)
8const loaded = atom({ plugin: 'skill-router', key: 'loaded' } as const, [] as string[])
9
10function num(value: unknown, fallback: number): number {
11 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
12}
13
14export const register: Register = (on, options) => {
15 const threshold = thresholdOf(options.threshold)
16 const max = Math.max(1, Math.floor(num(options.max_suggestions, 3)))
17 const timeoutMs = num(options.timeout_ms, 1500)
18
19 on('prompt.submit', async ($, e, next) => {
20 if (!shouldRoute(e.text, e.origin.kind)) {
21 // Clear the band so it never shows an earlier prompt's suggestions.
22 await update($, last, () => null)
23 return next(e)
24 }
25
26 let context = e.context
27 try {
28 // The engine's own skill listing is what the model may invoke; the summary
29 // breakdown is computed locally and sends nothing.
30 const usage = await $.session.usage({ breakdown: 'summary' })
31 const listing = usage.context.breakdown?.skills?.skillFrontmatter
32 if (!listing) throw new Error('the session lists no skills yet')
33 const skills = candidatesOf(
34 listing.map((s) => s.name),
35 await $.command.list(),
36 await read($, loaded),
37 )
38 if (skills.length === 0) {
39 await update($, last, () => null)
40 return next(e)
41 }
42
43 const { answers, elapsedMs } = await $.jev.ask({
44 state: stateOf(e.text),
45 questions: questionsOf(skills),
46 timeoutMs,
47 })
48 const hits = rank(skills, answers, threshold, max)
49 await update($, last, (): SkillRouterLast => ({ status: 'routed', hits, ms: elapsedMs }))
50
51 const instruction = instructionOf(hits)
52 if (instruction) context = [...(e.context ?? []), instruction]
53 } catch (error) {
54 // Fail open: a timeout or outage only costs the routing, never the prompt.
55 const reason = error instanceof Error ? error.message.replace(/^\$\.jev: /, '') : 'unknown error'
56 await update($, last, (): SkillRouterLast => ({ status: 'failed', reason }))
57 }
58 return next({ ...e, context })
59 }).catch(($, e, next) => next(e))
60
61 // Record every skill the main conversation loads, suggested or not, so it is
62 // not suggested again. A subagent's load never reaches the main context.
63 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
64 const result = await next(e)
65 if (e.agentId === undefined && !result.deny && !result.isError) {
66 await update($, loaded, (names) => (names.includes(e.skill) ? names : [...names, e.skill]))
67 }
68 return result
69 }).catch(($, e, next) => next(e))
70
71 // A /clear drops the loaded skills from the conversation, so forget them too.
72 on('session.end', async ($, e, next) => {
73 if (e.reason === 'clear') await update($, loaded, () => [])
74 return next(e)
75 })
76
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 const state = await read($, last)
79 if (e.props.hasSurvey || state === null) return next(e)
80
81 const { Box, Text } = $.ui.resolve(e)
82 if (state.status === 'failed') {
83 return (
84 <Box>
85 <Text dimColor>Jev · no suggestions: {state.reason}</Text>
86 </Box>
87 )
88 }
89 if (state.hits.length === 0) {
90 return (
91 <Box>
92 <Text dimColor>Jev · no skill over {threshold} ({state.ms} ms)</Text>
93 </Box>
94 )
95 }
96 return (
97 <Box>
98 <Text dimColor>Jev suggests · </Text>
99 {state.hits.map((hit, i) => (
100 <Text key={hit.name} color={i === 0 ? 'green' : undefined} dimColor={i > 0}>
101 {i > 0 ? ' · ' : ''}
102 {hit.name} {hit.p.toFixed(2)}
103 </Text>
104 ))}
105 <Text dimColor> ({state.ms} ms)</Text>
106 </Box>
107 )
108 })
109}
110hooks/route.ts 104 lines1import type { JevNoulQuestion } from '../.claude-plugin/types/jev'
2import type { SkillRouterHit } from '../types'
3
4export type Skill = { name: string; description: string }
5
6// Jev sees only this; the head of a long prompt carries the intent.
7const MAX_PROMPT_CHARS = 4000
8
9/** Whether a prompt is worth routing: typed by the person, not a command, not a one-word reply. */
10export function shouldRoute(text: string, originKind: string): boolean {
11 const t = text.trim()
12 return (originKind === 'composer' || originKind === 'bridge') && !t.startsWith('/') && t.length >= 12
13}
14
15/**
16 * The candidates: skills the model can invoke itself (the engine's own listing),
17 * minus those already loaded, with descriptions from the command list. User-only
18 * commands are left out; skills that act are not, which is why the router only
19 * ever suggests.
20 */
21export function candidatesOf(
22 modelSkills: readonly string[],
23 commands: readonly { name: string; description: string }[],
24 loaded: readonly string[],
25): Skill[] {
26 const invocable = new Set(modelSkills)
27 // The same skill installed twice (a user copy and a plugin's `plugin:name`)
28 // shares its description: keep one, preferring the shorter name, and treat
29 // either copy being loaded as both.
30 const done = new Set(loaded)
31 const doneDescriptions = new Set(commands.filter((c) => done.has(c.name)).map((c) => c.description.trim()))
32 const byDescription = new Map<string, Skill>()
33 for (const c of commands) {
34 const description = c.description.trim()
35 if (!invocable.has(c.name) || description === '' || doneDescriptions.has(description)) continue
36 const kept = byDescription.get(description)
37 if (!kept || c.name.length < kept.name.length) byDescription.set(description, { name: c.name, description: c.description })
38 }
39 return [...byDescription.values()]
40}
41
42export function stateOf(text: string): { request: string } {
43 return { request: text.trim().slice(0, MAX_PROMPT_CHARS) }
44}
45
46/**
47 * One noul per skill, so two fitting skills both score high; a single choice
48 * would split the probability between them. Ids are indices: Jev never sees them.
49 */
50export function questionsOf(skills: readonly Skill[]): Record<string, JevNoulQuestion> {
51 const questions: Record<string, JevNoulQuestion> = {}
52 skills.forEach((skill, i) => {
53 questions[`s${i}`] = {
54 type: 'noul',
55 instructions: {
56 claim: 'The user is asking the agent to carry out, right now, a task this skill covers.',
57 skill: { name: skill.name, description: skill.description },
58 },
59 criteria: {
60 true: "The request asks the agent to do the kind of task the skill's description covers, in this turn.",
61 false:
62 'The skill is unrelated or only loosely related, or the user only asks about, discusses, ' +
63 'or weighs whether to do such a task without asking for it to be done now.',
64 },
65 }
66 })
67 return questions
68}
69
70/** Skills at or over the threshold, best first, at most `max` of them. */
71export function rank(
72 skills: readonly Skill[],
73 nouls: Readonly<Record<string, { noul: number }>>,
74 threshold: number,
75 max: number,
76): SkillRouterHit[] {
77 return skills
78 .map((skill, i) => ({ name: skill.name, p: nouls[`s${i}`]?.noul ?? 0 }))
79 .filter((hit) => hit.p >= threshold)
80 .sort((a, b) => b.p - a.p)
81 .slice(0, max)
82}
83
84/**
85 * The hidden note the model reads beside the prompt. A suggestion, never an
86 * order: a mod cannot tell a skill that only teaches from one that acts
87 * (delegates work, runs a subagent), so the model decides.
88 */
89export function instructionOf(hits: readonly SkillRouterHit[]): string | undefined {
90 if (hits.length === 0) return undefined
91 const list = hits.map((hit) => `${hit.name} (${hit.p.toFixed(2)})`).join(', ')
92 return (
93 `[skill-router] Jev suggests these skills may fit this request: ${list}. ` +
94 'This is a suggestion, not an instruction. Load one with the Skill tool only if it would ' +
95 'clearly help; each skill costs context, so prefer one at most. Never load a skill that ' +
96 'runs actions or delegates work unless the user asked for that.'
97 )
98}
99
100/** A threshold outside 0 to 1 would match everything or nothing; fall back instead. */
101export function thresholdOf(value: unknown): number {
102 return typeof value === 'number' && value > 0 && value <= 1 ? value : 0.8
103}
104types/index.d.ts 18 lines1/** One skill suggested for the prompt. */
2export type SkillRouterHit = { name: string; p: number }
3
4/** What the band shows: the last prompt's routing. */
5export type SkillRouterLast =
6 | { status: 'routed'; hits: SkillRouterHit[]; ms: number }
7 | { status: 'failed'; reason: string }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'skill-router': {
12 last: SkillRouterLast | null
13 /** Skills the Skill tool already loaded this conversation, so they are not loaded twice. */
14 loaded: string[]
15 }
16 }
17}
18