writ-client
Official Rust SDK for the Writ local agent (writ-agentd) — the loopback HTTP
API your workflows, monitors, runs, personas, secrets, and files live behind
(http://127.0.0.1:8131, all routes under /v1).
Async-only, built on reqwest. The full wire contract lives in
sdks/openapi/writ-agent.yaml; the cross-language SDK
contract is sdks/DESIGN.md.
Install
[]
= "0.1.0"
= { = "1", = ["rt-multi-thread", "macros"] } # or any reqwest-compatible runtime
Quickstart
use ;
async
Auth & discovery
Every request carries Authorization: Bearer <token>. Tokens are opaque strings:
wlt_ (runtime token, full access), wlk_ (scoped API key), wlo_ (OAuth access
token).
WritAgent::discover() finds a running daemon the same way the daemon's own tools
do:
- Env overrides —
WRIT_API_URL(base URL) andWRIT_TOKEN(bearer). Both set ⇒ done; one set ⇒ it pins that field and the rest is discovered. runtime.jsoncandidates, first live one wins (each is probed with a 2 sGET /v1/agent; stale descriptors from crashed daemons fall through):$WRIT_HOME/runtime.json(whenWRIT_HOMEis set — always first)~/.writ/active_profile→~/.writ/profiles/<id>/runtime.json~/.writ/runtime.json- every
~/.writ/profiles/*/runtime.json(capped at 32)
Explicit configuration always wins, and build() does no discovery or network I/O:
use Duration;
use WritAgent;
let agent = builder
.base_url // trailing '/' ok; no trailing /v1
.token // or rely on WRIT_TOKEN
.timeout // per-request; default 30 s
.ca_pem_file // only for the HTTPS twin port 8132
.build?;
# Ok::
Errors
Three kinds, one enum ([WritError]): Api { status, code, message, body } for
non-2xx responses (JSON {error, code} bodies and axum's plain-text rejections are
both normalized), Connection for network failures/timeouts, Discovery when no
live daemon can be found at construction. No automatic retries in v1.
use WritError;
match agent.workflows.get.await
One deliberate exception: runs().cancel() / workflows().cancel() return
Ok(CancelOutcome) for both 202 cancel_requested and 409 not_running — a
run that already finished is an answer, not an error.
Lists are always a Page
The daemon mixes {data, count}, {data, count, total}, and bare-array envelopes
on the wire. Every list method here returns the same
Page<T> { data, count, total: Option<u64> } regardless. Models type the stable
scalar fields and keep unknown wire fields in an extra map, so newer daemons never
break deserialization.
Live run events (SSE)
runs().events(run_id) streams typed [RunEvent]s (Started, Step, Progress,
then a stream-closing Finished or Error; unknown future frames arrive as
RunEvent::Unknown). Keep-alive comment frames are handled inside the parser.
use StreamExt;
use RunEvent;
let mut events = agent.runs.events.await?;
while let Some = events.next.await
workflows().run_and_wait(id, &opts) wraps the whole lifecycle: start the run,
follow SSE, fall back to 1 s polling if the stream drops, enforce an overall
deadline (RunOptions::wait_timeout, default 600 s). On timeout the run is not
cancelled — it keeps executing on the daemon. Set include_results: true to fetch
runs().results() alongside the final run row.
Waiting: three ways
The daemon's run endpoint is async by default and you choose how to wait:
| You want | Call | You get |
|---|---|---|
| A handle to watch yourself | workflows().run(id, &opts) |
202 {run_id} — stream runs().events(..) |
| Just the answer, one request | workflows().run_wait(id, &opts, timeout) |
RunCompleted |
| Live events + the enriched feed item | workflows().run_and_wait(id, &opts) |
RunOutcome |
run_wait is the server-side wait (?wait=true): the daemon blocks and answers
with the run's own result — no SSE, no poll loop.
let done = agent
.workflows
.run_wait
.await?;
match done.status.as_str
A run that fails is an Ok value, not an Err. Only an expired budget is an error,
and it carries the still-valid run id so you collect the run rather than retry (a retry
starts a second run):
match agent.workflows.run_wait.await
timeout is clamped server-side to 1–3600 s (default 120). Reach for run_and_wait
when you want live progress or a longer deadline than the daemon's own ceiling.
Surface
agent(), workflows(), runs(), monitors(), selectors(), extractors(),
automations(), personas(), secrets() (metadata only — secret values never
cross the API), vault(), files() (multipart upload + raw byte download),
data() (queries + CSV/JSON exports), keys() (mint scoped wlk_ keys),
crawl() (Dragnet whole-site crawls), plus ws_ticket(route, channel) for
single-use WebSocket tickets.
crawl().list() returns a CrawlList { crawls } (this endpoint answers a named
object, not a Page); crawl().start(CrawlStartParams { url, .. }) kicks off a
crawl whose pages aggregate under the synthetic CrawlJob::data_workflow_id
workflow — read them back with data().workflow_data(data_workflow_id, &[]).
Out of scope for v1 (roadmap): OAuth AS endpoints, /mcp JSON-RPC, OpenAI-compat
routes, cloud-link surfaces, backup/update/network/TLS/settings/diagnostics/
notifications, the AI surfaces (ai-assist, ai-sessions, ai-concierge,
streaming sessions), relay, webhooks, and opening WebSockets.
Cloud tier
CloudClient is a separate surface from WritAgent: scrape, map, and
whole-site crawl that run on Writ Cloud, never on the local daemon. The
credential decides the tier (Firecrawl-style fallback):
- Metered — an API key (builder
api_key→WRIT_API_KEY) → the authed/api/crawl/*surface, billed per page.scrape,map, ANDcrawl. - Keyless — no key → the free
/v1/keyless/*tier, daily-capped per install (a stable~/.writ/client_iddevice header) and per IP.scrape+maponly;crawlreturnsWritError::ApiKeyRequiredbefore any network call.
use ;
# async
Config resolves per field: builder value → env (WRIT_API_KEY, WRIT_CLOUD_URL,
WRIT_CLIENT_ID) → default (https://api.usewrit.app; the client id is loaded/minted
at ~/.writ/client_id, ephemeral on a read-only fs). Cloud errors map to
WritError::RateLimited (429, carries reset_at + remaining allowances),
ApiKeyRequired (402 api_key_required), and InsufficientCredits (other 402s);
everything else is the generic Api variant.
Testing
cargo test runs everything against an in-process stub server. An env-gated smoke
test talks to your real daemon:
WRIT_SMOKE=1
License
MIT