- What this is
- Architecture
- Quick start
- How a run works
- The
serveprotocol - Sandbox isolation
- Storage & durability
- Billing, budgets & rate limits
- Observability
- Database & migrations
- Configuration
- Design principles
python-agent-web is a minimal web interface for validating agent
runtime and interaction patterns β a chat UI and API over a real,
sandboxed agent, used to exercise how runs, streaming events, mid-run
questions, files, and billing behave end to end.
Architecturally it is the trusted half of an agent platform: the API server, run controller, auth, billing, and secret storage.
The untrusted half β the agent runtime itself β is a separate
program, python-agent-harness. The web layer never imports it.
Instead it runs the harness as a resident subprocess per sandbox
(python-agent-harness serve) and talks to it over a bidirectional
JSON-lines protocol on stdin/stdout.
That separation is the whole point:
- The harness is a black box. No imports, no shared state β driven
only through its documented protocol. The two repos version and deploy
independently; the harness just needs to be on
PATH. - The trust boundary is a process, not a function call. Untrusted agent code never runs in the web process. In production it runs in an isolated container (see Sandbox isolation).
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser (views/index.html) β
β chat UI Β· file upload Β· task cards Β· SSE event stream β
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ²ββββββββββββββββ
β REST (JSON, Bearer JWT) β SSE (text/event-stream)
β POST /conversations/{id}/files β GET /conversations/{id}/runs/{rid}/stream
β POST /conversations/{id}/runs β start/delta/notify/log/result
β GET /conversations/{id}/artifacts β
βββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ΄ββββββββββββββββ
β FastAPI app (TRUSTED) β
β β
β routes/ HTTP only: dependencies, status codes, schemas β
β auth Β· conversations(+files, runs, artifacts) β
β billing Β· secrets β
β controllers/ all business logic β
β domain accounts Β· conversations Β· files Β· runs β
β secrets Β· usage (rules, persistence, refusals) β
β machinery manager.py run lifecycle, event fan-out (subscribe) β
β protocol.py JSONL line parsing β
β runner.py ServerRunner: one resident harness β
β process per conversation β
β infra/ config (PAW_* env) Β· db (engine + migrations) Β· β
β security (JWT, Fernet) β
β models/ ORM entities: users, conversations, runs, β
β files, usage ledger, secrets, sandboxes β
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β stdin/stdout pipes β bidirectional JSONL
β hostβagent: {"op": submit|answer|cancel|ping|shutdown}
β agentβhost: ready / start / delta / notify / log / result
β
βββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β python-agent-harness serve (UNTRUSTED, black box β never imported) β
β cwd = workspaces/<conversation_id>/ β
β tools, bash, pandas/openpyxl β¦ β
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β reads uploads / writes outputs
βββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β workspaces/<conversation_id>/ β
β a1b2c3d4_sales.xlsx β user upload (hex-prefixed) β
β final.xlsx β agent output (downloadable via /artifacts) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Two protocols, two links:
| Link | Transport | Direction |
|---|---|---|
| Backend β harness | JSON-lines over stdin/stdout pipes | ops down, events up |
| Browser β backend | Server-Sent Events (SSE) | events streamed to the UI |
The controller is a protocol translator: it parses each JSONL line
from the harness, fans it out to in-memory subscribers, and re-emits it
as an SSE data: frame. The browser therefore sees the same events the
harness's own TUI would render β tool progress, todos, errors, and
mid-run questions.
python -m venv venv && . venv/bin/activate
pip install -e ".[dev]"
uvicorn app.main:app --reload # http://127.0.0.1:8000 (UI served at /)- Harness:
python-agent-harnessmust be a command onPATH. If it isn't, pointPAW_HARNESS__CMDat the absolute binary. - Auth: register a user at
/auth/register, then log in β the UI does both for you. - Schema: created automatically on first startup (see Database & migrations); no manual step for dev.
Optional dependency groups:
pip install -e ".[postgres]" # psycopg driver for Postgres
pip install -e ".[s3]" # boto3 for S3 object storage
pip install -e ".[redis]" # redis for shared rate limitingOne conversation turn is one run:
- The browser
POSTs a prompt to/conversations/{id}/runs. - The controller creates a
Runrow (status=running), ensures a sandbox exists, and starts the harness exec on a worker thread. - The prompt is augmented with the conversation's uploaded filenames
and sent to the harness as an
op:submit. - The harness streams
start/delta/notify/logevents; the controller fans them out to any SSE subscribers on/conversations/{id}/runs/{rid}/stream. - A terminal
resultline finalizes the run: answer, token usage, and status land in the database; agent-produced files are synced to durable storage.
One running run per conversation is enforced by a partial unique index in the database, so the guarantee holds even across processes.
One sandbox = one long-lived serve process. The web side writes ops;
the harness answers with events.
host β harness: {"op": "submit", "prompt": ..., "run_id": ...}
{"op": "answer", "run_id": ..., "answers": [...]}
{"op": "cancel", "run_id": ...} / {"op": "ping"} / {"op": "shutdown"}
harness β host: {"type": "ready"} (once, on startup)
start / delta / notify / log (per run, streamed)
{"type": "result", "answer": ..., "usage": ..., "cancelled": ...}
Because the process is resident (survives between turns):
- Multi-turn memory β conversation history persists across turns.
- No per-turn spawn β the interpreter/harness stays warm.
- Mid-run Q&A β
answerdelivers the user's reply to a pending question (the agent's Question tool / plan-exit confirm). - Protocol-level cancel β
cancelis a message, not a signal.
notify lines carry the progress the UI renders, keyed by kind:
| kind | meaning |
|---|---|
tool_start |
the round's tool names |
tool_calls |
the same round with each call's arguments (e.g. Bash(command='ls -la')) |
tool_running, tool |
per-tool progress and completion |
todos |
the agent's task list |
compact, retry, error |
context compaction, retries, errors |
ask |
a mid-run question awaiting answer |
tool_calls is additive: a harness that doesn't emit it degrades
gracefully to the bare names from tool_start. log lines are shown
too, except internal session bookkeeping (the generated title).
The runner is selected by PAW_RUNNER:
-
server(default) β one residentharness serveprocess per sandbox. Fast and simple; suitable for a trusted single-node deployment. The agent shares the host environment. -
dockerβ one resident container per sandbox, same protocol over the container's stdio. This is the real trust boundary for untrusted/public users:- no host environment β the container gets only
PAW_NONINTERACTIVEplus the user's own decrypted secrets; - no host filesystem β only the conversation's workspace is mounted;
- no network by default (
PAW_DOCKER__NETWORK=none); - locked down β read-only rootfs, dropped Linux capabilities,
non-root user,
no-new-privileges, and memory/CPU/PID ceilings.
- no host environment β the container gets only
Idle sandboxes are reaped after PAW_SANDBOX__TTL_SECONDS.
Secret injection. User secrets are Fernet-encrypted at rest and
never returned by the API. For the docker runner they are decrypted
host-side (the key never enters the sandbox) and injected as scoped
environment variables into that user's container only.
Two tiers keep the agent's filesystem contract while surviving pod loss:
- Sandbox-facing tier β the per-conversation workspace
(
workspaces/<id>/) is the harness's working directory, so it can open uploads by name and write outputs directly. - Durable tier β a blob store (
PAW_STORAGE__BACKEND:localors3) is the system of record. Uploads are written through to it on arrival; agent outputs are synced to it when a run finishes. If a workspace is lost (pod restart, a different instance), files are rehydrated from the durable tier on demand.
Uploads are validated and quota-limited: a per-file size cap
(PAW_MAX_UPLOAD_BYTES), a per-user total quota
(PAW_MAX_USER_STORAGE_BYTES), and an optional magic-byte type
allow-list (PAW_UPLOAD_ALLOWED_TYPES).
- Usage ledger. On each run's
result, the controller records token usage (input/output/rounds) intousage_events, attributed to the user and conversation. - Budget kill-switch. With
PAW_BUDGET_ENFORCE=true, a run is refused (HTTP 402) when the user's consumed tokens reach their allowance β a free-tier grant plus the token value of purchased account points. - Rate limiting. A sliding-window limiter throttles the run endpoint
per user and login/register per IP (429 +
Retry-After). The backend ismemory(per-instance) orredis(shared/global across instances). - Token revocation. JWTs carry a
verclaim matched against the user'stoken_version; logout and password change bump it, instantly invalidating every outstanding token.
- Request correlation. Every request gets an
X-Request-ID(echoed if the client sent one), bound to a contextvar so everypaw.*log line carries it. Unhandled errors are logged with a traceback and returned as a 500 quoting the id.PAW_LOG_JSON=trueemits one JSON object per line for log shippers. - Metrics. Prometheus text at
GET /metricsβ HTTP requests (by method/status), request latency, runs by terminal state, run duration, and tokens consumed. - Tracing. Spans are marked in the code and become real spans once a
tracer is registered via
infra.metrics.set_tracer(a no-op otherwise).
Graceful shutdown. On SIGTERM the app stops accepting new runs and
drains in-flight ones for up to PAW_SHUTDOWN_DRAIN_SECONDS. Runs still
running at the deadline are reconciled to error on the next startup β
scoped to this instance's id (PAW_INSTANCE_ID), so a restart never
disturbs another instance's live runs.
The schema is owned by Alembic (migrations/). Startup runs
upgrade_to_head() automatically, so a fresh uvicorn run creates the
schema and stamps alembic_version β no manual step for dev. The test
suite migrates each throwaway database the same way, so tests always run
against the real migrations.
After changing a model:
alembic revision --autogenerate -m "describe the change" # writes migrations/versions/*
alembic upgrade head # apply it
alembic check # models β migrations in syncalembic check reporting "No new upgrade operations detected" means
the migrations match the models. SQLite uses batch mode for ALTERs; the
migration URL comes from PAW_DB_URL (see migrations/env.py).
All settings are environment variables with the PAW_ prefix. Nested
groups use a double underscore (e.g. PAW_HARNESS__CMD).
| var | default | note |
|---|---|---|
PAW_DB_URL |
sqlite:///./paw.db |
any SQLAlchemy URL (Postgres: postgresql+psycopg://β¦) |
PAW_SECRET_KEY |
dev default | set in production |
PAW_ACCESS_TOKEN_MINUTES |
30 |
JWT access TTL |
PAW_REFRESH_TOKEN_DAYS |
14 |
JWT refresh TTL |
PAW_LOG_JSON |
false |
emit JSON log lines instead of text |
| var | default | note |
|---|---|---|
PAW_DB_POOL_SIZE |
5 |
connection pool size |
PAW_DB_MAX_OVERFLOW |
10 |
extra connections beyond the pool under load |
PAW_DB_POOL_RECYCLE_S |
1800 |
recycle a pooled connection after N seconds |
| var | default | note |
|---|---|---|
PAW_HARNESS__CMD |
python-agent-harness |
harness binary |
PAW_HARNESS__CWD |
"" |
override the agent workspace dir per sandbox |
PAW_HARNESS__TIMEOUT |
unset | host-side wall-clock budget for one run |
PAW_WORKSPACE_ROOT |
./workspaces |
base dir for per-conversation workspaces |
PAW_RUNNER |
server |
server (host subprocess) or docker (isolated container) |
PAW_SANDBOX__TTL_SECONDS |
300 |
idle sandbox reaper TTL |
| var | default | note |
|---|---|---|
PAW_DOCKER__IMAGE |
python-agent-harness:latest |
sandbox image; pin by digest in prod |
PAW_DOCKER__NETWORK |
none |
container network mode; none = no egress |
PAW_DOCKER__MEM_LIMIT |
1g |
per-container memory ceiling |
| var | default | note |
|---|---|---|
PAW_STORAGE__BACKEND |
local |
durable blob store: local or s3 |
PAW_STORAGE__LOCAL_ROOT |
./storage |
root dir for the local backend |
PAW_STORAGE__S3_BUCKET |
"" |
bucket for the s3 backend |
PAW_STORAGE__S3_ENDPOINT_URL |
"" |
custom S3 endpoint (MinIO/localstack); empty = real AWS |
| var | default | note |
|---|---|---|
PAW_MAX_UPLOAD_BYTES |
104857600 |
per-file upload size cap (100 MB) |
PAW_MAX_USER_STORAGE_BYTES |
1073741824 |
per-user total storage quota (1 GB); 0 = unlimited |
PAW_UPLOAD_ALLOWED_TYPES |
[] |
magic-byte allow-list (e.g. ["xlsx","csv","pdf"]); empty = any type |
| var | default | note |
|---|---|---|
PAW_BUDGET_ENFORCE |
false |
enforce a per-user token budget before a run (spend kill-switch) |
PAW_BUDGET_FREE_TOKENS |
1000000 |
free-tier token allowance per user |
PAW_BUDGET_TOKENS_PER_POINT |
1000 |
token value of one account point (adds to the budget) |
PAW_RATE_LIMIT_BACKEND |
memory |
memory (per-instance) or redis (shared across instances) |
PAW_RATE_LIMIT_REDIS_URL |
"" |
Redis URL for the redis backend; empty = localhost default |
| var | default | note |
|---|---|---|
PAW_SHUTDOWN_DRAIN_SECONDS |
25 |
on SIGTERM, seconds to let in-flight runs finish before exit |
PAW_INSTANCE_ID |
host+pid | this instance's id (set to pod/task name); scopes run reconciliation |
Routes are thin; controllers own the rules. A route resolves
dependencies, calls a controller, and maps the result to a response
schema β nothing else. No route touches the DB, the filesystem, or
crypto. A controller refuses work by raising a typed error from
controllers/errors.py (NotFound, Conflict, InvalidRequest,
PayloadTooLarge, PaymentRequired, Unauthorized, Forbidden,
TooManyRequests); a single handler in main.py renders it as
FastAPI's {"detail": ...} shape with the right status. Business logic
never imports HTTPException, so a controller stays callable from a
test, a CLI, or a worker thread.
infra is stateless mechanism; controllers is stateful policy.
The line between the two layers is not "core vs supporting." infra
holds primitives with no entities and no session β password hashing,
JWT, Fernet encrypt/decrypt, storage backends, rate limiters, metrics β
and imports nothing but infra. Anything that takes a Session, reads
or writes an ORM entity, or can refuse a request lives in controllers.
Keeping that direction means the bottom layer never imports models or
controllers.errors.
The harness is decoupled. It is a black-box binary driven only by
its JSONL protocol (seq for ordering, run_id for correlation,
usage for billing). No imports, no shared state β the web side is
versioned and deployed independently.
Runs are protocol turns, not process lifecycles. One conversation
turn = one op:submit = one Run row. The resident process survives
the run and serves the next turn. Events stream to subscribers exactly
as the harness emitted them (plus run-lifecycle events), and the
result line is what lands in the database.