DEV Community

Cover image for crimson-crab: a Production-Grade Rust SDK for Claude (and Why tokio Leaves the Tree on wasm32)
AI Explore
AI Explore

Posted on

crimson-crab: a Production-Grade Rust SDK for Claude (and Why tokio Leaves the Tree on wasm32)

On a native target, cargo tree --edges normal --invert tokio puts tokio v1.52.4 squarely in your build. It arrives through reqwest, by way of hyper, hyper-rustls, hyper-util, tokio-rustls, tokio-util and tower.

Retarget that same query at wasm32-unknown-unknown and the tokio count is zero.

Same crate, same default features, same command, two correct answers. On wasm32 reqwest swaps to the browser's fetch backend, hyper leaves the dependency graph, and tokio leaves with it.

What it is

crimson-crab ๐Ÿฆ€ is a Rust SDK for Anthropic's Claude API. v0.1.0 went up on crates.io on 16 July 2026. It covers Messages and token counting, fine-grained SSE streaming with an accumulated final Message, tool use with custom tools and server-tool passthrough, extended and adaptive thinking, prompt caching with 5-minute and 1-hour TTLs, structured output via JSON Schema, Message Batches, and the Models endpoint.

The scope is deliberately narrow: Claude, and nothing else. If you are building against several model vendors, a multi-provider framework will serve you better, and rig and genai are genuinely good at that job. crimson-crab is for teams who have already chosen Claude and want the whole surface, exactly as Anthropic ships it.

cargo add crimson-crab
Enter fullscreen mode Exit fullscreen mode
use crimson_crab::model_ids::CLAUDE_OPUS_4_8;
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_4_8).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(())
}
Enter fullscreen mode Exit fullscreen mode

Note the #[tokio::main] in that snippet. On a native target you still bring a runtime; the crate simply declines to pick one for you. tokio sits in crimson-crab's manifest under [dev-dependencies], where the test suite and the seven examples use it. Client is Clone + Send + Sync and shares one connection pool, so you build it once and drop it in your axum state or a plain struct field. No Arc, no Mutex.

The dependency that depends on your target

Here is the native tree in full, because the tidy one-line summary of it would be a lie:

$ cargo tree --edges normal --invert tokio
tokio v1.52.4
โ”œโ”€โ”€ hyper v1.10.1
โ”‚ โ”œโ”€โ”€ hyper-rustls v0.27.9
โ”‚ โ”‚ โ””โ”€โ”€ reqwest v0.12.28
โ”‚ โ”‚ โ””โ”€โ”€ crimson-crab v0.1.0
โ”‚ โ”œโ”€โ”€ hyper-util v0.1.20
โ”‚ โ”‚ โ”œโ”€โ”€ hyper-rustls v0.27.9 (*)
โ”‚ โ”‚ โ””โ”€โ”€ reqwest v0.12.28 (*)
โ”‚ โ””โ”€โ”€ reqwest v0.12.28 (*)
โ”œโ”€โ”€ hyper-rustls v0.27.9 (*)
โ”œโ”€โ”€ hyper-util v0.1.20 (*)
โ”œโ”€โ”€ reqwest v0.12.28 (*)
โ”œโ”€โ”€ tokio-rustls v0.26.4
โ”‚ โ”œโ”€โ”€ hyper-rustls v0.27.9 (*)
โ”‚ โ””โ”€โ”€ reqwest v0.12.28 (*)
โ”œโ”€โ”€ tokio-util v0.7.18
โ”‚ โ””โ”€โ”€ reqwest v0.12.28 (*)
โ””โ”€โ”€ tower v0.5.3
 โ”œโ”€โ”€ reqwest v0.12.28 (*)
 โ””โ”€โ”€ tower-http v0.6.11
 โ””โ”€โ”€ reqwest v0.12.28 (*)
Enter fullscreen mode Exit fullscreen mode

Seven distinct paths converge on reqwest there. On a native target tokio is in your build, and it has to be: reqwest's default backend is hyper, and hyper runs on tokio. Any reqwest-based crate advertising itself as tokio-free on native is wrong, and cargo tree -i tokio settles the argument in about a second.

What crimson-crab actually claims is narrower. tokio is not a direct dependency of the library: it appears in the manifest only under [dev-dependencies], and in a native build it reaches you transitively through reqwest. Nothing in the public API names a runtime type either. Streaming hands back a futures_core::Stream, MessageStream is Send + Unpin, and crimson_crab::Error is Send + Sync + std::error::Error. The crate has no opinion about your executor and never asks for one.

use crimson_crab::prelude::*;
use futures_util::StreamExt;

// `request` is the same MessagesRequest built above; `stream` borrows it.
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);
}
Enter fullscreen mode Exit fullscreen mode

That plain Stream is what makes the wasm32 result possible at all. There, reqwest resolves to the browser's fetch backend, hyper falls out of the graph, and tokio falls out with it, which is how the count reaches zero.

cargo check --target wasm32-unknown-unknown passes on default features. Be precise about what that buys you, though: cargo check type-checks and borrow-checks, and it neither codegens nor links. So this is evidence that the crate and its dependency graph resolve cleanly for wasm32, with no feature juggling and no default-features = false incantation to memorize. It is not a wasm artifact, and it is not me promising you a browser demo. Whether your application links and runs in a browser is your integration problem, and this post makes no claim about it.

The honest headline is therefore about the direct dependency list and the public API surface, and it stops there. Say it more loosely than that and a reader runs one command and stops trusting you. Only the precise version survives contact with cargo tree.

The docs cannot rot

cargo test --all-features gives 191 passed, 0 failed. The composition is the interesting part:

Kind Count
Unit tests (in src/) 43
Integration tests (7 files, wiremock) 35
Doc-tests 113
Total 191 passed, 0 failed

113 of 191. The majority of this test suite is the documentation, executing.

Precision matters here too, because "every example runs" is exactly the sort of thing people say loosely. Of the 113 doc-tests, 21 are marked no_run: cargo test compiles and type-checks those against the real API but does not execute them, since they would need a live API key. The other 92 compile and run. None are marked ignore, so there is no example in the docs that the compiler never sees. A doc example that drifts out of sync with the API does not quietly become a stale snippet somebody files an issue about eighteen months later; it becomes a red build on the commit that broke it.

The integration tests run against wiremock, so the whole suite works offline: no API key, no network, no rate limits, no flakes. You can clone the repo on a plane and get a green run.

Panic-freedom you can check

Plenty of libraries describe themselves as panic-free in a README paragraph. This one is checkable in about ten seconds. src/lib.rs opens with:

#![forbid(unsafe_code)]
#![cfg_attr(
 not(test),
 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::todo)
)]
#![deny(missing_docs)]
Enter fullscreen mode Exit fullscreen mode

and cargo clippy --all-features --all-targets passes with 0 warnings. The not(test) scope means those denies bind the library build, so the property holds for a mechanical reason: the compiler refuses to produce a version of the library that violates it. deny(missing_docs) is quietly what keeps the doc-test count honest too, since an undocumented public item fails the build outright.

A response from a model that does not exist yet

Every wire enum in the crate carries an Unknown catch-all: content blocks, stream events, deltas, stop reasons, tool definitions, cache TTLs, thinking configs. An unrecognised variant is preserved as raw JSON and re-serialized unchanged, so deserialization never errors on it. The enums are #[non_exhaustive], so you match with a wildcard arm and a minor release can add a known variant without breaking your build.

That is the whole mechanism, and it happens to be one of the 113 doc-tests. Here it is exactly as it appears in src/types/content.rs:

use crimson_crab::types::{ContentBlock, TextBlock};

let json = serde_json::json!({"type": "text", "text": "Hello"});
let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
assert_eq!(block, ContentBlock::Text(TextBlock::new("Hello")));
assert_eq!(serde_json::to_value(&block).unwrap(), json);

// An unrecognised block type is preserved rather than rejected.
let novel = serde_json::json!({"type": "brand_new", "foo": 1});
let block: ContentBlock = serde_json::from_value(novel.clone()).unwrap();
assert!(matches!(block, ContentBlock::Unknown(_)));
assert_eq!(serde_json::to_value(&block).unwrap(), novel);
Enter fullscreen mode Exit fullscreen mode

The last two assertions are the ones that matter. A block type nobody had invented when the crate was published deserializes cleanly, and serializes back byte-equivalent to what arrived. Your process keeps running and your logs keep the payload.

The model field is an open string everywhere, which is the same principle applied one level up. The crate exports constants (CLAUDE_OPUS_4_8, CLAUDE_FABLE_5, CLAUDE_SONNET_5, CLAUDE_HAIKU_4_5) purely as conveniences; a model that is absent from that list still works, passed as a plain string. Beta flags get the same escape hatch. .beta("some-flag") appends an anthropic-beta flag and .extra_field(key, value) sets a top-level body field, so a beta that shipped this morning is reachable today without waiting on an SDK release.

Retries, and the streaming timeout

Connection errors, timeouts, 408, 409, 429 and 5xx retry with full-jitter exponential backoff: 0.5s base, 8s cap. retry-after is honored and capped at 60s, so a hostile or broken server cannot park your retry loop for an hour. Streaming requests retry only before the first byte, which is the only safe answer once tokens are already on the wire.

The streaming detail I like most is the timeout. The client applies an idle read timeout rather than a total-request deadline, so a long but actively flowing SSE response is never cut off merely for crossing an elapsed-time limit. If you have ever had a long generation guillotined at exactly thirty seconds by an HTTP client that counts elapsed time and ignores whether data is still arriving, you already know why that distinction earns its place in the design.

Status, honestly

v0.1.0, published 16 July 2026. A single crate: roughly 6,076 lines in src/ and 1,445 in tests/, with 7 runnable examples (basic, batches, prompt_caching, streaming, structured_output, thinking, tool_use). MSRV 1.75, edition 2021, dual-licensed MIT OR Apache-2.0. Raising the MSRV would be a minor-version change.

Things this post does not contain: benchmarks, latency figures, throughput numbers. None were measured, so none are quoted. There are no adoption numbers either, because it launched hours before this went up and there aren't any yet. What I can hand you instead is a test suite, a clippy run and a dependency tree, every one of which you can reproduce yourself in a few minutes on your own machine. For a v0.1.0, that seems like the honest trade.

Find crimson-crab

crimson-crab is an independent open-source project and is not affiliated with Anthropic. Every number in this post comes from a command you can run against a fresh clone: cargo test --all-features, cargo clippy --all-features --all-targets, and cargo tree.

Top comments (0)