Back to Blog

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

Prateek SinghJuly 16, 20268 min read
crimson-crab: a Production-Grade Rust SDK for Claude — and Why tokio Leaves the Dependency Tree on wasm32

crimson-crab is a Rust SDK for Anthropic's Claude API: v0.1.0, 191 passing tests, zero clippy warnings, and a library that denies unwrap, expect and panic at compile time. This is the launch post: why tokio sits in the native dependency tree and is absent from the wasm32 one, why 113 of the 191 tests are the documentation, and what happens when a response arrives from a model the SDK has never heard of.

Run cargo tree --edges normal --invert tokio against crimson-crab on a native target and tokio v1.52.4 is sitting right there, arriving through reqwest v0.12.28 and hyper v1.10.1. Run the same query with --target wasm32-unknown-unknown and the count is zero.

Nothing about the crate changed between those two commands. On wasm32, reqwest resolves to the browser's fetch backend; hyper falls out of the graph and tokio falls out with it. One source tree, default features both times, two dependency graphs.

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.

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(())
}

Note the #[tokio::main] in that snippet. On a native target you still bring a runtime; the crate simply declines to choose one for you. 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

Same builder code. Two dependency graphs. tokio is not a direct dependency of crimson-crab. Whether it is in your build at all is decided by your target.crimson-crab v0.1.09 direct dependencies · none is tokio reqwest v0.12.28the one HTTP dependency NATIVE TARGETwasm32-unknown-unknownhyper v1.10.1reqwest's default backendbrowser fetchreqwest's wasm backend tokio v1.52.4pulled in transitivelytokio · 0 crateshyper and tokio leave the graphPresent, via reqwest → hyper.Absent. Zero in the tree.Verified with cargo tree --edges normal --depth 1, cargo tree --edges normal --invert tokio, and cargo tree --edges normal --target wasm32-unknown-unknown.
tokio is not a direct dependency of crimson-crab. On a native target it arrives transitively through reqwest and hyper. On wasm32-unknown-unknown, reqwest resolves to the browser fetch backend, hyper leaves the graph, and tokio leaves with it: the tree contains zero tokio crates.

tokio is not a direct dependency of crimson-crab. cargo tree --edges normal --depth 1 lists nine: bytes, fastrand, futures-core, futures-util, pin-project-lite, reqwest, serde, serde_json, thiserror. In the manifest, tokio appears only under [dev-dependencies], where the test suite and the examples use it.

That command proves less than it looks like it proves. --depth 1 excludes dev-dependencies by definition, so it could not have printed tokio whatever the manifest said. The transitive tree is the evidence that counts, and on native it is unambiguous. tokio is in your build, and it has to be, because 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 survives that scrutiny is narrower and more useful. Nothing in the public API names a runtime type: 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. The claim worth making is about the public API surface and the direct dependency list, not about your final binary.

The wasm32 tree is where that design turns into a difference you can measure. On wasm32-unknown-unknown, reqwest resolves to the browser's fetch backend. hyper leaves the graph and tokio leaves with it, so the count is zero. cargo check --target wasm32-unknown-unknown passes on default features, with no feature juggling and no default-features = false incantation to memorize.

Two limits on what that establishes. cargo check type-checks and borrow-checks, then stops: no codegen, no linking. A clean check means the type system is satisfied. It does not hand you a wasm artifact, and it certainly does not demonstrate one running in a browser. The second limit is that a dependency graph without tokio is a statement about the graph, while what your bundler finally emits is a separate question. So the defensible version is a small one: the crate type-checks clean for wasm32 on default features, and tokio is absent from that target's tree. Anything past that is a promise this post cannot cash.

The docs cannot rot

191 tests pass. 113 of them are the documentation. cargo test --all-features → 191 passed, 0 failed. Doc-tests are the largest block, which is the point.35 integration43 unit113 doc-testsOne bar = the whole suite (191 tests). Segment width = share of the suite. Integration tests Across 7 files, wiremock-backed. The suite runs offline: no API key, no network. 35 Unit tests In src/, alongside the code they cover. 43 Doc-tests All 113 compile under cargo test; 92 also execute (21 are no_run). The docs cannot silently rot. 113 Total, passing 191 Failing: 0. Counted from the per-suite test result lines of cargo test --all-features.
cargo test --all-features reports 191 passed, 0 failed: 43 unit tests in src/, 35 integration tests across 7 files, and 113 doc-tests. The majority of the suite is the documentation, under compilation.

cargo test --all-features gives 191 passed, 0 failed. The composition is the interesting part: 43 unit tests in src/, 35 integration tests across 7 files, and 113 doc-tests.

113 of 191. The majority of this test suite is the documentation. Worth being exact about what that buys, because "the examples all work" is a claim people make loosely. All 113 are compiled by cargo test. 92 of them also execute. The remaining 21 carry no_run, so they compile and type-check and then stop, because their bodies call the live API and the suite has no key. None carry ignore, so nothing in the docs is hidden from the compiler.

The compile step is where most of the value sits, and it covers all 113. A doc example that drifts out of sync with the API stops compiling, and the build goes red on the commit that broke it. It never gets the chance to become the stale snippet somebody files an issue about eighteen months later.

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

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 where it does not. 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

A block type the SDK has never seen Same bytes in, two outcomes. The right-hand path is what forward-compatible enums buy you.on the wire{"type":"brand_new","foo":1} A STRICT CLIENTCRIMSON-CRAB✗  Deserialization errorthe type tag matches no known variant✓  ContentBlock::Unknownthe raw JSON is kept, exactly as it arrived no Messagethe blocks it did understand are lost too{"type":"brand_new","foo":1}re-serialized, unchangedNothing to read until a new SDK ships.Round-trips verbatim. Your build never breaks.This exact round-trip is asserted in a doc-test at src/types/content.rs:109-120, and it executes on every cargo test. One of the 92 doc-tests that run.
Every wire enum carries an Unknown catch-all. A strict client rejects an unrecognised type tag and loses the whole response; crimson-crab preserves the raw JSON and re-serializes it unchanged. This exact round-trip is asserted in a doc-test.

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. 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 is one of the 92 doc-tests that execute:

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);

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) as conveniences, and a model missing from that list still works: pass its id as a 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 counting wall-clock instead of silence, 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.

Subscribe to new posts from theaivibe.org

No spam — just new posts. One-click unsubscribe.
Share this article

Related Posts