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,
)
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
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}
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
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),
]
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)