Mert Özen Further With Every Line
Artificial Intelligence Building Applications with the Claude API — #02

The Claude Messages API in Depth: Roles, Content Blocks, Stop Reasons and Usage

Mert Özen Nov 15, 2026 15 min 5 views
The Claude Messages API in Depth: Roles, Content Blocks, Stop Reasons and Usage

The Claude Messages API is stateless, so the whole history is sent on every request; the response is a list of blocks, and stop_reason and usage should be read on every response. Mid-conversation system messages, text/image/PDF blocks, thinking counted in max_tokens, refusal categories, count_tokens, Bookstore's support bot and invoice PDF analysis.

Version note: The code in this post was built with .NET 10, Anthropic C# SDK 13.8.0 and Claude API version 2026-10-21, and its unit tests were run. Messages API behavior, the stop_reason values, PDF limits and token counting were checked on 15 November 2026 against the platform.claude.com documentation. This post is based on the official documentation; the response samples are the sample responses from the documentation.

Guess First

Gül from customer support comes with three complaints about Bookstore's support bot. Some answers stop in the middle of a sentence. Some customers see an empty bubble. And the bot asks a customer who gave the order number two messages earlier, "what is your order number?" The code for Bookstore, the online bookstore used as the shared example across this site's series, takes message.Content[0] as text as in the first post, and sends only the last message on every request.

What is your guess? Do all three complaints come from the same place? In this post we look at the shape of a Messages API response, why the conversation history is sent on every request, what to do with each value of stop_reason and what the numbers in usage tell you. The provider-neutral logic of message roles and system prompts was covered in the fifth post of the LLM Fundamentals series, and storing chat history in the fifth post of the AI with .NET series; here we look at what is specific to Claude.

A Stateless Conversation: Sending the History on Every Request

The Messages API is stateless. The "Using the Messages API" page says it plainly: you send the full conversation on every request. That is why the bot forgot the order number; the API does not remember the previous request, and everything it needs to remember must be in messages. According to the documentation, the assistant messages in the history do not have to come from Claude either; you can put in a synthetic assistant message you wrote yourself.

This is how the roles map in Claude. system is a separate field at the request level, not inside messages. Inside messages, user and assistant alternate. In SDK 13 these are one class per role: UserMessageParam, AssistantMessageParam. The third one is new: SystemMessageParam, a system message in the middle of the conversation. According to the documentation, it has the same authority as the top-level system, but because it is appended at the end of the history, it does not invalidate a prefix that was cached earlier. The rule: it must come right after a user turn, and then either messages ends or an assistant turn follows; it cannot be the first entry of messages. The feature works on Opus 5.5, Sonnet 5.5, Haiku 5.5 and the Fable models. In Bookstore we will use it for operator notes: when Gül leaves a note for a customer, "has an open complaint, offer a call back", the note goes in as a system message right after the customer's last message. Because the note comes from the application rather than the customer, it has higher priority than a user message in the model's eyes.

Sending the history every time has a cost: input tokens grow as the conversation gets longer. We will bring that down with prompt caching in the seventh post. The documentation says the message threads feature, in beta, can also keep the conversation on the server; in this series we store the history ourselves.

Content Blocks: Text, Image and Document

In a request, the content of a message can be a single string or a list of blocks. Text, image and document blocks are the most common. Images have base64, url and file sources; the supported types are image/jpeg, image/png, image/gif and image/webp. The file source points to a file uploaded with the Files API, which we will see in the eighth post.

On the response side, content is always a list of blocks. Besides text, blocks such as thinking (the model's reasoning), tool_use (the model's request to call a tool) and server tool results can arrive. Adaptive thinking is on in the current models, so the first block of the answer is often thinking. When display is omitted, the default, the thinking field of that block is an empty string. That was Gül's empty bubble: the code took the empty thinking block for text and showed it.

A technique used earlier is gone: assistant prefill, sending the last message as a half-written assistant message so that you write the start of the answer. According to the documentation, a request with prefill gets a 400 on Claude 4.6 and later models. If you want to control the shape of the output, you will use structured outputs from the sixth post or the system prompt.

stop_reason: What to Do with Each Value

Every successful response has a stop_reason that says why the model stopped. It is not an error; it is part of a response that returns HTTP 200. The list on the "Stop reasons and fallback" page reads like this for Bookstore's needs:

ValueWhenWhat to do
end_turnThe model finished the answer naturallyUse the answer
max_tokensThe max_tokens limit was reachedThe answer is cut off; raise the limit or continue it
stop_sequenceOne of the stop_sequences was producedRead which one from the stop_sequence field
tool_useThe model wants to call a toolRun the tool and return the result (fifth post)
pause_turnA server tool loop hit its iteration limitSend the response back as is to continue
refusalThe model declined the requestRead stop_details and give the user a fixed answer
model_context_window_exceededThe answer filled the context windowTreat the answer as cut off

Gül's first complaint, the cut-off answers, was most likely max_tokens. There is a point specific to Claude here: thinking tokens count toward max_tokens too. The thinking documentation says so: thinking tokens are billed as output even when the text is not returned to you, and they count within max_tokens. The 400 tokens we gave the summary in the first post can be short for a model that thinks. That is why the support bot gets 2048.

The shape of a refusal, as in the sample response in the documentation:

{
  "id": "msg_01XFUDYJgAACzvnptvVoYEL",
  "type": "message",
  "role": "assistant",
  "model": "claude-fable-5",
  "content": [],
  "stop_reason": "refusal",
  "stop_details": {
    "type": "refusal",
    "category": "cyber",
    "explanation": "This request was declined because it could enable cyber harm."
  },
  "usage": {
    "input_tokens": 412,
    "output_tokens": 0
  }
}

The stop_details.category field says which policy area the refusal came from: cyber, bio, frontier_llm, reasoning_extraction or general_harms; null if no category matches. The explanation text is not stable; the documentation recommends showing it as is rather than parsing it. Billing depends on the category too: a refusal that arrives before any output is billed when the category is bio, frontier_llm or reasoning_extraction, and not billed in the others; a refusal in the middle of a stream also bills the output streamed so far. Either way the request counts against the rate limit. reasoning_extraction is an interesting category: if you ask the model to write its reasoning into the answer text (such as a reasoning field in JSON), this refusal can come. If you want to see the reasoning, read the thinking blocks.

The documentation also describes retrying a refused request on another model. On the Claude API, fallbacks: "default" with the server-side-fallback-2026-07-01 beta header does this on the server within a single request. Haiku 5.5, which Bookstore uses, has no server-side fallback; for it the documentation recommends the SDK middleware or a retry you write yourself. We do not do this in the support bot: a refused support request should go to a person anyway.

Reading the Response in One Place

To avoid repeating these decisions in every service, we wrote a small helper that reads the response:

using Anthropic.Models.Messages;

namespace Bookstore.Api.Ai;

public enum ReplyOutcome
{
    Complete,
    Truncated,
    Refused,
    NeedsTools,
    Paused,
    ContextWindowFull,
    Unknown,
}

public sealed record TokenUsage(long Input, long Output, long CacheRead, long CacheWrite, long Thinking);

public sealed record ClaudeReply(
    ReplyOutcome Outcome,
    string Text,
    string? RefusalCategory,
    TokenUsage Usage,
    IReadOnlyList<ContentBlockParam> AssistantContent);

public static class ClaudeReplyReader
{
    public static ClaudeReply Read(Message message)
    {
        ReplyOutcome outcome = message.StopReason?.Value() switch
        {
            StopReason.EndTurn or StopReason.StopSequence => ReplyOutcome.Complete,
            StopReason.MaxTokens => ReplyOutcome.Truncated,
            StopReason.Refusal => ReplyOutcome.Refused,
            StopReason.ToolUse => ReplyOutcome.NeedsTools,
            StopReason.PauseTurn => ReplyOutcome.Paused,
            StopReason.ModelContextWindowExceeded => ReplyOutcome.ContextWindowFull,
            _ => ReplyOutcome.Unknown,
        };

        // Never assume content[0] is text: thinking or tool_use blocks can come first.
        string text = string.Concat(message.Content
            .Select(block => block.TryPickText(out TextBlock? textBlock) ? textBlock.Text : string.Empty));

        Usage usage = message.Usage;
        TokenUsage tokens = new(
            usage.InputTokens,
            usage.OutputTokens,
            usage.CacheReadInputTokens ?? 0,
            usage.CacheCreationInputTokens ?? 0,
            usage.OutputTokensDetails?.ThinkingTokens ?? 0);

        // Echo the assistant turn back unchanged, thinking blocks included.
        List<ContentBlockParam> assistantContent = message.Content
            .Select(block => new ContentBlockParam(block.Json))
            .ToList();

        return new ClaudeReply(outcome, text.Trim(), message.StopDetails?.Category?.Raw(), tokens, assistantContent);
    }
}

Note three things. StopReason?.Value() does not throw when a value the SDK does not know arrives; it returns an undefined enum value, which is why the _ branch of the switch exists. The streaming documentation also says new event types can be added and code should pass over unknown ones gracefully; the same logic applies here. Text is joined by checking the block type. And when the assistant turn is added to the history, the response blocks are converted back as they are with new ContentBlockParam(block.Json). This pattern comes from the C# sample in the documentation. The thinking documentation recommends sending thinking blocks back unchanged throughout the conversation; the API decides which ones it needs and bills input tokens only for the blocks it shows to the model.

The tests use the sample responses from the documentation and small fixtures derived from them; we exercise every branch of the reader without going to the API:

using System.Text.Json;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;

namespace Bookstore.Api.Tests;

public sealed class ClaudeReplyReaderTests
{
    private static Message Parse(string json) => JsonSerializer.Deserialize<Message>(json)!;

    [Fact]
    public void Read_DocumentedRefusal_ReturnsRefusedWithCategory()
    {
        // The refusal example from the "Refusals and fallback" page.
        Message message = Parse("""
            {"id":"msg_01XFUDYJgAACzvnptvVoYEL","type":"message","role":"assistant","model":"claude-fable-5","content":[],
             "stop_reason":"refusal",
             "stop_details":{"type":"refusal","category":"cyber","explanation":"This request was declined because it could enable cyber harm."},
             "usage":{"input_tokens":412,"output_tokens":0}}
            """);

        ClaudeReply reply = ClaudeReplyReader.Read(message);

        Assert.Equal(ReplyOutcome.Refused, reply.Outcome);
        Assert.Equal("cyber", reply.RefusalCategory);
        Assert.Equal(412, reply.Usage.Input);
        Assert.Empty(reply.Text);
    }

    [Fact]
    public void Read_ThinkingBeforeText_ReturnsOnlyTheText()
    {
        Message message = Parse("""
            {"id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-5-5",
             "content":[{"type":"thinking","thinking":"","signature":"sig"},{"type":"text","text":"Your order ships tomorrow."}],
             "stop_reason":"end_turn","usage":{"input_tokens":120,"output_tokens":45,"output_tokens_details":{"thinking_tokens":30}}}
            """);

        ClaudeReply reply = ClaudeReplyReader.Read(message);

        Assert.Equal(ReplyOutcome.Complete, reply.Outcome);
        Assert.Equal("Your order ships tomorrow.", reply.Text);
        Assert.Equal(30, reply.Usage.Thinking);
        Assert.Equal(2, reply.AssistantContent.Count);
    }

    [Fact]
    public void Read_MaxTokens_ReturnsTruncated()
    {
        Message message = Parse("""
            {"id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-5-5",
             "content":[{"type":"text","text":"Returns are accepted within"}],
             "stop_reason":"max_tokens","usage":{"input_tokens":200,"output_tokens":2048}}
            """);

        Assert.Equal(ReplyOutcome.Truncated, ClaudeReplyReader.Read(message).Outcome);
    }

    [Fact]
    public void Read_UnknownStopReason_ReturnsUnknown()
    {
        Message message = Parse("""
            {"id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-5-5","content":[],
             "stop_reason":"a_future_reason","usage":{"input_tokens":1,"output_tokens":0}}
            """);

        Assert.Equal(ReplyOutcome.Unknown, ClaudeReplyReader.Read(message).Outcome);
    }
}

The usage Fields and Counting Tokens

usage is where the bill comes from. The SDK's Usage class has these fields:

FieldMeaning
input_tokensInput tokens that were neither read from nor written to the cache
output_tokensGenerated tokens, thinking included
cache_creation_input_tokensInput tokens written to the cache (seventh post)
cache_read_input_tokensInput tokens read from the cache
output_tokens_details.thinking_tokensHow many of the output tokens were thinking

There is no separate price for thinking; thinking_tokens is only a breakdown for observation. When you compute the cost of a request, you multiply the output price by output_tokens. We will take up prices and models in the third post.

To learn the token count before sending a request, there is client.Messages.CountTokens. According to the token counting page, this endpoint is free, has its own rate limit independent of the Messages API (5,000 to 20,000 requests per minute depending on the tier) and its result is an estimate. Requests with server tools or the MCP connector, and image and document blocks with a url or file source, cannot be counted; to count a PDF you have to send it as base64. There is also a tokenizer note: Claude Opus 4.7 and later models use a new tokenizer, and the same text takes about 30 percent more tokens than on older models. Do not use counts measured on an older model to estimate the cost on a newer one. We saw in the third post of the LLM Fundamentals series why Turkish takes more tokens than English; counting the same Bookstore text in both languages with CountTokens is a useful measurement when planning a budget.

Bookstore's Support Bot: SupportChatService

The layer that stores the history is a simple interface:

using System.Collections.Concurrent;
using Anthropic.Models.Messages;

namespace Bookstore.Api.Support;

public interface IConversationStore
{
    IReadOnlyList<MessageParam> Get(Guid conversationId);
    void Save(Guid conversationId, IReadOnlyList<MessageParam> messages);
}

// The Messages API is stateless: the whole history is sent on every request.
// In production this lives in a database; here it stays in memory.
public sealed class InMemoryConversationStore : IConversationStore
{
    private readonly ConcurrentDictionary<Guid, IReadOnlyList<MessageParam>> _conversations = new();

    public IReadOnlyList<MessageParam> Get(Guid conversationId) =>
        _conversations.TryGetValue(conversationId, out IReadOnlyList<MessageParam>? messages) ? messages : [];

    public void Save(Guid conversationId, IReadOnlyList<MessageParam> messages) =>
        _conversations[conversationId] = messages;
}

The service adds the customer's message to the history, puts the operator note after it if there is one, reads the response and updates the history depending on the outcome:

using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Support;

public sealed record SupportAnswer(string Text, ReplyOutcome Outcome, TokenUsage Usage);

public sealed class SupportChatService(
    IAnthropicClient claude,
    IConversationStore conversations,
    IOptions<ClaudeOptions> options,
    ILogger<SupportChatService> logger)
{
    public const string SystemPrompt = """
        You are the customer support assistant of Bookstore, an online bookstore.
        Answer in the customer's language. Keep answers under 120 words.
        You can explain orders, shipping, returns and payments in general terms.
        You cannot see order data yet; if the customer needs a specific order checked, say that a team member will follow up.
        Returns are accepted within 14 days of delivery for unused books.
        """;

    // Room for adaptive thinking plus a 120-word answer; thinking counts toward max_tokens.
    public const int MaxTokens = 2048;

    private const string RefusalText = "I can't help with this request here. A member of our support team will contact you.";

    public static MessageCreateParams BuildRequest(IReadOnlyList<MessageParam> history, ClaudeOptions settings) => new()
    {
        Model = settings.Model,
        MaxTokens = MaxTokens,
        System = SystemPrompt,
        Messages = history,
    };

    public async Task<SupportAnswer> SendAsync(
        Guid conversationId, string customerText, string? operatorNote, CancellationToken cancellationToken)
    {
        List<MessageParam> history = [.. conversations.Get(conversationId), new UserMessageParam { Content = customerText }];

        if (operatorNote is not null)
        {
            // Goes right after the user turn; earlier turns stay byte-identical for the prompt cache.
            history.Add(new SystemMessageParam { Content = operatorNote });
        }

        Message message = await claude.Messages.Create(BuildRequest(history, options.Value), cancellationToken);
        ClaudeReply reply = ClaudeReplyReader.Read(message);

        logger.LogInformation(
            "Support {ConversationId}: outcome={Outcome} input={Input} output={Output} thinking={Thinking}",
            conversationId, reply.Outcome, reply.Usage.Input, reply.Usage.Output, reply.Usage.Thinking);

        switch (reply.Outcome)
        {
            case ReplyOutcome.Complete:
            case ReplyOutcome.Truncated:
                history.Add(new AssistantMessageParam { Content = reply.AssistantContent.ToList() });
                conversations.Save(conversationId, history);
                string text = reply.Outcome == ReplyOutcome.Truncated ? reply.Text + " [...]" : reply.Text;
                return new SupportAnswer(text, reply.Outcome, reply.Usage);

            case ReplyOutcome.Refused:
                // The refused turn is not saved; the customer gets a fixed answer and a person takes over.
                logger.LogWarning("Support {ConversationId} refused: category={Category}", conversationId, reply.RefusalCategory);
                return new SupportAnswer(RefusalText, reply.Outcome, reply.Usage);

            default:
                logger.LogError("Support {ConversationId}: unexpected outcome {Outcome}", conversationId, reply.Outcome);
                return new SupportAnswer(RefusalText, reply.Outcome, reply.Usage);
        }
    }
}

There are three decisions here. If the answer is complete or cut off, the assistant turn is added to the history; a cut-off answer goes to the user with a "[...]" mark and ends up in the log. On a refusal the history is not saved, so that the refused question is not carried to the next request. On an unexpected outcome (this service defines no tools, so tool_use should not come), the customer again gets a fixed answer and the error goes to the log.

The body of a request with an operator note, from RawBodyData (with the system prompt field left out), looks like this:

{
  "model": "claude-haiku-5-5",
  "max_tokens": 2048,
  "messages": [
    {
      "role": "user",
      "content": "Where is my order?"
    },
    {
      "role": "assistant",
      "content": "Could you share the order number?"
    },
    {
      "role": "user",
      "content": "It is 1042."
    },
    {
      "role": "system",
      "content": "This customer has an open complaint. Offer a call back."
    }
  ]
}

The system message sits right after the last user turn. We pinned that with a test too; it checks that the order of the roles in the body is user, assistant, user, system.

Having the Order Invoice PDF Read

A question Gül gets often: "Which books are on the invoice, and how much is the shipping fee?" We send the invoice PDF as a document block:

using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Support;

public sealed record InvoiceAnswer(string Text, ReplyOutcome Outcome, long CountedInputTokens, TokenUsage Usage);

public sealed class InvoiceAnalyzer(IAnthropicClient claude, IOptions<ClaudeOptions> options)
{
    public const long MaxInputTokens = 50_000;

    public static List<ContentBlockParam> BuildContent(byte[] pdf, string question) =>
    [
        // The documentation recommends putting the PDF before the text.
        new DocumentBlockParam { Source = new Base64PdfSource { Data = Convert.ToBase64String(pdf) } },
        new TextBlockParam($"This is a Bookstore order invoice. {question} Answer only from the invoice."),
    ];

    public async Task<InvoiceAnswer?> AnalyzeAsync(byte[] pdf, string question, CancellationToken cancellationToken)
    {
        ClaudeOptions settings = options.Value;
        List<ContentBlockParam> content = BuildContent(pdf, question);

        // count_tokens is free and has its own rate limit; it is an estimate.
        MessageTokensCount count = await claude.Messages.CountTokens(new MessageCountTokensParams
        {
            Model = settings.Model,
            Messages = [new UserMessageParam { Content = content }],
        }, cancellationToken);

        if (count.InputTokens > MaxInputTokens)
        {
            return null;
        }

        Message message = await claude.Messages.Create(new MessageCreateParams
        {
            Model = settings.Model,
            MaxTokens = settings.MaxTokens,
            Messages = [new UserMessageParam { Content = content }],
        }, cancellationToken);

        ClaudeReply reply = ClaudeReplyReader.Read(message);
        return new InvoiceAnswer(reply.Text, reply.Outcome, count.InputTokens, reply.Usage);
    }
}

According to the PDF support page (15 November 2026), the total size of a request on the Claude API is at most 128 MB and the page limit is 600; 100 pages when the request's context window is under 1M tokens. Encrypted PDFs are not supported. Each page is converted into an image and the page text is given alongside the image, so invoices with tables and charts can be read too. The cost follows from that: the documentation says each page typically uses 1,500 to 3,000 text tokens, plus image tokens. That is why we count with CountTokens first and reject files above 50,000 tokens. Following the documentation's advice, we put the PDF before the question text. One more API version note: in 2026-10-21, putting a document block in an assistant message gives a 400; documents must always go in a user message.

The endpoint and a request that calls it:

namespace Bookstore.Api.Support;

public static class SupportEndpoints
{
    public sealed record CustomerMessage(string Text, string? OperatorNote);

    public static IEndpointRouteBuilder MapSupportEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder support = app.MapGroup("/api/support");

        support.MapPost("/conversations/{conversationId:guid}/messages", async (
            Guid conversationId, CustomerMessage body, SupportChatService chat, CancellationToken ct) =>
            TypedResults.Ok(await chat.SendAsync(conversationId, body.Text, body.OperatorNote, ct)));

        support.MapPost("/invoices/analyze", async Task<IResult> (
            IFormFile invoice, string question, InvoiceAnalyzer analyzer, CancellationToken ct) =>
        {
            if (invoice.ContentType != "application/pdf")
            {
                return TypedResults.BadRequest("Only PDF invoices are accepted.");
            }

            using MemoryStream buffer = new();
            await invoice.CopyToAsync(buffer, ct);
            InvoiceAnswer? answer = await analyzer.AnalyzeAsync(buffer.ToArray(), question, ct);
            return answer is null
                ? TypedResults.Problem("The invoice is too large to analyze.", statusCode: StatusCodes.Status413PayloadTooLarge)
                : TypedResults.Ok(answer);
        }).DisableAntiforgery();

        return app;
    }
}
curl -X POST http://localhost:5231/api/support/invoices/analyze?question=Which%20books%20are%20on%20this%20invoice%3F \
  -F "invoice=@invoice-1042.pdf;type=application/pdf"

If you will use the same invoice for many questions, uploading it once with the Files API is better than sending base64 every time; we will do that in the eighth post.

All the tests:

dotnet test

  Bookstore.Api -> src/Bookstore.Api/bin/Debug/net10.0/Bookstore.Api.dll
  Bookstore.Api.Tests -> tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll

Passed!  - Failed:     0, Passed:     7, Skipped:     0, Total:     7, Duration: 35 ms - Bookstore.Api.Tests.dll (net10.0)

In Production

A growing history. The whole history goes on every turn, and input tokens grow with every message. Long conversations need a limit on the history (the last N turns, or a summary of the old turns) and prompt caching. Watching usage.input_tokens per conversation shows which conversations are getting expensive.

Choosing max_tokens. The limit is not on the answer but on thinking plus the answer. Too low and you get more answers cut off with max_tokens; too high and an answer can take longer than you expect. The documentation recommends streaming for large max_tokens values; that is the topic of the fourth post.

Measuring refusals. Refusals count against the rate limit and are billed in some categories. Keep a counter per category; if reasoning_extraction goes up, the problem is in the prompt and changing the model will not fix it.

Personal data. The invoice PDF contains the customer's name and address. We discussed the data that goes to the provider in the third post of the LLM Application Security series; before sending the PDF, think about whether it is really needed, and do not keep this data for long when you store the history.

Common Pitfalls

Taking content[0] for text. The first block can be thinking, tool_use or another type. Always read by type.

Not checking stop_reason. An answer cut off by max_tokens is shown like a proper answer, and a refusal shows up as an empty bubble. Look at stop_reason on every response.

Setting max_tokens without counting thinking. 400 tokens looks enough for two sentences, but a model that thinks spends part of that limit on thinking.

Sending only the last message. The API is stateless; without the history the model does not know the previous turn.

Reducing the assistant answer to text before sending it back. Dropping the thinking blocks and keeping only the text goes against the documentation's advice. Send the blocks back as they are with block.Json.

Forcing the format with prefill. It gives a 400 on current models. Use structured outputs for format.

A Small Experiment

★☆☆ Add a fixture with the stop_reason value pause_turn to ClaudeReplyReaderTests and check that it returns ReplyOutcome.Paused.

★★☆ Change InMemoryConversationStore so that it keeps only the last 10 messages of the history. Careful: the cut history must not start with an assistant message, and a system message must not be separated from the user turn before it. Show this with a test.

★★★ Write an endpoint (/api/ai/count-tokens) that counts the same Bookstore return policy text in Turkish and English with two separate MessageCountTokensParams. Measure the input_tokens difference between the two languages with your own key and compare it with the local tokenizer results from LLM Fundamentals.

In this post we kept the model fixed at claude-haiku-5-5. In the third post we move to model choice: the price and behavior differences between Opus 5.5, Sonnet 5.5, Haiku 5.5 and Fable 5.2, tuning quality and cost with output_config.effort, adaptive thinking, and a small evaluation harness for a task that classifies Bookstore's return requests.