DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Rust: Build Anthropic Content Blocks Without Reading the API Docs Twice

The image inside the tool result took 45 minutes to get right.

The Anthropic API has a specific nested structure for content blocks. An image is not just a URL or a base64 string. It is a JSON object with a type field, a source field, a nested type inside source, a media_type, and then the data. When that image appears inside a tool_result block, there is another nesting layer. Getting the shape wrong returns a 400 with a validation error that points at the wrong field.

After reading the docs a second time and running the request through a JSON inspector, the shape was correct. That 45 minutes should not have been necessary.

llm-content-blocks-rs makes each block type a one-function call.

The shape of the fix

Text block:

use llm_content_blocks::text;

let block = text("Here is the analysis.");
// {"type":"text","text":"Here is the analysis."}
Enter fullscreen mode Exit fullscreen mode

Image from raw bytes:

use llm_content_blocks::image_bytes;

let png_bytes: Vec<u8> = std::fs::read("chart.png").unwrap();
let block = image_bytes(&png_bytes, "image/png");
// {"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0K..."}}
Enter fullscreen mode Exit fullscreen mode

Image from URL:

use llm_content_blocks::image_url;

let block = image_url("https://example.com/chart.png");
// {"type":"image","source":{"type":"url","url":"https://example.com/chart.png"}}
Enter fullscreen mode Exit fullscreen mode

Tool use (model calling a tool):

use llm_content_blocks::tool_use;

let block = tool_use(
    "toolu_01abc",
    "get_weather",
    serde_json::json!({"location": "San Francisco"}),
);
Enter fullscreen mode Exit fullscreen mode

Tool result (returning output to the model):

use llm_content_blocks::tool_result;

let block = tool_result(
    "toolu_01abc",
    vec![text("Temperature: 72F"), image_bytes(&chart_bytes, "image/png")],
);
Enter fullscreen mode Exit fullscreen mode

That last one is the case that took 45 minutes to write by hand.

What it does NOT do

  • It does not send requests to the Anthropic API. It builds the JSON shapes. You pass those shapes to your HTTP client.
  • It does not validate that tool_id strings match between a tool_use block and its tool_result. Mismatched IDs will fail at the API layer, not here.
  • It does not handle streaming. Content blocks are for constructing complete request and response payloads.
  • It does not include a message builder. You get block constructors. Assembling them into a messages array is your responsibility.

Inside the lib

Base64 encoding happens inside the image_bytes builder. You pass raw bytes. The builder encodes them and sets media_type. You do not touch base64.

This prevents a specific mistake. When working with image data in Rust, it is easy to end up with bytes that are already base64-encoded strings at some point in the pipeline. If the caller is responsible for encoding, they may encode bytes that are already encoded. The result is double-encoded data. The API accepts it without error but the model cannot interpret the image. The bug is silent and confusing.

By putting encoding inside the builder, the contract is clear: pass raw bytes, get a correctly encoded block. If you have a base64 string already, there is a separate image_base64 function that accepts pre-encoded data directly.

use llm_content_blocks::image_base64;

let already_encoded = "iVBORw0K..."; // already base64
let block = image_base64(already_encoded, "image/png");
Enter fullscreen mode Exit fullscreen mode

Two functions, two contracts, no ambiguity.

The underlying representation is serde_json::Value. Every builder returns a Value. You can mix blocks from this crate with hand-constructed Value objects in the same message array. There is no custom type that requires conversion before passing to serde_json::to_string or reqwest.

Dependencies: serde, serde_json, base64. No Anthropic SDK. No HTTP client. No async runtime.

// total deps in Cargo.lock additions: 3
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.21"
Enter fullscreen mode Exit fullscreen mode

When useful

You are building an Anthropic API client in Rust without the official SDK. The SDK handles block construction for you, but if you are working with reqwest directly or building a custom client, you need these shapes.

You are writing tests for an agent that receives multimodal responses. You need to construct realistic tool_result payloads with images for your test fixtures. Building those by hand in JSON is tedious. Using builders keeps test setup readable.

You have an agent that generates screenshots or charts and returns them inside tool results. The image_bytes + tool_result combination is the direct solution.

You are working on a library that abstracts over multiple LLM providers and you need to translate internal representation to Anthropic wire format. The builders give you a clean serialization layer.

When NOT useful

If you are using the official anthropic-rs SDK or another high-level Rust client, those clients construct content blocks internally. You do not need this crate on top of them.

If you are building for OpenAI or another provider, the block shapes are different. This crate is Anthropic-specific.

If you only ever send text messages with no images or tool calls, the text() builder is a trivial wrapper that adds no meaningful value over constructing the JSON directly.

Install

[dependencies]
llm-content-blocks = "0.1"
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

Siblings

Lib Boundary Repo
llm-content-blocks (Python) Same builders, Python API MukundaKatta/llm-content-blocks
agentprompt-rs Jinja2-based message builder MukundaKatta/agentprompt-rs
claude-stream-rs Anthropic SSE response parser MukundaKatta/claude-stream-rs
agentvet-rs Validates tool args before calling MukundaKatta/agentvet-rs
tool-schema-from-fn (Python) fn signature to Anthropic tool schema MukundaKatta/tool-schema-from-fn

What is next

A MessageBuilder that wraps a Vec<Value> of content blocks and produces the full messages array would complete the round trip. Right now you assemble the array yourself. A builder with push_block() and build() methods would make the construction chainable and remove the manual serde_json::json!([...]) wrapping.

Document block support is on the list. Anthropic supports document blocks for PDF and text file context. The shape is similar to image blocks. Adding document_bytes() and document_url() would cover that case.

Source: MukundaKatta/llm-content-blocks-rs


Part of the Hermes Agent Challenge sprint.

Top comments (0)