The Hook Said Success: Agent Memory Lost at the Delivery Boundary

A hook's success status says its command ran. Whether the hook's output reached the model is settled afterwards, by a size check the hook never hears about. A session-start memory payload measured against the 10,000-character cap, and what follows from it: budget in the boundary's unit, order the payload for the cut, and measure delivery where it lands.

A hook that exits 0 and prints output that parses has succeeded, as far as the hooks reference for Claude Code is concerned. The status comes from the exit code and the shape of the output. Whether the text the hook printed reached the model is settled later, by a size check applied after the hook has exited, and the hook is never told the result.

vectr's session-start memory was lost in that gap from late July onward. vectr is the memory tool I build: it stores notes an agent writes while it works and delivers them back through hooks, so the agent does not have to remember to ask. Every substantive session-start injection in my own transcripts from late July onward went over the documented cap of 10,000 characters: 39 of 39, with payloads between 17 and 32 KB. Each time, the agent's harness saved the payload to a file and gave the model a preview of at most 2,000 characters, which held a header and one note out of the 16 to 218 that were sent. The hook reported success every time. The smaller injections that fire with each prompt and before tool calls stayed under 2.5 KB and arrived whole, so every signal visible from the sending side was green.

The cause is short arithmetic. vectr budgeted that payload at 8,000 tokens and converted at an estimated 4 characters per token, so it was allowed to render about 32,000 characters into a channel that holds 10,000. The fix follows from three rules that apply to anything handing context to a model through a channel it does not own: budget in the unit the boundary enforces, order the payload so the cut removes the least important part, and measure delivery where it is received, not where it is sent.

The fix is not in any vectr release yet. Release 1.12.0 and the public main branch both carry the token budget; the character bound is being built, and nothing you can install today includes it.

Part 01
The Boundary Between a Hook and the Model
01

What Hook Success Means, and What It Does Not Measure

Hooks are one way memory reaches an agent without the model deciding to fetch it. The harness runs a command at session start, before each prompt, or before a tool call. The command prints text, and the harness adds that text to the model's context. A memory tool uses the session-start hook for the notes that should be in the agent's head from the first turn, and the per-prompt and pre-tool hooks for the notes that matter at that moment. Claude Code Hooks and Deterministic Agent Behavior walks through that pipeline event by event.

The text travels in one of two shapes. A hook can print plain text, or it can print a JSON object whose additionalContext field holds the text for the model. vectr uses the JSON shape. Either way, the reference defines the hook's outcome like this:

Success: your hook exits 0. Claude Code applies any JSON output fields your hook printed, and the action goes ahead unless those fields block or deny it.

That is a statement about a process. The harness started the command, the command finished with exit 0, and its output parsed. "Applies" sounds like delivery, but applying a field means doing whatever the harness does with it, and for a field over the size limit, the harness replaces it with a file path and a short preview. The hook succeeds in both cases. Its status was never designed to say whether the model received the text, and it does not.

The harness's own telemetry keeps the two facts apart. Its hook_execution_complete event carries num_success, "Count that completed successfully", and, from version 2.1.280, num_outputs_persisted, "Number of hook outputs over the 10,000-character cap that Claude Code saved to a file". One hook run can add to both. A check on exit codes would have shown vectr's session-start hook as healthy in all 39 sessions.

Analogy: the mailroom receipt

A company mailroom has a rule. Any envelope thicker than a set limit goes into a locker, and the recipient's desk gets the first page plus a slip with the locker number. The sender's receipt says "accepted", which is true. The recipient sees one page and a number, and nothing on the slip says the rest of the letter matters.

The receipt is accurate about what it covers, which is acceptance. It is silent about reading. The analogy is fair to the harness in one more way: the locker is real and the recipient could open it. A delivery design that depends on the recipient choosing to open it is back to depending on the model's initiative, which is what hook delivery was meant to remove.

02

The 10,000-Character Hook Output Cap, Clause by Clause

The hooks reference states the limit in four sentences, under its section on JSON output:

A hook's additionalContext, systemMessage, and initialUserMessage strings, and its plain stdout, are capped at 10,000 characters.

Scope: Claude Code measures each string on its own, even when several hooks run for the same event. For JSON output, each field is measured separately; plain stdout is measured whole.

Over the limit: Claude Code saves the output to a file in the session directory and replaces it with the file path and a preview of up to the first 2,000 characters. [...] Unlike that Bash ceiling, this cap has no setting or environment variable to raise it.

Reading the file: Claude Code doesn't ask Claude to read the file, so keep anything Claude must always see within the cap.

The unit is characters, and the scope is one string from one command

The count is characters in a single string, with no session total and no event total behind it. Two hooks on the same event each get their own 10,000 characters. Two JSON fields from one hook are measured separately. Stdout printed as plain text is measured whole. Any budget kept elsewhere in a system, per session or in tokens, reaches this limit only through a conversion.

The preview keeps the front

Over the limit, the model receives "a preview of up to the first 2,000 characters". Whatever the payload puts first survives, and everything after it goes to the file. So the order of a payload decides what a truncated delivery contains, and the sender controls that order.

The file is reachable but not requested

The section on adding context repeats the point: "Claude can read the file, but Claude Code doesn't ask it to." The model gets a file path in its context with no instruction attached. It may open the file or it may not, and a delivery design cannot count on either.

There is nothing to raise

The Bash tool's output ceiling has a setting. This cap has none. The only party that can keep a hook's payload whole is the hook that produces it.

A cliff, not a ceiling

Put the clauses together and the amount delivered is a strange function of the amount sent.

Delivered characters as a function of sent characters

Let n be the length of one string a hook returns, with the cap C = 10,000 and the preview P = 2,000.

delivered(n) = n             if n ≤ C
delivered(n) ≤ P, plus a file path   if n > C

At n = 10,000, all 10,000 characters arrive. At n = 10,001, at most 2,000 do. One character over the cap costs at least 8,000 characters of delivery. A ceiling would clip the excess and lose one character. This rule keeps the front and moves everything else out of context, so past the cap, adding one more note to a payload can cut what the model sees by 80%.

None of this was hidden. The changelog entry for version 2.1.89, published on 31 March 2026, reads "Changed hook output over 10,000 characters to be saved to disk with a file path + a 2,000-character preview instead of being injected directly into context". A report about it was filed about a day later. A request to raise it or make it configurable was closed as not planned, and another was opened on 14 October. The limit was public for months before vectr's payload crossed it.

Interactive · Demo 1
What Arrives at Each Payload Size

Move the payload across the cap and watch the delivered amount: it rises with the payload up to 10,000 characters, then falls to the preview and stays there. The hook's status is the same at every size.

32,000
This payload
Grey: sent. Cyan: in the model's context. Red line: the 10,000-character cap. Scale 0 to 40,000 characters.
Delivered characters for every payload size
010,00020,00030,00040,000

Model: the documented rule applied to one string. Up to 10,000 characters the whole string arrives; above that, at most the first 2,000 characters arrive with a file path, which is not counted. Bar height is the delivered amount for a payload of that size; the gold bar is the payload selected above.

Part 02
How vectr's Session-Start Memory Went Over the Cap
03

A Token Budget Against a Character Cap

At session start, vectr's hook asks a local daemon for the session-start set: every standing rule (vectr calls these directives), the high-priority task notes that record work in progress, and up to five open gotchas, caveats anchored to a file. It wraps the result in additionalContext and prints it. The same hook fires again after the conversation is compacted, so this payload is also what puts memory back after a compaction. The daemon renders those notes under a budget set in agent/config.yaml. At commit 726e798, the head of the public main branch, the lines that matter are these:

agent/config.yaml at 726e798, lines 2156-2190, excerpt
  injection:
    # ...
    # Cumulative token cap for everything injected in one delivery moment
    # (one hook firing / one fire-evaluation call). Notes are packed in total-
    # order; once the budget is exhausted, remaining notes are evicted from
    # the bottom of that same order.
    per_session_token_cap: 8000
    # ...
    per_turn_token_cap: 500
    # ...
    # Chars-per-token estimate used to size injected text against the two
    # caps above (same rough ratio as hooks.log_chars_per_token; kept as a
    # separate key since the two domains may need to diverge later).
    chars_per_token: 4

8,000 tokens at 4 characters each is 32,000 characters, 3.2 times the cap. The comment above the number shows the budget was scoped correctly: "everything injected in one delivery moment (one hook firing...)" is the same scope as the harness's limit, one string from one hook command. The scope matched; the unit did not. Every vectr release from 1.3.0, published on 18 July 2026, through 1.12.0 carries both values.

The budget for the smaller injections, per_turn_token_cap: 500, governs what fires with each prompt and before tool calls. At the same ratio it allows about 2,000 characters, a fifth of the cap, which fits the measured size of those injections, under 2.5 KB. The per-turn budget had the same unit error. It was harmless only because its number was small.

One more piece sits outside the budget. On session start and on each prompt, the hook adds a fixed line in front of whatever the daemon returns, after the budget has been applied (_emit_hook_context in agent/hook_cli.py). That line is about 140 characters, small next to 32,000, but it marks a general gap: a budget enforced on note text is not a budget on the string the harness measures.

Why a smaller token number would not have been enough

A token budget T reaches characters through a ratio r, the characters per token of this particular text under the model's tokenizer:

length in characters = T × r

The config fixes an estimate, r = 4. The real ratio depends on the text: prose, identifiers, file paths and other languages tokenize differently. To stay under the cap C for every payload, the budget would need T ≤ C / rmax, where rmax is the highest ratio any payload can reach. An average-case estimate does not give you rmax, and the cap is checked on the exact length of the final string. The estimate's error lands right at the cliff.

A token budget answers how much of the model's context the payload will use. The cap is a different limit, enforced before the model sees anything by a component that counts characters. Two limits, in two units: the context window, counted in tokens, and the hook channel, counted in characters. The channel was the tighter one, by a factor of 3.2.

04

What Reached the Model: 39 of 39 Session Starts Truncated

The overflow came to light through someone else's measurement. On 7 October, JhouCode, who delivers memory to agents through hooks, commented on a feature request I had filed for triggered memory injection and listed two traps. The first one:

Hook output has a cap of 10,000 characters per command. Above that, the window receives a 2,000-character preview, while the hook still reports success. So whatever is injected by trigger has to be short.

That describes a failure with no trace on the sending side, so the only place to look was the receiving side. My reply on 12 October reported what my transcripts showed:

Scanning our own transcripts, every substantive session-start injection since late July was persisted and previewed: 39 of 39, between 17 and 32 KB, so the model saw the header and one note out of 16 to 218. The hook reported success every time. Per-prompt and pre-tool injections stayed under 2.5 KB and arrived whole.

The sizes are given in KB and the cap in characters. For ASCII text the two are the same count, so the payloads were roughly 17,000 to 32,000 characters, between 1.7 and 3.2 times the cap. The top of the range matches the budget: 32 KB is about what 8,000 tokens at 4 characters per token allows. Out of the 16 to 218 notes in each session-start set, the model saw one. The rest existed only in the saved file.

The line that survived

The first thing in the payload is the fixed line the hook adds. Its template tells the model its working-memory notes are "auto-injected below", and to call the recall tool "only for something not shown here". In a whole payload that is good advice: it saves the model a call to fetch notes it already has. In a truncated payload the line survived because it came first, and it told the model its notes were below, in a context that now held one note and a file path. The part of the payload that got through steered the model away from the one tool that could have recovered the rest.

Why nothing looked broken

From the outside, an agent that never received its standing rules behaves like one that received them and did not follow them. Both look like a model ignoring its memory. Agent Memory Fails in Three Places and Gets Measured as One separates those cases and lists six stages a delivered note passes through. This failure sits at the stage called sent, and the only evidence collected there came from the sender: the hook ran and the hook succeeded. A session that fails at that stage belongs to none of the three failures in that taxonomy, because the note never reached the model in the first place.

The failure was also partial, which kept it quiet. The per-prompt and pre-tool notes kept arriving whole, and they were about whatever the agent was doing at that moment, so memory visibly worked inside every session. What went missing was the standing rules and task context meant to be present from the first turn, and nothing in a session shows text that never arrived.

Counting from transcripts

The measurement comes from the session transcripts the agent client writes. They record what each hook returned and whether the harness persisted it. Count by unique record id, not by line: the same record can appear more than once in a transcript, and a line count inflates both the failures and the successes.

Part 03
The Fix, and the Rules Behind It
05

Budget Hook Output in the Unit the Boundary Enforces

As of 15 October 2026 the fix is not in any vectr release. Release 1.12.0 and the public main branch at 726e798 both carry the 8,000-token budget, and the change is being made now.

The session-start payload gets a bound in characters on the rendered string, in place of the token estimate. The bound has to cover the whole string the harness will measure: the header line, the separators, each note's framing and the pointer at the end, as well as the note text. Bounding the notes and then adding framing would repeat the original gap on a smaller scale. Which string gets measured depends on how the hook prints. With JSON output each field is measured on its own, so the JSON keys around additionalContext cost nothing. With plain stdout the whole output is measured, wrapper text included.

The bound is a constant per integration. Each harness that runs hooks sets its own limit, and nothing in the documented hook input reports it. So the number is copied from another project's documentation and pinned to the integration that needs it. A copied number can go stale without warning, which is why the fix also checks the receiving side.

It also sits below the cap with some margin. A few hundred characters of headroom cost almost nothing, against at least 8,000 lost for a single character over.

The token budget stays as a second limit, on context use, and in another harness it could be the tighter one. The rendered string has to satisfy both.

Two constraints on one string

Let s be the full rendered payload, including header, framing and pointer, C the cap and m a safety margin.

len(s) ≤ C − m      exact, counted in characters
tokens(s) ≤ T         estimated, a share of the context window

The packer adds whole notes in priority order and stops at the first note that would break either line. The first constraint is the one the boundary enforces, so it is the one that has to be computed on the real string rather than estimated.

Splitting the payload across several hook commands would multiply the allowance, since each string is measured on its own. That only moves the problem: every piece still needs its own bound, and the total still costs context.

06

Graceful Truncation: Ordering an Agent Memory Payload for the Cut

A bound decides how much fits. When a session-start set holds 218 notes, most of them cannot fit in 10,000 characters, so the packer has to leave notes out, and the order decides which. The fix uses must-see order: standing rules first, then the current task, then file-anchored caveats, then everything else.

The public config already has a kind order, used both for injection and for evicting notes from the bottom when a budget runs out: directive, gotcha, operational, task, finding, reference. For this payload the fix moves the current task ahead of the file-anchored caveats. A caveat anchored to a file has a second delivery path: in vectr a gotcha "resurfaces when its file is touched", through the pre-tool injection, and those injections arrived whole throughout. That second delivery comes when the agent is about to edit the file, later than session start but at the moment the caveat applies. A task note has no file to anchor it, so nothing re-delivers it at a predictable moment. For a payload with a hard size limit, priority depends on two things: how much a note matters, and how few other routes it has to the model.

The payload then ends with one line: how many notes were left out, of which kinds, and the recall call that fetches them. That line also repairs the header. "Call it only for something not shown here" is good advice only when the model can tell what is not shown, and the pointer tells it.

Order also protects the payload when the bound itself is wrong. If a harness lowers its cap, or someone raises the budget in config, the preview keeps the first 2,000 characters, and with must-see order those characters hold standing rules rather than whichever note happened to sort first.

There is a case ordering cannot rescue. If the standing rules alone exceed the bound, the current task is left out whatever the order, and the pointer can only report it. Delivering every standing rule in every session is a promise that holds up to roughly 10,000 characters of rules, minus the header and pointer. A store past that point has more standing rules than this channel can carry, and the remedy is fewer of them.

How older protocols truncate at a fixed boundary

Fixed-size boundaries that truncate are old, and the older specifications state who truncates, from which end, and who is told.

DNS is the closest model for the fix. A DNS answer usually travels over UDP, one packet per message. RFC 1035 limits messages carried by UDP to 512 bytes, has the sender set the TC bit in the header when it truncates one, and says "the truncation should start at the end of the response and work forward in the datagram". The party that knows the limit truncates its own message, from the least important end, and sets a flag the receiver is built to act on. RFC 7766 describes the receiver's half: "When the client receives such a response, it takes the TC flag as an indication that it should retry over TCP instead." TCP carries messages of any length, so the full answer is one more request away.

Syslog sits at the other end. Under RFC 5424 a receiver that gets an oversized message "SHOULD truncate the payload", "the truncation MUST occur at the end of the message", and afterwards "the message MAY contain invalid UTF-8 encoding or invalid STRUCTURED-DATA". A note cut off mid-sentence at character 2,000 is the agent-memory version of that last clause. The hook cap falls between the two: the receiver gets a pointer but no request to follow it, and the sender is told nothing.

BoundaryLimitWhat is cutWho is toldRoute to the rest
DNS response over UDP512 bytes in the original specificationFrom the end of the message, so answer records outlast additional recordsThe client, through the TC bit in the headerThe client retries over TCP
Syslog messageSet by the transport; receivers should accept 2,048 octetsThe end of the messageNo oneNone
Hook output string10,000 charactersEverything after a preview of up to 2,000 charactersThe model, through a file path in its contextThe file, which the model is not asked to read
Bounded session-start payloadBelow the cap, by constructionThe lowest-priority whole notesThe model, in the pointer lineThe recall call the pointer names

Sources: RFC 1035 sections 4.2.1 and 6.2; RFC 7766 section 4; RFC 5424 section 6.1; the hooks reference. The last row is the fix, not yet released.

Interactive · Demo 2
Packing a Session-Start Payload

Change the size of the session-start set and the packing policy, and see which kinds of notes reach the model whole. Watch which notes a cut removes, not only how many.

40
9,000
arrived whole cut mid-note only in the saved file left out, named in the pointer dropped before sending, unnamed

Notes are synthetic, with sizes drawn from fixed ranges by a seeded generator; chip width follows note length. The kinds follow vectr's session-start set: standing rules, high-priority task notes (newest first, so the first is the current task), and up to five open file-anchored caveats. Must-see order is standing rules, the current task, caveats, then the other task notes. "As shipped" uses the public kind order, packs whole notes until their text reaches 32,000 characters, then adds a 140-character header. The cap rule is the documented one: over 10,000 characters, at most the first 2,000 arrive.

07

Measure Hook Delivery Where It Is Received

A bound, an order and a pointer all act on the sending side, and the sender cannot verify delivery. The cap is applied after the hook exits, and the hook never sees the result. Evidence of delivery has to come from the receiving side: the record of what the model was actually given. The fix adds two checks, and they catch different failures.

A regression test on the rendered string

The test fills a store large enough to hit the budget, renders the session-start payload exactly as the hook would print it, header and pointer included, and asserts that its length stays under the cap at the configured budget. It catches changes made on the sending side: a budget raised in config, a longer header, a new field in each note's rendering. It runs in CI on every change. What it cannot catch is the copied cap itself being wrong, because it checks the sender against that copy.

A transcript check that fails loudly

The second check reads session transcripts, finds the records that belong to the hook, and looks for the marker the harness leaves when it persists output instead of delivering it. Any hit is a failure, reported as an error by the command that runs the check, not as a counter in a log. It catches what the test cannot: a harness that lowers its cap, a user configuration that overrides the budget, an injection path the test never rendered. A quiet counter would rebuild the original problem one level up, as a measurement that exists and that nobody reads.

The harness's own counter

If you export the harness's OpenTelemetry events, hook_execution_complete carries num_outputs_persisted from version 2.1.280, released on 22 September 2026. Charted next to num_success, it shows the gap between the two directly. The event also reports additional_context_chars, the "Total characters of additionalContext returned by the matching hooks". With one hook on an event it shows how close the payload runs to the cap. With several it is a sum, and overstates any single string, which is what the cap measures.

SignalTaken whereWhat it can seeWhat it cannot see
Exit code, num_successSending sideThe command ran and its output parsedAnything applied after exit, including the cap
Regression testSending side, before releaseThe rendered string against the configured budgetA cap that changed; settings in a user's environment
num_outputs_persistedThe harnessOutputs it saved to a fileWhich notes were lost; requires telemetry export
Transcript scanReceiving side, after the sessionWhat each hook returned and whether it was persistedWhether the model used what arrived

What the transcript scan cannot see, whether the model used what arrived, is the next stage of delivery: arriving is necessary for a note to be used, and not sufficient.

Close
Sender Status and Receiver Evidence
08

Every Delivery Boundary Has a Unit

A hook's success status answers a question about the sender, and answers it accurately. The mistake was reading it as an answer about the receiver.

Between a memory store and a model's context there can be several layers, each with its own size rule: hook output strings, tool results, whatever an agent framework wraps around the text it injects. For each one, three facts decide whether a payload survives: the unit and scope of the limit, what happens past it, and who is told. For hook output the answers are characters per string per command; a file path plus a preview of up to 2,000 characters; and the model, implicitly, with no request to read the file. The sender is not told at all.

Those three facts belong in the code, next to the budget they constrain, with a link to where each is documented. vectr's budget carried a comment that named its scope correctly and said nothing about the unit the boundary counts in. A line naming the unit, and the 2,000 characters that survive past the limit, would have made 8,000 × 4 look wrong to anyone reading the config.

By its own definition, vectr's session-start hook did its job in all 39 of those sessions. In each of them the model received at most 2,000 characters of a payload that ran to between 17 and 32 KB.

09

Sources