Skip to main content

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_wait with 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:

  1. Env overrides — WRIT_API_URL (base URL) and WRIT_TOKEN (bearer). If both are set, discovery is done; one alone fills that field and the rest is discovered.
  2. runtime.json candidates, in order (first live one wins):
    1. $WRIT_HOME/runtime.json (when WRIT_HOME is set)
    2. ~/.writ/active_profile → ~/.writ/profiles/<id>/runtime.json
    3. ~/.writ/runtime.json
    4. every ~/.writ/profiles/*/runtime.json (capped at 32)
  3. 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.

Metadata

Release files for writ-agent 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for writ-agent 1.2.0
File Size Uploaded Provenance
writ_agent-1.2.0.tar.gz 83.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for writ-agent 1.2.0
File Interpreter ABI Platform
writ_agent-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 152.5 kB

Release files / writ_agent-1.2.0.tar.gz

Download URL writ_agent-1.2.0.tar.gz
Size 83.3 kB
Tags Source
SHA-256 checksum
How to use checksums
fd3019eec4d0e9b18cf9f236385ad33ff6570d96d00de8569390edb6354363d6
BLAKE2b-256 checksum
How to use checksums
aa92c2d71bd84f8eed27f0e604debc8b14ee61ba868cf0c5d07472b98df52963
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 15, 2026.

Transparency log

Release files / writ_agent-1.2.0-py3-none-any.whl

Download URL writ_agent-1.2.0-py3-none-any.whl
Size 69.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3d5c124ab7d1c0256cba3b4919ff7cfa8f5f63bd990e6fdc1aaa07232cc89425
BLAKE2b-256 checksum
How to use checksums
eec2b40199d3ca1bd0268234d63336f8dec67edd32c57d61abd1fd6ceff9c045
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 15, 2026.

Transparency log

Security for writ-agent 1.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for writ_agent-1.2.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 15, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page