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.
52 KiB
6. Runtime Internals Reference
Purpose: Document the internal implementation details — hook I/O contracts, matching semantics, dedup state machines, audit log schemas, subagent coordination, and temp file ownership — at the code level.
Audience: Maintainers and contributors who need to understand the internal mechanics of hook execution, pattern compilation, dedup state machines, subagent coordination, and observability infrastructure.
Prerequisites: 01 Architecture Overview, 02 Injection Pipeline, and 04 Operations & Debugging.
Previous page: ← Reference · This is the final page in the documentation.
This document covers implementation details that go beyond the pipeline overview in 02-injection-pipeline.md and the operational guide in 04-operations-debugging.md. Where those documents explain what happens, this document explains how and why at the code level.
Table of Contents
- Session Lifecycle: Full Hook Invocation Sequence
- Matching Semantics
- Intent Routing and Priority Arithmetic
- Scoped Dedup System
- Audit Logging and Observability
- Subagent Lifecycle
- Verification Observer
- Post-Write Validation
- Session Cleanup
- Temp File Inventory
Session Lifecycle: Full Hook Invocation Sequence
The following diagram shows every hook that fires across a complete session, including the subagent sub-lifecycle. Hooks are registered in hooks/hooks.json and executed by the Claude Agent SDK runtime.
sequenceDiagram
participant User
participant Claude as Claude Code
participant SS as SessionStart Hooks
participant PTU as PreToolUse Hooks
participant UPS as UserPromptSubmit Hook
participant PostTU as PostToolUse Hooks
participant Sub as Subagent Hooks
participant SE as SessionEnd Hook
Note over SS: Session begins (startup/resume/clear/compact)
Claude->>SS: session-start-seen-skills.mjs
Note right of SS: Initialize VERCEL_PLUGIN_SEEN_SKILLS=""
Claude->>SS: session-start-profiler.mjs
Note right of SS: Scan project → set VERCEL_PLUGIN_LIKELY_SKILLS
Claude->>SS: inject-claude-md.mjs
Note right of SS: Output vercel.md ecosystem graph (~52KB)
loop Every user message
User->>Claude: Prompt text
Claude->>UPS: user-prompt-submit-skill-inject.mjs
Note right of UPS: Score prompt signals → inject ≤2 skills (8KB)
end
loop Every Read/Edit/Write/Bash tool call
Claude->>PTU: pretooluse-skill-inject.mjs
Note right of PTU: Match patterns → rank → dedup → inject ≤3 skills (18KB)
end
loop Every Agent tool call
Claude->>PTU: pretooluse-subagent-spawn-observe.mjs
Note right of PTU: Record pending launch metadata (JSONL)
end
loop Every Bash completion
Claude->>PostTU: posttooluse-shadcn-font-fix.mjs
Note right of PostTU: Fix shadcn font loading (if applicable)
Claude->>PostTU: posttooluse-verification-observe.mjs
Note right of PostTU: Classify boundary → emit structured event
end
loop Every Write/Edit completion
Claude->>PostTU: posttooluse-validate.mjs
Note right of PostTU: Run skill validation rules → return fix instructions
end
rect rgb(240, 248, 255)
Note over Sub: Subagent sub-lifecycle
Claude->>Sub: subagent-start-bootstrap.mjs
Note right of Sub: Inject context (profiler + skills) scoped by agent_id
Note over Sub: Subagent runs (own PreToolUse/PostToolUse cycle)
Claude->>Sub: subagent-stop-sync.mjs
Note right of Sub: Append to aggregate ledger (JSONL)
end
Note over SE: Session ends
Claude->>SE: session-end-cleanup.mjs
Note right of SE: Delete all session temp files + claim dirs
Hook Registration Summary
| Event | Hook File | Matcher | Timeout |
|---|---|---|---|
| SessionStart | session-start-seen-skills.mjs |
startup|resume|clear|compact |
— |
| SessionStart | session-start-profiler.mjs |
startup|resume|clear|compact |
— |
| SessionStart | inject-claude-md.mjs |
startup|resume|clear|compact |
— |
| PreToolUse | pretooluse-skill-inject.mjs |
Read|Edit|Write|Bash |
5 s |
| PreToolUse | pretooluse-subagent-spawn-observe.mjs |
Agent |
5 s |
| UserPromptSubmit | user-prompt-submit-skill-inject.mjs |
(empty — all prompts) | 5 s |
| PostToolUse | posttooluse-shadcn-font-fix.mjs |
Bash |
5 s |
| PostToolUse | posttooluse-verification-observe.mjs |
Bash |
5 s |
| PostToolUse | posttooluse-validate.mjs |
Write|Edit |
5 s |
| SubagentStart | subagent-start-bootstrap.mjs |
.+ |
5 s |
| SubagentStop | subagent-stop-sync.mjs |
.+ |
5 s |
| SessionEnd | session-end-cleanup.mjs |
— | — |
All hooks output JSON conforming to SyncHookJSONOutput from @anthropic-ai/claude-agent-sdk. Observer hooks (spawn-observe, verification-observe, stop-sync) output empty {}.
Hook I/O Contracts
Every hook reads JSON from stdin and writes JSON to stdout. The Claude Agent SDK provides the stdin envelope; hooks must return a SyncHookJSONOutput-conforming object. This section documents the exact shapes for each hook.
Common Stdin Fields
Most hooks receive these fields from the SDK (availability varies by event):
| Field | Type | Availability | Description |
|---|---|---|---|
tool_name |
string |
PreToolUse, PostToolUse | The tool being invoked (Read, Edit, Write, Bash, Agent) |
tool_input |
object |
PreToolUse, PostToolUse | Tool-specific arguments (e.g., file_path, command) |
session_id |
string? |
All events | Session identifier; fallback: SESSION_ID env var |
cwd |
string? |
PreToolUse, PostToolUse, UserPromptSubmit | Working directory; fallback: working_directory field |
agent_id |
string? |
PreToolUse, SubagentStart, SubagentStop | Agent identifier for subagent-scoped dedup |
prompt |
string |
UserPromptSubmit | User's prompt text |
agent_type |
string? |
SubagentStart, SubagentStop | Agent type label (e.g., general-purpose, Explore) |
SessionStart Hooks
session-start-seen-skills.mjs — No stdin parsing. Appends export VERCEL_PLUGIN_SEEN_SKILLS="" to CLAUDE_ENV_FILE. No stdout.
session-start-profiler.mjs — Reads session_id from stdin (optional). Scans project files and writes results to CLAUDE_ENV_FILE:
# Environment variables appended:
export VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE="0" # or "1"
export VERCEL_PLUGIN_GREENFIELD="true" # if empty project
export VERCEL_PLUGIN_LIKELY_SKILLS="nextjs,ai-sdk,vercel-storage"
export VERCEL_PLUGIN_BOOTSTRAP_HINTS="env-template,readme"
export VERCEL_PLUGIN_RESOURCE_HINTS="postgres,kv"
export VERCEL_PLUGIN_SETUP_MODE="1" # if ≥3 bootstrap hints
Also writes a profile cache to <tmpdir>/vercel-plugin-<sessionId>-profile.json:
{
"projectRoot": "/path/to/project",
"likelySkills": ["nextjs", "ai-sdk"],
"greenfield": false,
"bootstrapHints": ["env-template"],
"resourceHints": ["postgres"],
"setupMode": false,
"agentBrowserAvailable": true,
"timestamp": "2026-03-10T12:00:00.000Z"
}
inject-claude-md.mjs — No stdin. Writes plain markdown text to stdout (the vercel.md ecosystem graph, ~52 KB). If VERCEL_PLUGIN_GREENFIELD=true, appends a greenfield guidance section.
PreToolUse: pretooluse-skill-inject.mjs
Stdin:
{
"tool_name": "Read",
"tool_input": {
"file_path": "src/app/page.tsx",
"content": "..."
},
"session_id": "abc-123",
"cwd": "/path/to/project",
"agent_id": "def-456"
}
For Bash tools, tool_input.command replaces file_path. The agent_id field is present only for subagents.
Stdout (skill injected):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": "<!-- skill:nextjs -->\n...skill body...\n<!-- /skill:nextjs -->\n<!-- skillInjection: {\"version\":1,...} -->"
}
}
Stdout (no match):
{}
Embedded metadata comment (inside additionalContext):
{
"version": 1,
"toolName": "Read",
"toolTarget": "src/app/page.tsx",
"matchedSkills": ["nextjs", "react-best-practices"],
"injectedSkills": ["nextjs"],
"summaryOnly": [],
"droppedByCap": [],
"droppedByBudget": ["react-best-practices"],
"reasons": {
"nextjs": {
"trigger": "pattern-match",
"reasonCode": "pathPattern matched: app/**/page.tsx"
}
}
}
For Bash tool calls, toolTarget is redacted for security.
PreToolUse: pretooluse-subagent-spawn-observe.mjs
Stdin:
{
"tool_name": "Agent",
"tool_input": {
"name": "researcher",
"description": "Research API patterns",
"prompt": "Find all API routes...",
"subagent_type": "Explore",
"resume": null
},
"session_id": "abc-123"
}
Stdout: Always {} (observer only). Records a pending launch to <tmpdir>/vercel-plugin-<sessionId>-pending-launches.jsonl.
UserPromptSubmit: user-prompt-submit-skill-inject.mjs
Stdin:
{
"prompt": "my deploy keeps failing with a timeout error",
"session_id": "abc-123",
"cwd": "/path/to/project"
}
Prompts shorter than 10 characters are rejected immediately.
Stdout (skill injected):
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "<!-- skill:deployments-cicd -->\n...body...\n<!-- /skill:deployments-cicd -->\n<!-- skillInjection: {\"version\":1,...} -->"
}
}
Stdout (no match): {}
PostToolUse: posttooluse-verification-observe.mjs
Stdin:
{
"tool_name": "Bash",
"tool_input": { "command": "curl http://localhost:3000/api/users" },
"session_id": "abc-123",
"cwd": "/path/to/project"
}
Stdout: Always {} (observer only). Emits a verification.boundary_observed log event to stderr.
PostToolUse: posttooluse-validate.mjs
Stdin:
{
"tool_name": "Write",
"tool_input": { "file_path": "src/app/api/route.ts" },
"session_id": "abc-123",
"cwd": "/path/to/project"
}
Stdout (violations found):
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "⚠️ Validation issues...\n<!-- postValidation: {\"version\":1,\"hook\":\"posttooluse-validate\",\"filePath\":\"...\",\"matchedSkills\":[...],\"errorCount\":1,\"warnCount\":0} -->"
}
}
Stdout (no violations): {}
SubagentStart: subagent-start-bootstrap.mjs
Stdin:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"agent_id": "def-456",
"agent_type": "general-purpose"
}
Stdout:
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": "<!-- vercel-plugin:subagent-bootstrap ... -->\n...profile + skill content..."
}
}
Content is budget-scaled by agent type: Explore (~1 KB), Plan (~3 KB), general-purpose (~8 KB).
SubagentStop: subagent-stop-sync.mjs
Stdin:
{
"session_id": "abc-123",
"agent_id": "def-456",
"agent_type": "general-purpose",
"agent_transcript_path": "/path/to/transcript"
}
Stdout: No output. Appends a ledger entry to <tmpdir>/vercel-plugin-<sessionId>-subagent-ledger.jsonl.
SessionEnd: session-end-cleanup.mjs
Stdin: { "session_id": "abc-123" } (optional)
Stdout: No output. Deletes all vercel-plugin-<sessionId>-* files/dirs from tmpdir().
Matching Semantics
The injection engine uses three distinct pattern types. Each compiles differently and has different matching behavior.
Glob-to-Regex Conversion
Source: hooks/src/patterns.mts → globPatternToRegexSource()
The plugin uses a custom glob-to-regex converter (no external dependencies). Conversion rules:
| Glob Token | Regex Output | Semantics |
|---|---|---|
* |
[^/]* |
Any characters except path separators |
**/ |
(?:[^/]+/)* |
Zero or more path segments (with boundary) |
** (at end) |
.* |
Everything including slashes |
? |
[^/] |
Single non-slash character |
{a,b,c} |
(?:a|b|c) |
Alternation (requires commas) |
. ( ) + etc. |
\. \( \) \+ |
Escaped metacharacters |
The final regex is anchored: ^…pattern…$ (full-path match).
Edge cases:
- Empty patterns are rejected with an error
- Brace groups without commas are treated as literal
{} - Nested braces are handled recursively
Path Match Fallback Chain
Source: hooks/src/patterns.mts → matchPathWithReason()
Path matching uses a three-step fallback strategy, stopping at the first hit:
1. Full-path match e.g., "src/components/Button.tsx" vs "src/**/*.tsx"
↓ (no match)
2. Basename-only match e.g., "Button.tsx" vs "*.tsx"
↓ (no match)
3. Suffix segment scan e.g., try "Button.tsx", then "components/Button.tsx", etc.
All paths are normalized (backslashes → forward slashes) before matching.
Import Pattern Compilation and Flags
Source: hooks/src/patterns.mts → importPatternToRegex()
Import patterns start as package names (e.g., @vercel/postgres) and compile to a regex that matches ESM, CommonJS, and dynamic imports:
(?:from\s+|require\s*\(\s*|import\s*\(\s*)['"]<escaped-package>(?:/[^'"]*)?['"]
This matches:
- ESM:
from 'package'orfrom "package" - CommonJS:
require('package')orrequire( 'package' ) - Dynamic:
import('package')orimport( 'package' ) - Subpaths:
'package/subpath'(via optional(?:/[^'"]*)?)
Flags: Hardcoded "m" (multiline) — enables ^/$ to match line boundaries. Case-sensitive by default.
Wildcards in patterns (*) expand to [^'"]* (any non-quote characters).
Bash Regex Matching
Source: hooks/src/patterns.mts → matchBashWithReason()
Bash patterns are raw JavaScript RegExp strings — no transformation or escaping. They are passed directly to new RegExp(p).
- Single-pass: tests each pattern against the full command string
- Returns on first match — no fallback strategies (unlike path matching)
- Invalid regex syntax throws at compile time
Manifest Pre-compilation (Version 2)
Source: scripts/build-manifest.ts → compileRegexSources()
The manifest pre-compiles all patterns at build time to avoid runtime regex compilation in the hot path. The version 2 format uses paired arrays — index i in the pattern array corresponds to index i in the regex-source array:
{
"version": 2,
"skills": {
"skill-slug": {
"priority": 6,
"pathPatterns": ["**/*.tsx"],
"pathRegexSources": ["^(?:[^/]+\\/)*[^/]*\\.tsx$"],
"bashPatterns": ["npm run dev"],
"bashRegexSources": ["npm run dev"],
"importPatterns": ["next"],
"importRegexSources": [{ "source": "...", "flags": "m" }]
}
}
}
If a pattern fails to compile, it is dropped from both arrays together, preventing index desynchronization.
At runtime, compileSkillPatterns(skillMap) creates CompiledPattern { pattern: string, regex: RegExp } objects from the manifest. Compilation errors are reported via callbacks (onPathGlobError, onBashRegexError, onImportPatternError) but never crash the hook.
Intent Routing and Priority Arithmetic
Every matched skill receives an effective priority computed from its base priority plus contextual adjustments. The following table shows all modifiers:
| Mechanism | Adjustment | Scope | Condition |
|---|---|---|---|
| Base priority | 4–8 | All skills | Static in SKILL.md frontmatter |
| Profiler boost | +5 | VERCEL_PLUGIN_LIKELY_SKILLS |
Skill detected by project profiler |
| Vercel.json (match) | +10 | 4 routing skills | Skill's key found in vercel.json |
| Vercel.json (no match) | −10 | 4 routing skills | Skill's key absent from vercel.json |
| Setup-mode bootstrap | +50 | bootstrap skill |
Greenfield or ≥3 bootstrap hints |
| TSX review trigger | +40 | react-best-practices |
After N .tsx edits (default 3) |
| Dev-server verify | +45 | agent-browser-verify |
Dev server command detected |
Base Priority Range (4–8)
Every skill declares a metadata.priority between 4 and 8 in its SKILL.md frontmatter. Higher values indicate the skill is more broadly useful or more critical to inject early.
Profiler Boost (+5)
Source: hooks/src/session-start-profiler.mts
At SessionStart, the profiler scans the project for:
- File markers (14 patterns):
next.config.js/mjs/ts/mts,turbo.json,vercel.json,.mcp.json,middleware.ts/js,components.json,.env.local,pnpm-workspace.yaml - Package dependencies (~20 mapped):
next,ai,@ai-sdk/*,@vercel/*(blob, kv, postgres, edge-config, analytics, speed-insights, flags, workflow, queue, sandbox, sdk),turbo,@t3-oss/env-nextjs - Vercel.json keys:
crons,rewrites,redirects,headers,functions - Bootstrap signals (11 patterns): env templates, README, drizzle/prisma configs, setup scripts, auth/resource dependencies
Results are written to:
VERCEL_PLUGIN_LIKELY_SKILLS— comma-delimited skill listVERCEL_PLUGIN_GREENFIELD=true— if project is empty/newVERCEL_PLUGIN_BOOTSTRAP_HINTS— setup signal namesVERCEL_PLUGIN_RESOURCE_HINTS— resource dependency namesVERCEL_PLUGIN_SETUP_MODE=1— if ≥3 bootstrap hints detected
The observability skill is always added for non-greenfield projects.
Vercel.json Key Routing (±10)
Source: hooks/src/vercel-config.mts
Only applies to four skills: cron-jobs, deployments-cicd, routing-middleware, vercel-functions.
The key-to-skill mapping:
| vercel.json Key | Skill |
|---|---|
redirects, rewrites, headers, cleanUrls, trailingSlash |
routing-middleware |
crons |
cron-jobs |
functions, regions |
vercel-functions |
builds, buildCommand, installCommand, outputDirectory, framework, devCommand, ignoreCommand |
deployments-cicd |
If the skill's associated key exists in vercel.json → +10. If the skill is one of the four routing skills but its key is absent → −10.
Special-Case Boosts
- Setup-mode bootstrap (+50): When
VERCEL_PLUGIN_SETUP_MODE=1, thebootstrapskill receivesmax(basePriority + 50, maxOtherPriority + 1), ensuring it always ranks first. - TSX review trigger (+40): After
VERCEL_PLUGIN_REVIEW_THRESHOLD(default 3).tsxedits, injectsreact-best-practiceswith a +40 boost. Counter resets after injection. - Dev-server verify (+45): On
npm run dev,next dev,vercel dev, etc., injectsagent-browser-verify+verificationcompanion. Capped at 2 injections per session (loop guard). - Vercel env help: One-time injection when
vercel env add/update/pullcommands are detected.
Ranking Function
Source: hooks/src/patterns.mts → rankEntries()
After all priority adjustments:
Sort by: effectivePriority DESC → base priority DESC → skill name ASC (tiebreaker)
Budget Enforcement
Two independent budgets:
| Hook | Max Skills | Max Bytes | Env Override |
|---|---|---|---|
| PreToolUse | 5 | 18,000 | VERCEL_PLUGIN_INJECTION_BUDGET |
| UserPromptSubmit | 2 | 8,000 | VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET |
Enforcement logic in injectSkills():
- The first matched skill is always injected in full (never dropped)
- Subsequent skills are checked against remaining budget
- If full body exceeds budget but a
summaryexists → inject summary instead (wrapped in<!-- skill:name mode:summary -->markers) - If neither fits → skill is dropped entirely
Return metadata categorizes every matched skill into: loaded, summaryOnly, droppedByCap, or droppedByBudget.
Prompt Signal Scoring
Source: hooks/src/prompt-patterns.mts → matchPromptWithReason()
Prompt text is normalized (lowercased, contractions expanded, whitespace collapsed) then scored:
| Signal Type | Points | Semantics |
|---|---|---|
phrases |
+6 each | Exact substring match (case-insensitive) |
allOf |
+4 per group | All terms in group must match |
anyOf |
+1 each, cap +2 | Any term matches, capped total |
noneOf |
−∞ | Hard suppress (score → -Infinity) |
Default minScore: 6. A skill is matched if score >= minScore.
Lexical fallback (scorePromptWithLexical()): If exact scoring fails to reach threshold, a lexical skill index is searched with an adaptive boost multiplier:
1.5×if exact score = 0 (no signal overlap at all)1.35×if exact score > 0 but <minScore/2(weak signal)1.1×if exact score ≥minScore/2but <minScore(near-threshold)
Troubleshooting intent routing (classifyTroubleshootingIntent()): Three detection families:
- Browser-only (blank page, white screen, console errors) →
agent-browser-verify+investigation-mode - Flow-verification ("X but Y" patterns — loads but, submits but) →
verification - Stuck-investigation (hung, frozen, timeout, spinning) →
investigation-mode
Test framework mentions (jest|vitest|playwright test|cypress test|mocha|karma|testing library) suppress all verification-family skills.
Scoped Dedup System
The dedup system prevents the same skill from being injected twice in a session. It must handle concurrent hook invocations, subagent isolation, and env-var race conditions.
Three-Tier State Architecture
graph TD
A["Env Var<br/>VERCEL_PLUGIN_SEEN_SKILLS<br/>(comma-delimited)"] --> D[mergeSeenSkillStates]
B["Session File<br/><tmpdir>/vercel-plugin-<sid>-seen-skills.txt<br/>(comma-delimited snapshot)"] --> D
C["Claim Dir<br/><tmpdir>/vercel-plugin-<sid>-seen-skills.d/<br/>(one empty file per skill)"] --> D
D --> E["Union of all three<br/>(canonical seen-skills set)"]
| Tier | Storage | Persistence | Concurrency |
|---|---|---|---|
| Env var | VERCEL_PLUGIN_SEEN_SKILLS |
Process lifetime | Race-prone across hooks |
| Session file | …-seen-skills.txt |
Disk (session-scoped) | Overwrite-last-wins |
| Claim dir | …-seen-skills.d/ |
Disk (session-scoped) | Atomic via O_EXCL |
Atomic Claim Protocol
Source: hooks/src/hook-env.mts → tryClaimSessionKey()
openSync(path, "wx") // O_CREAT | O_EXCL — fails if file exists
- Creates an empty file named
encodeURIComponent(skillSlug)inside the claim dir - Returns
trueon success (skill claimed),falseon EEXIST (already claimed) - Prevents concurrent hook invocations from double-injecting the same skill
syncSessionFileFromClaims()reads the claim dir and writes a comma-delimited snapshot to the session file
Scope Isolation for Subagents
Each agent gets its own dedup scope identified by agent_id (or "main" for the lead agent):
Lead agent: vercel-plugin-<sessionId>-seen-skills.d/
Subagent A: vercel-plugin-<sessionId>-<agentAHash>-seen-skills.d/
Subagent B: vercel-plugin-<sessionId>-<agentBHash>-seen-skills.d/
This means:
- Subagents can re-inject skills that the lead agent already claimed
- Sibling subagents can re-inject each other's skills
- Only claims within the same scope prevent re-injection
State Merge Logic
Source: hooks/src/patterns.mts → mergeScopedSeenSkillStates()
if (scopeId === "main"):
merge(envValue, fileValue, claimValue) // All three tiers
else:
merge(fileValue, claimValue) // Exclude parent env var
Subagents exclude the env var because it contains the parent's accumulated state — using it would suppress skills the subagent hasn't seen yet.
Strategy Cascade
The dedup system selects a strategy based on available infrastructure:
1. "file" — Atomic claim dir (preferred, survives across invocations)
↓ (tmpdir unavailable or errors)
2. "env-var" — VERCEL_PLUGIN_SEEN_SKILLS only (fallback, race-prone)
↓ (env var unavailable)
3. "memory-only" — In-process Set (single invocation only)
↓ (explicit opt-out)
4. "disabled" — VERCEL_PLUGIN_HOOK_DEDUP=off (no dedup at all)
Strategy selection is logged at debug level for diagnostics.
Path Safety and Sanitization
All temp file paths are validated:
- Session IDs matching
^[a-zA-Z0-9_-]+$are used directly - Unsafe IDs are SHA256-hashed to prevent path traversal
- Skill keys in claim filenames use
encodeURIComponent() - Resolved paths are verified to stay within
tmpdir()— an error is thrown on escape attempts
All dedup file I/O functions swallow errors silently (try/catch, stderr logging only at debug level). This prevents hook timeouts from transient filesystem issues.
Audit Logging and Observability
Two independent logging systems capture runtime behavior: a persistent audit log file (JSONL on disk) and ephemeral structured logging (JSON to stderr). This section documents both systems, provides the formal JSONL schema, and catalogs every log event.
Audit Log File (JSONL)
Source: hooks/src/hook-env.mts → appendAuditLog()
A JSONL file (one JSON object per line) recording every skill injection decision. This is the only persistent record of plugin behavior across sessions.
Path resolution priority:
VERCEL_PLUGIN_AUDIT_LOG_FILEenv var (relative to project root)- If set to
"off"→ disabled - Default:
~/.claude/projects/<projectSlug>/vercel-plugin/skill-injections.jsonl
The parent directory is created automatically (mkdirSync with recursive: true). Write errors are logged to stderr but never propagate — audit logging is best-effort.
Audit Log JSONL Schema
Every line is a self-contained JSON object. The timestamp field is always injected by appendAuditLog().
PreToolUse record (event: "skill-injection"):
{
"timestamp": "2026-03-10T12:00:00.000Z",
"event": "skill-injection",
"toolName": "Read",
"toolTarget": "src/app/page.tsx",
"matchedSkills": ["nextjs", "react-best-practices"],
"injectedSkills": ["nextjs"],
"summaryOnly": [],
"droppedByCap": [],
"droppedByBudget": ["react-best-practices"]
}
UserPromptSubmit record (event: "prompt-skill-injection"):
{
"timestamp": "2026-03-10T12:01:00.000Z",
"event": "prompt-skill-injection",
"hookEvent": "UserPromptSubmit",
"matchedSkills": ["deployments-cicd", "vercel-functions"],
"injectedSkills": ["deployments-cicd"],
"summaryOnly": [],
"droppedByCap": ["vercel-functions"],
"droppedByBudget": []
}
Field reference:
| Field | Type | Description |
|---|---|---|
timestamp |
string (ISO 8601) |
When the record was written |
event |
string |
Record type: "skill-injection" (PreToolUse) or "prompt-skill-injection" (UserPromptSubmit) |
toolName |
string |
Tool that triggered injection (Read, Edit, Write, Bash) — PreToolUse only |
toolTarget |
string |
File path or redacted command — PreToolUse only. Bash commands are always "[redacted]" |
hookEvent |
string |
"UserPromptSubmit" — UserPromptSubmit only |
matchedSkills |
string[] |
All skills whose patterns/signals matched |
injectedSkills |
string[] |
Skills whose full body was injected |
summaryOnly |
string[] |
Skills injected as summary (over budget but summary fits) |
droppedByCap |
string[] |
Skills dropped by the per-invocation cap (5 PreToolUse, 2 UserPromptSubmit) |
droppedByBudget |
string[] |
Skills dropped because neither body nor summary fits within remaining budget |
Analyzing audit logs:
# Count injections per skill
cat ~/.claude/projects/*/vercel-plugin/skill-injections.jsonl | \
jq -r '.injectedSkills[]' | sort | uniq -c | sort -rn
# Find budget-dropped skills
cat ~/.claude/projects/*/vercel-plugin/skill-injections.jsonl | \
jq 'select(.droppedByBudget | length > 0)'
# Injection timeline for a specific tool target
cat ~/.claude/projects/*/vercel-plugin/skill-injections.jsonl | \
jq 'select(.toolTarget == "src/app/page.tsx")'
Structured Logger (stderr)
Source: hooks/src/logger.mts
All hooks emit structured JSON to stderr at configurable verbosity levels. Logs are ephemeral — they only exist while the process runs.
Log line format:
{
"invocationId": "a3f1c02e",
"event": "decision:match",
"timestamp": "2026-03-10T12:00:00.000Z",
"skill": "nextjs",
"matchType": "path",
"pattern": "**/*.tsx"
}
The invocationId (8-char hex) is shared across all hooks in the same process (stored in globalThis), enabling correlation of events within a single hook invocation.
Logger methods:
| Method | Min Level | Use Case |
|---|---|---|
summary(event, data) |
summary |
High-level injection decisions |
complete(reason, counts, timing) |
summary |
End-of-hook summary with counts |
debug(event, data) |
debug |
Match reasons, dedup decisions, priority adjustments |
trace(event, data) |
trace |
Per-pattern evaluation details |
issue(code, message, hint, ctx) |
summary |
Errors and warnings with fix hints |
CompleteCounts fields: matchedCount, injectedCount, dedupedCount, cappedCount, tsxReviewTriggered, devServerVerifyTriggered, matchedSkills, injectedSkills, droppedByCap, droppedByBudget, boostsApplied.
Log Level Resolution
Source: hooks/src/logger.mts → resolveLogLevel()
1. VERCEL_PLUGIN_LOG_LEVEL env var (explicit: "off" | "summary" | "debug" | "trace")
2. VERCEL_PLUGIN_DEBUG=1 → "debug" (legacy)
3. VERCEL_PLUGIN_HOOK_DEBUG=1 → "debug" (legacy)
4. Default: "off"
Hierarchy: off < summary < debug < trace. Each level includes all events from lower levels.
Log Event Taxonomy
Every structured log event emitted by the plugin, organized by source hook and level. All events include the standard invocationId, event, and timestamp fields.
PreToolUse (pretooluse-skill-inject)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
complete |
summary | reason, matchedCount, injectedCount, dedupedCount, cappedCount, matchedSkills, injectedSkills, droppedByCap, droppedByBudget, boostsApplied, elapsed_ms, timing_ms |
End-of-hook summary |
issue |
summary | code, message, hint, context |
Error or warning (see issue codes below) |
input-parsed |
debug | toolName, sessionId, cwd, scopeId |
Stdin successfully parsed |
tool-target |
debug | toolName, target |
Tool target identified (Bash commands redacted) |
manifest-loaded |
debug | path, generatedAt, version |
Manifest loaded from disk |
skillmap-loaded |
debug | skillCount |
Skill map built (with or without manifest) |
likely-skills |
debug | skills |
Profiler-detected skills |
setup-mode |
debug | active, bootstrapSkill |
Setup mode status |
dedup-strategy |
debug | strategy, sessionId, seenEnv |
Dedup strategy selected |
decision:match |
debug | hook, skill, score, reason |
A skill matched the trigger |
decision:dedup |
debug | hook, skill, reason |
A skill was skipped (already seen) |
decision:boost |
debug | hook, skill, score, reason |
A priority boost was applied |
decision:budget |
debug | hook, skill, reason |
A skill was dropped/summarized for budget |
decision:suppress |
debug | hook, skill, reason |
A skill was suppressed (e.g., noneOf) |
tsx-edit-count |
debug | count, threshold |
Current TSX edit counter |
tsx-review-triggered |
debug | count |
TSX review threshold reached |
tsx-review-not-fired |
debug | count, threshold, reason |
TSX review not triggered |
dev-server-verify-triggered |
debug | command, iteration |
Dev server detection fired |
dev-server-verify-not-fired |
debug | reason, iteration |
Dev server detection skipped |
pattern:test |
trace | skill, patternType, pattern, input, matched |
Individual pattern evaluation |
UserPromptSubmit (user-prompt-submit-skill-inject)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
complete |
summary | reason, matchedCount, injectedCount, dedupedCount, cappedCount, elapsed_ms, timing_ms |
End-of-hook summary |
stdin-empty |
debug | — | No stdin received |
prompt-too-short |
debug | length |
Prompt under 10 chars |
input-parsed |
debug | sessionId, cwd, promptLength |
Stdin successfully parsed |
normalized-prompt-empty |
debug | — | Prompt empty after normalization |
prompt-matches |
debug | totalWithSignals, matched (array of {skill, score}) |
Skills that met minScore |
prompt-dedup |
debug | rankedSkills, droppedByCap, previouslyInjected |
Post-dedup skill list |
prompt-selection |
debug | selectedSkills, droppedByCap, droppedByBudget, dedupStrategy, filteredByDedup, budgetBytes, timingMs |
Final selection |
decision:troubleshooting_intent_routed |
debug | intent, skills, reason |
Troubleshooting classifier matched |
decision:verification_family_suppressed |
debug | reason |
Test framework detected, verification skills suppressed |
decision:investigation_intent_detected |
debug | skills (array of {skill, score}) |
Investigation intent detected |
decision:companion_selected |
debug | skill, companion, reason |
Investigation companion chosen |
prompt:score |
debug | skill, score, breakdown |
Scoring breakdown for a skill |
prompt-signal-eval |
trace | skill, matched, score, reason |
Per-skill signal evaluation |
prompt-analysis-full |
trace | (full PromptAnalysisReport) |
Complete analysis report |
PostToolUse (posttooluse-verification-observe)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
verification.boundary_observed |
summary | boundary, verificationId, command, matchedPattern, inferredRoute, timestamp |
Bash command classified as verification boundary |
complete |
summary | reason, matchedCount, injectedCount |
End-of-hook summary |
verification-observe-skip |
debug | reason, command |
No boundary match or no bash input |
PostToolUse (posttooluse-validate)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
posttooluse-validate-output |
summary | filePath, matchedSkills, errorCount, warnCount |
Validation produced output |
complete |
summary | reason, matchedCount, injectedCount |
End-of-hook summary |
posttooluse-validate-skip |
debug | reason, toolName, filePath, hash, sessionId |
Validation skipped (various reasons) |
posttooluse-validate-input |
debug | toolName, filePath, sessionId |
Input parsed |
posttooluse-validate-loaded |
debug | totalSkills, skillsWithRules |
Validation rules loaded |
posttooluse-validate-matched |
debug | matchedSkills |
Skills matched for validation |
posttooluse-validate-violations |
debug | total, errors, warns |
Violation counts |
posttooluse-validate-no-output |
debug | reason |
No actionable violations |
posttooluse-validate-match |
trace | skill, matchType, pattern |
Individual skill-file match |
posttooluse-validate-rule-skip |
trace | skill, pattern, reason |
Rule skipped (skipIfFileContains matched) |
posttooluse-validate-regex-fail |
debug | skill, pattern |
Validation regex failed to compile |
SubagentStart (subagent-start-bootstrap)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
subagent-start-bootstrap:complete |
summary | agent_id, agent_type, claimed_skills, budget_used, budget_max, budget_category, pending_launch_matched |
Bootstrap finished |
subagent-start-bootstrap |
debug | agentId, agentType, sessionId |
Bootstrap started |
subagent-start-bootstrap:profile-cache-hit |
debug | sessionId, skills |
Profiler cache found |
subagent-start-bootstrap:profile-cache-miss |
debug | sessionId |
Profiler cache not found |
subagent-start-bootstrap:prompt-skill-match |
debug | promptLength, matchedSkills |
Prompt scored against skill signals |
subagent-start-bootstrap:pending-launch |
debug | sessionId, agentType, claimedLaunch, promptMatchedSkills, likelySkills |
Pending launch routing result |
subagent-start-bootstrap:dedup-claims |
debug | sessionId, agentId, scopeId, claimed |
Skills claimed for subagent scope |
SubagentStop (subagent-stop-sync)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
subagent-stop-sync:complete |
summary | agent_id, agent_type, skills_injected, ledger_entry_written |
Stop sync finished |
subagent-stop-sync |
debug | sessionId, agentId, agentType |
Stop sync started |
PreToolUse (pretooluse-subagent-spawn-observe)
| Event | Level | Payload Fields | Description |
|---|---|---|---|
pretooluse-subagent-spawn-observe-recorded |
debug | sessionId, subagentType, name |
Pending launch recorded |
pretooluse-subagent-spawn-observe-skip |
debug | reason, toolName |
Observation skipped |
Issue Codes
Issue events (emitted at summary level via logger.issue()) use these codes:
| Code | Hook | Meaning |
|---|---|---|
STDIN_EMPTY |
PreToolUse | No data on stdin |
STDIN_PARSE_FAIL |
PreToolUse | stdin is not valid JSON |
SKILLMD_PARSE_FAIL |
PreToolUse | SKILL.md YAML frontmatter failed to parse |
SKILLMAP_VALIDATE_FAIL |
PreToolUse | Skill map validation found errors |
SKILLMAP_LOAD_FAIL |
PreToolUse | Could not load skill map from manifest or filesystem |
SKILLMAP_EMPTY |
PreToolUse | Skill map loaded but contains zero skills |
PATH_REGEX_COMPILE_FAIL |
PreToolUse | Pre-compiled path regex failed to construct |
BASH_REGEX_COMPILE_FAIL |
PreToolUse | Pre-compiled bash regex failed to construct |
IMPORT_REGEX_COMPILE_FAIL |
PreToolUse | Pre-compiled import regex failed to construct |
PATH_GLOB_INVALID |
PreToolUse | Path glob pattern is invalid |
BASH_REGEX_INVALID |
PreToolUse | Bash regex pattern is invalid |
IMPORT_PATTERN_INVALID |
PreToolUse | Import pattern is invalid |
DEDUP_CLAIM_FAIL |
PreToolUse, UserPromptSubmit | Could not create claim file (permissions, disk full) |
For full error descriptions with common causes, fix steps, and verification commands, see 04-operations-debugging.md § Error Catalog. For symptom-based troubleshooting (skill not injecting, prompt signal mismatch, wrong subagent context, audit log issues, manifest drift), see 04-operations-debugging.md § Troubleshooting Playbooks.
End-to-End Trace: Single Request Flow
This diagram shows every log event emitted during a single PreToolUse invocation where one skill is injected, from stdin parse through stdout write:
sequenceDiagram
participant SDK as Claude Agent SDK
participant Hook as pretooluse-skill-inject
participant Log as stderr (JSONL)
participant Audit as Audit Log File
participant FS as Dedup Claim Dir
SDK->>Hook: stdin JSON (tool_name, tool_input, session_id)
Hook->>Log: debug: "input-parsed" {toolName, sessionId, cwd}
Hook->>Log: debug: "tool-target" {toolName, target}
Hook->>Log: debug: "manifest-loaded" {path, version}
Hook->>Log: debug: "skillmap-loaded" {skillCount: 46}
Hook->>Log: debug: "likely-skills" {skills: [...]}
Hook->>Log: debug: "dedup-strategy" {strategy: "file"}
loop For each skill pattern
Hook->>Log: trace: "pattern:test" {skill, patternType, matched}
end
Hook->>Log: debug: "decision:match" {skill: "nextjs", reason: "pathPattern"}
Hook->>Log: debug: "decision:boost" {skill: "nextjs", reason: "profiler +5"}
Hook->>Log: debug: "decision:dedup" {skill: "ai-sdk", reason: "already seen"}
Hook->>FS: tryClaimSessionKey("nextjs")
FS-->>Hook: true (claimed)
Hook->>Audit: append {event: "skill-injection", injectedSkills: ["nextjs"]}
Hook->>Log: summary: "complete" {reason: "injected", injectedCount: 1, elapsed_ms: 8}
Hook->>SDK: stdout JSON {hookSpecificOutput: {additionalContext: "..."}}
Subagent Lifecycle
When Claude spawns a subagent (via the Agent tool), four hooks coordinate to transfer context, prevent re-injection waste, and record observability data.
sequenceDiagram
participant Lead as Lead Agent
participant Observe as spawn-observe
participant Bootstrap as subagent-start-bootstrap
participant Sub as Subagent
participant Sync as subagent-stop-sync
Lead->>Observe: PreToolUse (Agent tool call)
Note right of Observe: Record pending launch<br/>(description, prompt, subagent_type)<br/>→ pending-launches.jsonl
Lead->>Bootstrap: SubagentStart fires
Bootstrap->>Bootstrap: claimPendingLaunch(sessionId, agentType)
Note right of Bootstrap: Match oldest pending launch<br/>Score prompt against skill signals<br/>Merge with profiler likelySkills
Bootstrap->>Sub: Inject additionalContext<br/>(profiler + skills, budget-scaled)
Note right of Sub: Runs with own dedup scope<br/>(scoped claim dir by agent_id)
Sub-->>Sub: Own PreToolUse/PostToolUse cycle
Sub->>Sync: SubagentStop fires
Note right of Sync: Append to session ledger<br/>(agent_id, type, skills_injected)
Spawn Observation (PreToolUse → Agent)
Source: hooks/src/pretooluse-subagent-spawn-observe.mts
Intercepts Agent tool calls and records metadata for later correlation:
interface PendingLaunch {
description: string;
prompt: string;
subagent_type: string;
resume?: string;
name?: string;
createdAt: number;
}
Storage: <tmpdir>/vercel-plugin-<sessionId>-pending-launches.jsonl
Uses file-based locking (.lock file with 2s wait timeout, 10ms polling, 5s stale-lock clearance) and atomic write via temp file + rename.
Bootstrap Injection (SubagentStart)
Source: hooks/src/subagent-start-bootstrap.mts
Injects project context into spawned subagents, scaled by agent type budget:
| Agent Type | Budget | Content |
|---|---|---|
| Explore | ~1 KB | Project profile + skill name list only |
| Plan | ~3 KB | Profile + skill summaries + deployment constraints |
| general-purpose | ~8 KB | Profile + top skills with full SKILL.md bodies |
| Other/custom | ~8 KB | Treated as general-purpose |
Context assembly:
- Read cached profiler results (
profile.json) — fall back toVERCEL_PLUGIN_LIKELY_SKILLS - Claim pending launch via
claimPendingLaunch(sessionId, agentType)— matches against pending records and scores the launch prompt against skillpromptSignals - Merge profiler skills + prompt-matched skills (prompt scores highest, deduplicated)
- Build wrapped context with
<!-- vercel-plugin:subagent-bootstrap ... -->markers - Persist dedup claims scoped by
agentIdviatryClaimSessionKey()
Stop Sync and Ledger (SubagentStop)
Source: hooks/src/subagent-stop-sync.mts
Records subagent metadata to a session-scoped JSONL ledger:
Ledger file: <tmpdir>/vercel-plugin-<sessionId>-subagent-ledger.jsonl
{
"timestamp": "2026-03-10T12:05:00.000Z",
"session_id": "abc-123",
"agent_id": "def-456",
"agent_type": "general-purpose",
"agent_transcript_path": "/path/to/transcript"
}
Also counts injected skills by reading the scoped claim dir (listSessionKeys(sessionId, "seen-skills", agentId)) and logs skills_injected as a summary metric.
Fresh-Env Behavior
Subagents spawned in a fresh environment (no inherited VERCEL_PLUGIN_SEEN_SKILLS) fall back to file-based dedup:
- Lead agent uses env var (comma-delimited string) as primary dedup state
- Subagent with empty/missing env var reads the claim directory directly
- Subagent dedup scope is isolated by
agentId— sibling subagents and the parent each have independent claim dirs
This means a subagent can re-inject skills that the lead agent already injected, which is intentional: subagents need their own context and should not be starved of skills just because the parent saw them first.
Verification Observer
Source: hooks/src/posttooluse-verification-observe.mts
An observer hook (PostToolUse on Bash) that classifies completed bash commands into verification boundaries and emits structured log events. It does not modify tool output — it is purely observational.
Boundary Classification
Eight pattern groups map bash commands to four boundary types:
| Boundary | Matched Patterns | Examples |
|---|---|---|
uiRender |
browser, screenshot, puppeteer, playwright, chromium, firefox, webkit, open http://… |
npx playwright test, open https://localhost:3000 |
clientRequest |
curl, wget, httpie, fetch(, npx undici |
curl http://localhost:3000/api, wget https://… |
serverHandler |
tail/less/cat on .log, tail -f, journalctl -f, vercel logs/inspect, lsof/netstat/ss on ports |
tail -f app.log, vercel logs project, lsof -i :3000 |
environment |
printenv, env, echo $VAR, vercel env, cat .env, node -e process.env |
printenv DATABASE_URL, vercel env pull |
unknown |
(no pattern matched) | git status, npm install |
Route Inference
Routes are inferred in priority order:
- From recent edits (
VERCEL_PLUGIN_RECENT_EDITSenv var): Extracts routes from file paths likeapp/settings/page.tsx→/settings. Strips file suffixes (page,route,layout,loading,error) and converts[id]to:id. - From URLs in command: Parses
http://localhost:3000/api/users→/api/users - Returns
nullif no route found
Event Structure
Emitted at summary log level:
{
"event": "verification.boundary_observed",
"boundary": "clientRequest",
"verificationId": "uuid-v4",
"command": "curl http://localhost:3000/api/users",
"matchedPattern": "curl/wget/httpie",
"inferredRoute": "/api/users",
"timestamp": "2026-03-10T12:03:00.000Z"
}
The verificationId (UUIDv4) enables correlation of boundary observations with other session events.
Post-Write Validation
Source: hooks/src/posttooluse-validate.mts
Runs skill-defined validation rules against files after Write/Edit operations.
Validation Pipeline
Parse Input → Load Rules → Match File → Run Validation → Format Output
- Parse input: Extract
toolName,filePath,sessionId,cwdfrom hook stdin. Skip if not Write/Edit or nofile_path. - Load rules: Scan all skills'
validate:arrays from SKILL.md frontmatter. Compile path and import patterns. - Match file: Test file path against skill
pathPatterns(glob) and file content againstimportPatterns(regex). Returns list of matched skills with validation rules. - Run validation: For each matched skill and rule:
- Check
skipIfFileContains(soft skip if file content matches this regex) - Compile rule
patternto RegExp withglobalflag - Test each line of file content
- Collect violations:
{ skill, line, message, severity, matchedText }
- Check
- Format output: Error-severity violations →
additionalContextwith fix instructions. Warn-severity → suggestions in debug mode only. Grouped by skill.
Validation rule schema (from SKILL.md frontmatter):
validate:
- pattern: "hardcoded-secret-regex"
message: "Do not hardcode secrets; use environment variables"
severity: "error"
skipIfFileContains: "process\\.env"
File-Hash Dedup
Prevents re-validating unchanged files:
- Computes MD5 hash of file content (first 12 chars)
- Tracks validated
path:hashpairs inVERCEL_PLUGIN_VALIDATED_FILESenv var and session file (…-validated-files.txt) - Skips validation if the same
path:hashpair was already validated in this session
Session Cleanup
Source: hooks/src/session-end-cleanup.mts
At SessionEnd, the cleanup hook deletes all session-scoped temp files:
const prefix = `vercel-plugin-${tempSessionIdSegment(sessionId)}-`
// Glob tmpdir for entries starting with prefix
// Directories (*.d, *-pending-launches) → rmSync({ recursive: true, force: true })
// Files → unlinkSync()
Cleanup is best-effort — all errors are silently ignored. The hook always exits 0.
Temp File Inventory
All session-scoped files live in os.tmpdir() with the prefix vercel-plugin-<sessionId>-:
| File/Dir | Format | Purpose | Created By |
|---|---|---|---|
…-seen-skills.d/ |
Dir of empty files | Atomic dedup claims | tryClaimSessionKey() |
…-seen-skills.txt |
Comma-delimited | Dedup snapshot (synced from claims) | syncSessionFileFromClaims() |
…-<agentHash>-seen-skills.d/ |
Dir of empty files | Scoped subagent dedup claims | subagent-start-bootstrap |
…-<agentHash>-seen-skills.txt |
Comma-delimited | Scoped subagent dedup snapshot | subagent-start-bootstrap |
…-profile.json |
JSON | Cached profiler results | session-start-profiler |
…-pending-launches.jsonl |
JSONL | Pending subagent spawn metadata | pretooluse-subagent-spawn-observe |
…-pending-launches.jsonl.lock |
Lock file | File-based lock for pending launches | pretooluse-subagent-spawn-observe |
…-subagent-ledger.jsonl |
JSONL | Aggregate subagent stop metadata | subagent-stop-sync |
…-validated-files.txt |
Comma-delimited | Validated file:hash pairs | posttooluse-validate |
All entries are cleaned up by session-end-cleanup.mjs at SessionEnd.