Summary
In the web UI you cannot send a message while the agent is working. The composer blocks the
send button for the whole turn, so a correction or a follow-up has to wait until the agent
goes idle, and the user has to watch for that moment.
Telegram and Slack do not have this problem. The channel router already queues inbound
messages per session and runs them after the current turn. Only the web path is missing the
behaviour.
The request is the Claude Code composer behaviour: type while the agent runs, see the queued
messages, remove one if you change your mind, and have them delivered automatically.
Current behaviour
Channels already queue. ChannelRouter.handle_message (nerve/channels/router.py:103)
appends every inbound message to _pending_batches with a future. If the per-session lock is
held, it returns the future and the running driver coroutine picks the message up. The driver
drains the queue after the current turn and merges the pending messages into a single turn via
_run_batch (nerve/channels/router.py:227), joining the texts with a blank line. A 0.60s
debounce (BATCH_DEBOUNCE, nerve/channels/router.py:101) collects rapid-fire sends.
Web bypasses the router. The WebSocket handler calls _engine.run() directly
(nerve/gateway/server.py:865), so it gets none of the queueing.
The composer blocks the send. canSend = !disabled && !isStreaming && ...
(web/src/components/Chat/ChatInput.tsx:316), and the send button is replaced by the stop
button while the turn is open.
Why removing the isStreaming gate is not enough
engine.run serializes per session on _session_locks (nerve/agent/engine.py:2456), so a
second send would wait rather than fail. But the WebSocket handler calls
_engine.register_task(session_id, task) for each send, and register_task
(nerve/agent/sessions.py:380) replaces the live task with the queued one and logs
"replacing live task for session %s (possible concurrent run)". After that, stop targets the
queued turn instead of the running one, so the stop button stops working.
There is also no way to see or cancel a queued message, and no debounce, so two fast sends
become two separate turns.
Where the queued message can be delivered
Three options, in increasing order of risk.
Option 1: at turn end
receive_turn exits on the first TurnCompleted (nerve/agent/backends/claude.py:1085), the
engine releases the session lock, and the queued message runs as its own turn through
engine.run. It is persisted, titled, broadcast, and metered like any other message.
- Cost: the message waits for the whole turn. A correction sent two minutes into a six minute
tool chain lands four minutes late.
- Risk: none. This is the path channels already use.
Option 2: mid-turn, at the next tool round
ClaudeSDKClient.query() writes a single JSON line to the CLI's stdin
(claude_agent_sdk/client.py:248). Nothing in the SDK prevents calling it while
receive_response() is still iterating. The CLI has an internal mid-turn delivery path: its
bundled strings include "is already waking; message queued for its next tool round (not delivered if the agent turns out to have been stopped)", which is the agent-to-agent
SendMessage case.
Unverified: whether a user message written to stdin in stream-json input mode reaches that
same tool-round path, or is buffered until the turn ends. This needs a spike. Write a second
query() mid-turn against a live session and check whether the injected text reaches the
model before the turn's ResultMessage.
Two things to handle if it does work:
- Turn accounting.
receive_turn returns on the first TurnCompleted. If the CLI closes
the current turn and opens a new one for the injected message, the second turn's events stay
in the SDK buffer. _idle_stream_watcher (nerve/agent/engine.py:3175) already drains
events that arrive with no active run, but the queued message would then render as an
autonomous turn instead of a user message.
- Persistence. A direct
client.query() skips _run_inner, which is what writes the user
message to the database and broadcasts the bubble. Without an explicit write, the UI shows
the agent answering a message that is not in the transcript, and a reload loses it.
Option 3: interrupt and resend
client.interrupt(), then run the queued message. Immediate, but it discards the in-flight
tool call. This is the current stop button plus a resend, not queueing.
Proposal
Build option 1, then spike option 2.
chatStore: add queued: Record<sessionId, QueuedMessage[]> beside the existing
per-session drafts map (web/src/stores/chatStore.ts:110, already persisted to
localStorage), with enqueueMessage, dequeueMessage(index), and clearQueue. Queue
fileIds with the text, because uploads complete before send.
ChatInput: allow send while streaming, keep stop as a separate control, and render the
queued messages as removable chips above the composer.
- Flush from one function,
flushQueue(sessionId), called by handleDone
(web/src/stores/handlers/streamingHandlers.ts:436). Send one message per queued item so
the order stays visible, rather than merging them the way _run_batch does.
- Spike option 2. If stdin messages do reach the next tool round, move the flush trigger to
the tool_result event and add the database write. The queue data model does not change,
only the trigger.
A client-side queue is per tab. It does not survive a reload, is invisible to other tabs and to
Telegram and Slack, and is lost if the browser closes. If the queue has to be shared, the web
path should move onto router.handle_message so it inherits the existing batching, or the
queue needs a table and WebSocket events. That is a larger change and should be a separate
issue.
Decisions needed
- On stop. Should an interrupt discard the queue? Suggested: keep the items as chips but do
not auto-send them, so handleStopped does not flush.
- On a mid-turn pause.
AskUserQuestion and ExitPlanMode park the turn while
is_running stays true (nerve/agent/interactive.py). Under option 1 a queued message sits
until the question is answered. That is probably right, but it is visible behaviour and
should be chosen rather than inherited.
- One turn or many. Channels merge a batch into one turn. The proposal above sends queued
web messages as separate turns. Worth deciding whether the two paths should match.
Summary
In the web UI you cannot send a message while the agent is working. The composer blocks the
send button for the whole turn, so a correction or a follow-up has to wait until the agent
goes idle, and the user has to watch for that moment.
Telegram and Slack do not have this problem. The channel router already queues inbound
messages per session and runs them after the current turn. Only the web path is missing the
behaviour.
The request is the Claude Code composer behaviour: type while the agent runs, see the queued
messages, remove one if you change your mind, and have them delivered automatically.
Current behaviour
Channels already queue.
ChannelRouter.handle_message(nerve/channels/router.py:103)appends every inbound message to
_pending_batcheswith a future. If the per-session lock isheld, it returns the future and the running driver coroutine picks the message up. The driver
drains the queue after the current turn and merges the pending messages into a single turn via
_run_batch(nerve/channels/router.py:227), joining the texts with a blank line. A 0.60sdebounce (
BATCH_DEBOUNCE,nerve/channels/router.py:101) collects rapid-fire sends.Web bypasses the router. The WebSocket handler calls
_engine.run()directly(
nerve/gateway/server.py:865), so it gets none of the queueing.The composer blocks the send.
canSend = !disabled && !isStreaming && ...(
web/src/components/Chat/ChatInput.tsx:316), and the send button is replaced by the stopbutton while the turn is open.
Why removing the
isStreaminggate is not enoughengine.runserializes per session on_session_locks(nerve/agent/engine.py:2456), so asecond send would wait rather than fail. But the WebSocket handler calls
_engine.register_task(session_id, task)for each send, andregister_task(
nerve/agent/sessions.py:380) replaces the live task with the queued one and logs"replacing live task for session %s (possible concurrent run)". After that, stop targets thequeued turn instead of the running one, so the stop button stops working.
There is also no way to see or cancel a queued message, and no debounce, so two fast sends
become two separate turns.
Where the queued message can be delivered
Three options, in increasing order of risk.
Option 1: at turn end
receive_turnexits on the firstTurnCompleted(nerve/agent/backends/claude.py:1085), theengine releases the session lock, and the queued message runs as its own turn through
engine.run. It is persisted, titled, broadcast, and metered like any other message.tool chain lands four minutes late.
Option 2: mid-turn, at the next tool round
ClaudeSDKClient.query()writes a single JSON line to the CLI's stdin(
claude_agent_sdk/client.py:248). Nothing in the SDK prevents calling it whilereceive_response()is still iterating. The CLI has an internal mid-turn delivery path: itsbundled strings include
"is already waking; message queued for its next tool round (not delivered if the agent turns out to have been stopped)", which is the agent-to-agentSendMessagecase.Unverified: whether a user message written to stdin in stream-json input mode reaches that
same tool-round path, or is buffered until the turn ends. This needs a spike. Write a second
query()mid-turn against a live session and check whether the injected text reaches themodel before the turn's
ResultMessage.Two things to handle if it does work:
receive_turnreturns on the firstTurnCompleted. If the CLI closesthe current turn and opens a new one for the injected message, the second turn's events stay
in the SDK buffer.
_idle_stream_watcher(nerve/agent/engine.py:3175) already drainsevents that arrive with no active run, but the queued message would then render as an
autonomous turn instead of a user message.
client.query()skips_run_inner, which is what writes the usermessage to the database and broadcasts the bubble. Without an explicit write, the UI shows
the agent answering a message that is not in the transcript, and a reload loses it.
Option 3: interrupt and resend
client.interrupt(), then run the queued message. Immediate, but it discards the in-flighttool call. This is the current stop button plus a resend, not queueing.
Proposal
Build option 1, then spike option 2.
chatStore: addqueued: Record<sessionId, QueuedMessage[]>beside the existingper-session
draftsmap (web/src/stores/chatStore.ts:110, already persisted tolocalStorage), with
enqueueMessage,dequeueMessage(index), andclearQueue. QueuefileIdswith the text, because uploads complete before send.ChatInput: allow send while streaming, keep stop as a separate control, and render thequeued messages as removable chips above the composer.
flushQueue(sessionId), called byhandleDone(
web/src/stores/handlers/streamingHandlers.ts:436). Send one message per queued item sothe order stays visible, rather than merging them the way
_run_batchdoes.the
tool_resultevent and add the database write. The queue data model does not change,only the trigger.
A client-side queue is per tab. It does not survive a reload, is invisible to other tabs and to
Telegram and Slack, and is lost if the browser closes. If the queue has to be shared, the web
path should move onto
router.handle_messageso it inherits the existing batching, or thequeue needs a table and WebSocket events. That is a larger change and should be a separate
issue.
Decisions needed
not auto-send them, so
handleStoppeddoes not flush.AskUserQuestionandExitPlanModepark the turn whileis_runningstays true (nerve/agent/interactive.py). Under option 1 a queued message sitsuntil the question is answered. That is probably right, but it is visible behaviour and
should be chosen rather than inherited.
web messages as separate turns. Worth deciding whether the two paths should match.