writ-agent
Official Python SDK for the Writ local agent (writ-agentd) — the loopback
daemon that runs your workflows, monitors, and automations on your own machine.
- Sync and async clients (
WritAgent,AsyncWritAgent), same surface. - Automatic daemon discovery (env →
runtime.json→ liveness probe). - Typed run events over SSE,
run_and_waitwith SSE→polling fallback. - One runtime dependency:
httpx.
pip install writ-agent
Requires Python ≥ 3.10 and a running Writ agent (the desktop app's daemon, or
writ-agentd standalone).
Quickstart (sync)
from writ_agent import WritAgent, run_row_id
with WritAgent() as client: # discovers the local daemon
for wf in client.workflows.list():
print(wf["id"], wf["name"])
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
Quickstart (async)
import asyncio
from writ_agent import AsyncWritAgent, run_row_id
async def main():
async with AsyncWritAgent() as client:
workflows = await client.workflows.list()
run = await client.workflows.run_and_wait(workflows[0]["id"])
data = await client.runs.data(run_row_id(run))
print(run["status"], data["data"])
asyncio.run(main())
On AsyncWritAgent every resource method returns an awaitable of the same
shape the sync client returns, and runs.events is an async generator.
Auth & discovery
The agent serves plain HTTP on 127.0.0.1:8131 (HTTPS twin on :8132) and
requires Authorization: Bearer <token> on every request. Tokens are opaque
strings (wlt_ runtime token, wlk_ scoped API key, or wlo_ OAuth token).
When you construct a client without base_url/token, the SDK discovers a
live daemon:
- Env overrides —
WRIT_API_URL(base URL) andWRIT_TOKEN(bearer). If both are set, discovery is done; one alone fills that field and the rest is discovered. runtime.jsoncandidates, in order (first live one wins):$WRIT_HOME/runtime.json(whenWRIT_HOMEis set)~/.writ/active_profile→~/.writ/profiles/<id>/runtime.json~/.writ/runtime.json- every
~/.writ/profiles/*/runtime.json(capped at 32)
- Each candidate is probed (
GET /v1/agent, 2 s budget); a stale descriptor from a crashed daemon falls through to the next candidate.
Explicit options always win: WritAgent("http://127.0.0.1:8131", "wlt_…").
WritAgent runs discovery in its constructor; AsyncWritAgent defers it to
async with entry (or the first request) so the constructor never blocks —
that's also where WritDiscoveryError may be raised on the async client.
For the HTTPS twin port pass the per-install CA: WritAgent(ca_file="~/.writ/tls/ca.pem").
Error handling
from writ_agent import WritAgent, WritApiError, WritConnectionError, WritDiscoveryError
try:
with WritAgent() as client:
client.workflows.get(999999)
except WritDiscoveryError as e:
print("no daemon:", e) # not running / no token found
except WritConnectionError as e:
print("daemon went away:", e) # network failure / timeout
except WritApiError as e:
print(e.status, e.code, e.message) # 404 not_found not found: workflow 999999
print(e.body) # parsed JSON body (or raw text)
code is a stable machine code — the daemon's own (not_found,
vault_locked, device_capacity, …) or derived from the status for plain-text
responses (400→bad_request, 409→conflict, 5xx→internal, …).
Special case: runs.cancel / workflows.cancel return the response body for
both 202 ({"status": "cancel_requested"}) and 409
({"status": "not_running"}) — a 409 there is a valid answer, not an exception.
Streaming run events (SSE)
started = client.workflows.run(3, inputs={"city": "Paris"})
for event in client.runs.events(started["run_id"]):
match event["event"]:
case "started": print("steps:", event["total_steps"])
case "step": print(event["index"], event["step_type"], event["status"])
case "progress": print(f'{event["completed"]}/{event["total"]}')
case "finished": print("done:", event["status"])
case "error": print("failed:", event["message"])
The iterator ends after the stream-closing finished/error event; a run
that already finished yields exactly one terminal frame. On the async client
use async for.
workflows.run_and_wait(id, inputs=…, wait_timeout=600, poll_interval=1.0, include_results=False) wraps this: it subscribes to SSE and, if the stream
fails or drops pre-terminal, falls back to polling runs.get — and raises
WritTimeoutError after wait_timeout without cancelling the run.
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) |
202 {run_id} — stream runs.events(run_id) |
| Just the answer, one request | workflows.run(id, wait=True) |
The terminal run document |
| Live events + the enriched feed item | workflows.run_and_wait(id) |
Final RunFeedItem (+ results) |
wait=True is the server-side wait (?wait=true): the daemon blocks and answers
with the run's own result — no SSE, no poll loop.
done = client.workflows.run(3, wait=True, timeout=60)
if done["status"] == "success":
print(done["data"])
else:
print(done.get("error")) # a FAILED run RETURNS — check status
A run that fails is a result, not an exception. Only an expired budget raises
WritRunTimeoutError, which carries the still-valid run_id — the run keeps going,
so collect it rather than retrying (a retry starts a second run):
from writ_agent import WritRunTimeoutError
try:
client.workflows.run(3, wait=True, timeout=30)
except WritRunTimeoutError as err:
for event in client.runs.events(err.run_id):
...
Both clients share one decode path, so await async_client.workflows.run(3, wait=True)
behaves identically. Reach for run_and_wait when you want live progress or a deadline
longer than the daemon's own ceiling (timeout is clamped server-side to 1–3600 s,
default 120).
Surface
Namespaces on both clients (all list methods return a uniform
Page(data, count, total) — iterable and len()-able — regardless of the
wire envelope):
| Namespace | Highlights |
|---|---|
client.agent |
status(), health() |
client.workflows |
list/create/get/update/delete, run, run_and_wait, cancel, session, clear_session |
client.runs |
list/get, results, data, data_csv (str), events (SSE), cancel |
client.monitors |
CRUD, run, changes, capacity, recent_changes |
client.selectors |
per-monitor CRUD, toggle, test, set_baseline, clear_baseline |
client.extractors |
CRUD, toggle, test |
client.automations |
CRUD, enable, run |
client.personas |
CRUD, runs, validate_totp, test_2fa |
client.secrets |
list, set, get (metadata only — values never come back), delete |
client.vault |
status, lock, unlock (app-lock; locked → 423 vault_locked) |
client.files |
list, upload (multipart), from_data, get, delete, content (bytes) |
client.data |
query, workflow_data, delete_workflow_data, facets, export, data_runs |
client.crawl |
list (→ {crawls:[…]}, not a Page), start, get, cancel (Dragnet whole-site crawl) |
client.cloud |
scrape, map, crawl, crawl_status, quota — the tiered cloud surface |
client.keys |
list, create (plaintext wlk_ key shown once), get, delete |
client.ws_ticket(route, channel=None) |
mint a single-use WebSocket ticket |
Cloud tier (client.cloud) — scrape / map / crawl
Runs on Writ Cloud (not the local daemon), Firecrawl-style, tier resolved from your credential:
# Metered: pass api_key (or set WRIT_API_KEY) → billed per page.
client = WritAgent(api_key="wt_…")
page = client.cloud.scrape("https://example.com") # {"markdown": …, "tier": "metered"}
job = client.cloud.crawl("https://example.com", page_budget=200)
# Keyless: no key → free, daily-capped per install (X-Writ-Client-Id) + per IP; scrape + map only.
anon = WritAgent()
p = anon.cloud.scrape("https://example.com") # {"markdown": …, "tier": "keyless", "quota": {…}}
anon.cloud.crawl("https://example.com") # raises WritApiKeyRequiredError
Precedence api_key → WRIT_API_KEY → keyless. Errors: 429 → WritRateLimitedError (.reset_at),
402 api_key_required → WritApiKeyRequiredError, other 402 → WritInsufficientCreditsError. The async
client (AsyncWritAgent) exposes the same client.cloud with awaitable methods.
Responses are plain dicts (annotated with TypedDicts) — new daemon fields
pass straight through. run_row_id(item) extracts the numeric row id from a
run feed item's composite "workflow-3" id.
The machine-readable spec lives at
sdks/openapi/writ-agent.yaml.
Roadmap (not in v1): OAuth AS endpoints, /mcp JSON-RPC, OpenAI-compat
routes, cloud link surfaces, backup/update/network/TLS/settings/diagnostics/
notifications, AI surfaces (ai-assist / ai-sessions / ai-concierge / streaming
sessions), relay, webhooks, opening WebSockets.
Development
pip install -e '.[dev]'
pytest # unit tests (mock transport + loopback stubs)
WRIT_SMOKE=1 pytest # + live smoke test against your running daemon
MIT license.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file writ_agent-1.0.0.tar.gz.
File metadata
- Download URL: writ_agent-1.0.0.tar.gz
- Upload date:
- Size: 47.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f215127fb5c17f640725547a27625a59e5d36fee6137c0f8bb07d15cbeb60853
|
|
| MD5 |
fc43efd8c4868f2aada8f376258ebb0c
|
|
| BLAKE2b-256 |
a24d202b65c28581dad8a77faac3bbe163dde0961f7daa0cd4b562519b38cbd3
|
Provenance
The following attestation bundles were made for writ_agent-1.0.0.tar.gz:
Publisher:
release-pypi.yml on usewrit/writ-sdks
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
writ_agent-1.0.0.tar.gz -
Subject digest:
f215127fb5c17f640725547a27625a59e5d36fee6137c0f8bb07d15cbeb60853 - Sigstore transparency entry: 2363240614
- Sigstore integration time:
-
Permalink:
usewrit/writ-sdks@965fab2fe5ff510d9d97daad32f904ee870bea60 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/usewrit
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-pypi.yml@965fab2fe5ff510d9d97daad32f904ee870bea60 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file writ_agent-1.0.0-py3-none-any.whl.
File metadata
- Download URL: writ_agent-1.0.0-py3-none-any.whl
- Upload date:
- Size: 42.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35384c27f00694f143c4c60ced9ad43e0f07b2e88c65c978c9c74bb74ef3f8dd
|
|
| MD5 |
4a1e764e6934b13b9ad073f7cc8a7d4c
|
|
| BLAKE2b-256 |
efd24355fca5cfc00db81b22bc3f6f89801e30c1ff9ff23148454589debf392d
|
Provenance
The following attestation bundles were made for writ_agent-1.0.0-py3-none-any.whl:
Publisher:
release-pypi.yml on usewrit/writ-sdks
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
writ_agent-1.0.0-py3-none-any.whl -
Subject digest:
35384c27f00694f143c4c60ced9ad43e0f07b2e88c65c978c9c74bb74ef3f8dd - Sigstore transparency entry: 2363240991
- Sigstore integration time:
-
Permalink:
usewrit/writ-sdks@965fab2fe5ff510d9d97daad32f904ee870bea60 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/usewrit
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-pypi.yml@965fab2fe5ff510d9d97daad32f904ee870bea60 -
Trigger Event:
workflow_dispatch
-
Statement type: