Every HTTP endpoint dendrux exposes through make_read_router, every SSE event type a client can receive, and every agent method you wrap in your own write routes. All parameters, all payloads, all exceptions.
HTTP API surface
Dendrux is a library. It does not run a server for you. You import make_read_router, pass it to app.include_router(...), and you own the rest (auth, prefix, CORS, TLS, deploy). For write-side actions (submit tool results, submit input, submit approval, cancel) you wrap Agent methods in your own routes.
This page is the full inventory: read endpoints, the SSE stream, the agent write methods, the event types that land on the stream, and the stable exceptions to catch. Everything below is lifted from the code in dendrux/http/read_router.py, dendrux/store/__init__.py, dendrux/agent.py, dendrux/errors.py, and dendrux/types.py.
Mounting the read router
from fastapi import FastAPI, Depends, HTTPException
from dendrux.http import make_read_router
from dendrux.store import RunStore
app = FastAPI()
async def authorize(request: Request) -> None:
token = request.headers.get("authorization")
if token != f"Bearer {EXPECTED_TOKEN}":
raise HTTPException(status_code=401, detail="unauthorized")
store = RunStore.from_database_url("postgresql+asyncpg://...")
router = make_read_router(store=store, authorize=authorize)
app.include_router(router, prefix="/dendrux")authorize runs on every route except /health. Raise HTTPException inside to deny; return any value (ignored) to allow. The factory does not interpret your return value or impose an auth shape.
Read endpoints
All routes are read-only. All pagination uses exclusive cursors or limit/offset. The router returns JSON bodies serialized from frozen dataclasses defined in dendrux/store/__init__.py.
GET /health
Unauthenticated. For Kubernetes probes and load balancers.
Response: {"status": "ok"}
GET /runs
List runs with SQL-side filtering and pagination.
Query parameters:
Response:
{
"items": [
{
"run_id": "01HXYZ...",
"agent_name": "calculator",
"status": "success",
"created_at": "2026-04-18T12:00:00Z",
"updated_at": "2026-04-18T12:00:04Z",
"iteration_count": 2,
"total_input_tokens": 120,
"total_output_tokens": 48,
"total_cache_read_tokens": 7706,
"total_cache_creation_tokens": 0,
"total_cost_usd": 0.0012,
"model": "claude-haiku-4-5",
"parent_run_id": null,
"delegation_level": 0
}
],
"total": 1,
"limit": 50,
"offset": 0
}GET /runs/{run_id}
Single run detail. Returns the same summary fields plus strategy, input_data, answer, error, failure_reason, cancel_requested (true while a cancel has been requested but the run has not yet reached a terminal status).
Errors: 404 if not found.
GET /runs/{run_id}/events
Paginated durable event log (polling variant).
Query parameters:
Response:
{
"items": [
{
"sequence_index": 4,
"iteration_index": 1,
"event_type": "llm.completed",
"correlation_id": null,
"data": {"input_tokens": 9, "output_tokens": 18, "model": "claude-haiku-4-5", "has_tool_calls": true},
"created_at": "2026-04-18T12:00:01Z"
}
],
"next_cursor": 4
}Empty-batch rule: next_cursor echoes your input after when the page is empty. That means "nothing new yet," not "end of stream." Do not reset the cursor to null on empty batches; you will miss future events.
GET /runs/{run_id}/events/stream
Server-Sent Events. Reads from run_events in a per-connection polling loop. No shared queue, no fan-out manager. Each connection is an independent async iterator.
Cursor resolution (first match wins):
Last-Event-IDheader (SSE reconnect convention).?after=Nquery param.None(stream from the beginning of the log).
A malformed Last-Event-ID falls back silently to ?after=N. Reverse proxies sometimes inject garbage on reconnect; treating that as a 500 would break reconnection.
Response headers:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: noFrame format:
id: 5
event: message
data: {"sequence_index": 5, "iteration_index": 1, "event_type": "run.paused", "correlation_id": null, "timestamp": "2026-04-18T12:00:02Z", "data": {...}}
Every frame uses event: message. The dendrux event type lives inside the JSON under event_type, so a generic EventSource client does not need to pre-enroll every dendrux event name.
Heartbeat: : keepalive\n\n every 15s of idleness. Prevents nginx (default 60s proxy_read_timeout) from closing the connection.
Poll cadence: 0.5s per connection when idle. That is ~2 DB queries per second per open SSE connection. Size your connection pool accordingly until Postgres LISTEN/NOTIFY ships.
Termination: the stream stays open until the client disconnects. Terminal events (run.completed, run.error, run.cancelled) arrive as normal frames; the client decides when to close. If the client drops, the generator ends on the next asyncio.sleep and the connection releases.
Reconnect pattern from a browser:
const es = new EventSource("/dendrux/runs/01HXYZ/events/stream?after=5", {
withCredentials: true
});
es.addEventListener("message", (e) => {
const frame = JSON.parse(e.data);
// e.lastEventId is frame.sequence_index, automatically sent back on reconnect
handle(frame);
});GET /runs/{run_id}/llm-calls
LLM round-trips recorded during the run.
Query parameters:
Response items carry iteration, provider, model, input_tokens, output_tokens, total_tokens, cache_read_input_tokens, cache_creation_input_tokens, cost_usd, duration_ms, provider_request, provider_response, created_at. provider_request and provider_response are raw adapter-boundary JSON; treat as opaque.
GET /runs/{run_id}/tool-calls
Server tools plus submitted client and approval tools. Denied tools are not in this table (see Access control).
Query parameters: same shape as /llm-calls.
Response items: iteration, tool_name, tool_call_id, provider_tool_call_id, target (server / client), params, result, success, error, duration_ms, created_at.
GET /runs/{run_id}/traces
Full conversation trace (user, assistant, tool messages) in order_index order.
Query parameters: limit, offset.
Response items: role, content, order_index, meta, created_at. content is always the raw stored value — DB is ground truth. PII guardrails redact at the LLM-call boundary only; replay against agent_runs.pii_mapping to render the LLM-eye view.
GET /runs/{run_id}/pauses
Pause/resume cycles derived from run_events. Never reads AgentRun.pause_data (that column is execution state — it can include a pii_mapping with raw values for continuity across pause/resume — and is cleared on finalize).
Response items:
{
"pause_sequence_index": 5,
"pause_at": "2026-04-18T12:00:02Z",
"resume_sequence_index": 8,
"resume_at": "2026-04-18T12:00:30Z",
"reason": "waiting_client_tool",
"pending_tool_calls": [{"id": "...", "name": "read_file", "target": "client", "params": {...}}],
"submitted_results": [{"name": "read_file", "call_id": "...", "payload": "...", "success": true}],
"user_input": null
}An unpaired run.paused at the end of the log represents an active pause (resume_sequence_index is null).
SSE event inventory
Every event_type that can land on the stream. Lifecycle events come from the runner; governance events come from the loop and guardrail engine. Field shapes are extracted from dendrux/runtime/runner.py, dendrux/runtime/persistence.py, and dendrux/loops/react.py.
Lifecycle events
Governance events
From dendrux.types.GovernanceEventType. The values below are deliverable through on_governance_event on notifiers and /events/stream.
Each (metric, fraction) of budget.threshold fires at most once per run, and budget.exceeded at most once per metric. Every pause persists the fired set, so resumed runs do not re-fire.
Write-side agent methods
Dendrux does not ship HTTP write routes. You wrap these methods in your own endpoints so auth, validation, and transport stay yours.
agent.submit_tool_results(run_id, results, *, notifier=None) -> RunResult
Submit results for a run paused in WAITING_CLIENT_TOOL. Race-safe: persist-first handoff + CAS claim in one atomic step. Blocks until the next pause or terminal state.
results is list[ToolResult]. Every pending call id must be covered; unknown or missing ids raise InvalidToolResultError and nothing is claimed. ToolResult fields:
@dataclass(frozen=True)
class ToolResult:
name: str # tool name
call_id: str # dendrux-owned ULID matching ToolCall.id
payload: str # JSON string — serialize once on your side
success: bool = True
error: str | None = None
duration_ms: int = 0Raises: PersistenceNotConfiguredError, RunNotFoundError, RunNotPausedError, PauseStatusMismatchError, RunAlreadyClaimedError, RunAlreadyTerminalError, InvalidToolResultError.
agent.submit_input(run_id, text, *, notifier=None) -> RunResult
Submit a clarification for a run paused in WAITING_HUMAN_INPUT. Same race-safety as submit_tool_results. Blocks until next pause or terminal.
Raises: PersistenceNotConfiguredError, RunNotFoundError, RunNotPausedError, PauseStatusMismatchError, RunAlreadyClaimedError, RunAlreadyTerminalError.
agent.submit_approval(run_id, *, approved=None, decisions=None, rejection_reason=None, notifier=None) -> RunResult
Submit an approval decision for a run paused in WAITING_APPROVAL. Pass exactly one of approved= and decisions=.
approved=True: CAS-claims, executes the approval-gated and held non-gated server calls, feeds results back to the LLM.approved=False: builds one synthetic failedToolResultper pending call, each carryingrejection_reason(default:"User declined to run this tool.") as a JSON string. The LLM decides what to do next.decisions={call_id: ApprovalDecision.approve() | ApprovalDecision.deny(reason)}: per-call decisions. The keys must be exactly the approval-gated call ids (theapproval.requestedevents); a missing, unknown or duplicate id raisesValueErrorbefore anything is claimed. A denied call gets a failed result{"denied": reason}.rejection_reasonis not allowed withdecisions.
Client-target calls in the batch never execute on the server in any form; the run pauses again as waiting_client_tool for them.
Raises: the same set as the other submit methods, plus ValueError for an invalid argument combination or decision ids.
agent.submit_resume(run_id, *, notifier=None) -> RunResult
Continue a run in status PAUSED (a requested or budget pause) with its full history. Same race-safety as the other submit methods. Blocks until the next pause or terminal state.
Raises: PersistenceNotConfiguredError, RunNotFoundError, RunNotPausedError, PauseStatusMismatchError (the run is paused for another reason), RunAlreadyClaimedError, RunAlreadyTerminalError.
agent.request_pause(run_id, *, cascade=True, tenant_id=..., reason=None) -> PauseOutcome
Ask a running run to pause at its next checkpoint. Returns once the request is recorded, not once the run has paused; run.paused confirms. A waiting_*, paused or terminal run is a no-op. cascade=True flags descendants too (flag propagation only). tenant_id scopes the request to one tenant.
Raises: PersistenceNotConfiguredError, RunNotFoundError, ValueError (bad reason).
agent.submit_note(run_id, text, *, metadata=None, tenant_id=...) -> str
Leave a note for a non-terminal run. Returns the note id. Delivered at the next iteration; at-least-once.
Raises: PersistenceNotConfiguredError, RunNotFoundError, RunAlreadyTerminalError, ValueError (empty text, text over 32 KiB, metadata not JSON or over 8 KiB).
agent.cancel_run(run_id, *, cascade=False, tenant_id=...) -> RunResult
Cooperative cancellation with atomic finalize on the paused path.
Behavior by current state:
- Paused (any process;
waiting_*orpaused): atomic CAS finalize toCANCELLEDin one round-trip. Emitsrun.cancelledwith{"reason": "cancel_requested"}(sequenced after any prior paused event so SSE clients reading byafter_sequence_indexdo not miss it). - Running (any process, persisted): sets
cancel_requested=True. The runner observes the flag at the top of the next iteration and at the pre-pause checkpoint. Tool calls complete; cancel is observed between steps. - Batch
run()on this agent instance: also cancels the awaiting task. The in-flight model call or tool is interrupted, andrun()returnsCANCELLEDwithmeta["interrupted"] = True. - Streamed run on this agent instance (
stream()/resume_stream()): also fires an in-process interrupt. The in-flight model stream is abandoned immediately and the stream yieldsRUN_CANCELLEDwith the partialanswer(persisted on the row). Once the model's final event is in hand, completion wins. - In-process submit/resume task on this agent instance: the asyncio task is also cancelled synchronously.
- Terminal: no-op. Returns current persisted state. Does not raise.
- Non-persisted: unsupported. Raises
PersistenceNotConfiguredError.
A cancel that arrives after the run's last checkpoint does not stop a run that then completes successfully. cascade=True also cancels non-terminal descendants (deepest first, per-run outcomes in meta["cascade"]); tenant_id scopes the call.
Raises: PersistenceNotConfiguredError, RunNotFoundError.
Minimal write-route wrappers
A single-tenant example follows. In a multi-tenant service, derive the caller's tenant from authentication and pass it to request_pause, submit_note, and cancel_run; omitting tenant_id is unscoped, while explicit tenant_id=None means untenanted runs only. The submit_* methods have no tenant parameter, so verify run ownership before calling them. The sample below does both: owned_run is a dependency that checks the run belongs to the caller's tenant before any route touches it.
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from dendrux.errors import (
InvalidToolResultError,
PauseStatusMismatchError,
PersistenceNotConfiguredError,
RunAlreadyClaimedError,
RunAlreadyTerminalError,
RunNotFoundError,
RunNotPausedError,
)
from dendrux import ApprovalDecision
from dendrux.store import RunNotFoundError as StoreRunNotFoundError
from dendrux.types import ToolResult
def tenant_of(request: Request) -> str:
"""Your auth layer: the tenant of the authenticated caller."""
return request.state.tenant_id
async def owned_run(run_id: str, tenant_id: str = Depends(tenant_of)) -> str:
"""Ownership check for every route. The submit_* methods take no tenant_id."""
try:
await store.get_subtree(run_id, tenant_id=tenant_id) # root must be in this tenant
except StoreRunNotFoundError:
raise HTTPException(404, "run not found")
return run_id
writes = APIRouter(dependencies=[Depends(authorize)])
class ToolResultBody(BaseModel):
name: str
call_id: str
payload: str
success: bool = True
error: str | None = None
duration_ms: int = 0
@writes.post("/runs/{run_id}/tool-results")
async def submit_tool_results(results: list[ToolResultBody], run_id: str = Depends(owned_run)):
try:
result = await agent.submit_tool_results(
run_id, [ToolResult(**r.model_dump()) for r in results]
)
except RunNotFoundError:
raise HTTPException(404, "run not found")
except InvalidToolResultError as e:
raise HTTPException(400, str(e))
except (RunNotPausedError, PauseStatusMismatchError,
RunAlreadyClaimedError, RunAlreadyTerminalError) as e:
raise HTTPException(409, str(e))
except PersistenceNotConfiguredError:
raise HTTPException(500, "persistence not configured")
return {"status": result.status.value, "answer": result.answer}
@writes.post("/runs/{run_id}/input")
async def submit_input(body: dict, run_id: str = Depends(owned_run)):
# (same try/except shape)
result = await agent.submit_input(run_id, body["text"])
return {"status": result.status.value, "answer": result.answer}
@writes.post("/runs/{run_id}/approval")
async def submit_approval(body: dict, run_id: str = Depends(owned_run)):
# Whole batch: {"approved": true}. Per call: {"decisions": {"<call_id>": {"approved": false, "reason": "..."}}}
if "decisions" in body:
decisions = {
call_id: ApprovalDecision(approved=d["approved"], reason=d.get("reason"))
for call_id, d in body["decisions"].items()
}
result = await agent.submit_approval(run_id, decisions=decisions)
else:
result = await agent.submit_approval(
run_id,
approved=body["approved"],
rejection_reason=body.get("rejection_reason"),
)
return {"status": result.status.value, "answer": result.answer}
@writes.post("/runs/{run_id}/pause")
async def request_pause(
body: dict | None = None,
run_id: str = Depends(owned_run),
tenant_id: str = Depends(tenant_of),
):
outcome = await agent.request_pause(
run_id, tenant_id=tenant_id, reason=(body or {}).get("reason")
)
return {"status": outcome.status_after.value, "affected": outcome.affected}
@writes.post("/runs/{run_id}/resume")
async def resume(run_id: str = Depends(owned_run)):
result = await agent.submit_resume(run_id)
return {"status": result.status.value, "answer": result.answer}
@writes.post("/runs/{run_id}/notes")
async def add_note(
body: dict,
run_id: str = Depends(owned_run),
tenant_id: str = Depends(tenant_of),
):
note_id = await agent.submit_note(
run_id, body["text"], metadata=body.get("metadata"), tenant_id=tenant_id
)
return {"note_id": note_id}
@writes.post("/runs/{run_id}/cancel")
async def cancel_run(run_id: str = Depends(owned_run), tenant_id: str = Depends(tenant_of)):
try:
result = await agent.cancel_run(run_id, tenant_id=tenant_id)
except RunNotFoundError:
raise HTTPException(404, "run not found")
except PersistenceNotConfiguredError:
raise HTTPException(500, "persistence not configured")
return {"status": result.status.value}
app.include_router(writes, prefix="/dendrux")That is the full write surface. No hidden endpoints, no middleware magic.
Exceptions
From dendrux.errors. Suggested HTTP mappings in the docstring there; use whatever fits your API.
Read-side types
From dendrux.store:
RunSummary- list view row.RunDetail- single run detail.RunSubtree,SubtreeNode,RunUsage- a run's delegation tree with live usage (RunStore.get_subtree).RunNote- one inbox note (RunStore.get_notes).StoredEvent- onerun_eventsrow.LLMCall- onellm_interactionsrow.ToolInvocation- onetool_callsrow.TraceEntry- onereact_tracesrow.PausePair- derived pause/resume pair.
All frozen dataclasses. Top-level fields are immutable; nested dict/list values are plain JSON (mutating them only touches your in-memory copy, never the DB).
RunStore construction:
# Store-owned engine (typical)
async with RunStore.from_database_url("postgresql+asyncpg://...") as store:
...
# Caller-owned engine (when your app already has one)
store = RunStore.from_engine(engine)Streaming via the Python API (if you do not want SSE):
from dendrux.store import RunStore, RunNotFoundError
async with RunStore.from_database_url("...") as store:
async for event in store.stream_events(run_id, after_sequence_index=5):
if event.event_type in {"run.completed", "run.error", "run.cancelled"}:
breakstream_events raises RunNotFoundError before the first yield if the run does not exist. Default poll interval is 0.5s, matching the SSE route. Caller decides when to stop (break out of the loop or call aclose() on the generator).
Where this fits
- Read endpoints and SSE:
dendrux/http/read_router.py, mounted viamake_read_router(store=, authorize=). - Read types:
dendrux/store/__init__.py(RunStore,RunSummary,RunDetail,StoredEvent,LLMCall,ToolInvocation,TraceEntry,PausePair). - Write-side agent methods:
dendrux/agent.py(submit_tool_results,submit_input,submit_approval,submit_resume,request_pause,submit_note,cancel_run). - Exceptions:
dendrux/errors.py. - Event type enum:
dendrux/types.py(GovernanceEventType,RunStatus). - Lifecycle event strings:
dendrux/runtime/runner.pyanddendrux/runtime/persistence.py.