Streaming, interrupts and sessions
Live output, stopping safely, and saving and resuming transcripts.
Create a free account Try part of it here
Why it matters
A harness is software people use, not a batch job. They want to see the reply as it's written, stop the agent when it heads the wrong way, close the laptop and carry on tomorrow. Each needs care: streamed tool inputs arrive as half-finished JSON, a stop in the middle of a turn can break the transcript, and a session file that's only written at the end is lost in a crash.
What you'll be able to do
- Rebuild a message from the Messages API's stream of events, including tool inputs sent in pieces
- Stop an agent mid-turn and leave the transcript valid
- Save sessions as append-only logs, then resume and fork them
- Tell the model what changed when a session resumes
What it covers
- The event stream
- Stopping safely
- Repairing a transcript after Stop
- Sessions on disk
- A transcript that survives a crash
- Joining tool input pieces
- Assembling a stream correctly
- Stop means stop
The event stream
Think of a news ticker, or a parcel tracker that updates as things happen. With "stream": true, the Messages API sends server-sent events as the reply is generated:
| event | carries |
|---|---|
message_start | the message shell, with starting usage |
content_block_start | a new block at an index: text, tool_use (name and id), or thinking |
content_block_delta | a piece of that block: text_delta, input_json_delta or thinking_delta |
content_block_stop | that block is complete |
message_delta | the stop_reason, and final output usage |
message_stop | the end |
There are also ping events to ignore, and error events to handle.
Text pieces can be shown at once: that's the live typing effect. Tool inputs are more like flat-pack furniture arriving in several boxes. They come as fragments of a JSON string ({"pa, th": "src/, app.py"}), and you can't build anything until the last box arrives. Join them, and parse only at content_block_stop. Parsing early fails, or worse, gives a partial input.
The SDKs do this assembly for you (client.messages.stream(...) with get_final_message()). Knowing how it works helps when you show partial output or debug a stream that broke halfway.
parts = ['{"sku"', ': "TENT', '-2"}'] import json print(json.loads("".join(parts))["sku"])
Show the answer
TENT-2
join puts the pieces back together as {"sku": "TENT-2"}, and ["sku"] reads TENT-2. Tool input arrives as pieces of JSON. Only the joined whole can be parsed.
A harness assembles the reply from these, and can stop safely between them.
Show the answer
At that block's content_block_stop, after joining all the pieces
Pieces are fragments of one JSON string. Only the joined whole is valid.
Stopping safely
A factory line has a stop cord, and everyone knows what happens when it's pulled. When the person presses Stop, the harness must:
- Stop generating. Close the stream.
- Stop work. Cancel running tools, killing their processes.
- Leave a valid transcript. If the last assistant message has
tool_useblocks without results, add a user message oftool_resultblocks saying they were interrupted, withis_error: true. Or drop that unfinished assistant message entirely. - Wait for the person. Their next message joins the conversation as usual.
Skip step 3, and the next request fails: every tool_use needs a tool_result. The interruption note also tells the model why its plan stopped, so it doesn't assume the tool ran.
Repairing a transcript after Stop
If someone presses Stop during a tool call, the last message has a tool_use with no result, and the next API call would fail. Repair it before saving.
def repair(messages): last = messages[-1] if last["role"] != "assistant": return messages open_calls = [b["id"] for b in last["content"] if b["type"] == "tool_use"] if open_calls: messages.append({"role": "user", "content": [ {"type": "tool_result", "tool_use_id": i, "content": "Interrupted by the user.", "is_error": True} for i in open_calls]}) return messages messages = [ {"role": "user", "content": [{"type": "text", "text": "Run the tests"}]}, {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "bash"}]}, ] repair(messages) print(messages[-1])
Now the transcript is valid and it's honest about what happened. When the session resumes, the model knows the tests didn't finish, so it won't assume they passed.
Sessions on disk
Save each session as an append-only log, like a ship's log written one line at a time: one JSON object per line (JSONL), written as each message is added. A crash loses at most the line being written. Resuming means reading the lines back into a message list.
- Resume: load the log and carry on.
- Fork: copy the first n messages into a new session, to try a different approach from the same point. It's like photocopying a draft up to page 10, then trying another ending.
- Metadata: session ID, start time, working folder, model. Never the API key.
The world moves on. After a vacation, you'd check what changed before picking up old work. Between sessions, files change and branches move. On resume, tell the model: "Session resumed. Files may have changed since; read them again before editing." The stale-edit check from the file tools lesson is your safety net.
A transcript that survives a crash
One JSON object per line means each message is saved as it happens. If the power goes mid-write, only the last line is damaged, and you can skip it.
import json lines = [ json.dumps({"role": "user", "content": "Fix the bug"}), json.dumps({"role": "assistant", "content": "On it"}), '{"role": "user", "cont', # the crash happened here ] def load(lines): messages = [] for n, line in enumerate(lines): try: messages.append(json.loads(line)) except ValueError: if n != len(lines) - 1: raise # damage in the middle isn't a crash: stop and look return messages print(load(lines))
Everything before the crash is safe. Note that only a bad last line is forgiven. A bad line in the middle means something else is wrong, and quietly skipping it would hide lost messages.
Show the answer
Copies the first part of a session into a new one, to try another approach from that point
The original stays untouched, so you can compare approaches from the same starting point.
Joining tool input pieces
Each piece alone is broken JSON. Together they parse.
import json pieces = ['{"pa', 'th": "src/', 'app.py", "li', 'mit": 50}'] for p in pieces: try: json.loads(p) print("parsed alone:", p) except json.JSONDecodeError: print("not JSON alone:", repr(p)) print(json.loads("".join(pieces)))
This is why a harness keeps a buffer per block index and parses at the block's stop event.
Show the answer
content_block_start
The block starts with its type, name and id. The input then arrives in input_json_delta pieces.
Assembling a stream correctly
Don't parse tool input until it's finished. Tool input arrives as pieces of JSON text, and each piece on its own is usually invalid.
import json pieces = ['{"path": "sr', 'c/app.py", ', '"limit": 10}'] try: json.loads(pieces[0]) except ValueError as err: print("too soon:", err.msg) print(json.loads("".join(pieces)))
Collect the pieces by the block's index, and parse once at content_block_stop. If you want to show progress before then, display the raw text.
Save as you go
If you only save the transcript when a session ends, then a crash loses everything, including what the agent did to your files. So append each message when it happens. Disk writes cost almost nothing next to a model call.
Resuming isn't simply reloading
A person might come back to a session hours or days later. In that time, files can change, branches can move, and a test that was failing may have been fixed. The transcript still describes the old world, and the model will trust it. So when resuming, tell the model that time has passed, and what's changed if you can tell. For example, add a note: "This session resumed after 3 days. Files may have changed, so read them again before editing." It's a small message, and it prevents the agent from confidently editing code that's no longer there.
Stop means stop
The emergency stop on a machine doesn't wait for the current cycle to finish. When a person presses Stop, they may have spotted the agent doing something wrong. So stopping must take effect at once: close the stream, kill running commands, and don't start anything new. A Stop that waits for the current tool to finish isn't really a Stop. Test it on purpose, with a long-running command, since this is the kind of path that nobody exercises until the day it matters, and then it really has to work.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Which stream event carries the stop_reason?