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.
30 KiB
2. Injection Pipeline Deep-Dive
This document explains how vercel-plugin decides which skills to inject, when, and why. It covers both the PreToolUse hook (file/bash/import pattern matching) and the UserPromptSubmit hook (prompt signal scoring), including the ranking pipeline, dedup state machine, budget enforcement, and special-case triggers.
Table of Contents
- Overview
- PreToolUse Pipeline
- UserPromptSubmit Pipeline
- Pattern Matching In Depth
- Prompt Signal Scoring
- Ranking Pipeline
- Dedup State Machine
- Budget Enforcement
- Special-Case Triggers
- User Story: Why Didn't My Skill Inject?
Overview
The injection pipeline is the core mechanism that makes vercel-plugin context-aware. Instead of dumping all 43 skills into every Claude session, the plugin watches what Claude is doing and injects only the skills that are 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 command 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 to prevent re-injecting skills that were already delivered in the current session.
flowchart LR
subgraph "Claude Code Session"
A[User types prompt] --> B{UserPromptSubmit}
C[Claude calls tool] --> D{PreToolUse}
end
B --> E[Prompt Signal Scoring]
D --> F[Pattern Matching]
E --> G[Rank + Dedup + Budget]
F --> G
G --> H[additionalContext injection]
H --> I[Claude sees skill content]
PreToolUse Pipeline
The PreToolUse hook fires every time Claude calls a Read, Edit, Write, or Bash tool. It runs a six-stage pipeline, each stage independently importable and testable.
sequenceDiagram
participant CC as Claude Code
participant Hook as PreToolUse Hook
participant SM as Skill Map
participant FS as File System
participant Dedup as Dedup Engine
CC->>Hook: stdin JSON (tool_name, tool_input, session_id)
Note over Hook: Stage 1: parseInput
Hook->>Hook: Extract toolName, toolInput, toolTarget, scopeId
Note over Hook: Stage 2: loadSkills
Hook->>SM: Try manifest (generated/skill-manifest.json)
alt Manifest exists (v2)
SM-->>Hook: Pre-compiled regex patterns
else No manifest
Hook->>FS: Scan skills/*/SKILL.md
FS-->>Hook: Build + validate skill map
Hook->>Hook: Compile glob→regex at runtime
end
Note over Hook: Stage 3: matchSkills
alt Read/Edit/Write tool
Hook->>Hook: Match file_path against pathPatterns
Hook->>Hook: Match file content against importPatterns
else Bash tool
Hook->>Hook: Match command against bashPatterns
end
Note over Hook: Stage 4: deduplicateSkills
Hook->>Dedup: Read seen-skills (env + file + claims)
Dedup-->>Hook: Merged seen set
Hook->>Hook: Filter already-seen
Hook->>Hook: Apply vercel.json routing (±10)
Hook->>Hook: Apply profiler boost (+5)
Hook->>Hook: Apply setup-mode routing (+50)
Hook->>Hook: rankEntries() → sort by effectivePriority DESC
Note over Hook: Stage 5: injectSkills
Hook->>FS: Read SKILL.md body for each ranked skill
Hook->>Hook: Budget check (18KB / 3 skills)
Hook->>Hook: Summary fallback if over budget
Hook->>Dedup: Claim injected skills (atomic file lock)
Note over Hook: Stage 6: formatOutput
Hook->>CC: stdout JSON with additionalContext
Stage 1: parseInput
Source: pretooluse-skill-inject.mts:parseInput()
Reads JSON from stdin and extracts:
toolName— one ofRead,Edit,Write,BashtoolInput— the tool's arguments (e.g.,file_path,command)sessionId— used for file-based deduptoolTarget— the primary target (file path for file tools, command string for Bash)scopeId— agent ID for subagent-scoped dedup (undefined for the main agent)
Unsupported tools (anything not in ["Read", "Edit", "Write", "Bash"]) are rejected immediately with an empty {} response.
Stage 2: loadSkills
Source: pretooluse-skill-inject.mts:loadSkills()
Loads the skill catalog with a two-tier strategy:
-
Try the manifest (
generated/skill-manifest.json) — a pre-built JSON file containing all skill metadata with pre-compiled regex sources. Version 2 manifests include paired arrays (pathPatterns↔pathRegexSources) so the hook can reconstructRegExpobjects directly without re-running glob-to-regex compilation. -
Fall back to live scan — if no manifest exists, scans
skills/*/SKILL.md, parses YAML frontmatter viabuildSkillMap(), validates withvalidateSkillMap(), and compiles patterns at runtime.
The manifest path is always preferred because it's faster (no filesystem scan, no YAML parsing, no glob compilation).
Stage 3: matchSkills
Source: pretooluse-skill-inject.mts:matchSkills()
For file tools (Read/Edit/Write):
- Match
file_pathagainst each skill's compiledpathPatterns - If no path match, attempt import matching — scan file content (
content,old_string,new_string) againstimportPatterns
For Bash:
- Match
commandagainst each skill's compiledbashPatterns
Each match produces a MatchReason with the winning pattern and match type (full, basename, suffix, import).
Stage 4: deduplicateSkills
Source: pretooluse-skill-inject.mts:deduplicateSkills()
This is where priority adjustments and filtering happen:
- Filter already-seen — remove skills present in the merged dedup state
- Vercel.json routing — if the target is
vercel.json, read its keys and adjust priorities (see Ranking Pipeline) - Profiler boost — skills in
VERCEL_PLUGIN_LIKELY_SKILLSget +5 priority - Setup-mode routing — in greenfield projects,
bootstrapgets a +50 priority boost - Rank — sort by
effectivePriorityDESC, then skill name ASC - Cap — take the top N skills (default 5)
Stage 5: injectSkills
Source: pretooluse-skill-inject.mts:injectSkills()
For each ranked skill (in priority order):
- Check the hard ceiling (max 3 skills)
- Read
skills/<name>/SKILL.mdfrom disk - Strip YAML frontmatter, keep only the body
- Wrap in HTML comment markers:
<!-- skill:name -->...<!-- /skill:name --> - Check byte budget — the first skill always gets full body; subsequent skills must fit within remaining budget
- If over budget, try summary fallback (see Budget Enforcement)
- Atomically claim the skill in the dedup system
Stage 6: formatOutput
Assembles the final JSON output:
{
"hookSpecificOutput": {
"additionalContext": "<!-- skill:nextjs -->\n...skill body...\n<!-- /skill:nextjs -->"
}
}
Also embeds a metadata comment for debugging:
<!-- skillInjection: {"version":1,"hookEvent":"PreToolUse","matchedSkills":[...],"injectedSkills":[...],...} -->
UserPromptSubmit Pipeline
The UserPromptSubmit hook fires when the user types a prompt, before any tool calls. It uses a different matching strategy — prompt signal scoring instead of pattern matching.
Source: user-prompt-submit-skill-inject.mts
Pipeline stages:
- parsePromptInput — extract prompt text, session ID, cwd; reject prompts shorter than 10 characters
- normalizePromptText — lowercase, expand contractions, stem tokens, collapse whitespace
- loadSkills — reuses the same
loadSkills()from PreToolUse - analyzePrompt — score every skill's
promptSignalsagainst the normalized prompt (see Prompt Signal Scoring) - Troubleshooting intent routing — classify prompt into flow-verification, stuck-investigation, or browser-only buckets
- Investigation companion selection — when
investigation-modeis selected, pick the best companion skill - Dedup + inject — filter seen skills, load SKILL.md bodies, enforce 8KB budget / 2 skill cap
- formatOutput — build banner explaining why skills were auto-suggested
Key differences from PreToolUse:
- Budget: 8 KB (vs 18 KB)
- Max skills: 2 (vs 5)
- Match method: prompt signal scoring (not file/bash patterns)
- Minimum prompt length: 10 characters
Pattern Matching In Depth
Glob-to-Regex Compilation
Source: patterns.mts:globToRegex()
Skill frontmatter uses glob patterns for pathPatterns. At build time (or runtime if no manifest), these are compiled to JavaScript RegExp objects.
Supported wildcards:
| Glob | Regex | Meaning |
|---|---|---|
* |
[^/]* |
Match any characters except / |
** |
.* |
Match anything (including /) |
**/ |
(?:[^/]+/)* |
Match zero or more path segments |
? |
[^/] |
Match exactly one character (not /) |
{a,b} |
(?:a|b) |
Brace expansion (alternation) |
Examples:
Glob: **/*.tsx
Regex: ^(?:[^/]+/)*[^/]*\.tsx$
Match: src/components/Button.tsx ✓
Button.tsx ✓
src/styles/global.css ✗
Glob: app/**/page.{ts,tsx}
Regex: ^app/(?:[^/]+/)*page\.(?:ts|tsx)$
Match: app/page.tsx ✓
app/dashboard/settings/page.ts ✓
lib/page.tsx ✗
Glob: vercel.json
Regex: ^vercel\.json$
Match: vercel.json ✓
app/vercel.json ✗ (but see suffix matching below)
The manifest (v2) stores the compiled regex source alongside the original glob, so the hook only needs new RegExp(source) at runtime — no glob compilation needed.
Path Matching Strategy
Source: patterns.mts:matchPathWithReason()
Path matching attempts three strategies in order:
- Full path match — test the entire normalized path against the regex
- Basename match — test just the filename (
Button.tsx) - Suffix match — progressively test longer suffixes (
components/Button.tsx,src/components/Button.tsx, etc.)
This multi-strategy approach means vercel.json in a glob will match both /project/vercel.json and /project/apps/web/vercel.json.
Bash Pattern Matching
Source: patterns.mts:matchBashWithReason()
Bash patterns are regular expressions tested against the full command string. No special normalization is applied — the regex is tested directly.
# In SKILL.md frontmatter:
metadata:
bashPatterns: ["\\bnext\\s+dev\\b", "\\bnpm\\s+run\\s+build\\b"]
Import Pattern Matching
Source: patterns.mts:importPatternToRegex()
Import patterns match against file content (not file paths). They detect import, require, and dynamic import() statements.
# In SKILL.md frontmatter:
metadata:
importPatterns: ["@vercel/analytics", "ai"]
The pattern ai generates a regex that matches:
from 'ai'
from 'ai/react'
require('ai')
import('ai/rsc')
Import matching is a fallback — it only runs when path matching produces no hit for a given skill.
Prompt Signal Scoring
Source: prompt-patterns.mts
Each skill can define promptSignals in its frontmatter to declare what user prompts should trigger injection.
Scoring Weights
flowchart TB
subgraph "Scoring Engine"
P[Prompt text] --> N[Normalize: lowercase + expand contractions + stem]
N --> NONE{noneOf check}
NONE -->|term found| SUPPRESS[score = -Infinity, REJECT]
NONE -->|no match| PHRASES
PHRASES[phrases check] -->|+6 per hit| SUM
ALLOF[allOf check] -->|+4 per group| SUM
ANYOF[anyOf check] -->|+1 per hit, max +2| SUM
SUM[Total Score] --> THR{score >= minScore?}
THR -->|Yes| ACCEPT[MATCHED]
THR -->|No| REJECT2[NOT MATCHED]
end
| Signal Type | Weight | Behavior |
|---|---|---|
phrases |
+6 per hit | Exact substring match (case-insensitive, after normalization) |
allOf |
+4 per group | All terms in the group must appear in the prompt |
anyOf |
+1 per hit, capped at +2 | Any term matches; cap prevents low-signal flooding |
noneOf |
-Infinity | Hard suppress — if any noneOf term matches, the skill is excluded |
minScore |
threshold (default 6) | Score must meet or exceed this to qualify |
Example: A skill with phrases: ["deploy to vercel"] and minScore: 6:
- "How do I deploy to vercel?" → score 6 (one phrase hit) → matched
- "How do I deploy?" → score 0 (no phrase hit) → not matched
Example: Reaching threshold via allOf + anyOf only:
promptSignals:
allOf: [["cron", "schedule"]] # +4
anyOf: ["vercel", "deploy", "production"] # +1 each, capped at +2
minScore: 6
- "I need to schedule a cron job on vercel for production" → allOf +4, anyOf +2 = score 6 → matched
Normalization and Contractions
Before scoring, both the prompt text and the signal terms undergo normalization:
- 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" - Whitespace collapse — multiple spaces/newlines → single space
This means skill authors don't need to account for contractions or verb tenses in their signal definitions.
Lexical Fallback Scoring
Source: prompt-patterns.mts:scorePromptWithLexical()
When exact prompt signal scoring doesn't reach the threshold, a lexical index (TF-IDF based) provides a fallback. The lexical score is boosted by 1.35x and compared against the exact score. The higher score wins.
This ensures skills with strong keyword overlap still get matched even if the user's phrasing doesn't exactly hit the configured phrases.
Troubleshooting Intent Classification
Source: prompt-patterns.mts:classifyTroubleshootingIntent()
A regex-based classifier detects three troubleshooting intents in user prompts:
| 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, etc.) suppress all verification-family skills to avoid injecting browser verification guidance during unit testing.
Ranking Pipeline
Every matched skill goes through a ranking pipeline that determines injection order. The pipeline applies priority adjustments in layers:
flowchart TB
BASE["Base Priority<br/>(from SKILL.md frontmatter)<br/>Range: 4–8, default 5"]
VJ["Vercel.json Routing<br/>Relevant key: +10<br/>Irrelevant key: -10"]
PROF["Profiler Boost<br/>Likely skill: +5"]
SETUP["Setup Mode<br/>bootstrap: +50"]
RANK["rankEntries()<br/>Sort: effectivePriority DESC<br/>Tiebreak: skill name ASC"]
CAP["Cap at MAX_SKILLS<br/>(5 for PreToolUse)"]
BASE --> VJ --> PROF --> SETUP --> RANK --> CAP
style BASE fill:#e1f5fe
style VJ fill:#fff3e0
style PROF fill:#e8f5e9
style SETUP fill:#fce4ec
style RANK fill:#f3e5f5
style CAP fill:#fff9c4
Base Priority
Set in each skill's SKILL.md frontmatter:
metadata:
priority: 6 # Range 4–8, default 5
Higher priority means earlier injection. Skills with equal priority are sorted alphabetically.
Vercel.json Key-Aware Routing
Source: vercel-config.mts
When the tool target is a vercel.json file, the hook reads the file's keys and adjusts priorities for skills that claim vercel.json in their pathPatterns:
| vercel.json Key | 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 |
Priority adjustment:
- Skill is relevant to the file's keys → +10
- Skill is not relevant (but claims vercel.json) → -10
This prevents irrelevant skills from being injected when editing vercel.json. For example, editing a vercel.json with { "crons": [...] } will boost cron-jobs by +10 and demote routing-middleware by -10.
Profiler Boost
The session-start profiler scans the project's dependencies, config files, and directory structure to predict which skills are likely relevant. These "likely skills" are stored in VERCEL_PLUGIN_LIKELY_SKILLS.
Boost: +5 to effectivePriority for any matched skill that's also in the likely-skills set.
The boost stacks on top of vercel.json routing:
effectivePriority = base + vercelJsonAdjustment + profilerBoost
Setup Mode Routing
When the project is greenfield (VERCEL_PLUGIN_GREENFIELD=true), the bootstrap skill gets a massive priority boost of +50, ensuring it's always injected first. If bootstrap didn't naturally match the tool call, it's synthetically added to the match set.
Unified Ranker
Source: patterns.mts:rankEntries()
After all priority adjustments, skills are sorted:
- Primary:
effectivePriorityDESC (or basepriorityif no adjustments) - Secondary: skill name ASC (alphabetical tiebreak)
Dedup State Machine
The dedup system prevents the same skill from being injected twice in a session. It uses three independent state sources that are merged into a unified view.
stateDiagram-v2
[*] --> Initialized: SessionStart hook sets<br/>VERCEL_PLUGIN_SEEN_SKILLS=""
state "Three State Sources" as sources {
EnvVar: Env Var<br/>VERCEL_PLUGIN_SEEN_SKILLS<br/>(comma-delimited)
ClaimDir: Claim Directory<br/>tmp/vercel-plugin-{sessionId}-seen-skills.d/<br/>(one file per skill, atomic O_EXCL)
SessionFile: Session File<br/>tmp/vercel-plugin-{sessionId}-seen-skills.txt<br/>(comma-delimited snapshot)
}
Initialized --> sources: Hook invocation
sources --> Merge: mergeSeenSkillStates()
Merge --> Check: Is skill in merged set?
Check --> Skip: Yes → already injected
Check --> Inject: No → new skill
Inject --> Claim: tryClaimSessionKey()<br/>(atomic openSync O_EXCL)
Claim --> ClaimSuccess: File created
Claim --> ClaimFail: File exists<br/>(concurrent hook won)
ClaimSuccess --> Sync: syncSessionFileFromClaims()
Sync --> UpdateEnv: Update env var + session file
ClaimFail --> Skip
state "Fallback Strategies" as fallback {
File: "file" strategy<br/>(primary: atomic claims)
EnvOnly: "env-var" strategy<br/>(no session ID)
Memory: "memory-only" strategy<br/>(single invocation)
Disabled: "disabled" strategy<br/>(VERCEL_PLUGIN_HOOK_DEDUP=off)
}
Note right of fallback: Strategies degrade gracefully:<br/>file → env-var → memory-only → disabled
Three State Sources
-
Environment variable (
VERCEL_PLUGIN_SEEN_SKILLS): Comma-delimited list of skill slugs. Fast to read, but can drift if multiple hooks run concurrently. -
Claim directory (
<tmpdir>/vercel-plugin-<sessionId>-seen-skills.d/): Contains one empty file per claimed skill. Files are created atomically withopenSync(path, "wx")(O_EXCL flag), which provides a filesystem-level mutex. If two hooks try to claim the same skill simultaneously, only one succeeds. -
Session file (
<tmpdir>/vercel-plugin-<sessionId>-seen-skills.txt): A comma-delimited snapshot periodically synced from the claim directory. Acts as a checkpoint.
Merge Strategy
Source: patterns.mts:mergeSeenSkillStates()
All three sources are unioned into a single Set<string>. A skill is considered "seen" if it appears in any of the three sources.
mergedSeen = union(envVar, claimDir, sessionFile)
Fallback Strategies
The dedup system degrades gracefully:
| Strategy | When | Behavior |
|---|---|---|
| file | Session ID available, filesystem writable | Full atomic claims + session file + env var |
| env-var | No session ID, or claim dir unavailable | Env var only (no cross-process safety) |
| memory-only | No env var support | In-memory set for single invocation |
| disabled | VERCEL_PLUGIN_HOOK_DEDUP=off |
No dedup — every match is injected |
Scope-Aware Dedup for Subagents
Source: patterns.mts:mergeScopedSeenSkillStates()
When running inside a subagent (identified by scopeId / agent_id):
- The parent's env var is excluded from the merge, because it carries the parent's seen-skills and would incorrectly suppress skills the subagent hasn't seen
- Only the session file and claim directory are merged
- Claims are scoped to the subagent's scope ID
This ensures subagents get fresh skill injection while still deduplicating within their own scope.
Budget Enforcement
Budget enforcement prevents the plugin from flooding Claude's context window with too much skill content.
PreToolUse Budget
| Parameter | Default | Env Override |
|---|---|---|
| Byte budget | 18,000 bytes (18 KB) | VERCEL_PLUGIN_INJECTION_BUDGET |
| Max skills | 5 | — |
Rules:
- The first skill always gets its full body, regardless of budget
- Subsequent skills must fit within the remaining byte budget
- Skills are measured as UTF-8 bytes after wrapping in comment markers
UserPromptSubmit Budget
| Parameter | Default | Env Override |
|---|---|---|
| Byte budget | 8,000 bytes (8 KB) | VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET |
| Max skills | 2 | — |
The smaller budget reflects that prompt-based injection is speculative — the user hasn't started working with specific files yet.
Summary Fallback
When a skill's full body would exceed the remaining budget, the hook checks if a summary field exists in the frontmatter:
summary: "Brief guidance for this skill (injected when budget exceeded)"
If the summary fits within budget, it's injected with a mode:summary marker:
<!-- skill:nextjs mode:summary -->
Brief guidance for this skill...
<!-- /skill:nextjs -->
If neither the full body nor summary fits, the skill is dropped with a droppedByBudget classification.
Special-Case Triggers
These triggers operate alongside the normal pattern-matching pipeline and have their own dedup/counter mechanisms.
TSX Review Trigger
Source: pretooluse-skill-inject.mts:checkTsxReviewTrigger()
After a configurable number of .tsx file edits (default: 3), the react-best-practices skill is injected with a massive priority boost (+40).
| Parameter | Default | Env Override |
|---|---|---|
| Edit threshold | 3 | VERCEL_PLUGIN_REVIEW_THRESHOLD |
| Priority boost | +40 | — |
Behavior:
- Every Edit/Write on a
.tsxfile incrementsVERCEL_PLUGIN_TSX_EDIT_COUNT - When the count reaches the threshold, the trigger fires
- The counter resets after injection, allowing re-injection after another N edits
- This trigger bypasses the 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 (e.g., next dev, npm run dev, vite), the agent-browser-verify skill is injected to encourage browser-based verification.
Detected 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) |
Graceful degradation: If agent-browser is not installed (VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE=0), the hook injects an unavailability notice instead, suggesting the user install it.
Vercel Env Help
Source: pretooluse-skill-inject.mts:checkVercelEnvHelp()
One-time injection of a quick-reference guide when Claude runs vercel env add, vercel env update, or vercel env pull. The guide clarifies common pitfalls (e.g., "do NOT pass NAME=value as a positional argument").
This uses the standard dedup system with key vercel-env-help — once shown, it won't appear again in the session.
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" skill from a prioritized list:
workflow(highest priority)agent-browser-verifyvercel-cli
The companion must have independently matched (score >= its minScore). This ensures debugging prompts get both the investigation methodology and a relevant tooling skill.
User Story: Why Didn't My Skill Inject?
Scenario: You authored a new skill called my-feature with pathPatterns: ["**/my-feature.config.ts"], but when Claude reads src/my-feature.config.ts, the skill doesn't appear.
Step 1: Use vercel-plugin explain
vercel-plugin explain src/my-feature.config.ts
This shows which skills match the file path, with a priority breakdown:
Matches for "src/my-feature.config.ts":
✓ my-feature priority=5 pattern="**/my-feature.config.ts" match=suffix
Budget simulation (18000 bytes, max 3 skills):
1. my-feature body=2340B cumulative=2340B ✓ within budget
If your skill doesn't appear here, the pattern doesn't match. Check:
- Is the glob correct? Run
bun run build:manifestto recompile. - Is the pattern in
pathPatterns(notbashPatterns)?
Step 2: Check dedup state
echo $VERCEL_PLUGIN_SEEN_SKILLS
If my-feature is already in the list, it was injected earlier in the session and dedup is filtering it out. This is expected behavior — skills inject once per session.
To test without dedup:
VERCEL_PLUGIN_HOOK_DEDUP=off vercel-plugin explain src/my-feature.config.ts
Step 3: Check budget
If your skill appears in explain output but the budget simulation shows it as "dropped by budget", the preceding skills consumed too much of the 18 KB budget. Options:
- Increase the budget:
VERCEL_PLUGIN_INJECTION_BUDGET=25000 - Reduce your skill's body size
- Add a
summaryfield for budget-constrained injection
Step 4: Enable debug logging
VERCEL_PLUGIN_LOG_LEVEL=debug
This produces structured JSON logs on stderr showing every pipeline stage:
input-parsed— what the hook receivedmatches-found— which skills matched and whydedup-filtered— which skills were filtered outskill-injected/skill-dropped— final injection decisions
For maximum detail, use VERCEL_PLUGIN_LOG_LEVEL=trace to see every pattern evaluation.
Step 5: Check the manifest
cat generated/skill-manifest.json | jq '.skills["my-feature"]'
Verify:
pathPatternscontains your globpathRegexSourcescontains the compiled regex- The regex actually matches your file path
If the manifest is stale, rebuild:
bun run build:manifest
Common Gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Skill never matches | Glob doesn't cover the path | Test with vercel-plugin explain <path> |
| Skill matched but not injected | Already in SEEN_SKILLS |
Expected — dedup prevents re-injection |
| Skill matched but "dropped by budget" | Too many higher-priority skills | Add a summary fallback or increase budget |
| Skill matches locally but not in session | Stale manifest | Run bun run build:manifest |
| Prompt-based skill not matching | Phrases don't match after normalization | Check stemming (e.g., "deploying" stems to "deploy") |
| Skill injected in parent but not subagent | Scope-aware dedup working correctly | Subagents get fresh dedup state |