The trusted side of an agent platform: API server, controller, auth,
billing, secrets. The untrusted side — the agent runtime itself — is
python-agent-harness, executed as a resident subprocess per
sandbox: python-agent-harness serve, a bidirectional JSON-lines
protocol over stdin/stdout. The harness is never imported; the repos
stay decoupled (the harness only needs to be on PATH of whatever runs
the agent).
┌──────────────────────────────────────────────────────────────────────┐
│ 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/ auth · conversations(+files, runs, artifacts) │
│ billing · secrets │
│ controllers/ 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) · security (JWT, Fernet) │
│ models/ SQLite (SQLAlchemy): 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) │
└──────────────────────────────────────────────────────────────────────┘
Protocol split: JSONL is the backend↔harness link (pipes);
SSE is the browser↔backend link. The controller is a protocol
translator — each parsed JSONL line is fanned out to in-memory
subscribers and re-emitted as an SSE data: frame, so the browser
sees the same events the harness TUI renders (tool progress, todos,
errors, mid-run questions).
app/
main.py FastAPI app factory + routers + static UI
models/ (model layer)
__init__.py ORM models: User, Conversation, ConversationFile, Run,
UsageEvent, Secret, Sandbox
db.py SQLite engine/session (SQLAlchemy ORM)
validation/ (request/response validation)
schemas.py Pydantic request/response models
routes/ (view layer, HTTP)
auth.py POST /auth/register /auth/login /auth/refresh /auth/me
conversations.py CRUD + POST /{id}/files (upload) + GET/DELETE
/{id}/artifacts (agent outputs) + POST /{id}/runs
(start) + POST /{id}/answer + GET /{id}/stream (SSE)
billing.py usage summary (token ledger)
secrets.py CRUD (write-only read: value never returned)
controllers/ (business logic)
protocol.py Parse harness event lines (seq/run_id/result/usage)
manager.py Controller: start_run, event pump, subscribe, cancel, answer
runner.py Runner protocol + ServerRunner (resident serve process,
per-conversation workspace cwd)
views/ (static UI)
index.html Chat UI: upload, task cards, SSE progress (tool status,
todos, ask/answer), artifact downloads
infra/ (cross-cutting infrastructure)
config.py Settings (pydantic-settings, env prefix PAW_)
security.py JWT auth (access/refresh) + password hashing
secrets_store.py Fernet-encrypted secret values
python -m venv venv && . venv/bin/activate
pip install -e ".[dev]"
uvicorn app.main:app --reload # http://127.0.0.1:8000 (UI at /)python-agent-harness must be importable-on-PATH as a command; point
PAW_HARNESS__CMD at the absolute binary if it is not. Auth: create
a user via /auth/register, then log in; the UI does this for you.
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"} then per-run
start/delta/notify/log lines and a terminal
{"type": "result", "answer": ..., "usage": ..., "cancelled": ...}
Because the process is resident: conversation history persists across
turns (multi-turn memory), no per-turn interpreter spawn, answer
delivers the user's reply to a pending mid-run question (the agent's
Question tool / PlanExit confirm), and cancel is a protocol message —
no signal semantics.
| var | default | note |
|---|---|---|
PAW_DB_URL |
sqlite:///./paw.db |
any SQLAlchemy URL |
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_HARNESS__CMD |
python-agent-harness |
harness binary |
PAW_HARNESS__CWD |
"" |
agent workspace dir per sandbox (overrides per-conversation workspaces) |
PAW_WORKSPACE_ROOT |
./workspaces |
base dir for per-conversation workspaces; uploads land in <root>/<conversation_id>/ |
PAW_HARNESS__TIMEOUT |
unset | host-side wall-clock budget for one run |
PAW_RUNNER |
server |
server only; docker later |
PAW_SANDBOX__TTL_SECONDS |
300 |
idle reaper TTL |
- Decoupling: the harness is a black-box binary driven by its
documented JSONL protocols (the same
start/delta/notify/log/resultline shapes on the residentservepipe and the one-shotheadless --jsonpipe;seqfor ordering,run_idfor correlation,usagefor billing). No imports, no shared state; the web side can be versioned and deployed independently. - Runs are protocol turns, not process lifecycles: one
conversation turn = one
op:submit= oneRunrow; the resident process survives the run and serves the next turn. Events stream to subscribers over SSE exactly as the harness emitted them (plus run lifecycle events), and theresultline lands in the DB. - Secrets are Fernet-encrypted at rest and never returned by the API; they are meant to be injected into the sandbox environment by the sandbox manager (not exposed to agents via the API).
- Billing is a token ledger: the controller snapshots
result.usage(input/output/rounds) from the harness intousage_events, attributed to the user and conversation.