GitHub

A Rust SDK for Anthropic's Claude API that can't panic on you.

The full Claude API in idiomatic Rust — streaming, tool use, extended thinking, prompt caching, and batches — built on one hard guarantee: no unwrap, expect, panic!, or todo! anywhere in the library, denied at compile time. A malformed or unexpected response is always an Error you handle, never a panic in your service. Wire-faithful types and forward-compatible enums keep working the day Anthropic ships a new model or block type. Claude-only, and deep on purpose.

crates.io docs.rs CI license MSRV

232 tests · zero clippy warnings · a panic-free library (unwrap/expect/panic denied at compile time) · MSRV 1.75 · MIT OR Apache-2.0


Install

cargo add crimson-crab

Streaming and batch results are plain futures_core::Streams. To drive them with .next(), add the StreamExt extension trait:

cargo add futures-util

30-second quickstart

use crimson_crab::model_ids::CLAUDE_OPUS_5;
use crimson_crab::prelude::*;
#[tokio::main]
async fn main() -> crimson_crab::Result<()> {
    // Reads ANTHROPIC_API_KEY from the environment.
    let client = Client::from_env()?;
    let request = MessagesRequest::builder()
        .model(CLAUDE_OPUS_5)
        .max_tokens(1024)
        .messages(vec![MessageParam::user("Explain Rust's borrow checker in one line.")])
        .build()?;
    let message = client.messages().create(&request).await?;
    println!("{}", message.text());
    Ok(())
}

Client is Clone + Send + Sync and shares one connection pool, so build it once and store it in your axum state, your MCP server struct, or a plain field — no Arc, no Mutex, no manual bounds.

Why crimson-crab

  • Panic-free by construction — the one guarantee for code in your request path. unwrap, expect, panic!, and todo! are denied at compile time across the whole library and enforced in CI — not a style guideline, a lint gate. However surprising the JSON on the wire, you get an Error to handle, never a panic in an async task. Few Claude clients make this promise; this one does, and you can verify it in src/lib.rs.
  • Wire-faithful types — your code never breaks on new models. Types mirror the API field-for-field; there are no renamed concepts or leaky abstractions to relearn, and no adapter layer to lag behind a release.
  • Forward-compatible enums — future models work day one. Every wire enum (content blocks, stream events, deltas, stop reasons, tool definitions, cache TTLs, thinking configs) carries an Unknown catch-all that preserves the raw JSON and re-serializes it unchanged instead of erroring. A response from a model the SDK has never heard of — including future Fable- and Mythos-class models — deserializes cleanly and round-trips verbatim.
  • A tokio-free public API — runs anywhere. The public surface exposes futures_core::Stream, not runtime-specific types; tokio is a dev-dependency only. The same builder code compiles for native and wasm32-unknown-unknown on default features.
  • Official-SDK-parity retries — production-grade out of the box. Connection errors, timeouts, 408/409/429, and 5xx are retried with full-jitter exponential backoff (0.5s base, 8s cap) and honor retry-after — capped at 60s so a hostile or broken server can't park your retry loop for hours. Streaming requests retry only before the first byte.
  • Streaming that never truncates mid-generation. The client uses an idle read timeout rather than a total-request deadline, so a long-but-actively-flowing SSE response is never cut off just because total elapsed time crossed a limit.
  • Tested against real API fixtures. Every content block and stream event from the wire reference has a serde round-trip test, and every endpoint has wiremock coverage — 232 tests, zero clippy warnings.

Feature coverage

Capability crimson-crab Generic multi-provider clients
Messages (create / count tokens)
Fine-grained SSE streaming + accumulated final Message usually text-only
Tool use (custom tools + server-tool passthrough) partial
Agentic tool loop driven for you (messages().runner) varies
Extended thinking (adaptive / budgeted / display) rare
Prompt caching (cache_control, 5m/1h TTLs) rare
Structured output (output_config JSON Schema) rare
Schemas derived from Rust types (schemars feature) varies
Message Batches (create / poll / cancel / stream results) rare
Models endpoint (get / list with pagination) varies
Forward-compatible unknown-variant handling varies
New beta flags without an SDK release (betas + extra_body) rare

Claude-specific features are first-class here because Claude is the only API this crate targets. Building against several model vendors? A multi-provider framework will serve you better — rig and genai are genuinely good. crimson-crab is for teams who have chosen Claude and want the whole surface, exactly as Anthropic ships it.

Streaming

Iterate typed events as they arrive; the stream accumulates a complete Message for you in the background.

use crimson_crab::prelude::*;
use futures_util::StreamExt;
# async fn run(client: &Client, request: &MessagesRequest) -> crimson_crab::Result<()> {
let mut stream = client.messages().stream(request).await?;
while let Some(event) = stream.next().await {
    if let StreamEvent::ContentBlockDelta {
        delta: ContentDelta::TextDelta { text },
        ..
    } = event?
    {
        print!("{text}");
    }
}
// After draining, the accumulated `Message` is identical in shape to a
// non-streaming response.
if let Some(message) = stream.final_message() {
    println!("\n[stop_reason: {:?}]", message.stop_reason);
}
# Ok(())
# }

Relaying text deltas (SSE bodies, channels, web handlers)

MessageStream is Send + Unpin and crimson_crab::Error is Send + Sync + std::error::Error, so a streaming response drops straight into an axum Sse body (or any channel) with no Box::pin and no wrapper error type. Map the event stream down to plain String deltas with filter_map:

use crimson_crab::prelude::*;
use futures_util::StreamExt;
# async fn run(client: &Client, request: &MessagesRequest) -> crimson_crab::Result<()> {
// A `Stream<Item = crimson_crab::Result<String>>` of plain text deltas, ready to
// hand to an `axum` `Sse` body, a channel, or any consumer.
let text_deltas = client
    .messages()
    .stream(request)
    .await?
    .filter_map(|event| async move {
        match event {
            Ok(StreamEvent::ContentBlockDelta {
                delta: ContentDelta::TextDelta { text },
                ..
            }) => Some(Ok(text)),
            // A late/in-stream error surfaces as an `Err` item — forward it as a
            // final `event: error` frame instead of dropping the connection.
            Err(e) => Some(Err(e)),
            Ok(_) => None,
        }
    });
forward(text_deltas).await;
# Ok(())
# }
# async fn forward<S>(_deltas: S)
# where
#     S: futures_util::Stream<Item = crimson_crab::Result<String>>,
# {
# }

Tool use (manual agentic loop)

message.into_param() converts a response Message straight into a request MessageParam — the two tool_use blocks echoed verbatim — so the "append the assistant turn, then a user message of tool results" contract is two lines with no lossy serde round-trip. Parallel tool calls need no special handling: just iterate message.content.

use crimson_crab::prelude::*;
use crimson_crab::types::ToolResultBlockParam;
# async fn run(client: &Client, mut messages: Vec<MessageParam>, tool: Tool) -> crimson_crab::Result<()> {
loop {
    let request = MessagesRequest::builder()
        .model("claude-opus-4-8")
        .max_tokens(1024)
        .messages(messages.clone())
        .tool(tool.clone()) // `.tool(_)` appends any `Into<ToolUnion>`; `.tools(vec)` replaces
        .build()?;
    let message = client.messages().create(&request).await?;
    if message.stop_reason != Some(StopReason::ToolUse) {
        println!("{}", message.text());
        break;
    }
    // Answer every tool call. `ContentBlock` is in the prelude, so matching
    // `ToolUse` needs no extra import.
    let mut results = Vec::new();
    for block in &message.content {
        if let ContentBlock::ToolUse(call) = block {
            match run_tool(&call.name, &call.input) {
                // Success: the discoverable `ContentBlockParam::tool_result` helper.
                Ok(output) => results.push(ContentBlockParam::tool_result(&call.id, output)),
                // Failure: surface it to the model with `is_error: true`.
                Err(why) => results.push(ContentBlockParam::ToolResult(
                    ToolResultBlockParam::error(&call.id, why),
                )),
            }
        }
    }
    // Echo the assistant turn back verbatim, then one user message of results.
    messages.push(message.into_param());
    messages.push(MessageParam::user(results));
}
# Ok(())
# }
// Your tool dispatch. Note `std::result::Result<_, _>`: see "Imports & the prelude".
# fn run_tool(_name: &str, _input: &serde_json::Value) -> std::result::Result<String, String> {
#     Ok("tool output".to_string())
# }

Tool runner (the loop, driven for you)

Same contract, none of the bookkeeping. messages().runner(request) takes the request by value and owns it: registering a tool appends it to tools, and every turn appends the assistant turn and the tool_result message to messages. A tool is described to the model in the same place it's implemented, so the two can't drift apart.

use crimson_crab::prelude::*;
use serde::Deserialize;
/// The arguments of the weather tool — the model's `input` lands here.
#[derive(Deserialize)]
struct GetWeather {
    location: String,
}
# async fn run(client: &Client, request: MessagesRequest, weather: Tool) -> crimson_crab::Result<()> {
let result = client
    .messages()
    .runner(request)
    // The tool definition and its handler, together. `.tool_raw` takes a
    // `serde_json::Value` instead, for inputs not worth a struct.
    .tool(weather, |args: GetWeather| async move {
        Ok::<_, String>(format!("22C and sunny in {}", args.location))
    })
    .max_turns(8)                        // default 10; one turn = one round-trip
    .on_turn(|m| eprintln!("{:?}", m.stop_reason))  // logging/progress hook
    .run()
    .await?;
println!("{} (after {} turns)", result.message.text(), result.turns);
// `result.messages` is the whole conversation, ending with the final assistant
// turn — push a follow-up question onto it and keep going.
# Ok(())
# }

Three things worth knowing, because they're the ones a hand-written loop usually gets wrong:

  • Tool failures are data, not errors. A handler Err, an input that doesn't deserialize into the handler's argument type, and a call to a tool you never registered all come back to the model as a tool_result with is_error: true and an explanation — the run continues, and the model gets to correct itself. Nothing is silently dropped.
  • Parallel tool calls just work. Every tool_use block in a response is executed concurrently, and all results go back in one user message, in the order the calls appeared.
  • The runner doesn't judge the outcome. Any stop reason other than tool_use ends the run and is handed back as-is, refusal and max_tokens included; you read result.message.stop_reason. Only hitting max_turns while the model is still asking for tools is an error — Error::ToolRunner { turns, .. }.

Richer per-turn hooks — approving a call before it runs, rewriting a result on the way back — are on the roadmap. on_turn is the observer; it can watch, not veto.

Typed schemas — schemars feature

Enable the optional schemars feature and the JSON Schema comes from your Rust type, so the schema and the type that parses against it cannot drift apart. Your crate needs its own schemars dependency for the derive — use major version 1, the same one crimson-crab links (mixing schemars 0.8 in produces a confusing "trait JsonSchema is not implemented" error, because the two majors define different traits):

cargo add crimson-crab --features schemars
cargo add schemars@1
cargo add serde --features derive

messages().parse::<T>() derives T's schema, tightens it into the shape structured output requires (subschemas inlined, additionalProperties: false, every property required), sets it on a copy of your request, and deserializes the response — while handing back the whole Message so usage and stop_reason stay in reach. Option<T> fields become nullable properties rather than absent ones, so "everything is required" costs you nothing. Doc comments ride along as schema descriptions the model reads.

# #[cfg(feature = "schemars")]
# mod demo {
use crimson_crab::prelude::*;
use schemars::JsonSchema;
use serde::Deserialize;
/// A contact extracted from free-form text.
#[derive(Debug, Deserialize, JsonSchema)]
struct Contact {
    /// The contact's full name.
    name: String,
    /// The employer, or `null` if none was mentioned.
    company: Option<String>,
}
# async fn run(client: &Client) -> crimson_crab::Result<()> {
let request = MessagesRequest::builder()
    .model("claude-opus-5")
    .max_tokens(512)
    .messages(vec![MessageParam::user("Extract: Ada Lovelace, no employer.")])
    .build()?;
let parsed = client.messages().parse::<Contact>(&request).await?;
println!("{} ({:?})", parsed.data.name, parsed.data.company);
println!("{} output tokens", parsed.message.usage.output_tokens);
# Ok(())
# }
# }

A refusal or a max_tokens truncation is reported as Error::StructuredOutput naming the offending message, rather than surfacing as an opaque "EOF while parsing" from serde.

The same derivation builds tool schemas. Tool::from_type::<T>(name, description) fills in input_schema from the argument type, and the model's input deserializes straight back into it:

# #[cfg(feature = "schemars")]
# mod demo {
use crimson_crab::prelude::*;
use schemars::JsonSchema;
use serde::Deserialize;
/// The arguments of the weather tool.
#[derive(Deserialize, JsonSchema)]
struct GetWeather {
    /// The city to look up, e.g. "Paris".
    location: String,
}
# fn run(input: &serde_json::Value) -> crimson_crab::Result<()> {
let tool = Tool::from_type::<GetWeather>("get_weather", "Get the current weather for a location");
let args: GetWeather = serde_json::from_value(input.clone())?;
println!("{} → {}", tool.name, args.location);
# Ok(())
# }
# }

Tool schemas are left exactly as schemars emits them — ordinary JSON Schema, where Option<T> arguments stay optional — because the strict-tool contract is opt-in via .strict(true).

Prompt caching & token budgeting

The simplest caching path: a plain-string system prompt plus a top-level cache_control, which auto-places one breakpoint on the last cacheable block — no per-block wiring.

use crimson_crab::prelude::*;
# async fn run(client: &Client) -> crimson_crab::Result<()> {
let request = MessagesRequest::builder()
    .model("claude-opus-4-8")
    .max_tokens(256)
    .system("A long, reusable system prompt worth caching…")
    .cache_control(CacheControl::ephemeral()) // or `CacheControl::ephemeral_with_ttl(CacheTtl::OneHour)`
    .messages(vec![MessageParam::user("Restate rule one.")])
    .build()?;
let message = client.messages().create(&request).await?;
let usage = &message.usage;
println!(
    "fresh input: {}  written to cache: {:?}  read from cache: {:?}",
    usage.input_tokens,
    usage.cache_creation_input_tokens,
    usage.cache_read_input_tokens,
);
# Ok(())
# }

Token accounting for cost reports. The three input buckets are disjoint: input_tokens counts only the uncached input, while cache_creation_input_tokens and cache_read_input_tokens are separate. Total input tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens. Never add the cache buckets into input_tokens — that double-bills the cached prefix.

Need the number before you spend on generation? Derive a count request from the same messages request — no rebuilding the prompt twice:

# use crimson_crab::prelude::*;
# async fn run(client: &Client, request: &MessagesRequest) -> crimson_crab::Result<()> {
let count = client.messages().count_tokens(&request.as_count_request()).await?;
println!("this request will cost {} input tokens", count.input_tokens);
# Ok(())
# }

For fine-grained control you can attach a breakpoint to an individual block instead: build a TextBlockParam (in crimson_crab::types), set cache_control, and pass it as a system block or message content — see examples/prompt_caching.rs.

Message Batches

The whole submit → poll → stream → tally pipeline. BatchRequestItem::from_request turns a MessagesRequest into a batch entry with no hand-rolled JSON; BatchStatus and BatchRequestCounts are typed for progress display; and results() decodes the JSONL stream line-by-line, tolerating blank lines and a missing trailing newline.

use crimson_crab::api::{BatchRequestItem, BatchResultOutcome, BatchStatus};
use crimson_crab::prelude::*;
use futures_util::StreamExt;
# async fn run(client: &Client, request: &MessagesRequest) -> crimson_crab::Result<()> {
// Submit, each entry tagged with your own custom id.
let items = vec![BatchRequestItem::from_request("row-1", request)?];
let batch = client.batches().create(&items).await?;
// Poll until the batch reaches a terminal state. (A built-in `poll_until_ended`
// helper is on the roadmap; until then, loop with your runtime's timer and
// your own deadline guard.)
let batch = loop {
    let current = client.batches().get(&batch.id).await?;
    if current.processing_status == BatchStatus::Ended {
        break current;
    }
    tokio::time::sleep(std::time::Duration::from_secs(30)).await;
};
// Results arrive in any order — key them by `custom_id`, never by position.
let mut results = client.batches().results(&batch.id).await?;
while let Some(result) = results.next().await {
    let result = result?;
    match result.result {
        BatchResultOutcome::Succeeded(ok) => {
            println!("{}: {}", result.custom_id, ok.message.text());
        }
        BatchResultOutcome::Errored(err) => {
            // `err.error` is the raw error envelope (a `serde_json::Value`).
            println!("{}: errored: {}", result.custom_id, err.error);
        }
        BatchResultOutcome::Canceled(_) | BatchResultOutcome::Expired(_) => {}
        // `BatchResultOutcome` is `#[non_exhaustive]`; the wildcard keeps you
        // forward-compatible with outcome types added in a future release.
        _ => {}
    }
}
# Ok(())
# }

Imports & the prelude

use crimson_crab::prelude::*; is the fastest way to get the common types — Client, MessagesRequest, MessageParam, StreamEvent, ContentDelta, ContentBlock, ContentBlockParam, Tool, ToolChoice, StopReason, CacheControl, and more.

One thing worth knowing: the prelude also re-exports the crate's Result<T> and Error type aliases, which shadow std::result::Result / std::error::Error inside a glob import. If you write a two-type-argument Result<T, E> in the same scope — common with axum handlers, thiserror, or macro-heavy crates like rmcp — it resolves to the one-argument alias and fails with a confusing E0107 ("type alias takes 1 generic argument but 2 were supplied"). Two easy fixes:

  • Fully-qualify it: std::result::Result<String, String> (as in the tool-loop example above); or
  • Skip the glob and import exactly what you need. Curated paths: crimson_crab::Client, request/response types under crimson_crab::api::* (e.g. MessagesRequest), and the wire types under crimson_crab::types::* (e.g. MessageParam, TextBlockParam, ToolResultBlockParam).

Platform support

The crate compiles for native targets and wasm32-unknown-unknown on default features — no feature juggling. On wasm, reqwest resolves to the browser fetch backend and TLS features are ignored, so you do not need to disable rustls-tls; cargo tree -i tokio --target wasm32-unknown-unknown prints nothing.

Two honest caveats for edge/browser deployments: the retry loop cannot sleep on wasm (there are no threads), so on that target retries fire without backoff and do not observe retry-after — the browser applies its own backpressure. Streaming type-checks on wasm but is best-effort and not exercised in a headless browser in CI. For a 429-sensitive edge worker, cap max_retries accordingly.

More examples

Runnable programs live in examples/: basic, streaming, tool_use, thinking, prompt_caching, and structured_output, plus typed_parse, typed_tools, and tool_runner (a two-tool weather agent on the runner) on the schemars feature. There's also live_smoke — a manual end-to-end check that makes real API calls (token count + non-streaming + streaming). Run one with:

ANTHROPIC_API_KEY=sk-ant-... cargo run --example streaming
ANTHROPIC_API_KEY=sk-ant-... cargo run --features schemars --example typed_parse

Build a Claude-powered MCP server

Want a ready-to-run Model Context Protocol server backed by Claude? Start from the template — a Rust MCP server (rmcp + crimson-crab) you can spin up in one click:

crimson-crab-mcp-template — click Use this template, or:

cargo generate --git https://github.com/singhpratech/crimson-crab-mcp-template

Roadmap

Shipping fast and iterating; spec fidelity and release cadence are the whole point. schemars-derived schemas — parse::<T>() and Tool::from_type::<T>() — landed in 0.2.0. The tool runner (messages().runner) landed next. Still on deck: the Files API (beta), a #[tool] attribute macro, approval and result-rewriting hooks for the runner, a batches().poll_until_ended() convenience, the Admin/Usage API, and Vertex/Bedrock transports. The model field is an open string everywhere, so any model Anthropic ships works today with zero changes.

Minimum supported Rust version

MSRV is 1.75. Raising it is a minor-version change.

Semver policy

The wire enums are #[non_exhaustive] and carry Unknown catch-all variants, so a minor release may add a new known variant (for a feature Anthropic ships) without breaking your build. Match with a wildcard _ arm on SDK enums to stay forward-compatible across minor versions.

License

Licensed under either of MIT or Apache-2.0 at your option.


🦀 Crimson Crab · crimson-crab is an independent open-source project and is not affiliated with Anthropic.

Read the original on github.com ↗