How to tell if a Claude Code skill actually fired
By Maximo Correa · 2026-08-19
To tell if a Claude Code skill actually fired, do not trust the model's summary. Open the session's JSONL transcript and look for a tool_use content block with name Skill and an input.skill field naming it. That structured event is the only record created when a skill is truly invoked. Descriptive text is not evidence.
The sentence that sounds like proof
Ask Claude Code to do something and it will often narrate what it did afterward: "I used the code-review skill for this," or "invoking the test-driven-development skill first." That sentence has the shape of a fact. A named skill, a past-tense verb, delivered in the same confident voice as the rest of the answer.
It is not a fact. It is a description, generated by the same process that generated everything else in the response, and that process has no special access to its own history. A model can narrate a tool call it made. It can narrate one it meant to make, almost made, or recalled from memory instead of actually calling, in exactly the same tone. Nothing in the sentence tells you which one happened.
If you want to know whether a skill actually fired, stop reading the narration and go find the event instead. There is one, and it is not hard to check.
What "fired" actually means
Skill is not a figure of speech in Claude Code. It is a real tool, the same category as Read or Bash: something the model calls with a specific input, and something the system logs the moment it is called. A skill firing is a distinct, structured event. A skill being mentioned in a sentence is not, and from the outside the two can look identical.
That is the whole method, stated as one question. Not "what did it say it did," but "what event did the system record." One of those is a claim. The other is a fact you can go look at yourself.
Where the evidence lives
Claude Code writes every session to a transcript on disk: a JSONL file under ~/.claude/projects/, in a folder named for the project you were working in, one file per session, one JSON record per line. When the assistant calls a tool, that call is its own block inside the record, separate from the text the assistant writes around it.
A real Skill call looks like this, formatted here for readability (on disk it is one compact line):
{
"type": "assistant",
"message": {
"content": [
{ "type": "tool_use", "name": "Skill", "input": { "skill": "code-review" } }
]
}
}
That block exists if and only if the tool was actually called. The model cannot produce it by describing the skill in words, the way it produces everything else on the page. It is either there, written by the system at the moment the call happened, or it is not there at all.
The check itself
Open the transcript for the turn in question and look for that shape: a block with type: tool_use, name: Skill, and an input.skill naming what you expected. If it is there, the skill fired. If all you find is text saying it happened, it did not, no matter how specific that text reads.
Once you have found the file, a rough first pass:
> grep -c '"Skill"' session.jsonl
Treat the count as a lead, not an answer. It matches any line containing the word "Skill" in quotes, including a plain mention inside a sentence, so open the matching lines and confirm you are looking at a tool_use block and not a sentence about one. Telling a mention apart from an event is the entire point, and a plain text search cannot make that call for you.
A stated plan is not a log
We learned this by checking, not by assuming. Our own routing instructions used to ask the model to announce a plan at the start of a turn: name the skill it was about to use, before using it. It read like a commitment, and the sentence showed up reliably.
Checked against the transcripts, the sentence and the tool call did not reliably agree. Writing the line was apparently enough to satisfy whatever made the model feel it had complied. Sometimes the call followed. Sometimes a different skill got called instead. Sometimes the skill's contents got recalled from memory and never invoked at all, which reads identically to firing it until you check the log underneath.
The fix changed nothing about the wording. It changed the order. The same sentence is now only written after the tool call already appears in the transcript: a receipt, not a plan. Any claim a model can make before doing the thing carries the same evidentiary weight as the claim it makes afterward describing it. None, on its own.
Try it on your own session
This works on any Claude Code session, with or without a router installed.
- Give it a task where you expect one specific skill to fire.
- Let it answer and describe what it did, as usual.
- Open that session's transcript and search for a
tool_useblock namedSkill. - Compare what you find to what it told you.
Run this once on a session you already trust and you will know whether narration and log agree in your setup. Run it on a long session, well past the point where you stopped reading closely, and you will learn something more useful: whether the gap between the two grows as the conversation does.
Measured, not promised
This is the same discipline we hold our own claims to. When we say a skill fired, we mean we found the event in the transcript, not that a sentence said so. Our honest, measured edge is continuity: keeping a skill firing on turn nine of a long session, not just turn one, after a compaction, without anyone re-explaining the setup by hand. It is not that Claude picks a badly-described skill over a well-described one; a fair test we ran ourselves found native skill selection holding up fine on its own, even with a large number of skills installed. Selection was never the part that failed. Firing, forty prompts in, is the part worth checking, and now you know exactly where to look.
Sources
