lumo

Module 01 · Lesson 13 of 13Free

Lab: a command-line chat with streaming

Level
Beginner
Topic
AI engineering fundamentals
Reading time
16 min

When you finish this page you can

  1. Build a command-line chat in Python that keeps the conversation in a list and streams each reply with the anthropic SDK.
  2. Print each turn’s input, output and thinking tokens and its cost, and handle a max_tokens cut-off and Ctrl+C without breaking the history.
  3. Predict how input tokens and cost per turn grow as a conversation gets longer, and explain why.

Builds on 2 lessons

Start with lesson 315 min
  1. Calling a model API: request, response and cost15 min
  2. Streaming responses: showing output as it arrives13 min

Video lesson · coming soon

Tomás’s test script forgets and makes him wait

Deskora is a help desk app. A shop’s support team uses it to answer customer emails from one shared inbox. Each customer problem becomes a ticket: one customer’s whole conversation with the support team, message by message.

Tomás, an engineer at Deskora, is about to design a summary for long tickets. First he wants to try questions on a real 4,000-token ticket. His test script sends the ticket and a question. The terminal shows nothing while the model works, then the whole answer at once. He asks a follow-up, “Did anyone promise a date?”, and the model has no idea which ticket he means.

In this lab you build the tool he needs: a terminal chat that remembers the conversation, prints each reply as it arrives and shows what each turn cost.

This lab answers one question: how do you build a chat on an API that remembers nothing, and why does each turn cost more than the one before?

Deskora is made up, and the ticket sizes and token amounts below are estimates for this lab.

Check yourselfAnswer in your head first, then reveal
Streaming prints the reply a few words at a time. Does a streamed reply cost less, the same or more than the same reply without streaming?Show answerHide answer
The same. You pay for the tokens the model reads and produces, and streaming only changes when they reach you. Not “less, because you see it sooner”: the usage line your chat prints counts the same input, thinking and text tokens either way.

Every turn of the chat runs the same six steps.

Diagram not drawn yet: cli-chat-turn-loop.svg

Six steps from top to bottom, inside a box labelled chat.py. 1: read your question from the terminal. 2: append it to the history list as a user turn. 3: send the whole history to the Claude Messages API as one streaming request. 4: dashed arrows return text pieces, printed as they arrive. 5: when the stream ends, read the final message with stop_reason and usage. 6: if stop_reason is end_turn, append the reply to the history as an assistant turn and print the turn’s tokens and cost; otherwise drop the question. An arrow loops from step 6 back to step 1.

One turn of the chat. The history list in your program is the only memory; the whole list goes out on every request.

Set up Python, the SDK and your key

You need Python 3.10 or newer. As of October 2026, the latest anthropic package is version 2.3.0, and it requires Python 3.10 or later 3. Pinning it keeps this code working if a later SDK changes.

bash
python3 --version              # 3.10 or newer
mkdir deskora-chat && cd deskora-chat
python3 -m venv .venv          # Windows: py -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install "anthropic==2.3.0"
export ANTHROPIC_API_KEY="paste-your-key-here"
# Windows PowerShell: $env:ANTHROPIC_API_KEY = "paste-your-key-here"

The virtual environment keeps this lab’s packages apart from the rest of your machine. The lives in an environment variable, a named value your shell passes to every program it starts. The SDK reads ANTHROPIC_API_KEY from there, so the key never appears in your code 5. Anyone holding the key can spend your money, so never paste it into a file.

For the second run you also need a ticket. Save any long email thread as ticket.txt, with names and addresses removed, or write a made-up one. About 3,000 words of English is roughly 4,000 tokens; Tokens explains why the ratio varies.

What the whole lab costs

Claude Haiku 5.5 costs $0.10 per million input tokens and $0.50 per million output tokens, for prompts up to 100,000 tokens, as of October 2026 2. Thinking tokens are billed as output 4. The formula is the one from Calling a model API.

The formula

cost of a turn = input tokens × $0.10 ÷ 1,000,000 + output tokens × $0.50 ÷ 1,000,000

Output tokens include thinking. The lab’s cost is the sum over every turn you send.

First run: a plain chat

5 turns: 3,450 input × $0.10/M + 1,500 output × $0.50/M = $0.000345 + $0.00075 = about $0.0011

Assumed per turn: a 20-token question, 200 thinking tokens and a 100-token reply.

Second run: a 4,000-token ticket

10 turns: 56,100 input × $0.10/M + 3,000 output × $0.50/M = $0.00561 + $0.0015 = about $0.0071

Worked out in the last section of this lab.

Whole lab

$0.0011 + $0.0071 + about $0.0005 for the failure tests = about $0.009

About one cent. Trying a 15,000-token ticket as well adds about two cents.

The chat prints a running total, so you always see what you have really spent.

Write the chat in three parts

Put the three parts below in one file, chat.py, in this order.

The first part sets the model, the prices and the system prompt. If you pass a ticket file, its text joins the system prompt, so every turn can see it.

python
import sys

import anthropic

MODEL = "claude-haiku-5-5"
MAX_TOKENS = 2000  # room for thinking plus a reply of a few paragraphs
# claude-haiku-5-5, US dollars per million tokens, prompts up to
# 100,000 tokens, as of October 2026
INPUT_PRICE = 0.10
OUTPUT_PRICE = 0.50  # thinking tokens are billed at this price too

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment


def load_system_prompt() -> str:
    system = ("You help a Deskora engineer test questions about "
              "support tickets. Answer in a few short lines.")
    if len(sys.argv) > 1:
        with open(sys.argv[1], encoding="utf-8") as f:
            system += "\n\nThe ticket:\n" + f.read()
    return system


def turn_cost(usage: anthropic.types.Usage) -> float:
    return (usage.input_tokens * INPUT_PRICE
            + usage.output_tokens * OUTPUT_PRICE) / 1_000_000

The second part sends one turn. client.messages.stream opens a streaming request. text_stream yields only the pieces of reply text, so the hidden thinking never prints. get_final_message() waits for the stream to end and returns the full message, with stop_reason and usage 1. How the events travel is covered in Streaming responses.

python
def ask(history: list[dict], system: str) -> anthropic.types.Message:
    with client.messages.stream(
        model=MODEL,
        max_tokens=MAX_TOKENS,
        system=system,
        messages=history,
    ) as stream:
        for text in stream.text_stream:
            print(text, end="", flush=True)
        message = stream.get_final_message()
    print()
    return message

The third part is the loop. history is a plain Python list that grows by two entries per finished turn: your question and the model’s reply.

python
def main() -> None:
    system = load_system_prompt()
    history: list[dict] = []
    turn, total = 0, 0.0
    print(f"Chatting with {MODEL}. Ctrl+C or Ctrl+D to quit.")
    while True:
        try:
            question = input("\nyou> ").strip()
        except (KeyboardInterrupt, EOFError):
            break
        if not question:
            continue
        history.append({"role": "user", "content": question})
        try:
            print("claude> ", end="", flush=True)
            message = ask(history, system)
        except KeyboardInterrupt:
            history.pop()  # forget the unanswered question
            print("\n[stopped: this turn was not kept]")
            continue
        turn += 1
        usage = message.usage
        details = getattr(usage, "output_tokens_details", None)
        thinking = details.thinking_tokens if details else 0
        cost = turn_cost(usage)
        total += cost
        print(f"[turn {turn}: in={usage.input_tokens} out={usage.output_tokens} "
              f"(thinking={thinking}) cost=${cost:.6f} total=${total:.6f}]")
        if message.stop_reason != "end_turn":
            history.pop()  # don't build later turns on an unfinished reply
            hint = " Ask for a shorter answer." if message.stop_reason == "max_tokens" else ""
            print(f"[stop_reason={message.stop_reason}: turn not kept.{hint}]")
            continue
        history.append({"role": "assistant", "content": message.content})
    print(f"\nBye. {turn} turns, ${total:.6f} in total.")


if __name__ == "__main__":
    main()

Three details matter. The reply goes back into the history as message.content, the full list of content blocks, thinking block included. The usage line prints after the reply, because usage is final only when the stream ends; the message_delta event at the end carries cumulative counts 1. If the network fails before any text arrives, the SDK retries twice by default 5; Model API errors, rate limits and retries covers the rest.

The one real design choice: what to keep when a reply doesn’t finish.

A reply stops early: it hits max_tokens, or you press Ctrl+C. What should the history keep?

Keep the partial reply as an assistant turn.

The next turn builds on half an answer. After a max_tokens stop during thinking there may be no text at all. The partial reply is also billed as input on every later turn.

Send another request that asks the model to continue.

Good for long documents. But each continuation resends the whole history, so one answer can cost two or three calls, and in a test tool it hides that MAX_TOKENS is too small.

Why this choice

Tomás’s tool exists to show him how the model behaves. Complete turns keep that clear, and the printed stop_reason tells him something went wrong instead of hiding it.

When the choice changes. A feature that writes long output, such as a full reply draft, should raise max_tokens or continue automatically. The streaming documentation describes the continue pattern for newer models: a new user message holding the partial text and an instruction to go on 1.

Run it and read the usage line

Start with a plain chat, no ticket:

bash
python chat.py

Ask three or four short questions that build on each other, like the ones below. Then quit with Ctrl+C at the you> prompt.

Here is an illustrative session, made up to show the shape; your text and numbers will differ on every run:

text
Chatting with claude-haiku-5-5. Ctrl+C or Ctrl+D to quit.

you> Name a common reason parcels arrive late.
claude> A common reason is a missed handover between carriers...
[turn 1: in=52 out=184 (thinking=96) cost=$0.000097 total=$0.000097]

you> How would a support agent explain that to a customer?
claude> They could say the parcel is still moving, but...
[turn 2: in=250 out=212 (thinking=88) cost=$0.000131 total=$0.000228]

What to look for:

  • A pause after claude>. The model is thinking before it writes. You pay for that thinking and never see its text.
  • thinking inside out. Output minus thinking is the reply you read. On an easy question, thinking can be 0, because the model may skip it.
  • Turn 2’s in is close to turn 1’s in plus turn 1’s out plus your new question. Here 52 + 184 + about 14 = 250. Your counts may be off by a few tokens of message formatting.
  • The second answer uses the first. The model knows what “that” means only because the list sent turn 1 again.

If the first turn fails with anthropic.AuthenticationError, the key in your shell is missing or wrong. Run the export line again in the same terminal.

Make it stop early on purpose

Set MAX_TOKENS = 100 and ask for “ten ideas for faster support replies, with a sentence on each”. Look for a reply that stops mid-list, or no text at all, then [stop_reason=max_tokens: turn not kept. Ask for a shorter answer.]. Your next turn’s in does not include the cut-off reply. Set MAX_TOKENS back to 2000.

Then ask a long question and press Ctrl+C while text is printing. The chat stays open and the turn is dropped. Tokens produced before you pressed it may still be billed, and this script can’t show them, because usage arrives only at the end of the stream.

What happened on each turn

The Messages API is a : each request stands alone, and nothing about your chat is stored on the provider’s side. The conversation exists only in history, inside your running program. Quit, and it is gone.

So every request carries the whole list. On turn 3 it holds five entries: question 1, reply 1, question 2, reply 2 and question 3. Each reply includes its thinking block. Claude Haiku 5.5 keeps earlier turns’ thinking in the conversation and bills it as input 4. That is why turn 2’s input includes all of turn 1’s output, not only the text you read.

Streaming changed none of this. The SDK sent one request and the reply came back as on the same connection 1. You saw the first words sooner; the token counts are the same.

The history only grows, so the next question is how fast.

Predict the cost of turn 10

Change one thing: start the chat with your ticket.

bash
python chat.py ticket.txt
Check yourselfAnswer in your head first, then reveal
With a 4,000-token ticket in the system prompt, you ask a 20-token question on turn 1. About how many input tokens will turn 1 report, and will turn 10 cost more, less or the same?Show answerHide answer
About 4,170: the ticket and instructions go out with the question. Turn 10 costs about half again as much, because it also resends nine earlier questions, replies and their thinking. Not “the same, since each question is short”: the question is the smallest part of the input.

Ask ten short follow-ups about the ticket and watch in and cost on each usage line. Here is the arithmetic to compare against.

Assumptions for this run

system prompt 4,150 tokens (ticket 4,000 + instructions 150); per turn: question 20, thinking 200, reply 100

Estimates. Your thinking and reply lengths will differ; the usage line shows the real ones.

Turn 1

input = 4,150 + 20 = 4,170 tokens; cost = 4,170 × $0.10/M + 300 × $0.50/M = $0.000417 + $0.00015 = $0.000567

The ticket is already most of the bill.

Each later turn adds

20 question + 200 thinking + 100 reply = 320 input tokens

The previous turn is resent in full, thinking included.

Turn 10

input = 4,170 + 9 × 320 = 7,050 tokens; cost = $0.000705 + $0.00015 = $0.000855

About 1.5 times turn 1, for a question of the same size.

All ten turns

input = 10 × 4,170 + 320 × (0 + 1 + … + 9) = 41,700 + 14,400 = 56,100 tokens; cost = $0.00561 + $0.0015 = $0.0071

The ticket and instructions are 41,500 of those tokens: paid for ten times.

Diagram not drawn yet: cli-chat-input-per-turn.svg

Ten vertical bars, one per turn, for a chat about a 4,000-token ticket. Each bar has a grey bottom segment of 4,150 tokens labelled ticket and instructions, resent, the same height in every bar. Above it a blue segment labelled earlier turns grows by 320 tokens a turn, from 0 on turn 1 to 2,880 on turn 10. A thin top segment is the 20-token new question. Turn 1 is labelled 4,170 tokens, $0.000567; turn 10 is labelled 7,050 tokens, $0.000855.

Input per turn for a chat about one ticket, estimated. The ticket is resent every turn, and the earlier turns pile on top.

The same per-turn growth looks very different next to different starting sizes. All rows use the same per-turn assumptions:

Chat (estimates, Claude Haiku 5.5) Turn 1 input Turn 10 input Turn 10 ÷ turn 1 cost Ten turns
No ticket 50 2,930 about 2.9 $0.0030
Typical ticket, 4,000 tokens 4,170 7,050 about 1.5 $0.0071
Long ticket, 15,000 tokens 15,170 18,050 about 1.2 $0.018

Input grows by a fixed amount each turn, so the cost of one turn grows in a straight line. The total for a conversation grows faster: ten turns resend turn 1 nine times. Fractions of a cent don’t matter in a test tool. In a feature thousands of agents use all day, they would.

Three levers change the curve, each taught later. Prompt caching makes the repeated start of the history cheaper to resend; see KV cache and prompt caching. Trimming or summarizing old turns keeps the list short; see Agent memory. And a long enough chat stops fitting at all, covered in Context windows.

What to take away

  • A chat is a loop around a stateless call. Your list is the memory, and you choose what goes in it.
  • Keep only complete turns, and print stop_reason, so a cut-off reply is visible instead of quietly shaping later turns.
  • Usage arrives at the end of the stream. Log it every turn, thinking included, because it is the only true cost record.
  • Input per turn grows with the history. Anything at the start of the conversation, such as a ticket, is paid for on every turn.

Tomás now has a tool for trying questions, and the same loop becomes the summary call in Design: a ticket summary feature. One thing in the lab is still unexplained: why the reply arrives a few words at a time. Next-token prediction opens the model and shows how it writes.

Common mistakes

  • Creating the history list inside the loop. Every turn then sends only the newest question, and follow-ups fail without an error.
  • Appending the reply before checking stop_reason. A cut-off answer then shapes every later turn and is billed again as input each time.
  • Leaving the question in the list after Ctrl+C. The next request repeats the old question, and the model may answer both.
  • Reading usage inside the text loop. The counts are final only after the stream ends.

Check your understanding

Check yourselfAnswer in your head first, then reveal
Turn 3 reported in=4,810 and out=300. Your next question is about 20 tokens. Predict turn 4’s input.Show answerHide answer
About 5,130: everything turn 3 sent, plus its 300 output tokens, plus the new question. Not 4,830: the reply and its thinking are resent too, because Claude Haiku 5.5 bills earlier thinking as input.
Check yourselfAnswer in your head first, then reveal
A colleague moves history = [] inside the while loop. What breaks, and does the cost per turn go up or down?Show answerHide answer
The model forgets everything after each turn, so follow-ups like “what about the second one?” fail. Cost per turn goes down and stays flat, because each request carries only one question. Not an API error: the requests are valid, just missing the conversation.
Check yourselfAnswer in your head first, then reveal
With MAX_TOKENS at 100, a turn shows out=100 and thinking=100. What did you see on screen, and what is in the history now?Show answerHide answer
Probably nothing after “claude>”, because thinking used the whole budget before any text. The stop_reason was max_tokens, so the script dropped the question and the history is unchanged. You still paid for 100 output tokens, about $0.00005.
Check yourselfAnswer in your head first, then reveal
Why does turn 10 cost about 2.9 times turn 1 in a plain chat, but only about 1.2 times with a 15,000-token ticket?Show answerHide answer
Both chats add the same 320 tokens of history per turn. With the ticket, that growth is small next to the 15,000 tokens resent every turn; in a plain chat, the history soon becomes most of the input.

Summary

  1. A chat on a stateless API is a loop: append your question to a list, send the whole list, append the finished reply. Each turn costs more because the list, earlier thinking included, is resent and billed as input every time.
  2. client.messages.stream with text_stream prints the reply as it arrives; get_final_message() gives stop_reason and usage once the stream ends. Streaming changes when you see text, not what you pay.
  3. Print input, output and thinking tokens and the cost after every turn: input × input price + output × output price, with thinking counted as output.
  4. Keep only complete turns: when stop_reason isn’t end_turn, or you press Ctrl+C, drop the question and say so.
  5. On Claude Haiku 5.5, as of October 2026, ten turns about a 4,000-token ticket cost about $0.007 (estimate), with turn 10 about 1.5 times turn 1.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.