Index Ai Academy
Phase 10 · Agent harnesses · Scaling & shipping

Streaming, interrupts and sessions

Live output, stopping safely, and saving and resuming transcripts.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The event stream
  2. Stopping safely
  3. Repairing a transcript after Stop
  4. Sessions on disk
  5. A transcript that survives a crash
  6. Joining tool input pieces
  7. Assembling a stream correctly
  8. 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:

eventcarries
message_startthe message shell, with starting usage
content_block_starta new block at an index: text, tool_use (name and id), or thinking
content_block_deltaa piece of that block: text_delta, input_json_delta or thinking_delta
content_block_stopthat block is complete
message_deltathe stop_reason, and final output usage
message_stopthe 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.

Quick check
What does this print?
parts = ['{"sku"', ': "TENT', '-2"}']
import json
print(json.loads("".join(parts))["sku"])
  1. sku
  2. TENT
  3. TENT-2
  4. an error
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.

Try it now

A harness assembles the reply from these, and can stop safely between them.

Quick check
A tool_use block's input arrives in three input_json_delta pieces. When should the harness parse it?
  1. At that block's content_block_stop, after joining all the pieces
  2. Never; tool inputs aren't JSON
  3. As each piece arrives
  4. At message_start
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:

  1. Stop generating. Close the stream.
  2. Stop work. Cancel running tools, killing their processes.
  3. Leave a valid transcript. If the last assistant message has tool_use blocks without results, add a user message of tool_result blocks saying they were interrupted, with is_error: true. Or drop that unfinished assistant message entirely.
  4. 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.

Worked example

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])
It runs right here in your browser, with nothing to install.

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.

Worked example

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))
In the lesson you can change this and run it.

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.

Quick check
What does forking a session do?
  1. Merges two sessions
  2. Copies the first part of a session into a new one, to try another approach from that point
  3. Deletes the session
  4. Runs the session on two models at once
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.

Worked example

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)))
In the lesson you can change this and run it.

This is why a harness keeps a buffer per block index and parses at the block's stop event.

Quick check
In the stream, which event first tells you a tool call's name and id?
  1. message_delta
  2. content_block_start
  3. message_stop
  4. content_block_delta
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

Create a free account

Try a quiz question

Which stream event carries the stop_reason?

  1. message_delta
  2. message_start
  3. content_block_stop
  4. ping

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson