This article is based on the author's production handoff hooks. The author designed the system, measured 240 real handoff records, and verified the delivery behavior. AI recomposed the approved Japanese source for DEV readers under the standing delegation for English-market publication. #ABotWroteThis
I had a multi-agent coordination problem that looked like a context-window problem.
Several coding sessions needed to exchange questions, progress, and completion notices. A hook injected those handoffs into the next conversation turn. The first implementation took the conservative route: include the full message every time so nothing gets lost.
That safety margin became a recurring tax. Long messages were injected again on later turns. A free-form request addressed to another session could even be interpreted as an instruction for the current one.
I replaced full-message delivery with short notifications and an on-demand inbox. Replaying 240 production handoffs reduced the average payload per handoff from 969 bytes to 123 bytes: an 87.3% reduction.
Then the first version failed in a more important way. Seven unanswered questions existed in the previous 24 hours, but the first notification batch contained none of them.
The lesson was not “compress harder.” It was this:
Push the existence and priority of a handoff. Pull its full content only when needed. Measure payload reduction and critical-message delivery as separate SLOs.
TL;DR
- Do not inject full handoff messages on every turn.
- Push a compact notification containing type, state, sender, summary, and retrieval path.
- Keep the full message in a pull-based inbox.
- Rank unanswered questions ahead of status and completion messages before applying count or byte limits.
- Test both payload size and first-turn delivery. A smaller payload that hides the next required decision is a regression.
Quick answer: how do you reduce agent context without losing handoffs?
Separate notification from retrieval.
The always-on hook should answer only:
- What kind of handoff exists?
- Is it still actionable?
- Who sent it?
- What should the receiving agent do next?
- Where can it fetch the full message?
The inbox keeps the background, investigation notes, and free-form request. The receiving session pulls those details only when the notification is relevant.
Before
hook -> [full handoff text] -> every conversation turn
After
hook -> [short prioritized notice] -> conversation context
|
+-> pull full text from inbox when needed
This is not deletion. It is a routing change.
The dangerous version of “include everything just in case”
Full-message injection feels safe because the receiving agent cannot complain that information was omitted. But the design quietly conflates four responsibilities:
- storing the durable message;
- notifying the recipient that it exists;
- deciding whether it is still actionable;
- choosing how much of it belongs in the current turn.
When one hook does all four, every message becomes recurring context. The system pays for old background even when it needs only a one-line signal.
It also widens the instruction boundary. A message like “restart the deployment after the owner approves” may be useful evidence for one session and an unsafe command for another. Injecting arbitrary prose directly into the active context asks the model to rediscover that distinction every time.
A notification is a safer boundary:
[question][unanswered] from=session-api: Need decision on retry policy — open inbox item 841
The notification reports state. It does not impersonate a new instruction.
What the 240-record replay measured
I replayed 240 handoffs that had occurred in the real workflow through the before and after formatters.
| Measurement | Before | After | Change |
|---|---|---|---|
| Mean bytes per handoff | 969 B | 123 B | -87.3% |
| Maximum bytes per handoff | 3,066 B | 295 B | -90.4% |
| Auto-commit notice | 281 B | 82 B | -70.8% |
All three rows are measurements from this 240-record replay. The per-flow table further down measures a different population — the raw 893-event snapshot — which is why its auto-commit mean is not the same number. The two are not comparable as a single measurement.
You can recheck the arithmetic on those three rows directly:
awk 'BEGIN{
printf "mean: %.1f%%\n", (969-123)/969*100
printf "max: %.1f%%\n", (3066-295)/3066*100
printf "notice: %.1f%%\n", (281-82)/281*100
}'
mean: 87.3%
max: 90.4%
notice: 70.8%
The byte boundary is the one the hook actually emits, so it is measurable with wc -c rather than estimated. A single push notice looks like this:
printf '%s' '[question][unanswered] from=session-api: Need decision on retry policy — open inbox item 841' | wc -c
94
94 bytes carries the type, the actionable state, the sender, the decision at stake, and the retrieval path. Whatever the original message said, the receiving session can now decide whether to read it before paying for it.
After the priority fix, a complete delivery response—including the inbox hint and unanswered-count summary—measured between 883 and 1,034 bytes in the observed cases. The notification-line budget was 1,000 bytes, so the final response could slightly exceed it when fixed guidance was added.
These are UTF-8 byte measurements at the hook output. They are not token counts, model input billing, or a claim that total daily context fell by 87.3%. The comparison is intentionally narrow: the same handoff records, through two formatters, measured at the same boundary.
That boundary is still useful. It tells us whether the change reduced the flow we actually modified without pretending to measure the entire agent runtime.
The first optimization hid every unanswered question
The compact formatter initially selected the newest five messages.
That sounds reasonable until the inbox contains several kinds of events: progress, completion, claims, answers, and questions. Five recent status messages can consume the whole batch while an older unresolved question remains the item that actually blocks work.
In the failure case:
- seven unanswered questions existed in the previous 24 hours;
- the first delivery contained zero questions;
- the second delivery exposed one;
- the third delivery exposed two.
Nothing was deleted. The questions were still in the inbox. But “available after several turns” is not the same as “delivered when the recipient needed to act.”
The 87.3% reduction had passed while the operational outcome had failed.
This is the core measurement trap in context optimization:
smaller payload != better delivery
You need at least two independent signals:
- Volume: How many bytes were injected?
- Reachability: Did the most important actionable item appear in the first eligible turn?
Combining them into one score hides the trade-off. A formatter can look excellent on volume precisely because it removed the information the recipient needed.
Rank meaning before enforcing limits
I changed the candidate order to:
- unanswered questions;
- state changes;
- answers;
- accepted work;
- progress and completion notices.
Answered questions are removed from the delivery candidates. If the byte or count limit cannot fit every unanswered question, the remaining items stay unread and the compact output continues to say how many remain.
After the change, the first batch carried at least one unanswered question instead of none.
The implementation used three delivery controls:
- at most five detailed notices per turn;
- at most three redeliveries of the same notice;
- approximately 1,000 bytes of notification lines.
One exception matters: the highest-priority item is shown even if it alone exceeds the byte budget. A strict limit that can erase the only blocking question is not a safety control; it is a denial-of-information mechanism.
The ordering rule should therefore come before the size rule:
PRIORITY = {
"unanswered_question": 0,
"state_change": 1,
"answer": 2,
"work_accepted": 3,
"progress": 4,
"completed": 5,
}
def select_for_push(items, max_items=5, byte_budget=1000):
candidates = [item for item in items if item.is_actionable]
candidates.sort(key=lambda item: (PRIORITY[item.kind], item.created_at))
selected = []
used = 0
for item in candidates:
notice = item.compact_notice()
size = len(notice.encode("utf-8"))
if selected and (len(selected) >= max_items or used + size > byte_budget):
break
selected.append(notice) # Always allow the highest-priority first item.
used += size
return selected
This example is deliberately incomplete. Production code also needs durable read state, stable identities, concurrency control, malformed-record handling, and a pull command that returns the exact original message. The important part is the sequence: semantic priority first, transport limits second.
Test delivery as a state machine
A “formatter output is shorter” test would have approved the broken version. The useful tests describe how messages move through states.
| Scenario | Expected behavior |
|---|---|
| No notices | Inject nothing and exit successfully |
| Normal delivery | Show a compact notice and the full-message retrieval path |
| More than five candidates | Show five; leave the remainder for a later turn |
| Byte budget exceeded | Defer the next notice, except the highest-priority first item |
| Unanswered question exists | Deliver at least one in the first eligible batch |
| Question has an answer | Remove that question from redelivery candidates |
| Redelivery limit reached | Stop repeating the body, but keep an unresolved-count signal |
| Malformed JSONL row | Skip the row without breaking the whole hook |
Keep the volume assertion and delivery assertion separate:
def test_compact_notice_reduces_bytes():
assert mean_bytes(compact_records) < mean_bytes(full_records)
def test_first_batch_contains_unanswered_question():
batch = deliver(inbox_with_unanswered_questions)
assert any(item.kind == "unanswered_question" for item in batch)
The second test is not a quality bonus. It protects the purpose of the system.
Measure by flow, not only by global total
Before the optimization, the broader measurement snapshot contained 893 injection events totaling 1,131,429 bytes through 2026-08-08 05:26:38 UTC.
The largest flows were:
| Source | Events | Total bytes | Mean | Maximum |
|---|---|---|---|---|
| Cross-session handoff delivery | 159 | 617,058 B | 3,880 B | 9,407 B |
| Session list at startup | 5 | 68,052 B | 13,610 B | 14,132 B |
| Auto-commit notices | 164 | 66,030 B | 402 B | 755 B |
These are raw snapshot events, not the 240-record replay above, which is why the auto-commit mean here (402 B) differs from the 281 B replay row.
The startup list had the largest mean, but handoff delivery dominated the total because it ran often and redelivered pending content.
That is why one aggregate number is not enough. Track at least:
- event count;
- total bytes;
- mean bytes;
- maximum bytes;
- first-turn delivery of the highest-priority unresolved class.
Which flow you optimize depends on the outcome. Mean size points toward startup. Total recurring cost points toward handoff delivery. Reachability tells you whether either optimization broke the coordination loop.
Record each injection as one JSON Lines row carrying the source, the UTF-8 byte length, and a UTC timestamp. The table above is then reproducible in your own environment with jq:
METRICS_LOG=/path/to/context-emission.jsonl
jq -s '
[.[]
| select(
.metric == "context_emission_size"
and .ts >= "2026-08-08T00:00:00Z"
and .ts <= "2026-08-08T05:26:38Z"
)
] as $rows
| {
count: ($rows | length),
bytes: ($rows | map(.byte_len) | add),
by_source: (
$rows
| group_by(.hook)
| map({
source: .[0].hook,
count: length,
bytes: (map(.byte_len) | add),
average: ((map(.byte_len) | add) / length | floor),
maximum: (map(.byte_len) | max)
})
| sort_by(-.bytes)
)
}
' "$METRICS_LOG"
Bytes are used because they are cheap and reliable to capture immediately after a hook writes its output. This is not a substitute for token measurement; the goal is a comparable before/after series for each injection path.
A practical implementation sequence
If you are retrofitting an existing agent workflow, this order keeps the evidence interpretable.
1. Instrument before compressing
Log the source, timestamp, UTF-8 byte count, and delivery class for every injected payload. Do not begin with token estimates if the component you can directly observe emits bytes.
2. Find the expensive flow
Group by source and inspect count, total, mean, and maximum. A rare large payload and a small payload repeated hundreds of times need different fixes.
3. Split push from pull
Keep type, actionable state, summary, sender, and retrieval identity in push. Store the complete immutable message behind a pull path.
4. Define semantic priority
Write down what must appear before applying max_items or byte_budget. In a coordination system, unresolved questions usually outrank progress narration.
5. Test state transitions
Cover unanswered, answered, overflow, redelivery exhaustion, and corrupt storage rows independently.
6. Continue measuring both axes
Watch injected bytes and critical-message reachability. A regression in either one is a reason to repair the change, not average the metrics together.
Architecture boundary
The design becomes easier to reason about when notification generation, durable message storage, prioritization, and measurement remain separate.
┌────────────┐ ┌──────────────────────┐
│ producers │ ---> │ durable inbox/details│
└────────────┘ └──────────┬───────────┘
│ pull
v
┌──────────────────────┐
hook push --------> │ priority + short note│ ----> active context
└──────────┬───────────┘
v
metrics + delivery tests
This separation also gives failures a location. If the inbox contains the question but the notification omits it, the problem is prioritization. If the notice appears but pull cannot retrieve the original, the problem is identity or storage. If both work but the hook repeats it forever, the problem is acknowledgement state.
“The agent did not see the message” stops being one vague failure mode.
FAQ
Why measure bytes instead of tokens?
Because bytes are what the hook emits. Token counts depend on the tokenizer and the model, and they are an estimate at the point where I could take a direct measurement. Measuring bytes at the hook boundary keeps the claim narrow and reproducible: the same 240 records, two formatters, one measuring point. If you need a billing number, measure it at the API boundary instead — that is a different question with a different instrument.
Doesn't a pull-based inbox just move the cost to a later turn?
Only when the recipient actually needs the detail. The failure mode of full injection is paying for every message on every turn regardless of relevance. Pull moves that cost from unconditional to conditional. The measurement that keeps this honest is the second SLO: if the notice is so thin that the agent must pull on every turn anyway, reachability will not improve and you have not gained anything.
What if the highest-priority notice alone exceeds the byte budget?
Show it anyway. Truncation applies to everything below the top of the ordering, never to the top itself: every other item defers to the next turn, and the first-priority item does not.
How do I know my compression didn't break delivery?
Assert the two axes separately. test_compact_notice_reduces_bytes and test_first_batch_contains_unanswered_question must both exist and must not be averaged into one score. In my case the first test passed at -87.3% while the second would have failed at 0 of 7 unanswered questions delivered. A single combined metric would have reported success.
Does this apply outside multi-agent setups?
The push/pull split applies anywhere a recurring hook injects variable-length content: notification digests, CI status summaries, monitoring alerts routed to an assistant. The specific priority ordering does not transfer — "unanswered questions first" is a property of a coordination inbox. Write down your own ordering before you write the byte limit.
The rule I kept
I still want context injection to be small. I just no longer accept smallness as the outcome.
The durable rule is:
Push only enough context to identify the next relevant action. Preserve the full handoff behind pull. Prove both the byte reduction and the first-turn delivery of what matters.
An 87.3% reduction is useful evidence. Zero of seven unanswered questions in the first batch is stronger evidence that the first design was wrong.

Top comments (0)