Skip to content

Error in hook callback hook_0: Error: Tool permission stream closed before response received

This one never left your process. There is no HTTP status, no error.type, and no request_id attached to it, which rules out the entire documented API error surface in one step — it is not a 401, not a 429, not a 529, and checking your key, your quota or the status page is time you will not get back. A callback you registered was asked to approve a tool call, and the channel it was supposed to answer on closed before an answer arrived. The tool never ran.

Data as of 2026-09. Vendor limits and defaults change; check the official docs for current values before acting on any number below.

hook_0 is a position, not a name

The zero is an index into the list of callbacks the runtime is holding, in the order they were registered. It is not a name you chose, and nothing in the message identifies your code. That is the first thing to resolve, because everything after it depends on knowing which callback is being blamed.

Go to where callbacks are registered — the options object you pass when creating the client or starting the session — and count from zero in registration order. The trap is that source order and runtime order are not always the same thing. If the list is built from an array, a config file, an object’s keys, a filtered set, or a directory of plugins, then index 0 is whatever ended up first at runtime, which changes when configuration changes. A hook that was index 1 in development can be index 0 in production because one entry was disabled by an environment variable.

Log the registration list at startup. Print the length and an identifier for each entry in order, once, when the session is created. It costs one line and it converts every future message of this shape from a guess into a lookup. Until you have that, the safest assumption is the weakest one: some callback did not answer, and you do not yet know which.

What the permission channel is actually waiting on

When the model asks for a tool, the turn suspends. The request goes to your callback, and the runtime waits on a channel bound to that turn for a decision to come back. Nothing else in the turn proceeds while it waits — your callback is sitting in the critical path of a live request.

That has two consequences people do not expect. Anything your callback awaits — a human clicking a button, a policy service, a database, a mutex — extends the turn for exactly as long as it takes. And when the channel closes first, the runtime has no decision to apply, so it reports the thing it can observe: the stream closed before a response was received. The message names the stream because the stream is what noticed. The thing that failed is your callback’s liveness.

There are only two ways a callback fails to answer. It never resolves — a branch that logs and falls through, a switch with no default, a promise nobody settles, an exception thrown where nothing catches it — or it resolves too late, after the turn it belonged to is gone. Those look identical in the log and have completely different fixes, which is what the next two sections are for.

Is retrying useful?

Yes — exactly once, and mostly to find out which of the two causes you have.

The channel can also close for reasons that have nothing to do with your code: the request was aborted, the client was disposed while a turn was in flight, the process started shutting down, or the underlying response stream ended early. Those are situational, and a second attempt often sails straight through.

So retry once and watch what fails. If it fails on the same tool every time, you have a branch in your callback that never returns a decision, and no amount of retrying will fix it. If it fails on different tools, or intermittently under load, your callback is probably fine and something is ending turns underneath it. Either way you now know which half of this page to read.

One thing that will not help: raising the SDK’s retry count. The official SDKs retry transient failures — connection errors, rate limits, 5xx — with exponential backoff, twice by default, honoring retry-after when it is present. This failure is not an HTTP response at all, so there is nothing for that mechanism to classify and nothing for it to back off from. If you want a retry here, it has to be yours, and it should be bounded at one attempt so a deterministic dead branch does not become a loop.

Telling the causes apart

  • Always the same tool — a branch that does not answer for that tool name. Log the tool name on entry and the decision on exit; the missing exit line is your bug.
  • Only tools whose approval needs a human or a network lookup — a timing problem, not a logic one. Your decision is slower than the turn survives.
  • Random tools, clustering around cancellations, timeouts or shutdown — something is ending the turn beneath you. Look for an abort signal, a disposed client, or a process exiting while work is in flight.
  • Only under concurrency — shared state in the callback. A single in-flight promise, a module-level variable, or a non-reentrant lock will serialise decisions until one of them outlives its turn.
  • Started with a deploy that added or reordered hooks — the index in the message is the clue; count from zero in the new runtime order, not the old source order.

Fixes, keyed to the test above

  • Dead branch — make the callback total. Every path returns a decision, and the default path returns an explicit deny rather than falling through. An explicit deny is a working callback; silence is a broken one, and it is also the safer default, since a callback that cannot decide should not be approving anything.
  • Thrown exception — wrap the body in a catch that converts the failure into a deny with a reason. A throw is not an answer, and from the runtime’s point of view it is indistinguishable from a hang.
  • Slow decision — get the blocking work out of the callback. Precompute the policy, cache the lookup, or deny with a message that tells the caller to re-ask after the human has answered. Awaiting a person inside a permission callback means the turn’s lifetime is now a UX question.
  • Turn ended underneath you — fix the lifecycle rather than the callback. Do not dispose the client in a finally that can run during a live turn, propagate the abort signal so the callback can bail deliberately, and check whether a long non-streaming request is the trigger: the SDKs validate that a non-streaming request is not expected to exceed a 10-minute timeout, and the documented advice for work longer than that is to stream or to use the batch path instead.
  • Concurrency — make the callback reentrant and stateless, keyed entirely on its arguments.

Confirming it, without trusting a single clean run

Intermittent failures are confirmed by repetition, never by one success. Give yourself something observable first: a log line at entry and at return, including the tool name and a correlation id for the turn. A healthy callback produces a matched pair for every permission request, including the ones it denies. Unmatched entry lines are the signature of this bug, and they are visible long before the error surfaces.

Then re-run the workload that failed, at the same concurrency, at least twice. For the dead-branch case the test is sharper: deliberately trigger a request for the tool that used to hang and confirm a decision comes back — even a denial counts as a pass, because the failure you are fixing is the absence of an answer, not the answer itself.

  • API Error: Stream idle timeout - partial response received is the case where the model’s own stream stalled rather than your callback — read it if the log shows partial output before the error rather than a tool request.
  • API Error: Connection closed mid-response is the closest-looking failure from the other side of the boundary: a stream that died after a successful status, where the SDK’s retry also never fires, but for a completely different reason.
  • No response from API · Retrying in 2m 25s is what a stalled request looks like while it is still considered alive — if your permission callbacks are timing out, check whether this is what the turn was doing while they waited.
  • The AI coding error triage tool starts from the question that saves the most time here: does the error carry a request id and a status code, or did it never leave your process?