Skip to main content

Your Laravel Agent Skill Stops Saving Tokens the First Time It Loads

Skills Stop Saving cover: a dark cover with a bar chart of ten turns, each with a grey instructions bar and an emerald skill bar, the emerald bars about half the grey height for turns 1 to 5 and slightly taller than the grey from turn 6 to 10, each of those topped by a thin muted-red cap, illustrating a Laravel agent skill that saves tokens until its first load and costs more after

laravel/ai 1.1 shipped Agent Skills on October 5. The docs explain them in one line: the agent sees each skill’s name and description, and loads the full instructions “only when a prompt calls for it.”

So you moved your refund policy out of instructions() and into resources/skills/refund-policy/.

Turn one of a support chat dropped from 11,300 input tokens to 2,278.

Then a customer asked for a refund, and the agent loaded the skill. Three turns later it read the skill’s reference file too.

By turn seven the same customer wants your support hours. Nothing about refunds. The skill version sent 578 more tokens than the instructions version you replaced.

Each part of the skill stopped saving at its first load. Neither saving comes back for the rest of the conversation.

I measured it on laravel/ai 1.2.1: 56 recorded conversations, mostly on Claude Haiku 5.5, with the same agent built both ways. Here’s where the extra comes from, why prompt caching leaves no saving at all, and the middleware that takes back most of the tokens.

Pricing a skill per prompt misses the conversation

HasSkills adds one tool to your agent, LoadSkill. Its description is the catalog: every skill’s name and description. The model calls it with a skill’s name to load the SKILL.md body. It calls it again with a path to read a bundled file, like references/EDGE-CASES.md.

Catalog, body, bundled files. Those are the three tiers, and the Laravel write-ups look at them one prompt at a time.

hafiz.dev prices them: “That’s three tiers, and you only pay for the first one on every prompt.” Then: “A loaded skill costs the same tokens it would in the system prompt, plus one tool round trip.”

Both lines hold for a stateless agent. Laravel News and azeemhassni.com stop at the load. So do the docs.

Add RemembersConversations and the load becomes part of the conversation.

LoadSkill returns the skill as a tool result (LoadSkill.php:120-128). The conversation store rebuilds every stored tool result on every later turn, which the context-hygiene post measured for search results. And the loop resends the whole message list on every step, which the cost-math post measured.

So from a piece’s first load on, every prompt pays for it. Whether that prompt needs the policy or not.

Claude Code runs the same SKILL.md format, and its skills docs say it plainly: a loaded skill “stays there across later turns.” Claude Code also guards against a second copy and caps the catalog’s size. laravel/ai does neither, and that comes later.

The setup: one support agent, two places for the policy

The same support agent as my earlier Laravel AI posts: LookupOrder, a remembered conversation, a refund policy. The docs’ own skill example is a refund-policy folder with references/EDGE-CASES.md, so I built exactly that.

resources/skills/refund-policy/
    SKILL.md                  3,891 bytes, 1,354 tokens as loaded
    references/EDGE-CASES.md  10,821 bytes, 3,456 tokens

The sizes come from real skills. Across 136 SKILL.md files on my machine, the median is 3.9 KB and the 75th percentile is 10.9 KB. Those are coding-agent skills in the same format, not laravel/ai app skills, but they’re the only sample I have.

The code lives in the companion repo, laravel-ai-research-agent, on the ai-1x branch. The skill version of the agent:

namespace App\Ai\Agents;

use App\Ai\Tools\LookupOrder;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasSkills;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;

class SupportAgent implements Agent, Conversational, HasSkills, HasTools
{
    use Promptable, RemembersConversations;

    public function instructions(): string
    {
        return "You are the customer support agent for Larkspur. For anything about a specific order, call LookupOrder. Never guess an order's details.";
    }

    public function skills(): iterable
    {
        return [resource_path('skills')];
    }

    public function tools(): iterable
    {
        return [new LookupOrder];
    }

    public function maxSteps(): int
    {
        return 6;
    }
}

The instructions() here is abridged. The measured one is 929 characters, with store facts and the VERDICT: line format; the full class is in the companion.

skills() takes a directory, and the SDK picks up every */SKILL.md under it.

Arm A is the same agent with no skills and the policy pasted into instructions(): the SKILL.md body plus EDGE-CASES.md, 4,755 tokens. That’s what the agent carried before skills existed. Loaded through LoadSkill, the same two files come to 4,810, because of the <skill_content> wrapper and its file list.

maxSteps() is there for a reason you need too. Left alone, the SDK budgets round(tools × 1.5) steps (TextGenerationLoop.php:577). LoadSkill counts as a tool. So adding skills to a one-tool agent quietly moves its budget from 2 steps to 3. Pin it, in both arms, or the comparison measures the budget.

The script has ten turns. Three ask for a refund: sealed headphones at turn two, a damaged gift at turn five, an opened lamp at turn eight. Only the gift needs EDGE-CASES.md. The rest ask where an order is, whether you ship to Canada, your support hours. Every refund answer ends with a VERDICT: line, and a fixed check scores it.

Every run names the model at the call site, provider: 'anthropic', model: 'claude-haiku-5-5'. That’s also what #[UseCheapestModel] resolves to on Anthropic since 1.2.0 (#1107). Five runs per arm, and three for each size and position variant.

Every turn after the load costs more

Input tokens per turn, all steps summed, median of five runs:

TurnThe customer asksA: instructionsB: skill
1Where is order 1002?11,3002,278
2Refund for sealed headphones (B loads the skill)11,9264,365
3When did order 1003 arrive?12,5306,683
4Do you ship to Canada?6,4313,389
5Refund for a damaged gift (B reads EDGE-CASES.md)13,33310,936
6Which carrier has order 1002?6,9157,450
7What are your support hours?6,9577,535
8Refund for an opened lamp14,30115,856
9Did order 1005 download?14,98016,136
10Sum up today in three bullets7,7078,279

Turn one took two steps, the lookup and the answer, so A paid for its policy twice.

Read the rest in two halves.

Up to turn five, B sends less on every turn. Turn four still saves 3,042 tokens, because the 3,456-token reference isn’t loaded yet. Each piece saves its full size until its own first load.

From turn six on, B sends more on every turn.

Turn seven calls no tool and asks nothing about refunds. It still carries the skill, the reference, and the calls that loaded them.

The load itself was cheap, to be fair to hafiz.dev. Haiku called LoadSkill and LookupOrder in the same step, so turn two took no extra round trip.

The extra after the load is overhead around the same text. The loaded files are 55 tokens bigger than the same text in instructions(). LoadSkill’s own definition adds 246 tokens to every step. The history adds the call blocks.

On the five turns after both loads, the median B turn sent 7% more than the median A turn. I ran that comparison six more ways, and none of them got the saving back:

SetupB per turn vs A, after both loads
Haiku 5.5, median skill107%
Haiku 5.5, 1.3 KB SKILL.md (25th percentile)111%
Haiku 5.5, 17.9 KB SKILL.md (about the 90th percentile)105%
Haiku 5.5, first refund at turn seven107%
Haiku 5.5, cached on both sides107%
Sonnet 5.5, one run105%
gpt-6-luna, three runs126%

The saving is the turns before the first load

Over all ten turns, B still sent 78% of A’s input. All of that saving came from turns one to five, and turns six to ten gave a little of it back.

Push the first refund question to turn seven and B sends 53%.

Skill size barely moves the ratio: 79%, 78% and 79% for the small, median and large SKILL.md. Position moves it: the earlier your users need the skill, the less it saves.

And the dollars move less than the tokens. Uncached, B cost $0.0109 per conversation against A’s $0.0119, 8% less, and the ranges overlap ($0.0096 to $0.0119 against $0.0117 to $0.0128).

B wrote more output, and almost all of the extra is Haiku’s thinking, which bills as output at five times the input rate. B’s median conversation thought for 3,344 tokens, A’s for 1,142. Thinking swung widely between runs in both arms (799 to 2,844 for A, 1,341 to 3,912 for B), so five runs can’t tell you whether the skill causes it.

Fractions of a cent on Haiku. On Sonnet 5.5, the SDK’s default Anthropic model, one run of each cost $0.2486 for A and $0.1902 for B. Multiply by your conversations.

With caching on, the skill saves nothing

The obvious objection: turn on prompt caching and the replayed skill costs a tenth of the price.

It does, and it helps the instructions version more.

I cached both sides the same way. The two cache attributes, plus Anthropic’s top-level cache_control, so the breakpoint moves forward with the conversation:

use Laravel\Ai\Attributes\CacheInstructions;
use Laravel\Ai\Attributes\CacheToolDefinitions;
use Laravel\Ai\Contracts\HasProviderOptions;
use Laravel\Ai\Enums\Lab;

#[CacheInstructions]
#[CacheToolDefinitions]
class SupportAgent implements Agent, Conversational, HasProviderOptions, HasSkills, HasTools
{
    // ...

    public function providerOptions(Lab|string $provider): array
    {
        $provider = is_string($provider) ? Lab::tryFrom($provider) : $provider;

        return $provider === Lab::Anthropic
            ? ['cache_control' => ['type' => 'ephemeral']]
            : [];
    }
}

Ten turns, five runs each, Haiku 5.5. Each column is its own median:

ArmInput tokensRead from cacheWritten to cachePrice
A: instructions106,30300$0.0119
A-cached107,043104,5902,616$0.0037
B: skill82,50300$0.0109
C: skill, cached83,98671,58611,700$0.0045

Caching cut A’s price by 69% and B’s by 59%. Input tokens didn’t move. The model still reads every one of them.

With caching on both sides, the skill version cost more: $0.0045 per conversation against $0.0037. The ranges overlap ($0.0035 to $0.0054 against $0.0022 to $0.0041), so call it no saving rather than a loss.

The reason is where each policy sits.

A’s policy is in the system prompt, ahead of the first message. Every conversation sends that same prefix, so every conversation reads it from one cache entry. Run back to back, A-cached’s second to fifth conversations read the whole 5,543-token prefix from cache on turn one and wrote nothing.

B’s skill sits after the customer’s first refund question. That prefix belongs to one conversation. C’s conversations shared only the 1,032 tokens of tools and base instructions. Each one wrote 11,199 to 13,128 tokens to the cache, where A-cached’s repeats wrote 1,585 to 3,033. Anthropic bills a cache write at 1.25 times the input price.

A-cached’s shared entry needs traffic. Anthropic keeps it five minutes by default, and each hit resets the clock. A quiet support bot finds it cold more often. Even so, the first A-cached run, which started cold and wrote the whole prefix, cost $0.0039.

Anthropic only caches when you ask. OpenAI caches prefixes without being asked, and bills cache writes at 1.25 times input from GPT-5.6 on. OpenAI is the SDK’s default provider (config/ai.php:16).

So I ran A and B on gpt-6-luna, three runs each. B cost $0.0027 per conversation against A’s $0.0017, and the ranges don’t overlap.

Output was level, so most of that gap is cache writes. B wrote a median of 7,337 tokens to the cache, A wrote 1,618. B also sent 12% more input over the ten turns. Both come from the same place: the first guard laravel/ai doesn’t have.

What Claude Code guards against and LoadSkill doesn’t

The first guard: in Claude Code, invoking a skill that’s already loaded, with the same content, adds “a short note that the skill is already loaded rather than a second copy.”

LoadSkill::handle() has no such check (:81-94). Every call returns the full skill.

Haiku 5.5 never put that to the test. In all 19 of its runs that kept the history whole, it loaded the skill once and reused the replayed copy. gpt-6-luna loaded the skill again on every refund turn, in all three runs. By turn eight it was carrying three copies, and each new copy was new text to write to the cache. That’s the 126% in the table above.

Whether you get a second copy depends on the model, not on your code.

The second guard: Claude Code caps the skill listing at 1% of the context window and 1,536 characters per entry.

LoadSkill builds its description from every skill’s description, with no cap per entry and no total (:70-76). Its schema puts every skill name in an enum (:101-115). Both go out on every step, loaded or not.

I counted the catalog with Anthropic’s free count_tokens, against the same agent with no skills:

Skills in the catalogAdded to every step
1246 tokens
5514
201,472

In the 5 and 20 rows, the added descriptions are 146 to 165 characters, a little under the 188-character median of the 136 real ones. A catalog of real skills costs a bit more. Twenty skills that cost nothing until loaded cost 1,472 tokens on every step of every turn. A three-step turn pays it three times.

One catalog rule that isn’t about cost. A SKILL.md with no description, or no frontmatter, is dropped with no log (Skill.php:35-51). When two sources define the same name, the first one listed wins, also with no log (LoadSkill.php:190-201). The docs state the no-description skip and the precedence rule, but nothing tells you at runtime which skill went missing.

Keep the call, drop the skill

The fix is the context-hygiene post’s “keep the call, drop the payload”, aimed at one tool. Keep every LoadSkill call, and replace what it returned on earlier turns with one line. The n8n community’s skills walkthrough suggests stripping past loads from memory in its section 9.

I wrote it as agent middleware. Since laravel/ai 1.0, agent middleware wraps every generation step, and PendingStep::withMessages() replaces what that step sends:

namespace App\Ai\Middleware;

use Closure;
use Laravel\Ai\Gateway\StepResult;
use Laravel\Ai\Messages\ToolResultMessage;
use Laravel\Ai\Messages\UserMessage;
use Laravel\Ai\PendingStep;
use Laravel\Ai\Responses\Data\ToolResult;

final class StubLoadedSkills
{
    public function handle(PendingStep $step, Closure $next): StepResult
    {
        $messages = $step->messages;
        $currentTurn = array_key_last(array_filter($messages, fn ($message) => $message instanceof UserMessage));

        foreach ($messages as $i => $message) {
            if ($i < $currentTurn && $message instanceof ToolResultMessage) {
                $messages[$i] = new ToolResultMessage(
                    $message->toolResults->map(fn (ToolResult $result) => $result->name === 'LoadSkill' ? new ToolResult(
                        id: $result->id,
                        name: $result->name,
                        arguments: $result->arguments,
                        result: '[cleared from context] This skill was loaded in an earlier turn. Load it again if you need it.',
                        resultId: $result->resultId,
                        denied: $result->denied,
                        failed: $result->failed,
                    ) : $result)
                );
            }
        }

        return $next($step->withMessages($messages));
    }
}

The current turn starts at the last UserMessage, the prompt the SDK appends last. Everything before it is history. So a skill loaded this turn reaches the step that applies it whole, and every older load becomes a one-line receipt. Reference reads go through LoadSkill too, so they get the same stub. Results from other tools stay untouched.

Register it on the agent:

use App\Ai\Middleware\StubLoadedSkills;
use Laravel\Ai\Contracts\HasMiddleware;

class SupportAgent implements Agent, Conversational, HasMiddleware, HasSkills, HasTools
{
    // ...

    public function middleware(): array
    {
        return [new StubLoadedSkills];
    }
}

The companion’s test sends a conversation with an earlier turn’s load in its history through the Anthropic gateway, with HTTP faked. Both steps of the new turn send the earlier turn’s skill as a stub and its order lookup whole. This turn’s load reaches step two whole. Every tool_result still answers a tool_use.

Ten turns, five runs each, Haiku 5.5:

ArmInput tokensPriceSkill loads per runRefund verdicts right
A: instructions106,303$0.0119015/15
A-cached107,043$0.0037015/15
B: skill82,503$0.0109115/15
C: skill, cached83,986$0.0045115/15
D: skill with StubLoadedSkills63,072$0.00934 to 615/15

D got every refund right and sent 41% less input than A, 24% less than B.

It also reloaded the skill when it didn’t need to. On turn three (“When did order 1003 arrive?”) in all five runs, on turn four (“Do you ship to Canada?”) in four, and on turn six in one. Output rose 36% over B, so the price fell 15%, not 24%.

And D still cost 2.5 times what A-cached did. I didn’t run D with caching, or on gpt-6-luna. Those are the two cases where you’d most want it, so measure them before you rely on it.

My guess at the extra loads is the stub’s last sentence: “Load it again if you need it.” I haven’t tested other wording. To count loads in your own app, listen for Laravel\Ai\Events\ToolInvoked and count the events whose tool is a LoadSkill.

Don’t stub the skill inside the turn that uses it

The context-hygiene post’s in-run trim, StubSeenToolResults, stubs every tool result except the newest step’s. On a skill it broke one refund answer in three.

Arm E is B plus StubSeenToolResults. It got 10 of 15 refund turns right, and the other five ended with no verdict at all. It also sent more input than B.

A refund answer needs the order record and the policy in the same step. Whenever Haiku fetched them in different steps, one of them was a stub. So it fetched that one again, which turned the other into a stub. One turn-two answer went round like that until the six-step limit and came back empty.

E stubs replayed history too. As soon as turn two fetched anything, turn one’s order lookup became a stub. In one run Haiku told the customer “Our system isn’t returning the order record for it.” Then it retracted its turn-one answer, saying it had given those details “without a valid lookup.”

None of that needs a skill. StubSeenToolResults keeps only the newest step’s results whole, and any answer that needs two results from different steps sees one of them as a stub. A skill just guarantees that every policy answer needs two. I didn’t run StubSeenToolResults on an agent without skills. If you use it, test the turns whose answers combine two tool results.

The rule that held: stub what earlier turns loaded, and leave everything in the current turn whole. That’s what D does.

Which version to ship

Price it before you move anything. Per step, the instructions version sends the whole policy. The skill version sends its catalog line, plus every piece loaded so far, once per copy. Count the steps before the first load and the steps after it, and you have both totals.

Then the rule, from these runs:

  • Most conversations need the policy: put it in instructions() and cache it. It was the cheapest version on both providers. On OpenAI caching needs no setup, and the skill version lost by more.
  • Many skills, each rarely used: use skills. A skill nobody loads costs its catalog line and nothing else.
  • Stateless agents, or policies users need late: use skills. The saving is every turn before the first load, and a stateless agent never replays it.
  • Skills in a remembered conversation: add StubLoadedSkills. Measured on Haiku without caching only.

Three more levers exist, and I didn’t measure any of them. A refunds sub-agent with the policy in its own instructions keeps it out of the main conversation, and costs a second model call on every refund turn. Anthropic’s context editing clears old tool results on their side. laravel/ai has no attribute for it, and I haven’t tested passing it through providerOptions(). Lowering maxConversationMessages() drops the old load eventually, along with whole turns of the conversation.

skills() can also return Skill objects you build yourself, from database rows for example. If your users can edit those rows, a skill body is text the model follows as instructions. That’s a prompt-injection channel, and the guardrails post covers it.

Every measurement in this post comes from a recorded run on laravel/ai 1.2.1. skills:conversation in the companion repo runs any arm and writes every step’s usage to a run file. Prices are the published rates on 2026-10-13, per million tokens. Haiku 5.5 and gpt-6-luna: $0.10 input, $0.50 output, $0.01 cache read, $0.125 cache write. Sonnet 5.5: $2, $10, $0.10 and $2.50. The 56 conversations, plus one smoke run, cost $0.95 in total.

What you can do now

You started with a docs line that says a skill loads only when a prompt calls for it. That’s true, for one prompt.

Now you can price a skill for a whole conversation: its catalog line on every step, and each piece on every turn after its first load. Its cache entry belongs to one conversation, while your system prompt’s belongs to all of them.

You can tell when the instructions version is cheaper. And when the skill is the right call, you can stop the old loads from riding along, with a middleware that kept every refund right in my runs.

Price the conversation, not the prompt. Stub old loads, never the current turn. Count your reloads.