usewrit
The server side of the Writ Bridge for Python. Your website calls its Writ functions from the server, and each call runs where you chose: on the signed-in visitor's own computer (through the Writ browser extension and Writ desktop, with a signed receipt), or on Writ Cloud with your API key. One object, one route to mount.
Guides, the public wire contract and runnable examples: usewrit/writ-for-websites.
- The same API as the Node package
@usewrit/server, in snake_case; both produce the same bytes (tokens, challenges, canonical JSON). - FastAPI router, a framework-free ASGI app, and a plain
handle()core for anything else. - In-memory state by default; Redis for several workers.
- Runtime dependencies:
cryptography(Ed25519) andhttpx(Writ Cloud).
pip install usewrit # core
pip install "usewrit[fastapi]" # + FastAPI router helper
pip install "usewrit[redis]" # + RedisStore for several workers
Requires Python 3.10 or later.
Quick start (FastAPI)
- Build your workflows in Writ and download
writ-contract.json. - Set
WRIT_BRIDGE_SECRET(at least 32 bytes:openssl rand -base64 48). For the cloud modes, also setWRIT_API_KEY.
from pathlib import Path
from fastapi import Depends, FastAPI
from usewrit import create_writ_bridge
bridge = create_writ_bridge(Path("writ-contract.json"), mode="required")
app = FastAPI()
app.include_router(bridge.fastapi_router("/writ-bridge")) # the one route to mount
@app.get("/api/writ-token")
async def writ_token(user=Depends(current_user)):
# Hand this to the page's bridge. It identifies the user; the secret never leaves the server.
return {"token": bridge.user_token(user.id)}
@app.post("/api/invoices/{invoice_no}/receive")
async def receive(invoice_no: str, user=Depends(current_user)):
result = await bridge.user(user.id).pos.receive({"invoice_no": invoice_no}, wait_for_user_ms=10_000)
return {"received": result.data, "ran_on": result.venue} # venue: "user" or "cloud"
The page creates its side with @usewrit/web and the token:
import { createWritBridge } from '@usewrit/web'
const bridge = createWritBridge({ contract, app: 'Arrivos', mode: 'required', endpoint: '/writ-bridge', userToken })
Dotted contract keys nest: "pos.receive" is bridge.user(uid).pos.receive(...). A key whose
segments are not Python names is reached by item: bridge.user(uid)["orders.import"](...).
Switching mode is one line
bridge = create_writ_bridge(Path("writ-contract.json"), mode="recommended") # or "required", "cloud"
The rest of your code does not change. required needs no Writ Cloud account; recommended and
cloud read WRIT_API_KEY and need a contract published from Writ Cloud (it carries each
function's cloud: {workflow_id, function} mapping).
| mode | The user's page is connected, Writ is ready and the function is granted | Otherwise |
|---|---|---|
required |
Runs on the user's computer | USER_OFFLINE (their pages went away), NOT_GRANTED, or WRIT_REQUIRED (Writ not set up), after an optional wait_for_user_ms |
recommended |
Runs on the user's computer | Runs on Writ Cloud (your account and plan) |
cloud |
Runs on Writ Cloud | Runs on Writ Cloud |
bridge.pos.lookup({...})without a user runs on Writ Cloud (recommended/cloud) and raisesWRIT_REQUIREDinrequiredmode.- A function whose contract lists
local_networkhosts always runs asrequired: Writ Cloud cannot reach your user's network. Setting it to another mode is refused when the bridge is created. - Per function:
function_modes={"pos.lookup": "cloud"}, ormodeon the contract entry.
create_writ_bridge refuses at creation what would fail later: a recommended / cloud
function with no cloud mapping or no WRIT_API_KEY (CLOUD_NOT_CONFIGURED), a secret shorter
than 32 bytes or missing while a function can run on a user's computer, and a contract key whose
first segment is a member of the bridge (CONFIG_INVALID).
Calls
result = await bridge.user(user_id).auth.verify_login(
{"company": company, "username": username},
timeout_ms=60_000, # 1000 to 900000; default 120000
wait_for_user_ms=5_000, # required mode: wait this long for the user's page; default 0
dry_run=False, # preview a write without sending it, where the function supports it
)
result.data # the function's data
result.venue # "user" or "cloud"
result.receipt # the verified Writ receipt, when it ran on the user's computer
Inputs are checked against the contract before anything is routed: every required input present,
nothing undeclared, JSON only, at most 1 MB (INPUT_INVALID). await bridge.online(user_id)
tells whether one of the user's pages is connected with Writ ready.
A login gate: challenge and verify
When the page itself calls a function (for example, before a login, to prove the visitor's computer checked something), mint a single-use challenge, let the page pass it to its call, and verify the receipt the page relays:
@app.post("/api/login/challenge")
async def login_challenge(body: LoginStart, session=Depends(session)):
session["writ_challenge"] = bridge.challenge(body.username, "auth.verify_login", ttl_seconds=120)
return {"challenge": session["writ_challenge"]}
@app.post("/api/login")
async def login(body: LoginFinish, session=Depends(session)):
check = await bridge.verify(
body.receipt,
challenge=session.pop("writ_challenge"),
key="auth.verify_login",
origin="https://app.example.com",
data=body.data, # the data the page reports; its hash must match
)
if not check.ok:
raise HTTPException(401, check.reason)
...
verify never raises for a bad receipt; it answers
VerifyResult(ok, outcome, reason, device, unknown_device). ok is true only when the receipt's
Ed25519 signature is genuine, its signer is a device pinned for the challenge's subject (see
Device pins), it names your challenge and function, the challenge was minted by
this server, is unexpired and was never used before, the origin and data match when given, and
the call succeeded. A challenge is spent once a genuine receipt from a pinned device answers it,
whatever the outcome. Pass data=None to check JSON null; leave data out to skip the data
check.
Device pins
A receipt carries the public key that signed it, so a valid signature alone says nothing about
who signed: anyone can make an Ed25519 key and sign a receipt that claims success. Pinning ties
receipts to the user's own Writ. pin_devices decides:
pin_devices |
Accepted signers |
|---|---|
"first-use" (default) |
The first device whose receipt verifies for a user is pinned to that user; afterwards only pinned devices verify |
a function (user_id, device) -> bool (may be async) |
Your own registry decides |
"off" |
Any signer. Receipts then stop replays and cross-site reuse, but not forgery: use it only where the result gates nothing |
A receipt from another device is refused with unknown_device=True (verify()), or, for a
call through bridge.user(...), RESULT_UNVERIFIED with details["unknown_device"] = True
and details["device"]. When you have confirmed that computer is the user's (an email link, a
signed-in confirmation on a known device), approve it:
try:
result = await bridge.user(user.id).pos.receive({"invoice_no": invoice_no})
except WritBridgeError as error:
if error.code == "RESULT_UNVERIFIED" and error.details.get("unknown_device"):
await send_device_confirmation_email(user, error.details["device"]) # your flow
raise
# Later, once the user confirmed it:
await bridge.approve_device(user.id, device) # the device mapping, or its public_key
await bridge.forget_devices(user.id) # a lost computer: the next one pins anew
Pins live in the bridge's store and never expire. MemoryStore forgets them on restart (and the
next device to answer pins anew), so in production use a durable store (RedisStore) or your own
registry as a function.
Several workers: Redis
The default MemoryStore serves one process. With uvicorn --workers N, gunicorn, several
instances behind a load balancer, or serverless hosts, the page's poll and your call can land in
different processes: share state through Redis (6.2 or later).
import redis.asyncio as redis
from usewrit import RedisStore, create_writ_bridge
store = RedisStore(redis.from_url(os.environ["REDIS_URL"])) # or RedisStore(url=...)
bridge = create_writ_bridge(Path("writ-contract.json"), mode="recommended", store=store)
Keys live under writ:bridge: (the Node package's layout, so a Node and a Python server can share
one Redis); device pins are sets under writ:bridge:devices:<user> with no expiry, and the
first-use pin is one atomic EVAL, so the server must allow Lua scripting. Waits use BLPOP, which holds one connection of the client's pool while a page polls
(up to poll_seconds) or a call waits: leave socket_timeout unset and the pool large enough for
your concurrent pollers. To plug in another store, implement usewrit.Store.
Other frameworks
# Starlette
Route("/writ-bridge", bridge.asgi, methods=["GET", "POST"])
# Any ASGI server, the bridge as the whole app
uvicorn.run(bridge.asgi)
# Anything else: the framework-free core
status, headers, body = await bridge.handle(method, headers, query_string, body_bytes)
bridge.asgi watches for the page leaving while a poll waits, so a closed tab never takes a
command with it. With handle(), pass disconnected=asyncio.Event() and set it when the client
goes away, if your framework can tell.
Run tokens
A linked workflow (contract §11) keeps its recipe in your Writ Cloud. Published in token
mode, it is handed out only to calls that carry a run token your backend minted, so only your
signed-in users can run it. The page's getRunToken (@usewrit/web) fetches one from a route of
yours:
from fastapi.responses import PlainTextResponse
from usewrit import mint_run_token
# Page: getRunToken: () => fetch("/api/writ-token").then((r) => r.text())
@app.get("/api/writ-token", response_class=PlainTextResponse)
def writ_token(user: User = Depends(current_user)) -> PlainTextResponse:
token = mint_run_token(
os.environ["WRIT_PUBLICATION_SECRET"],
"pub_0123456789abcdefABCDEF", # the publication's id
sub=str(user.id), # optional: who it is for
ttl_seconds=600, # default 600, at most 3600
)
return PlainTextResponse(token, headers={"Cache-Control": "no-store"})
- The secret key is the publication's
wsk_..., which Writ Cloud shows once when you publish or rotate it; notWRIT_BRIDGE_SECRET, and never the publishablewpk_...key. It stays on your server. Put the route behind your own sign-in: whoever gets a token can run the workflow until it expires. - The token is a JWT, HS256, keyed with the secret key's UTF-8 bytes: header
{"alg":"HS256","typ":"JWT"}, claims{"pub","iat","exp","sub"?}in that key order, compact JSON, base64url without padding. The Node twin'smintRunTokenmints the same bytes (sdks/fixtures/run_token_golden.json). Standard library only (hmac,hashlib,base64). - Refusals:
CONFIG_INVALIDfor a secret that is not awsk_key, or that holds spaces or a line break (an environment variable read with its newline; the message never repeats the secret);INPUT_INVALIDfor a badpublicationorsub;BAD_REQUESTforttl_secondsoutside 1 to 3600. - The page keeps a token in memory until a minute before its
exp, so a 600 s token means one request to this route every 9 minutes or so per visitor.
Errors
Every failure is a WritBridgeError with a stable code (branch on it, never on the message)
and details (the receipt of a failed call, the Writ Cloud task id).
| code | when |
|---|---|
WRIT_REQUIRED |
the function runs only on the user's computer, and Writ is not set up there (or no user was given) |
USER_OFFLINE |
none of the user's pages is connected (it was before) |
NOT_GRANTED |
the user has not granted the function; or Writ Cloud refused WRIT_API_KEY (401/403) |
RESULT_UNVERIFIED |
the page answered without a receipt that verifies for this call |
TIMEOUT |
the call did not finish in timeout_ms |
CLOUD_NOT_CONFIGURED |
no cloud mapping or API key; the mapped workflow or function does not exist |
CLOUD_UNAVAILABLE |
Writ Cloud could not be reached, or answered 5xx |
QUOTA_EXCEEDED |
your Writ Cloud plan is out of runs (402), or of linked workflow deliveries |
RATE_LIMITED |
Writ Cloud is limiting runs (429), or too many page runs at once for one user |
RUN_FAILED |
the run failed |
INPUT_INVALID / BAD_REQUEST |
inputs or call options refused |
CONFIG_INVALID |
the bridge configuration is invalid |
| others | any code from the visitor's side (VAULT_LOCKED, CONSENT_DENIED, GRANT_STALE, and for linked workflows RECIPE_UNAVAILABLE, PUBLICATION_REVOKED, ...) |
The page channel answers errors as {"error": {"code", "message"}}.
Security notes
- The secret stays on the server.
WRIT_BRIDGE_SECRETsigns user tokens and challenges (HMAC-SHA256 over RFC 8785 canonical JSON). Rotate it to log every page out of the bridge. - User tokens identify the user to the channel. Mint them only for the signed-in user, keep
their lifetime short (
ttl_seconds, default 3600), and serve the route on the same origin as the page: the page bridge refuses any other endpoint, and the channel answers no CORS. - Results from a user's computer are verified before you see them. Each command carries a
challenge minted for its id; the result must come with a Writ receipt whose Ed25519 signature
verifies, from a device pinned for that user, that names that challenge and function, and
whose data hash matches the posted data. A page that alters a result, replays one, or signs one
with a fresh key gets
RESULT_UNVERIFIED. - Device pinning is on by default (
"first-use"). A signature only proves that some key signed; pinning is what proves it was the user's own Writ. Keep pins in a durable store (Redis) or your own registry (pin_devices=a function) in production: aMemoryStorerestart lets the next device pin itself. Approve another computer withapprove_device()only after confirming it out of band; never on the page's word alone, since the page is what an attacker controls.pin_devices="off"removes the protection against forged results. - What a receipt cannot prove: that the visitor did not run a modified Writ on their own pinned computer. It stops script and console forgery, replay and device swaps; it is not a proof about the upstream site.
- Page calls routed to Writ Cloud spend your plan. Gate them with
authorize_run=lambda user_id, key, inputs: ...; at most 4 run at once per user per process. - Hashed data: numbers are printed as RFC 8785 says (JavaScript's
JSON.stringifyform), so the bytes match the Writ engine's and the Node package's;NaNand infinities cannot be hashed. WRIT_API_KEYis used only server-side, sent only tocloud_base(https, or http on localhost).
Development
pip install -e ".[dev]" && python -m pytest -q
The tests read the golden vectors shared with the Writ engine and the Node package: the
repository's fixtures/bridge_tokens_golden.json, and the Writ engine's receipt golden file when
WRIT_RECEIPT_GOLDEN names it; otherwise the byte-for-byte copies in tests/fixtures/, which a
test keeps in step with the shared files whenever they are at hand.
License
MIT
Metadata
Release files for usewrit 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| usewrit-0.1.0.tar.gz | 90.9 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| usewrit-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 162.0 kB
Release files / usewrit-0.1.0.tar.gz
| Download URL | usewrit-0.1.0.tar.gz |
|---|---|
| Size | 90.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2e183892d16ae03c41f76f3595f46b5f36a4a9589390943349e4ad8595f68ecf
|
|
BLAKE2b-256 checksum How to use checksums |
95e9021deab834336998aefd770d0bebdc30209fdbdd90b8e9ea4b37f9c0ce62
|
| 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 Oct 10, 2026.
Transparency logRelease files / usewrit-0.1.0-py3-none-any.whl
| Download URL | usewrit-0.1.0-py3-none-any.whl |
|---|---|
| Size | 71.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f334d85d32f05343f2ce774dd2b37c49fd44411c272ff164dcc3d1cab17133dd
|
|
BLAKE2b-256 checksum How to use checksums |
6e3ff6ef99676648e42ea114505a5d91283d9983e6c334f6b45f1e72d00cb49b
|
| 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 Oct 10, 2026.
Transparency logSecurity for usewrit 0.1.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.
Attestation for usewrit-0.1.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 Oct 10, 2026.
Transparency logA 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.