DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Build Anthropic Content Blocks Without Wrestling With Dicts

Every Anthropic API call involves building message content blocks. Text blocks. Tool use blocks. Tool result blocks. Image blocks. Each has a specific shape that must be exactly right or the API rejects it.

Most teams build these as raw dicts, which means typos, missing fields, and subtle schema violations that only surface at runtime.

llm-content-blocks provides a typed builder for Anthropic content blocks.


The Shape of the Fix

from llm_content_blocks import (
    text_block, tool_use_block, tool_result_block,
    image_block, user_message, assistant_message,
)

# Instead of raw dicts:
messages = [
    user_message([
        text_block("Analyze this image and search for more context."),
        image_block(base64_data=img_b64, media_type="image/png"),
    ]),
    assistant_message([
        tool_use_block(id="call-001", name="web_search", input={"query": "context about image"}),
    ]),
    user_message([
        tool_result_block(tool_use_id="call-001", content="Search results: ..."),
    ]),
]

response = client.messages.create(
    model="claude-sonnet-4-6",
    messages=messages,
    max_tokens=1024,
)
Enter fullscreen mode Exit fullscreen mode

Typed functions. No raw dict mutation. Schema violations caught at call time, not at API call time.


What It Does NOT Do

llm-content-blocks does not support OpenAI or other providers. It is Anthropic-specific.

It does not validate that the sequence of messages is logically correct. You can build a tool_use_block without a following tool_result_block and the builder will not complain. Use agent-message-sanitize for structural validation.

It does not handle streaming. The builder constructs message objects; what you do with them (streaming vs non-streaming) is separate.


Inside the Library

Each function returns a typed dict that matches Anthropic's API schema:

from typing import TypedDict

class TextBlock(TypedDict):
    type: Literal["text"]
    text: str

class ToolUseBlock(TypedDict):
    type: Literal["tool_use"]
    id: str
    name: str
    input: dict

class ToolResultBlock(TypedDict):
    type: Literal["tool_result"]
    tool_use_id: str
    content: str | list

def text_block(text: str) -> TextBlock:
    return {"type": "text", "text": text}

def tool_use_block(id: str, name: str, input: dict) -> ToolUseBlock:
    return {"type": "tool_use", "id": id, "name": name, "input": input}

def tool_result_block(
    tool_use_id: str,
    content: str | list,
    is_error: bool = False,
) -> ToolResultBlock:
    result = {"type": "tool_result", "tool_use_id": tool_use_id, "content": content}
    if is_error:
        result["is_error"] = True
    return result
Enter fullscreen mode Exit fullscreen mode

Helper wrappers for complete messages:

def user_message(content: str | list) -> dict:
    if isinstance(content, str):
        content = [text_block(content)]
    return {"role": "user", "content": content}

def assistant_message(content: str | list) -> dict:
    if isinstance(content, str):
        content = [text_block(content)]
    return {"role": "assistant", "content": content}
Enter fullscreen mode Exit fullscreen mode

Caching support: text_block(text, cache=True) adds "cache_control": {"type": "ephemeral"} for prompt caching. This avoids the repetitive boilerplate of adding cache control manually to every block.


When to Use It

Use it for any code that builds Anthropic API messages programmatically. Tool-calling agents, multi-turn conversation builders, image-processing pipelines.

The primary benefit is IDE support. With raw dicts, your IDE cannot tell you that "type": "tool_use" is missing or that "input" should be a dict, not a string. With typed builders, those errors surface in the editor before the code runs.

Use it for code review and maintainability. tool_use_block(id=call.id, name=call.name, input=call.input) is more readable than {"type": "tool_use", "id": call.id, "name": call.name, "input": call.input}.

Skip it for simple single-turn conversations with only text messages. {"role": "user", "content": "Hello"} does not need a builder.


Install

pip install git+https://github.com/MukundaKatta/llm-content-blocks
Enter fullscreen mode Exit fullscreen mode
from llm_content_blocks import (
    text_block, tool_use_block, tool_result_block,
    user_message, assistant_message,
)

def handle_tool_calls(messages: list, response) -> list:
    """Process tool calls and add results to message history."""
    tool_blocks = []
    result_blocks = []

    for block in response.content:
        if block.type == "tool_use":
            tool_blocks.append(
                tool_use_block(id=block.id, name=block.name, input=block.input)
            )
            result = execute_tool(block.name, block.input)
            result_blocks.append(
                tool_result_block(
                    tool_use_id=block.id,
                    content=str(result),
                    is_error="error" in result,
                )
            )

    return messages + [
        assistant_message(tool_blocks),
        user_message(result_blocks),
    ]
Enter fullscreen mode Exit fullscreen mode

Sibling Libraries

Library What it solves
agent-message-sanitize Validate and fix message list structure
agent-message-window Trim message list to fit context window
conversation-codec Persist message lists to JSONL files
llm-context-rotate Stateful rolling chat history management
agentvet Validate tool call arguments from tool_use blocks

The message assembly pipeline: llm-content-blocks builds typed message objects, agent-message-sanitize validates the structure, agent-message-window trims to context limit, conversation-codec persists for next session.


What's Next

Pydantic models as an alternative to TypedDict. TextBlock as a Pydantic BaseModel would give you validation on instantiation, not just type hints. The tradeoff: adds pydantic as a dependency.

OpenAI block builders: the current library is Anthropic-only. OpenAI has a similar message format with slightly different field names. A unified builder interface with provider-specific implementations would cover both.

Caching shorthand: cached_text_block(text) as a shortcut for text_block(text, cache=True). Small but reduces visual noise in code that uses caching heavily.


Built as part of the agent-stack family: composable Python primitives for production LLM agents.

Top comments (0)