Honest eyes for your AI agent — the Rust SDK for Enconvert. Blocking client built on reqwest's blocking API — no async runtime required.
Read any web page or file into clean Markdown, JSON, or screenshots, and get a render_quality score (0.0–1.0) on every read — so a blocked, challenge, or empty-SPA page comes back flagged with a low score and warnings, never mistaken for real content. Perceive, discover, look up, distill, ingest, and watch the web; convert 43 file and document formats through the same key.
Wiring an agent (Claude, Cursor, Windsurf, n8n, …)? The MCP server is the native path —
npx @enconvert/mcp setup. This SDK is the programmatic REST path for everything else.
cargo add enconvertor add it to Cargo.toml:
[dependencies]
enconvert = "0.0.1"use enconvert::{Enconvert, PerceiveOptions, PerceiveOutputName};
let client = Enconvert::new("sk_...")?;
// Read a page the way your agent should — with a quality score attached.
let op = client.v2().perceive("https://example.com", PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Markdown, PerceiveOutputName::Structured]),
..Default::default()
})?;
println!("{:?}", op.render_quality); // e.g. Some(0.93)The V2 namespace turns web pages into agent-ready data: render, search, extract, ingest, and monitor. All V2 endpoints require a private API key and are plan-gated — a disabled feature or exhausted monthly quota returns Error::Quota (HTTP 402).
Every render carries render_quality (0.0–1.0). A low score means the page didn't render cleanly (challenge page, cookie wall, empty shell); the content is still returned, flagged, so a bad read never quietly enters your agent's context.
use enconvert::{PerceiveExtractName, PerceiveOptions, PerceiveOutputName};
let op = client.v2().perceive("https://example.com", PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Markdown, PerceiveOutputName::Screenshot, PerceiveOutputName::Structured]),
extract: Some(vec![PerceiveExtractName::Tables, PerceiveExtractName::Metadata]),
..Default::default()
})?;
println!("{:?}", op.render_quality); // honesty score, 0.0-1.0
println!("{:?}", op.outputs.get("markdown").and_then(|a| a.url.as_ref())); // 15-min signed URL
println!("{:?}", op.structured);
// Re-sign artifact URLs later:
let again = client.v2().get_perceive_operation(&op.operation_id)?;
// Batch (<=1000 URLs; small batches run inline, larger return "queued" — poll):
use enconvert::{PerceiveBatchOptions, PerceiveBatchOutputMode};
let batch = client.v2().perceive_batch(
vec!["https://a.com".to_string(), "https://b.com".to_string()],
PerceiveBatchOptions {
render: PerceiveOptions { outputs: Some(vec![PerceiveOutputName::Markdown]), ..Default::default() },
output_mode: Some(PerceiveBatchOutputMode::Zip),
},
)?;
let done = client.v2().get_perceive_batch(&batch.job_id)?;
// Direct download — stream one artifact's raw bytes instead of the JSON envelope
// (exactly one artifact-producing output; metadata arrives via headers):
let direct = client.v2().perceive_direct("https://example.com", PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Pdf]),
..Default::default()
})?;
std::fs::write(direct.filename.as_deref().unwrap_or("page.pdf"), &direct.content)?;
// Re-download a stored artifact later (`None` when the operation has only one):
let saved = client.v2().download_perceive_artifact(&direct.operation_id, Some(PerceiveOutputName::Pdf))?;use enconvert::{DiscoverMode, DiscoverOptions};
let found = client.v2().discover("https://example.com", DiscoverOptions {
mode: Some(DiscoverMode::Hybrid), // Sitemap | Crawl | Hybrid
max_urls: Some(200),
exclude_patterns: Some(vec!["/tag/".to_string()]),
..Default::default()
})?;
println!("{} {:?}", found.total, found.urls);use enconvert::{LookupCategory, LookupOptions};
let search = client.v2().lookup("best static site generators", LookupOptions {
category: Some(LookupCategory::Web), // Web | News | Images | Scholar | Patents | Maps
num_results: Some(10),
perceive_top: Some(3), // auto-render top 3 results (uses perceive quota)
..Default::default()
})?;
for hit in &search.results {
println!("{:?} {:?} {:?}", hit.title, hit.url, hit.perceive.as_ref().map(|p| p.render_quality));
}use enconvert::{CssField, CssFieldType, CssSchema, DistillOptions};
use serde_json::json;
// A small helper since CssField has no Default (field_type is required,
// so it deliberately has no silent default):
fn text_field(name: &str, selector: &str) -> CssField {
CssField {
name: name.to_string(),
field_type: CssFieldType::Text,
selector: Some(selector.to_string()),
attribute: None,
pattern: None,
default: None,
transform: None,
fields: None,
}
}
let extraction = client.v2().distill(DistillOptions {
urls: Some(vec!["https://example.com/pricing".to_string()]),
schema: json!({ "plans": "list of plan names with monthly prices" }).as_object().unwrap().clone(),
css_schema: Some(CssSchema { // optional free CSS pass before the LLM tier
base_selector: ".plan-card".to_string(),
fields: vec![text_field("name", "h3"), text_field("price", ".price")],
name: None,
target_field: None,
}),
..Default::default()
})?;
println!("{:?} {:?}", extraction.results[0].data, extraction.results[0].extraction_tier);
// Or discover-then-distill:
use enconvert::DistillDiscoverFrom;
client.v2().distill(DistillOptions {
discover_from: Some(DistillDiscoverFrom::new("https://example.com")),
schema: json!({ "title": "page title", "summary": "one-line summary" }).as_object().unwrap().clone(),
..Default::default()
})?;Turn a whole site — or a set of uploaded documents — into chunked, RAG-ready JSONL through one pipeline.
use enconvert::{IngestChunkOptions, IngestFilesOptions, IngestMode, IngestOptions};
// From a site:
let job = client.v2().ingest(IngestOptions {
mode: Some(IngestMode::Sitemap),
url: Some("https://docs.example.com".to_string()),
max_pages: Some(100),
chunk: Some(IngestChunkOptions { max_words: Some(512), sentence_overlap: Some(1) }),
webhook_url: Some("https://my.app/hooks/enconvert".to_string()),
..Default::default()
})?;
// Or from uploaded files (PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD, legacy/ODF office):
let file_job = client.v2().ingest_files(
vec!["handbook.pdf".into(), "notes.docx".into()],
IngestFilesOptions {
chunk: Some(IngestChunkOptions { max_words: Some(512), sentence_overlap: Some(1) }),
..Default::default()
},
)?;
let status = client.v2().get_ingest_job(&job.job_id)?; // poll
if status.status == enconvert::IngestStatus::Completed {
println!("{:?}", status.output_url); // JSONL
}
client.v2().list_ingest_jobs(Default::default())?;
client.v2().cancel_ingest_job(&job.job_id)?; // idempotent
// Webhook signing (HMAC):
let secret = client.v2().get_webhook_secret()?;
println!("{} {}", secret.secret, secret.signature_header);
client.v2().rotate_webhook_secret()?; // invalidates old secret
client.v2().retry_ingest_webhook(&job.job_id)?; // re-deliveruse enconvert::{WatchCreateOptions, WatchDiffMode, WatchUpdateStatus, WatcherUpdate};
let watcher = client.v2().create_watcher("https://example.com/pricing", WatchCreateOptions {
frequency_minutes: Some(60), // hourly floor
diff_mode: Some(WatchDiffMode::Auto), // Auto | Text | Structured | Tables | Metadata
webhook_url: Some("https://my.app/hooks/changes".to_string()),
notify_email: Some(true),
..Default::default()
})?;
client.v2().list_watchers(Default::default())?;
client.v2().get_watcher(&watcher.watcher_id)?;
client.v2().get_watcher_snapshots(&watcher.watcher_id, enconvert::SnapshotListOptions { limit: Some(10) })?;
client.v2().update_watcher(&watcher.watcher_id, WatcherUpdate { status: Some(WatchUpdateStatus::Paused), ..Default::default() })?;
client.v2().update_watcher(&watcher.watcher_id, WatcherUpdate { webhook_url: Some(String::new()), ..Default::default() })?; // clears webhook
client.v2().delete_watcher(&watcher.watcher_id)?; // soft-delete, idempotentuse enconvert::IngestOptions;
match client.v2().ingest(IngestOptions {
urls: Some(vec!["https://example.com".to_string()]),
..Default::default()
}) {
Err(e) if e.is_quota() => eprintln!("Upgrade plan or wait for quota reset"),
Err(e) => return Err(e.into()),
Ok(_) => {}
}The same key also converts 43 file and document formats. Two "anything → X" endpoints auto-detect the input; the format-specific methods below give you a validated, typed path.
use enconvert::{ConvertToMarkdownOptions, ConvertToPdfOptions, PdfOptions};
// Any document → clean Markdown (a RAG-ingestion building block):
client.convert_to_markdown("report.docx", ConvertToMarkdownOptions {
save_to: Some("report.md".into()),
..Default::default()
})?;
// PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD, and legacy/ODF office. (Images not supported.)
// Almost anything → PDF:
client.convert_to_pdf("slides.pptx", ConvertToPdfOptions {
save_to: Some("slides.pdf".into()),
..Default::default()
})?;
// office/ODF/Pages/Numbers/RTF/CSV, HTML, Markdown, text, images, SVG, EPUB, or a PDF passthrough.
// Only pdf_options.grayscale is honored on this endpoint:
client.convert_to_pdf("scan.pdf", ConvertToPdfOptions {
pdf_options: Some(PdfOptions { grayscale: Some(true), ..Default::default() }),
save_to: Some("gray.pdf".into()),
..Default::default()
})?;use enconvert::ConvertImageOptions;
let result = client.convert_image(
"photo.heic",
ConvertImageOptions {
output_format: "webp".to_string(),
save_to: Some("photo.webp".into()),
..Default::default()
},
)?;Any pair among jpeg, png, svg, heic, webp — plus PDF rasterization (pdf → jpeg). Unsupported pairs error before any request is made:
use enconvert::{valid_outputs_for, IMPLEMENTED_CONVERSIONS};
valid_outputs_for("json"); // ["csv", "toml", "xml", "yaml"]
valid_outputs_for("pdf"); // ["jpeg"]use enconvert::ConvertDocumentOptions;
client.convert_document("report.docx", ConvertDocumentOptions {
save_to: Some("report.pdf".into()),
..Default::default()
})?;
client.convert_document("data.json", ConvertDocumentOptions {
output_format: Some("yaml".to_string()),
save_to: Some("data.yaml".into()),
..Default::default()
})?;
client.convert_document("notes.md", ConvertDocumentOptions {
output_format: Some("html".to_string()),
save_to: Some("notes.html".into()),
..Default::default()
})?;Supported inputs: doc/docx, xls/xlsx, ppt/pptx, odt, ods, odp, ots, pages, numbers, html, markdown, csv, json, xml, yaml, toml. (EPUB → use convert_to_pdf / convert_to_markdown.)
| Input | Outputs |
|---|---|
| json | csv, toml, xml, yaml |
| xml | csv, json |
| yaml | json |
| csv | json, xml |
| toml | json |
| markdown | html, pdf |
| html | |
| doc, excel, ppt, odt, ods, odp, ots, pages, numbers | |
| jpeg, png, svg, heic, webp | each other (all 20 pairs) |
| jpeg |
use enconvert::{UrlToMarkdownOptions, UrlToPdfOptions, UrlToScreenshotOptions};
client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
save_to: Some("page.pdf".into()),
..Default::default()
})?;
client.convert_url_to_screenshot("https://example.com", UrlToScreenshotOptions {
save_to: Some("shot.png".into()),
..Default::default()
})?;
client.convert_url_to_markdown("https://example.com/article", UrlToMarkdownOptions {
save_to: Some("article.md".into()),
..Default::default()
})?;Discover every page of a website (sitemap, or full crawl on higher plans), convert each in the background, and receive a single ZIP. Requires a private API key with crawl access.
use enconvert::{CrawlMode, WaitForBatchOptions, WebsiteConversionOptions, WebsiteToPdfOptions};
let batch = client.convert_website_to_pdf(
"https://example.com",
WebsiteToPdfOptions {
website: WebsiteConversionOptions {
crawl_mode: Some(CrawlMode::Sitemap), // Auto (default) | Sitemap | Full
exclude_patterns: Some(vec!["/blog/tag/".to_string()]), // full crawl mode only
..Default::default()
},
..Default::default()
},
)?;
println!("{} {} {:?}", batch.batch_id, batch.url_count, batch.discovery_method);
// Block until done and save the ZIP:
let status = client.wait_for_batch(
&batch.batch_id,
WaitForBatchOptions {
save_to: Some("site.zip".into()),
..Default::default()
},
)?;
println!("{} of {} pages converted", status.completed, status.total);convert_website_to_screenshot works the same way and produces a ZIP of PNGs.
use enconvert::{PdfHeaderFooter, PdfMargins, PdfOptions, PdfOrientation, UrlToPdfOptions};
let result = client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
pdf_options: Some(PdfOptions {
page_size: Some("A4".to_string()), // or custom dimensions via page_width + page_height
orientation: Some(PdfOrientation::Landscape),
margins: Some(PdfMargins { top: Some(10.0), bottom: Some(10.0), left: Some(15.0), right: Some(15.0) }),
header: Some(PdfHeaderFooter { content: Some("Quarterly Report".to_string()), height: Some(15.0) }),
footer: Some(PdfHeaderFooter { content: Some("Confidential".to_string()), height: Some(12.0) }),
..Default::default()
}),
save_to: Some("report.pdf".into()),
..Default::default()
})?;All URL and website conversions also accept HTTP Basic Auth, cookies, and custom headers for pages behind a login (plan-gated):
use enconvert::{BrowserCookie, HttpBasicAuth, UrlRenderOptions, UrlToPdfOptions};
use std::collections::HashMap;
client.convert_url_to_pdf("https://internal.example.com/report", UrlToPdfOptions {
render: UrlRenderOptions {
auth: Some(HttpBasicAuth { username: "user".to_string(), password: "pass".to_string() }),
// or cookies / headers:
cookies: Some(vec![BrowserCookie {
name: "session".to_string(),
value: "abc123".to_string(),
domain: Some("internal.example.com".to_string()),
..Default::default()
}]),
headers: Some(HashMap::from([("X-Tenant".to_string(), "acme".to_string())])),
..Default::default()
},
save_to: Some("report.pdf".into()),
..Default::default()
})?;Do not combine auth with an Authorization header — the API rejects the conflict.
let status = client.get_job_status("job_abc123")?;
if status.status == enconvert::JobStatusValue::Success {
println!("{:?}", status.presigned_url);
}use enconvert::Error;
match client.convert_url_to_pdf("https://example.com", Default::default()) {
Ok(result) => println!("{}", result.presigned_url),
Err(e) if e.is_authentication() => eprintln!("Invalid API key"),
Err(e) if e.is_rate_limit() => eprintln!("Too many requests — slow down"),
Err(Error::Api { status, message }) => eprintln!("API error [{status}]: {message}"),
Err(e) => return Err(e.into()),
}use std::time::Duration;
use enconvert::Enconvert;
let client = Enconvert::with_options(
"sk_...",
None, // base_url override, defaults to https://api.enconvert.com
Some(Duration::from_millis(300_000)), // request timeout, default
)?;Sign up at enconvert.com. Free tier: 100 ops/month, no credit card.
MIT