Runs onWrit CloudSelf-hosted
On this page
Four SDKs. One agent.
TypeScript, Python, Go and Rust — published, versioned, and thin. Each client discovers the running Writ agent on your machine, and the same client reaches Writ Cloud when you hand it a wt_ key.
install ▸ first run
Install, discover, run.
Every quickstart is the same three beats: the client finds the running agent (no URL, no token to paste), lists your workflows, runs one and reads the extracted rows back. These samples are the published packages, verbatim.
| TypeScript | typescript/ | From the repo · Node ≥ 18 · zero runtime deps |
| Python | python/ | From the repo · Python ≥ 3.10 · import writ_agent |
| Go | github.com/usewrit/writ-sdks/go | go get · Go ≥ 1.23 · stdlib only |
| Rust | rust/ | Git dependency · async, any reqwest-compatible runtime |
run.ts
import { WritAgent, runRowId } from "@usewrit/agent-sdk";
const client = new WritAgent(); // discovers the running agent + token
const { data: workflows } = await client.workflows.list();
const run = await client.workflows.runAndWait(workflows[0].id, {
inputs: { city: "Paris" },
});
const { data: rows } = await client.runs.data(runRowId(run));
console.log(run.status, rows); run.py
from writ_agent import WritAgent, run_row_id
with WritAgent() as client: # discovers the local daemon
run = client.workflows.run_and_wait(3, inputs={"city": "Paris"})
print(run["status"], run["rows_extracted"])
print(client.runs.data(run_row_id(run))["data"]) # extracted rows run.go
client, err := writ.Discover(ctx) // find the running agent
page, _ := client.Workflows.List(ctx, nil)
item, _ := client.Workflows.RunAndWait(ctx, page.Data[0].ID, nil)
rowID, _ := item.RowID()
csv, _ := client.Runs.DataCSV(ctx, rowID) // extracted rows as CSV
fmt.Println(item.Status, "
", csv) run.rs
use writ_client::{RunOptions, WritAgent};
let agent = WritAgent::discover().await?; // find the running daemon
let workflows = agent.workflows().list().await?;
let wf = &workflows.data[0];
let outcome = agent.workflows().run_and_wait(wf.id, &RunOptions::default()).await?;
let rows = agent.runs().data(outcome.run.row_id().unwrap()).await?;
println!("{} → {}: {}", wf.name, outcome.run.status, rows.data); call.sh
curl -X POST https://api.usewrit.app/v1/acme/price-check \
-H "Authorization: Bearer $WRIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/product/42"}' surfaces ▸ two
One client, two surfaces.
The SDKs speak to two different places, and the docs never blur them. The local agent is the software on your machine: loopback only, free, with its own token families. Writ Cloud is the hosted surface a wt_ key unlocks — and it has a keyless tier that needs no account at all.
| Surface | Base URL | Auth |
|---|---|---|
| Local agent (writ-agentd) | http://127.0.0.1:8131 · https://127.0.0.1:8132 | wlt_ runtime token · wlk_ scoped key · wlo_ OAuth |
| Writ Cloud | https://api.usewrit.app | wt_ API key (metered) · X-Writ-Client-Id (keyless) |
Talk to 127.0.0.1, not localhost — the daemon enforces a DNS-rebind guard on the Host and Origin it accepts. The HTTPS twin on :8132 uses a per-install local CA at ~/.writ/tls/ca.pem.
Environment variables
Discovery runs env-first, then reads runtime.json from the Writ home, probing each candidate for liveness. Identical names across all four SDKs:
| Variable | What it does |
|---|---|
WRIT_API_URL | Local daemon base URL override |
WRIT_TOKEN | Local daemon bearer token override |
WRIT_HOME | First runtime.json candidate directory |
WRIT_API_KEY | Writ Cloud metered API key (wt_) |
WRIT_CLOUD_URL | Writ Cloud base URL override |
WRIT_CLIENT_ID | Keyless device id override |
runs ▸ three ways to wait
Run it, then wait your way.
Every SDK exposes the same three postures for the same run:
- Async handle — run() returns immediately with a run id — poll or stream when you choose.
- Server-side wait — run with wait — the HTTP call itself blocks until the run settles (timeout in seconds, clamped server-side).
- runAndWait — The SDK subscribes to the live event stream and polls as a fallback, returning the settled run.
A failed run is a result, not an error: you get the run back with its status. Only an expired wait budget raises — and the error still carries the run id so nothing is lost.
Run-feed items carry a composite string id like workflow-3. Every runs.* call takes the numeric row id — extract it with the per-language helper: runRowId(run) (TS), run_row_id(run) (Python), item.RowID() (Go), item.row_id() (Rust).
Live events over SSE
Step-by-step progress streams from the daemon; each language gets its native idiom — an async iterator, a generator, a range-over-func, a Stream.
events.ts
for await (const ev of client.runs.events(runRowId(run))) {
console.log(ev.type, ev);
} events.py
for ev in client.runs.events(run_row_id(run)):
print(ev["type"], ev) events.go
for ev, err := range client.Runs.Events(ctx, rowID) {
if err != nil { break }
fmt.Println(ev.Type, ev)
} events.rs
use futures_util::StreamExt;
use writ_client::RunEvent;
let mut events = agent.runs().events(run_id).await?;
while let Some(ev) = events.next().await {
match ev? {
RunEvent::Step { index, step_type, status, .. } => println!("{index} {step_type} {status}"),
RunEvent::Finished { status, .. } => println!("done: {status}"),
_ => {}
}
} surface ▸ services
The whole agent, namespaced.
One client object carries the full surface: agent, workflows, runs, monitors, selectors, extractors, automations, personas, secrets, vault, files, data, crawl, datasets, keys — plus cloud. The names are identical across languages; the idioms are native:
| TypeScript | Promise-based namespaces; Page<T> envelopes; run() overloads for wait and dry-run. |
| Python | Sync WritAgent and AsyncWritAgent twins; responses are plain dicts; Page is iterable. |
| Go | Every method takes ctx first; errors are errors.As-friendly typed pointers; zero dependencies. |
| Rust | Async-only; filtered lists via *_with variants; Cloud is a separate CloudClient; one flat WritError enum. |
cloud ▸ metered + keyless
The cloud tier is built in.
Hand the client a wt_ key and cloud Scrape, Map and Crawl calls are metered from your credit pool. Without any key, the keyless tier reads public pages identified only by a device id — with a quota endpoint that tells you what remains.
cloud.ts
const cloud = new CloudApi({ apiKey: process.env.WRIT_API_KEY }); // wt_…
const page = await cloud.scrape("https://example.com");
const site = await cloud.map("https://example.com", { search: "pricing", limit: 20 });
console.log(cloud.tier); // "metered" | "keyless" cloud.py
cloud = Cloud(api_key=os.environ["WRIT_API_KEY"]) # wt_… — no daemon needed
page = cloud.scrape("https://example.com")
site = cloud.map("https://example.com", search="pricing", limit=20)
print(cloud.tier) # "metered" | "keyless" metered.sh
# Metered — wt_ API key, billed from your credit pool
curl -X POST https://api.usewrit.app/api/crawl/scrape \
-H "Authorization: Bearer $WRIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}' keyless.sh
# Keyless — no account, no key: a stable device id is the only identity.
# 429 keyless_rate_limited when the allowance is spent.
curl -X POST https://api.usewrit.app/v1/keyless/scrape \
-H "X-Writ-Client-Id: $WRIT_CLIENT_ID" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
curl https://api.usewrit.app/v1/keyless/quota -H "X-Writ-Client-Id: $WRIT_CLIENT_ID" The client exposes its tier ("metered" or "keyless") so your code can branch. Keyless answers 429 when the allowance is spent; metered answers 402 when the pool is empty — both as typed errors.
keys ▸ errors
Scoped keys, typed failures.
Minting a scoped wlk_ key (scopes: read, run, admin) requires the full-access runtime token — so a leaked CI key can never mint broader access for itself.
const key = await client.keys.create({ name: "ci-runner", scopes: "read,run" }); key = client.keys.create("ci-runner", scopes="read,run") key, err := client.Keys.Create(ctx, "ci-runner", "read,run") let key = agent.keys().create("ci-runner", Some("read,run")).await?; # Minting keys requires the full-access runtime token (wlt_)
curl -X POST http://127.0.0.1:8131/v1/keys \
-H "Authorization: Bearer $WRIT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "ci-runner", "scopes": "read,run"}' Error taxonomy
The same failure is the same type in every language — catch what you can handle, let the rest carry status, code and body:
ApiError | Any non-2xx with a stable code: bad_request, unauthorized, forbidden, not_found, vault_locked (423), too_many_requests, internal. |
RunTimeout | A wait budget expired — carries the still-valid run id. |
RateLimited | Keyless allowance spent — carries reset time and remaining counters. |
InsufficientCredits | Metered pool empty (402). Top up or fall back to local. |
ApiKeyRequired | A metered cloud call was made without a wt_ key. |
Connection / Discovery | No live agent found, or the daemon is unreachable. |
rest ▸ no sdk
No SDK? The endpoint is plain REST.
A published workflow endpoint is an ordinary HTTPS POST with a wt_ Bearer token — these wrappers are the whole integration if you would rather own the HTTP yourself.
writ.py
import os, requests
WRIT_BASE = "https://api.usewrit.app"
def run_workflow(slug: str, path: str, inputs: dict) -> dict:
res = requests.post(
f"{WRIT_BASE}/v1/{slug}/{path}",
headers={"Authorization": f"Bearer {os.environ['WRIT_CONSUMER_KEY']}"},
json=inputs,
timeout=120,
)
res.raise_for_status()
return res.json()
payload = run_workflow("acme", "price-check", {"url": "https://example.com/product/42"})
print(payload["data"]) writ.ts
const WRIT_BASE = "https://api.usewrit.app";
export async function runWorkflow<T>(slug: string, path: string, inputs: unknown): Promise<T> {
const res = await fetch(`${WRIT_BASE}/v1/${slug}/${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WRIT_CONSUMER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(inputs),
});
if (!res.ok) throw new Error(`Writ ${res.status}: ${await res.text()}`);
return res.json() as Promise<T>;
} writ.go
package writ
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const Base = "https://api.usewrit.app"
func RunWorkflow(slug, path string, inputs any) (map[string]any, error) {
body, err := json.Marshal(inputs)
if err != nil {
return nil, err
}
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/v1/%s/%s", Base, slug, path), bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("WRIT_CONSUMER_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("writ %d", res.StatusCode)
}
var out map[string]any
return out, json.NewDecoder(res.Body).Decode(&out)
} writ.rs
use serde::Serialize;
use serde_json::Value;
pub const BASE: &str = "https://api.usewrit.app";
pub async fn run_workflow<T: Serialize>(
slug: &str,
path: &str,
inputs: &T,
) -> Result<Value, Box<dyn std::error::Error>> {
let key = std::env::var("WRIT_CONSUMER_KEY")?;
Ok(reqwest::Client::new()
.post(format!("{BASE}/v1/{slug}/{path}"))
.bearer_auth(key)
.json(inputs)
.send()
.await?
.error_for_status()?
.json()
.await?)
} run.sh
# WRIT_CONSUMER_KEY must be exported (csk_...)
curl -sS -X POST https://api.usewrit.app/v1/acme/price-check \
-H "Authorization: Bearer $WRIT_CONSUMER_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/product/42"}' | jq .data reference ▸ next
Build out the integration
The full local + cloud surface, endpoint by endpoint.
→ AuthenticationToken families, scopes, rotation.
→ Managed endpoints/v1/{slug}/{path} — your published doors.
→ Consumer keysDistribute access to partners.
→ WebhooksSigned deliveries and verification.
→ MCPThe same workflows as tools for any MCP client.
→faq
SDK questions, answered.
Do I need an SDK to use Writ?
Which languages are published?
How do the SDKs find my agent?
How do I handle long-running workflows?
Do the SDKs work in a browser?
end ▸ ship
Install one and make the first call.
The quickstart runs your first workflow in a few minutes, locally and for free.