unlob Docs
Browse documentation

Rust

reqwest and serde, with the response shapes typed.

[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
anyhow = "1"
rand = "0.8"

Types

use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct Hit {
    pub id: String,
    pub url: String,
    pub host: String,
    pub title: String,
    pub vertical: String,
    /// The passage text — usually the answer, not a preview of one.
    pub snippet: String,
    pub score: f32,
    pub host_rank: f64,
    pub quality: i64,
    pub fetched_at: i64,
    pub source: String,
    pub story_id: String,
    #[serde(default)]
    pub group_size: Option<usize>,
    #[serde(default)]
    pub published_at: Option<i64>,
    #[serde(default)]
    pub independent_sources: Option<i64>,
    #[serde(default)]
    pub centrality: Option<f64>,
    #[serde(default)]
    pub topics: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct SearchResponse {
    pub vertical: Option<String>,
    pub routed: bool,
    pub mode: String,
    pub total: usize,
    pub results: Vec<Hit>,
    /// True when part of the corpus was unreachable — the answer is INCOMPLETE,
    /// not merely short. Serialized only when true, hence the default.
    #[serde(default)]
    pub partial: bool,
}

Every optional field is #[serde(default)] because the API omits what does not apply rather than sending nulls — a missing published_at means the page declared no date.

The client

use anyhow::{bail, Result};
use std::time::Duration;

pub struct Unlob {
    http: reqwest::Client,
    base: String,
}

#[derive(Debug, thiserror::Error)]
#[error("monthly quota exhausted: {0}")]
pub struct QuotaExhausted(String);

impl Unlob {
    pub fn new(api_key: &str) -> Result<Self> {
        let mut headers = reqwest::header::HeaderMap::new();
        headers.insert("x-api-key", api_key.parse()?);
        Ok(Self {
            http: reqwest::Client::builder()
                .default_headers(headers)
                .timeout(Duration::from_secs(20))
                .build()?,
            base: "https://api.unlob.com".into(),
        })
    }

    async fn get<T: serde::de::DeserializeOwned>(
        &self,
        path: &str,
        params: &[(&str, String)],
    ) -> Result<T> {
        for attempt in 0..4u32 {
            let resp = self
                .http
                .get(format!("{}{path}", self.base))
                .query(params)
                .send()
                .await?;

            if resp.status() == 429 {
                match resp.headers().get("retry-after") {
                    Some(v) => {
                        let secs: u64 = v.to_str()?.parse().unwrap_or(1);
                        tokio::time::sleep(Duration::from_secs(secs)).await;
                        continue;
                    }
                    // No retry-after means a hard-capped monthly quota, not a rate
                    // limit. Retrying is a loop that cannot succeed.
                    None => bail!(QuotaExhausted(resp.text().await?)),
                }
            }

            if resp.status().is_server_error() || resp.status() == 408 {
                if attempt < 3 {
                    // Full jitter: clients that failed together must not retry
                    // together and re-create the spike.
                    let backoff = rand::random::<f64>() * f64::from(1u32 << attempt);
                    tokio::time::sleep(Duration::from_secs_f64(backoff)).await;
                    continue;
                }
            }

            return Ok(resp.error_for_status()?.json().await?);
        }
        bail!("exhausted retries")
    }

    pub async fn search(&self, q: &str, params: &[(&str, String)]) -> Result<SearchResponse> {
        let mut all = vec![("q", q.to_string())];
        all.extend_from_slice(params);
        self.get("/search", &all).await
    }
}

Using it

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let unlob = Unlob::new(&std::env::var("UNLOB_API_KEY")?)?;

    let body = unlob
        .search(
            "how does tokio schedule tasks",
            &[
                ("vertical", "code".into()),
                ("collapse", "story".into()),
                ("limit", "5".into()),
            ],
        )
        .await?;

    if body.partial {
        anyhow::bail!("incomplete results — part of the corpus was unreachable");
    }

    for hit in &body.results {
        println!("{} — {}", hit.title, hit.url);
        println!("{}\n", hit.snippet);
    }
    Ok(())
}

MCP over stdio

If you are embedding an MCP client rather than calling REST, rmcp — the official Rust MCP SDK — is what the unlob server itself is built on. Its client feature connects to https://api.unlob.com/mcp over the Streamable HTTP transport.

Next