dendrux
v0.4.0Get started →

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:

NameTypeDefaultMeaning
statuslist[str] (repeatable)allFilter by RunStatus values. Repeat for OR: ?status=running&status=waiting_approval.
agent_namestrallExact-match agent name.
parent_run_idstrallReturn only child runs of this parent.
tenant_idstrallMulti-tenant isolation filter.
started_afterISO-8601 datetimenoneInclusive lower bound on created_at.
started_beforeISO-8601 datetimenoneExclusive upper bound on created_at.
limitint 1-100050Page size.
offsetint >= 00Rows to skip.

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:

NameTypeDefaultMeaning
afterintnoneExclusive cursor. Pass the previous batch's last sequence_index.
limitint 1-1000100Page size.

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):

  1. Last-Event-ID header (SSE reconnect convention).
  2. ?after=N query param.
  3. 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: no

Frame 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:

NameTypeDefaultMeaning
iterationintallFilter to one iteration index.
limitint 1-1000100Page size.
offsetint >= 00Rows to skip.

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

event_typeWhendata payload
run.startedFirst event on any run{"agent_name": str, "system_prompt": str}
run.pausedRunner finalizes the current iteration into a pause{"status": "waiting_client_tool" | "waiting_human_input" | "waiting_approval" | "paused", "pending_tool_calls": [{"id", "name", "target", "params"}]}. A paused status adds "reason": "requested" | "budget" and, if request_pause(reason=...) gave one, "message" (up to 500 chars); a budget pause adds "exceeded": [{"metric", "used", "max"}]. pending_tool_calls is empty for a pause with nothing outstanding
run.resumedSubmit method claims a paused run{"resumed_from": str, "submitted_results": [{"call_id", "name", "success"}]} or {"resumed_from": str, "user_input": str}; a result that carried images adds "image_count" to its entry, and an input with images adds "image_count" to the payload; resumed_from is "paused" after submit_resume
run.completedTerminal success, error, or max_iterations returned by the loop{"status": str}
run.cancelledPaused-run CAS cancel, in-flight run observes the flag, run or stream interrupted, or a budget cancel{"reason": "cancel_requested"}; adds "interrupted": true, "partial_output": bool when cancel_run abandoned a model call (partial_output is always false for batch run()); {"reason": "task_cancelled"} when the task awaiting run() was cancelled; {"reason": "stream_closed", "partial_output": bool} when the consumer closed the stream; {"reason": "budget_exceeded", "exceeded": [{"metric", "used", "max"}]} for Budget(on_exceed="cancel")
run.errorTerminal failure{"error": str} (truncated to 500 chars)
run.interruptedsweep(stale_running=...) finalized a stale running run{"failure_reason": "stale_running" | "never_started"}
llm.completedprovider.complete() returns{"input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "reasoning_tokens", "cost_usd", "cost_source", "usage_reported", "model", "has_tool_calls", "phase"}
tool.completedServer tool or submitted client/approval tool finishes{"tool_name", "target", "success", "duration_ms"} (plus "image_count" when the result has images); malformed calls also add "parse_error" and "raw_length". correlation_id = tool call id

Governance events

From dendrux.types.GovernanceEventType. The values below are deliverable through on_governance_event on notifiers and /events/stream.

event_typeFires whendata payload
policy.deniedLLM called a tool in the agent's deny= set, or the before_tool hook denied the callStatic: {"tool_name", "call_id", "reason": "denied_by_policy"}. Hook: {"tool_name", "call_id", "reason": str, "source": "hook"}. correlation_id = tool call id
policy.substitutedThe before_tool hook returned ToolDecision.substitute(...); the tool did not run{"tool_name", "call_id"}, correlation_id = tool call id
policy.hook_errorThe hook raised, returned a non-decision, or substituted a non-JSON value; the call was denied{"tool_name", "call_id", "error_type": str} (exception type only), correlation_id = tool call id
policy.hook_warningThe hook asked for approval on a client-target tool; the call was denied{"tool_name", "call_id", "warning": "approval is not supported for client tools"}, correlation_id = tool call id
approval.requestedA gated call reached the approval gate: a tool in require_approval=, or a hook require_approvalStatic: {"tool_name", "call_id", "reason": "requires_approval"}. Hook: {"tool_name", "call_id", "reason": str, "source": "hook"}. One event per gated call; correlation_id = tool call id
approval.decidedHuman submitted approval decision{"decision": "approved" | "rejected" | "mixed", "run_id": str, "decisions": [{"call_id", "tool_name", "decision": "approved" | "denied" | "not_gated_executed" | "client_pending", "reason": str | null}]}. decisions lists every pending call, in order
inbox.deliveredThe runner delivered an inbox note to the model{"note_id": str}, correlation_id = note id. Never carries the note text
budget.thresholdUsage crossed a warn_at fraction of a limit{"metric": "tokens" | "cost_usd", "fraction": float, "used": number, "max": number, "reason": "threshold_crossed"}
budget.exceededUsage reached a limit{"metric": "tokens" | "cost_usd", "used": number, "max": number, "reason": "budget_exceeded", "action": "warn" | "pause" | "cancel"}
budget.unpricedA max_cost_usd budget met its first unpriced LLM call in the run (once per run){"metric": "cost_usd", "used": float, "max": float, "unpriced_calls": int, "known_cost_usd": float}
guardrail.detectedGuardrail engine found entities{"direction": "incoming" | "incoming_image" | "outgoing", "findings_count": int, "entities": list[str]}
guardrail.redactedIncoming scan replaced entities with placeholders before the LLM call{"direction": "incoming", "entities": list[str]}
guardrail.blockedGuardrail blocked a request or response{"direction": "incoming" | "incoming_image" | "outgoing", "error": str}
guardrail.image_unscannedGuardrails are configured, a request carries images, and none scans images (once per run){"count": int}
image.rejectedThe provider refused an image; one event per candidate image{"image_id", "sha256", "tenant_scope", "provider", "model", "status_code", "located": bool, "retried": bool}
image.stateA request recorded new image descriptions, guardrail verdicts or placeholders{"tenant_scope", "described": [{"sha256", "text"}], "guardrail_verdicts": [{"sha256", "guardrail_id", "guardrail_version", "origin", "tool_name", "action", "entities"}], "placeholders": [{"slot", "reason"}]}
image.view_changedThe drop-old-images window advanced{"epoch": int, "dropped_through": int, "visible": int} (visible = eligible images still shown)
image.degradedImages were replaced by placeholders{"reason": "purged" | "policy" | "rejected" | "unsupported" | "described" | "guardrail" | "drop", "count": int, "slots": [int]}; once per image slot
image.warningDrop policy combined with provider thinking (Anthropic, or OpenAI Responses thinking=True; once per run){"warning": "drop_with_thinking"}
guardrail.unmapped_placeholderFinal output contains placeholders that cannot be deanonymized{"placeholders": list[str]}
skill.catalog_resolvedRun start, when skills are configured{"delivery": "progressive" | "inlined", "skills": [{"name", "revision", "digest"}], "denied": list[str]}
skill.activatedA skill's body was delivered to the model{"name": str, "digest": str, "origin": "model" | "explicit" | "inlined"}
skill.activation_faileduse_skill named an unknown or denied skill{"name": str, "reason": "not_found" | "denied"}
skill.resource_readread_skill_resource returned a text file{"name": str, "path": str, "chars": int, "truncated": bool}
skill.script_requestedA run_skill_script call passed its checks, before any approval{"name": str, "path": str, "argc": int}
skill.script_completedThe skill executor returned{"name": str, "path": str, "exit_code": int, "duration_ms": int, "stdout_chars": int, "stderr_chars": int, "truncated": bool}
skill.script_failedDendrux's timeout expired, the executor raised, it returned something other than a SkillExecutionResult, or an approved script resumed on an agent with no executor{"name": str, "path": str, "error_type": str} (timeout, an exception class name, invalid_result, or executor_unavailable)
mcp.connectedMCP source discovery completed{"source_name": str, "namespace": str, "tool_count": int, "tool_names": list[str]}
mcp.errorMCP discovery failedStrict failure: {"error": str, "error_type": str | null, "code": str}. Best-effort source failure also includes "source_name", "namespace", and "failure_mode". OAuth failures may add "reason"; error is truncated to 500 characters
mcp.tools_skippedA view skipped individual tools at discovery{"skipped": [{"source_name", "namespace", "tool", "reason"}]}
provider.retryA provider transport response has a retryable status{"status_code": int, "endpoint": str | null, "attempt": int, "retry_after": str | null}

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 = 0

Raises: 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 failed ToolResult per pending call, each carrying rejection_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 (the approval.requested events); a missing, unknown or duplicate id raises ValueError before anything is claimed. A denied call gets a failed result {"denied": reason}. rejection_reason is not allowed with decisions.

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_* or paused): atomic CAS finalize to CANCELLED in one round-trip. Emits run.cancelled with {"reason": "cancel_requested"} (sequenced after any prior paused event so SSE clients reading by after_sequence_index do 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, and run() returns CANCELLED with meta["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 yields RUN_CANCELLED with the partial answer (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.

ExceptionBaseTypical HTTPMeaning
RunNotFoundErrorLookupError404run_id does not exist.
RunNotPausedErrorRuntimeError409Run is not in any paused state.
PauseStatusMismatchErrorRuntimeError409Run is paused, but for a different reason than this method expects.
RunAlreadyClaimedErrorRuntimeError409Concurrent submit won the CAS. Poll for completion; do not retry the submit.
RunAlreadyTerminalErrorRuntimeError409Run is already terminal; cannot resume. cancel_run does not raise this; terminal cancel is a silent no-op.
InvalidToolResultErrorValueError400Submitted results don't match pending calls (unknown / duplicate / missing ids). No partial claim.
PersistenceNotConfiguredErrorRuntimeError500Agent has no DB. Configuration bug, not runtime condition.
ContextLengthExceededErrorRuntimeError413 or 400An OpenAI-compatible endpoint rejected the request as larger than the model's context window. Raised by run() / stream(), not by a submit method.
SkillMissingErrorSkillContinuityError409The run was created with a skill this agent does not have. Run stays paused; attach the skill and retry.
SkillMismatchErrorSkillContinuityError409A skill the run was created with has different content now. Run stays paused; attach the original content and retry.

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 - one run_events row.
  • LLMCall - one llm_interactions row.
  • ToolInvocation - one tool_calls row.
  • TraceEntry - one react_traces row.
  • 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"}:
            break

stream_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 via make_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.py and dendrux/runtime/persistence.py.