Instead of injecting the full SKILL.md body as additionalContext, inject "You must run the Skill(<name>) tool." — a more conventional way of telling the agent to use the Skill tool for context loading.
31 KiB
Skill Injection Engine
Audience: Plugin developers, skill authors, and anyone debugging why a skill did or didn't inject.
This document explains the complete skill injection pipeline — how vercel-plugin decides which skills to surface, when, and why. It covers both injection hooks (PreToolUse and UserPromptSubmit), the ranking system with all boost factors, the dedup state machine, budget enforcement, prompt signal scoring, and special-case triggers.
Table of Contents
- How Injection Works (Overview)
- PreToolUse Pipeline
- UserPromptSubmit Pipeline
- Prompt Signal Scoring
- Ranking & Boost Factors
- Dedup State Machine
- Budget Enforcement
- Special-Case Triggers
- User Stories
- PostToolUse Validation
- Environment Variables Reference
How Injection Works (Overview)
The plugin watches what Claude is doing and injects only the skills relevant to the current action. There are two independent injection paths:
| Hook | Trigger | Budget | Max Skills | Match Method |
|---|---|---|---|---|
| PreToolUse | Claude calls Read, Edit, Write, or Bash | 18 KB | 5 | File path globs, bash regex, import patterns |
| UserPromptSubmit | User types a prompt | 8 KB | 2 | Prompt signal scoring (phrases/allOf/anyOf/noneOf) |
Both hooks share the same dedup system — once a skill is injected, it won't be injected again in the same session.
flowchart LR
subgraph "Claude Code Session"
A["User types prompt"] --> B{"UserPromptSubmit<br/>hook"}
C["Claude calls tool"] --> D{"PreToolUse<br/>hook"}
end
B --> E["Prompt Signal<br/>Scoring"]
D --> F["Pattern<br/>Matching"]
E --> G["Rank → Dedup → Budget"]
F --> G
G --> H["additionalContext<br/>injection"]
H --> I["Claude sees<br/>skill content"]
PreToolUse Pipeline
The PreToolUse hook fires every time Claude calls Read, Edit, Write, or Bash. It runs a six-stage pipeline:
flowchart TD
INPUT["stdin JSON<br/>(tool_name, tool_input, session_id)"]
PARSE["Stage 1: parseInput<br/>Extract toolName, toolTarget, scopeId"]
LOAD["Stage 2: loadSkills<br/>Manifest (fast) or live scan (fallback)"]
MATCH["Stage 3: matchSkills<br/>Path globs / bash regex / import patterns"]
SPECIAL["Stage 3.5: Special Triggers<br/>TSX review (+40) · Dev server (+45) · Env help"]
RANK["Stage 4: Rank & Deduplicate<br/>vercel.json (±10) · Profiler (+5) · Setup (+50)"]
INJECT["Stage 5: injectSkills<br/>Budget: 18KB · Ceiling: 3 skills · Summary fallback"]
FORMAT["Stage 6: formatOutput<br/>JSON with additionalContext"]
INPUT --> PARSE --> LOAD --> MATCH --> SPECIAL --> RANK --> INJECT --> FORMAT
style PARSE fill:#e1f5fe
style LOAD fill:#e8f5e9
style MATCH fill:#fff3e0
style SPECIAL fill:#fce4ec
style RANK fill:#f3e5f5
style INJECT fill:#fff9c4
style FORMAT fill:#e0f2f1
Stage 1: Parse Input
Source: pretooluse-skill-inject.mts:parseInput()
Reads JSON from stdin and extracts:
| Field | Description |
|---|---|
toolName |
One of Read, Edit, Write, Bash |
toolInput |
The tool's arguments (e.g., file_path, command) |
sessionId |
Used for file-based dedup |
toolTarget |
Primary target — file path for file tools, command string for Bash |
scopeId |
Agent ID for subagent-scoped dedup (undefined for main agent) |
Unsupported tools (anything not in ["Read", "Edit", "Write", "Bash"]) are rejected with an empty {} response.
Stage 2: Load Skills
Source: pretooluse-skill-inject.mts:loadSkills()
Two-tier loading strategy:
-
Manifest (
generated/skill-manifest.json): Pre-built JSON with pre-compiled regex. Version 2 includes paired arrays (pathPatterns↔pathRegexSources) so hooks reconstructRegExpobjects directly — no glob compilation needed. -
Live scan (fallback): Scans
skills/*/SKILL.md, parses YAML frontmatter viabuildSkillMap(), validates withvalidateSkillMap(), and compiles patterns at runtime.
The manifest path is always preferred (faster: no filesystem scan, no YAML parsing, no glob compilation).
Stage 3: Match Skills
Source: pretooluse-skill-inject.mts:matchSkills()
For file tools (Read/Edit/Write):
flowchart TD
FILE["file_path from tool input"] --> FULL{"Full path<br/>matches glob?"}
FULL -->|Yes| HIT["Match found<br/>(type: full)"]
FULL -->|No| BASE{"Basename<br/>matches glob?"}
BASE -->|Yes| HIT2["Match found<br/>(type: basename)"]
BASE -->|No| SUFFIX{"Suffix<br/>matches glob?"}
SUFFIX -->|Yes| HIT3["Match found<br/>(type: suffix)"]
SUFFIX -->|No| IMPORT{"File content<br/>matches importPatterns?"}
IMPORT -->|Yes| HIT4["Match found<br/>(type: import)"]
IMPORT -->|No| MISS["No match"]
For Bash: Match command string against each skill's compiled bashPatterns regex.
Each match produces a MatchReason with the winning pattern and match type.
Stage 4: Rank & Deduplicate
Source: pretooluse-skill-inject.mts:deduplicateSkills()
Priority adjustments are applied in layers. See Ranking & Boost Factors for the complete boost table.
Steps:
- Filter already-seen — remove skills present in the merged dedup state
- vercel.json routing — if target is
vercel.json, read keys, adjust priorities (±10) - Profiler boost — skills in
VERCEL_PLUGIN_LIKELY_SKILLSget +5 - Setup-mode routing —
bootstrapgets +50 in greenfield projects - Rank — sort by
effectivePriorityDESC, then skill name ASC - Cap — take the top N skills (default 5)
Stage 5: Inject with Budget Enforcement
Source: pretooluse-skill-inject.mts:injectSkills()
For each ranked skill in priority order:
- Check hard ceiling (max 3 skills) — drop with
cap_exceeded - Read
skills/<name>/SKILL.md, strip frontmatter, keep body - Wrap in comment markers:
<!-- skill:name -->...<!-- /skill:name --> - Check byte budget — first skill always gets full body; subsequent must fit remaining budget
- If over budget, try
summaryfallback (see Budget Enforcement) - Atomically claim skill in dedup system via
tryClaimSessionKey()(O_EXCL)
Stage 6: Format Output
Assembles final JSON:
{
"hookSpecificOutput": {
"additionalContext": "<!-- skill:nextjs -->\n...body...\n<!-- /skill:nextjs -->\n<!-- skillInjection: {...metadata...} -->"
}
}
The metadata comment includes matched skills, injected skills, match reasons, boost factors, and budget usage — useful for debugging.
UserPromptSubmit Pipeline
The UserPromptSubmit hook fires when the user types a prompt, before any tool calls. It uses prompt signal scoring instead of pattern matching.
Source: user-prompt-submit-skill-inject.mts
flowchart TD
PROMPT["User prompt text"] --> LEN{"Length >= 10<br/>characters?"}
LEN -->|No| SKIP["Skip (too short)"]
LEN -->|Yes| NORM["Normalize prompt<br/>(lowercase, contractions, stem, whitespace)"]
NORM --> SCORE["Score every skill's<br/>promptSignals against prompt"]
SCORE --> INTENT["Classify troubleshooting<br/>intent (if any)"]
INTENT --> COMPANION["Select investigation<br/>companion (if needed)"]
COMPANION --> DEDUP["Filter seen skills"]
DEDUP --> BUDGET["Budget enforcement<br/>(8KB / 2 skills)"]
BUDGET --> OUTPUT["Format JSON output<br/>with additionalContext"]
Key differences from PreToolUse:
| Parameter | PreToolUse | UserPromptSubmit |
|---|---|---|
| Budget | 18 KB | 8 KB |
| Max skills | 5 | 2 |
| Match method | File/bash/import patterns | Prompt signal scoring |
| Min input | — | 10 characters |
Prompt Signal Scoring
Source: hooks/src/prompt-patterns.mts
Each skill can define promptSignals in its frontmatter to declare which user prompts should trigger injection.
Normalization
Both the user's prompt and the signal terms undergo normalization before scoring:
- Lowercase —
"Deploy to Vercel"→"deploy to vercel" - Contraction expansion —
"it's"→"it is","don't"→"do not","can't"→"cannot" - Stemming —
"deploying"→"deploy","configured"→"configur","building"→"build" - Whitespace collapse — multiple spaces/newlines → single space
Skill authors don't need to account for contractions or verb tenses in signal definitions.
Scoring Weights
flowchart TD
N["Normalized prompt"] --> NONE{"noneOf<br/>matches?"}
NONE -->|"Any term found"| SUPPRESS["score = -Infinity<br/>HARD SUPPRESS"]
NONE -->|"No match"| PHRASES
PHRASES["phrases check<br/>+6 per exact substring hit"] --> ALLOF
ALLOF["allOf check<br/>+4 per group where ALL terms match"] --> ANYOF
ANYOF["anyOf check<br/>+1 per hit, capped at +2 total"] --> TOTAL
TOTAL{"total >= minScore?"}
TOTAL -->|"Yes"| MATCHED["MATCHED — inject skill"]
TOTAL -->|"No"| LEXICAL["Try lexical fallback"]
style SUPPRESS fill:#ffcdd2
style MATCHED fill:#c8e6c9
| Signal Type | Weight | Behavior |
|---|---|---|
phrases |
+6 per hit | Exact substring match (case-insensitive, word-boundary aware) |
allOf |
+4 per group | All terms in the group must appear in the prompt |
anyOf |
+1 per hit, capped at +2 | Prevents low-signal flooding |
noneOf |
-Infinity | Hard suppress — skill is permanently excluded for this prompt |
minScore |
threshold (default 6) | Score must meet or exceed to qualify |
Scoring Walkthrough
Prompt: "I want to add the ai sdk for streaming"
Skill signals:
promptSignals:
phrases: ["ai sdk"] # "ai sdk" is a substring → +6
allOf: [["streaming", "generation"]] # Only "streaming" matched → +0
anyOf: ["streaming"] # "streaming" present → +1
minScore: 6
| Component | Matches? | Score |
|---|---|---|
phrases: "ai sdk" |
Yes (substring) | +6 |
allOf: ["streaming", "generation"] |
Partial (1 of 2) | +0 |
anyOf: "streaming" |
Yes | +1 |
| Total | 7 |
7 >= minScore 6 → skill injects.
Another example — reaching threshold via allOf + anyOf only:
promptSignals:
allOf: [["cron", "schedule"]] # +4
anyOf: ["vercel", "deploy", "prod"] # +1 each, capped at +2
minScore: 6
Prompt: "I need to schedule a cron job on vercel for production"
- allOf
["cron", "schedule"]both present → +4 - anyOf
"vercel"+1,"prod"(stemmed from "production") +1 → +2 (cap reached) - Total: 6 >= 6 → matched
Lexical Fallback
Source: prompt-patterns.mts:scorePromptWithLexical()
When exact scoring doesn't reach the threshold, a TF-IDF lexical index provides a fallback. The lexical score is boosted by 1.35x and compared against the exact score. The higher wins.
This catches prompts that are topically relevant but don't exactly hit configured phrases.
Troubleshooting Intent Classification
Source: user-prompt-submit-skill-inject.mts:classifyTroubleshootingIntent()
A regex classifier detects three troubleshooting intents:
| Intent | Pattern Examples | Routed Skills |
|---|---|---|
flow-verification |
"loads but", "submits but", "works locally but" | verification |
stuck-investigation |
"stuck", "frozen", "timed out", "not responding" | investigation-mode |
browser-only |
"blank page", "white screen", "console errors" | agent-browser-verify + investigation-mode |
Suppression: Test framework mentions (jest, vitest, playwright test) suppress all verification-family skills to avoid injecting browser guidance during unit testing.
Ranking & Boost Factors
Every matched skill goes through a ranking pipeline that applies priority adjustments in layers:
flowchart LR
BASE["Base Priority<br/>SKILL.md frontmatter<br/>Range 4–8, default 5"]
VJ["vercel.json Routing<br/>Relevant key: +10<br/>Irrelevant: −10"]
PROF["Profiler Boost<br/>Likely skill: +5"]
SETUP["Setup Mode<br/>bootstrap: +50"]
TSX["TSX Review<br/>react-best-practices: +40"]
DEV["Dev Server<br/>agent-browser-verify: +45"]
BASE --> VJ --> PROF --> SETUP
SETUP --> RANK["rankEntries()<br/>effectivePriority DESC<br/>Tiebreak: name ASC"]
TSX -.-> RANK
DEV -.-> RANK
style BASE fill:#e1f5fe
style VJ fill:#fff3e0
style PROF fill:#e8f5e9
style SETUP fill:#fce4ec
style TSX fill:#f3e5f5
style DEV fill:#ede7f6
Complete Boost Factor Table
| Boost | Value | Condition | Target Skill | Source |
|---|---|---|---|---|
| Base priority | 4–8 | Always applied | All skills | SKILL.md frontmatter |
| Profiler | +5 | Skill is in VERCEL_PLUGIN_LIKELY_SKILLS |
Any detected skill | session-start-profiler.mts |
| vercel.json relevant | +10 | Tool target is vercel.json and file keys match the skill |
routing-middleware, cron-jobs, vercel-functions, deployments-cicd |
vercel-config.mts |
| vercel.json irrelevant | −10 | Tool target is vercel.json but file keys don't match |
Skills that claim vercel.json in pathPatterns |
vercel-config.mts |
| Setup mode | +50 | 3+ bootstrap hints detected; greenfield project | bootstrap |
session-start-profiler.mts |
| TSX review | +40 | 3+ .tsx edits (configurable via VERCEL_PLUGIN_REVIEW_THRESHOLD) |
react-best-practices |
pretooluse-skill-inject.mts |
| Dev server | +45 | Bash command matches dev server pattern (next dev, etc.) |
agent-browser-verify + companions |
pretooluse-skill-inject.mts |
Priority formula:
effectivePriority = basePriority
+ vercelJsonAdjustment (±10 or 0)
+ profilerBoost (+5 or 0)
+ setupModeBoost (+50 or 0)
+ specialTriggerBoost (+40 or +45 or 0)
vercel.json Key-to-Skill Routing
Source: hooks/src/vercel-config.mts
When the tool target is vercel.json, the hook reads the file's top-level keys and maps them to skills:
| vercel.json Keys | Relevant Skill |
|---|---|
redirects, rewrites, headers, cleanUrls, trailingSlash |
routing-middleware |
crons |
cron-jobs |
functions, regions |
vercel-functions |
builds, buildCommand, installCommand, outputDirectory, framework, devCommand, ignoreCommand |
deployments-cicd |
Skills relevant to the file's keys get +10; skills that claim vercel.json but aren't relevant get −10.
Profiler Detection
Source: hooks/src/session-start-profiler.mts
The session-start profiler scans the project and sets VERCEL_PLUGIN_LIKELY_SKILLS:
| Detected Signal | Skills Added |
|---|---|
next.config.* file |
nextjs, turbopack |
turbo.json file |
turborepo |
vercel.json file |
vercel-cli, deployments-cicd, vercel-functions |
middleware.ts|js file |
routing-middleware |
components.json file |
shadcn |
next in package.json deps |
nextjs |
@vercel/blob|kv|postgres in deps |
vercel-storage |
@vercel/flags in deps |
vercel-flags |
crons key in vercel.json |
cron-jobs |
| ...and many more | See session-start-profiler.mts |
Bootstrap/setup mode: When 3+ "bootstrap hints" are detected (e.g., .env.example, prisma/schema.prisma, auth dependencies, storage resource dependencies), VERCEL_PLUGIN_SETUP_MODE=1 is set, enabling the +50 boost for the bootstrap skill.
Dedup State Machine
The dedup system prevents re-injecting skills already delivered in the current session. It uses three independent state sources merged into a unified view:
stateDiagram-v2
[*] --> Init: SessionStart sets<br/>VERCEL_PLUGIN_SEEN_SKILLS=""
state "Three State Sources" as sources {
EV: Env Var<br/>VERCEL_PLUGIN_SEEN_SKILLS<br/>(comma-delimited)
CD: Claim Directory<br/>tmp/.../seen-skills.d/<br/>(one file per skill, O_EXCL)
SF: Session File<br/>tmp/.../seen-skills.txt<br/>(comma-delimited snapshot)
}
Init --> sources: Hook invocation
sources --> Merge: mergeSeenSkillStates()<br/>union all three sources
Merge --> Check: Skill in merged set?
Check --> Skip: Yes → already injected
Check --> Claim: No → try atomic claim
Claim --> Success: openSync(path, "wx")<br/>File created
Claim --> Race: File already exists<br/>(concurrent hook won)
Success --> Sync: syncSessionFileFromClaims()
Race --> Skip
state "Fallback Strategies" as fb {
F1: file — atomic claims (primary)
F2: env-var — no session ID
F3: memory-only — single invocation
F4: disabled — HOOK_DEDUP=off
}
Key design choice: The atomic openSync(path, "wx") (O_EXCL) flag means if two hooks try to claim the same skill simultaneously, only one succeeds. This provides filesystem-level mutual exclusion.
Subagent isolation: Subagents (identified by scopeId) get their own dedup scope. The parent's env var is excluded from the subagent's merge, so subagents get fresh skill injection.
Cleanup: session-end-cleanup.mts deletes all temporary dedup files and claim directories when the session ends.
Budget Enforcement
Budget enforcement prevents the plugin from flooding Claude's context window.
PreToolUse Budget
| Parameter | Default | Env Override |
|---|---|---|
| Byte budget | 18,000 bytes (18 KB) | VERCEL_PLUGIN_INJECTION_BUDGET |
| Max skills | 5 | — (constant MAX_SKILLS) |
UserPromptSubmit Budget
| Parameter | Default | Env Override |
|---|---|---|
| Byte budget | 8,000 bytes (8 KB) | VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET |
| Max skills | 2 | — (constant MAX_SKILLS) |
How Budget Is Applied
flowchart TD
START["Ranked skills in priority order"] --> FIRST{"First skill?"}
FIRST -->|Yes| ALWAYS["Always inject full body<br/>(even if over budget)"]
FIRST -->|No| CHECK{"Body fits in<br/>remaining budget?"}
CHECK -->|Yes| FULL["Inject full body"]
CHECK -->|No| SUM{"Summary field<br/>exists + fits?"}
SUM -->|Yes| SUMMARY["Inject summary only<br/><!-- skill:X mode:summary -->"]
SUM -->|No| DROP["Drop skill<br/>(droppedByBudget)"]
ALWAYS --> CAP{"Ceiling reached?<br/>(5 or 2 skills)"}
FULL --> CAP
SUMMARY --> CAP
CAP -->|Yes| DONE["Stop — cap_exceeded"]
CAP -->|No| NEXT["Next skill"] --> CHECK
Rules:
- First skill always gets full body regardless of budget
- Skills are measured as UTF-8 bytes after wrapping in comment markers
- Summary fallback injects with a
mode:summarymarker - Skills that neither fit as full body nor summary are dropped
Special-Case Triggers
These operate alongside the normal matching pipeline with their own counter/dedup mechanisms.
TSX Review Trigger
Source: pretooluse-skill-inject.mts:checkTsxReviewTrigger()
Injects react-best-practices after repeated .tsx edits to catch React antipatterns early.
| Parameter | Default | Env Override |
|---|---|---|
| Edit threshold | 3 | VERCEL_PLUGIN_REVIEW_THRESHOLD |
| Priority boost | +40 | — |
| Counter env | — | VERCEL_PLUGIN_TSX_EDIT_COUNT |
Behavior:
- Every Edit/Write on a
.tsxfile incrementsVERCEL_PLUGIN_TSX_EDIT_COUNT - When count >= threshold, the trigger fires
- Counter resets after injection, allowing re-injection after another N edits
- Bypasses normal SEEN_SKILLS dedup — the counter is the sole gate
Dev Server Detection
Source: pretooluse-skill-inject.mts:checkDevServerVerify()
When Claude runs a dev server command, agent-browser-verify injects to encourage browser-based verification.
Detected dev server patterns:
next dev, npm run dev, pnpm dev, bun dev, bun run dev,
yarn dev, vite dev, vite, nuxt dev, vercel dev, astro dev
| Parameter | Value |
|---|---|
| Priority boost | +45 |
| Max iterations | 2 per session |
| Loop guard env | VERCEL_PLUGIN_DEV_VERIFY_COUNT |
| Companion skills | verification (co-injected as summary) |
Graceful degradation: If agent-browser is not installed (VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE=0), the hook injects an unavailability notice suggesting the user install it.
Vercel Env Help
Source: pretooluse-skill-inject.mts
One-time injection of a quick-reference guide when Claude runs vercel env add|update|pull. Uses standard dedup with key vercel-env-help.
Investigation Companion Selection
Source: user-prompt-submit-skill-inject.mts:selectInvestigationCompanion()
When investigation-mode is selected via prompt signals, the second skill slot is reserved for the best companion from a prioritized list:
workflow(highest priority)agent-browser-verifyvercel-cli
The companion must have independently scored above its minScore.
User Stories
User Story 1: TSX Edit Trigger
Scenario: Sarah is building a dashboard. She's been editing React components and is on her 3rd
.tsxfile edit.
What happens:
sequenceDiagram
participant S as Sarah
participant CC as Claude Code
participant Hook as PreToolUse Hook
S->>CC: "Add a useEffect to fetch data"
CC->>Hook: Edit tool on dashboard.tsx
Note over Hook: TSX_EDIT_COUNT: 1 → 2
Hook-->>CC: (no injection, count < 3)
S->>CC: "Now add the loading state"
CC->>Hook: Edit tool on dashboard.tsx
Note over Hook: TSX_EDIT_COUNT: 2 → 3
Note over Hook: Threshold reached!
Note over Hook: Inject react-best-practices (+40 boost)
Hook-->>CC: additionalContext with React best practices
Note over CC: Claude now has guidance on:<br/>- Hook dependencies<br/>- Memoization<br/>- Error boundaries<br/>- "use client" directive
S->>CC: "Extract this into a custom hook"
CC->>Hook: Edit tool on useData.tsx
Note over Hook: TSX_EDIT_COUNT: 0 → 1 (reset after injection)
Hook-->>CC: (no injection, fresh counter)
Why it matters: After several TSX edits, Claude accumulates context about what the developer is building. The react-best-practices skill arrives at the right moment — when Claude has enough context to apply the guidance meaningfully, and before the code grows too large to refactor easily.
Key details:
- The counter increments on any
.tsxfile edit (Write or Edit tool) - After injection, the counter resets to 0, not to 1
- The trigger bypasses normal dedup — it can fire multiple times per session
- The +40 boost ensures
react-best-practicesoutranks almost any other skill
User Story 2: Dev Server Detection
Scenario: Marcus just finished implementing a feature and asks Claude to start the dev server so he can test it.
What happens:
sequenceDiagram
participant M as Marcus
participant CC as Claude Code
participant Hook as PreToolUse Hook
participant Prof as Session Profiler
Note over Prof: Session start: detected agent-browser CLI<br/>Set AGENT_BROWSER_AVAILABLE=1
M->>CC: "Start the dev server"
CC->>Hook: Bash tool: "npm run dev"
Note over Hook: Dev server pattern matched!<br/>agent-browser available ✓<br/>Iteration count: 0 < 2
Note over Hook: Inject agent-browser-verify (+45)<br/>+ verification companion (summary)
Hook-->>CC: additionalContext with browser verification guide
Note over CC: Claude now knows to:<br/>1. Wait for server to be ready<br/>2. Open browser to verify<br/>3. Check for console errors<br/>4. Take a screenshot
M->>CC: "Looks broken, restart the dev server"
CC->>Hook: Bash tool: "npm run dev"
Note over Hook: Iteration count: 1 < 2
Hook-->>CC: Browser verification injected again
M->>CC: "One more restart please"
CC->>Hook: Bash tool: "npm run dev"
Note over Hook: Iteration count: 2 >= 2<br/>Loop guard hit!
Hook-->>CC: (no injection — prevents infinite loops)
Why it matters: When a developer starts a dev server, they expect to see their changes in a browser. The plugin nudges Claude to verify using browser automation rather than just assuming the server started correctly.
Key details:
- The profiler checks at session start whether
agent-browserCLI is on PATH - If not installed, the hook injects an unavailability notice instead (suggesting installation)
- The loop guard (max 2 iterations) prevents the skill from being injected on every dev server restart
- The
verificationskill is co-injected as a summary-only companion
User Story 3: Prompt Signal Matching
Scenario: Jess is building a Next.js app and types "my deploy keeps failing with a timeout error" into Claude Code.
What happens:
sequenceDiagram
participant J as Jess
participant CC as Claude Code
participant Hook as UserPromptSubmit Hook
J->>CC: "my deploy keeps failing with a timeout error"
Note over Hook: Step 1: Normalize prompt
Note over Hook: "my deploy keep fail with a timeout error"<br/>(lowercase + stem "keeps"→"keep", "failing"→"fail")
Note over Hook: Step 2: Score all skills with promptSignals
rect rgb(255, 243, 224)
Note over Hook: investigation-mode scoring:<br/>phrase "timeout" → not exact (no "timeout error" phrase)<br/>allOf ["timeout", "api"] → "timeout" ✓, "api" ✗ → +0<br/>anyOf ["timeout", "stuck", "debug"] → "timeout" +1 → +1<br/>Score: 1 < minScore 4 → NOT MATCHED
end
rect rgb(232, 245, 233)
Note over Hook: deployments-cicd scoring:<br/>phrase "deploy" → +6<br/>allOf ["deploy", "fail"] → both present → +4<br/>anyOf ["timeout", "error"] → +1, +1 → +2 (capped)<br/>Score: 12 >= minScore 6 → MATCHED ✓
end
rect rgb(227, 242, 253)
Note over Hook: vercel-functions scoring:<br/>phrase "timeout" → +6 (if configured)<br/>Score: 6 >= minScore 6 → MATCHED ✓
end
Note over Hook: Step 3: Rank by score DESC
Note over Hook: deployments-cicd (12) > vercel-functions (6)
Note over Hook: Step 4: Budget check (8KB, max 2 skills)
Hook-->>CC: additionalContext with deployment + functions guidance
Note over CC: Claude now has guidance on:<br/>- Deployment debugging steps<br/>- Function timeout configuration<br/>- Vercel build logs analysis
Why it matters: The prompt signal system catches user intent even when they don't mention specific technologies. The scoring formula ensures the most relevant skill wins — deployments-cicd scores higher because it matches on both the phrase "deploy" and the allOf group ["deploy", "fail"].
Key details:
- Stemming converts "failing" → "fail" and "keeps" → "keep", making signals match naturally
- The
noneOfmechanism ensures skills aren't injected for irrelevant contexts (e.g., investigation-mode hasnoneOf: ["css stuck", "sticky position"]) - The 8KB budget and 2-skill cap keep prompt injection lean since it's speculative
- If both investigation-mode and a companion were matched, investigation companion selection would kick in
PostToolUse Validation
Source: hooks/src/posttooluse-validate.mts
After Claude writes or edits a file, the PostToolUse hook runs validation rules from matched skills.
flowchart TD
WRITE["Claude writes/edits file"] --> MATCH["Match file path → skills<br/>(using pathPatterns)"]
MATCH --> LOOP["For each matched skill"]
LOOP --> RULE["For each validate rule"]
RULE --> SKIP{"skipIfFileContains<br/>matches?"}
SKIP -->|Yes| NEXT["Skip rule"]
SKIP -->|No| TEST{"pattern matches<br/>any line?"}
TEST -->|Yes| REPORT["Report violation<br/>(severity + message)"]
TEST -->|No| NEXT
NEXT --> RULE
REPORT --> LOOP
Validation rule fields:
| Field | Type | Required | Description |
|---|---|---|---|
pattern |
string (regex) |
Yes | Pattern to search for in file content |
message |
string |
Yes | Actionable fix instruction for Claude |
severity |
"error" or "warn" |
Yes | error = must fix; warn = advisory |
skipIfFileContains |
string (regex) |
No | Skip rule if file matches this pattern |
Example — Next.js async cookies rule:
validate:
- pattern: (?<!await )\bcookies\(\s*\)
message: 'cookies() is async in Next.js 16 — add await'
severity: error
skipIfFileContains: "^['\"]use client['\"]"
This catches cookies() calls without await, but skips client components (which can't call cookies() anyway).
Dedup: Validation uses MD5 content hashing to avoid re-validating the same file content. The hash is tracked in VERCEL_PLUGIN_VALIDATED_FILES.
Environment Variables Reference
| Variable | Default | Set By | Description |
|---|---|---|---|
VERCEL_PLUGIN_SEEN_SKILLS |
"" |
session-start | Comma-delimited already-injected skills |
VERCEL_PLUGIN_LIKELY_SKILLS |
"" |
profiler | Comma-delimited profiler-detected skills (+5 boost) |
VERCEL_PLUGIN_GREENFIELD |
— | profiler | "true" if project is empty |
VERCEL_PLUGIN_SETUP_MODE |
— | profiler | "1" if 3+ bootstrap hints detected (+50 boost) |
VERCEL_PLUGIN_BOOTSTRAP_HINTS |
— | profiler | Comma-delimited bootstrap signals found |
VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE |
"0" |
profiler | "1" if agent-browser CLI is on PATH |
VERCEL_PLUGIN_TSX_EDIT_COUNT |
"0" |
pretooluse | Current .tsx edit count |
VERCEL_PLUGIN_DEV_VERIFY_COUNT |
"0" |
pretooluse | Dev server injection iteration count |
VERCEL_PLUGIN_VALIDATED_FILES |
— | posttooluse | Content hashes of validated files |
VERCEL_PLUGIN_INJECTION_BUDGET |
18000 |
— | PreToolUse byte budget |
VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET |
8000 |
— | UserPromptSubmit byte budget |
VERCEL_PLUGIN_REVIEW_THRESHOLD |
3 |
— | TSX edits before react-best-practices triggers |
VERCEL_PLUGIN_LOG_LEVEL |
off |
— | off / summary / debug / trace |
VERCEL_PLUGIN_HOOK_DEDUP |
— | — | off to disable dedup entirely |
VERCEL_PLUGIN_AUDIT_LOG_FILE |
— | — | Audit log path or off |