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.