Writing · September 22, 2026 · 9 min read
A hook that keeps coding-agent sessions on one topic
Claude Code · AI agents · context engineering · developer tooling
TL;DR: My coding agent sessions kept turning into one long chat that untangled ten unrelated threads, and measuring my transcripts showed that turns above 300K tokens of context are where almost all the tokens go. A written rule saying "new topic, new session" did not fix it, because the model knew the rule and I did not follow it. What fixed it is a small piece of agent harness: a launcher that starts each session with a declared intent, and a hook that runs on every prompt, blocks off-topic requests, offers to park them in a one-line inbox instead, and escalates at 250K, 400K, 550K and 650K tokens of context until the session writes a handoff note and ends. This post covers the design, the thresholds and why they moved, and a flag-parsing bug that taught me a general rule about launchers.
The problem: sessions that never end
You open a session to fix a bug. Halfway through you notice a copy problem on another page, then a question about analytics, then an SEO issue. Each detour is small and reasonable in the moment. Four hours later the session holds half a million tokens of context, and every new turn re-reads all of it.
The cost side of this is measurable. In a month of transcripts, 88% of input tokens were spent in turns where the context was already above 300K, and a turn at 800K costs about eight times as much as a turn at 100K for an answer of the same length. Quality suffers too: a model working through a context full of unrelated threads has more to get confused by.
I already had the obvious instruction in my global rules file: a new topic means a new session. It did nothing, and the reason is worth stating. The instruction is addressed to the model, but the person drifting is me. The model has no authority to refuse my next question, and I am not going to stop and open a new terminal while I am on a roll.
What is an agent harness, and where does a guard like this fit?
An agent harness is everything around the model that shapes how it runs: the launcher that starts it, the instructions and memory it loads, the tools and permissions it gets, and the hooks that fire on its events. The model is the engine; the harness decides what it sees and when it stops. The options I considered for enforcing session boundaries all live in different parts of that harness.
A stronger instruction. Cheap and already tried. The pro is zero tooling. The con is that it relies on the model pushing back on the user mid-flow, which it will not do reliably, and it lives in context that can be compacted away.
Manual discipline with the built-in commands. Clear the context on a topic change, compact after a big stage. The pro is that it works perfectly when used. The con is the same as the first option: it depends on me noticing, which is the part that fails.
A lower automatic compaction threshold. The pro is that it happens by itself. The con is that compaction summarizes the history in place: the session carries on with a lossy summary of five mixed topics, and nothing about the thread is written down anywhere I can resume from.
A hook that enforces the boundary. Claude Code runs a UserPromptSubmit hook before each of my prompts reaches the model. The hook can block the prompt with a reason I see in the terminal, or let it through with extra context attached for the model. The pro is that it acts on me, at the moment I drift, without relying on memory. The con is that it needs to know what the session is for, and that is what the launcher provides. I went with this one.
A launcher that declares the session's intent
Starting a well-configured session used to mean remembering the right flags for model, reasoning effort, which MCP servers to load and which skill to reach for. MCP, the Model Context Protocol, is the standard way to plug external tools such as analytics or search consoles into an agent, and each server adds tool definitions to the context, so loading all of them everywhere is wasteful.
The launcher is a short shell script. I type the command plus what I want to do, in plain words. It matches the words against profiles defined per repository and assembles the full command: which MCP servers, which model, which effort level, plus a short hint appended to the system prompt about which skill to load and which file to start from. A simplified example, one row per intent:
| Profile | Keywords | MCP | Model | Effort | Hint |
|---|---|---|---|---|---|
| code | bug, fix, test, refactor | none | top | high | start from the repo plan |
| seo | keyword, ranking, sitemap | seo | mid | high | load the seo skill |
| quick | what is, where is, explain | none | small | low | answer, do not edit |
The model column follows a simple ladder, as of September 2026: Claude Fable 5.1 wherever a wrong answer costs more than the tokens (plans, incidents, product code, anything a customer reads); Opus 5 for profiles that load heavy MCP servers, because there the cost is input tokens from tools, not depth of reasoning; Sonnet 5 for quick questions and mechanical edits. Two rules from running it: lower the effort before you lower the model, and choose the model at the start of a session rather than switching mid-way, because the prompt cache is per model and a switch pays for the whole context again.
The part that makes the hook possible: the launcher exports the chosen profile into the session's environment. Any hook can then ask what this session was started for. That turned out to be the most portable idea in the whole setup. Any launcher that knows the intent of a session can hand it to the hooks.
How do you keep a coding agent session on one topic?
On every prompt, the hook scores my message against the keywords of each profile. If another profile clearly wins (at least two keyword hits, and more than the current profile gets), the prompt is blocked. The message I see names the matching profile and gives me the exact command to open that session, with the model and effort it would use. Some profiles are open by design, such as planning, where any topic is fair game, and some profiles cover neighbors, so a coding session does not block a question about an incident. Very short prompts pass untouched, as do slash commands, and a short prefix forces anything through when the detour really is part of the current task.
# illustrative shape of the hook contract
event = json.load(stdin) # includes the prompt and the transcript path
if clearly_other_topic(event["prompt"], session_profile):
stderr.write(reason_with_resume_command)
exit(2) # block; the reason is shown to the user
note = context_brake(event["transcript_path"])
if note:
print(json.dumps({"hookSpecificOutput": {"additionalContext": note}}))The first version had a flaw I only saw after two days of use. Sending every side thought to its own new session multiplied open chats, and I ended up with a handful of half-finished sessions fighting over the same files. So the block now offers a second way out: a prefix that parks the idea. With it, the prompt goes through, and the model does exactly one thing: it appends a single line to a hot-todo inbox file in the repo (date, profile tag, what and where) and goes back to the current task. In repos that have the inbox, the model parks side ideas there by default instead of sending me elsewhere. One dedicated session later works through the inbox from the top and closes each item with a link to the change that resolved it. The idea gets captured in ten seconds, and the current session stays on its topic.
Context thresholds that end in a handoff
The second job of the same hook is a context brake. It reads the token usage of the last response from the transcript and escalates in four steps, each firing once per session:
- 250K, checkpoint. Write the current thread state into the repo docs and keep working.
- 400K, soft. Start closing the thread and write the handoff. I aim to move to a new session somewhere between here and the next step.
- 550K, hard. Finish in this turn. Writing the handoff note is mandatory: goal, what is done, what is left, open decisions, the exact next step, the files to open. Commit it, then print one command that starts a fresh session pointed at that note.
- 650K, stop. No more work, only the handoff and the command.
The hook passes each step to the model as additional context, and the model opens its reply with a one-line warning, so I see it without reading anything else. Every message also carries an anti-duplication rule: one canonical place per thread, search the docs first, update the existing file in place, create a new handoff file only if none exists. Without that rule, handoffs turned into a pile of near-identical notes. A handoff in the docs is the cheapest continuation available: the next session reads one short file instead of inheriting half a million tokens. How to structure those files so resuming stays cheap is its own topic, covered in my post on docs as agent memory.
The thresholds are not where I started. The first version braked at 300K, straight from the cost measurement. In practice that fired far too early: on models with a one-million-token window I saw no drop in quality at 300K, and the brake interrupted sessions that were going well. Above roughly 550K is where cost and latency climb and quality starts to slip, so the hard stop sits there, with the checkpoint at 250K making sure the state is on disk long before anything forces the issue.
The same hook runs under OpenAI's Codex CLI, which accepts the same hook contract. Codex compacts its own context automatically at around 258K, so it gets its own checkpoint-only thresholds at 120K, 200K and 245K. After a compaction the context drops back below the first threshold, and the hook re-arms, so the checkpoints fire again in the next stretch of the session.
The bug where a model name swallowed the prompt
The launcher lets me pass extra flags through to the agent, such as resuming the last conversation. Once I started a session with an explicit model override followed by the task. The session came up on the default model, printed a warning that the model name was not in its catalog, and had a context window cut to 200K.
The cause was the argument parser. It treated every flag it did not recognize as a switch without a value. So the model name after the flag fell into the task text, and because pass-through flags were placed right before the prompt, the agent received two model flags, the second one with the entire task glued onto it as the model name.
The fix had four parts. The model override became a flag the launcher owns, which overrides the profile column. Flags that take a value and flags that do not are listed separately for each CLI, since the same short letter means different things in the two tools. Unknown bare flags now produce a warning asking for the --flag=value form, and a value flag with nothing after it exits with an error. And the task text always goes after a lone --, which both CLIs' parsers treat as the end of options. I tested that with a prompt that itself starts with --model. The general rule for any launcher: a flag value must never be able to fall into positional text, and positional text always goes after --.
A smaller lesson from writing it for the old bash that ships with macOS: under strict mode, a function ending in a false conditional one-liner returns failure and kills the script without a message.
Limits, and what I would tell someone building this tomorrow
Keyword matching is crude. It misses detours phrased in unusual words and occasionally blocks a message that belongs to the current task, which is why the force prefix exists and why the threshold is two hits instead of one. The context brake depends on the transcript recording usage faithfully. The handoff is only as good as what the model writes, so it is worth a read before the next session starts. And the launcher only helps when started from the repo root: started from the home directory, it once silently fell back to a generic profile set without the repo's skills or docs. It warns about that now.
- Enforce boundaries on the person, not the model. The drift comes from the user; a hook on prompt submit is the place that sees it.
- Give sessions a declared intent. A launcher that exports the session's purpose makes every later guard simple.
- Offer a cheaper exit than a new session. Parking a one-line idea keeps focus without multiplying open chats.
- End long sessions with a written handoff. Resuming from a short note beats both compaction and carrying the full context.
- Set thresholds from use, then move them. My first number came from a cost table and was too aggressive in practice.
- Put positional text after
--. Any wrapper that forwards flags will eventually eat a prompt otherwise.
Questions this post answers
- How do I stop a Claude Code session from growing too long?
- Use a UserPromptSubmit hook that reads the context size from the session transcript and escalates at set thresholds, for example a checkpoint at 250K tokens and a hard stop at 550K. At the hard stop the agent writes a handoff note to the repo docs and prints one command to resume in a fresh session.
- What is an agent harness?
- An agent harness is everything around the model that shapes how it runs: the launcher that starts it, the instructions and memory it loads, its tools and permissions, and the hooks that fire on its events. The model is the engine, and the harness decides what it sees and when it stops.
- Can a Claude Code hook block a prompt?
- Yes. A UserPromptSubmit hook runs before the prompt reaches the model; exiting with code 2 blocks it and shows the hook's stderr to the user as the reason. Printing JSON with additionalContext instead lets the prompt through and adds instructions for the model.