Skip to content

augur decorators

The Augur SDK has three pieces of public surface. You import them as:

from novarch import augur, NovarchKillError

Read tools are buffered; the next write tool drains the buffer, materializes an Action, and gates on the Novarch engine’s verdict:

  • pass: the write runs.
  • hold (buyer-facing: PAUSED): the call blocks until an operator decides. Approve runs the write; deny / skip / timeout raise NovarchKillError.
  • kill (buyer-facing: BLOCKED): raises NovarchKillError.

Uncertainty (backend unreachable, operator timeout) fails closed with a NovarchKillError carrying a [fail_closed_*] rationale prefix, unless the deployment’s posture says to release the write instead.

@augur.session

Decorate your agent’s entry-point function. The decorator opens a session for the duration of the call; every Action inside it shares the session’s tenant_id, team_id, and agent_id.

def session(
self,
*,
tenant_id: str,
team_id: str,
agent_id: str,
server_url: str | None = None,
session_id: str | None = None,
poll_timeout_s: float = 300.0,
poll_interval_s: float = 1.0,
attach: bool = False,
max_hold_s: MaxHoldDeclaration = None, # float | int | None | ADOPT_DEPLOYMENT_WINDOW
resumable: bool = False,
resume: bool = False,
reasoning_profile: str = "provided", # "provided" | "unavailable"
) -> Callable[[F], F]: ...
ParameterMeaning
tenant_id, team_id, agent_idThe hierarchy keys (tenant → team → agent), pinned per call-site.
server_urlThe novarch_server URL. When omitted, resolves from the NOVARCH_SERVER_URL environment variable (default http://localhost:8000).
session_idOptional explicit session id. When omitted, the server mints one.
poll_timeout_sThe longest this process can block on a hold. Defaults to 300s (5 min). The deployment’s hold window can shorten the wait this session actually gets.
poll_interval_sBackoff between retries on a transient polling error.
attachRe-enter an existing session instead of creating one. Requires session_id. See Durable runtimes.
max_hold_sWhat this process declares it can wait when an action is PAUSED. Omit to declare poll_timeout_s; pass augur.ADOPT_DEPLOYMENT_WINDOW to declare no ceiling of your own; pass a number for that ceiling, in whole seconds rounded down. See The hold window.
resumableWhether this runtime can park across the wait rather than block through it. On a deployment whose hold window is indefinite, this is what earns the session a park policy: a PAUSED action raises NovarchPausedError instead of blocking. A finite window polls either way. See The resumable gate.
resumeThis run is coming back for commitments that were already decided. Every write then presents its action_id as a continuation key and the gate answers the standing verdict rather than judging again. Requires attach=True. See The resumable gate.
reasoning_profileWhat this agent’s silence means. "provided" (the default) says the agent explains itself, so a write that stages nothing is recorded missing. "unavailable" says it produces no reasoning at all, and the same absence is recorded unavailable. Declared once for the run and sent on create and on attach. See Declaring that your agent has no reasoning.

The decorator is polymorphic on the wrapped function: an async def entry point gets an async wrapper that awaits the body inside the session context.

@augur.session(tenant_id="acme", team_id="ap-ops", agent_id="ap-bot")
def process_invoice(invoice_id: str) -> None:
...

@augur.tool

Wrap a read or write tool. The kind argument selects the behavior.

def tool(
self,
*,
kind: Literal["read", "write"],
action_type: str | None = None,
context_builder: Callable[..., ActionContext] | None = None,
action_id_builder: Callable[..., str | None] | None = None,
) -> Callable[[F], F]: ...

kind="read"

Read tools are best-effort: if no session is active they execute unwrapped, so you can unit-test your tool functions without mocking the SDK. Inside a session, each invocation is buffered as a ToolCall for the audit drill-down.

@augur.tool(kind="read")
def lookup_vendor(vendor_id: str) -> dict:
...

kind="write"

Write tools require an active @augur.session and a context_builder that turns the call’s args into an ActionContext. The call blocks on the Novarch engine’s verdict.

@augur.tool(
kind="write",
action_type="process_invoice",
context_builder=lambda invoice: APContext(...),
)
def pay_invoice(invoice: dict) -> None:
...
ParameterMeaning
action_typeRequired for writes. The string label for the materialized Action (e.g. "process_invoice").
context_builderRequired for writes. A callable that receives the tool call’s args/kwargs and returns an ActionContext (APContext, CSContext, or GenericContext).
action_id_builderOptional. Takes the same arguments and returns your id for this work item (po-1234). That id is half the continuation key a later run presents to resume the commitment. Omit it and the server mints one, which is right for a session nothing will resume. It is a builder rather than a fixed string because the id belongs to the call: one id pinned at decoration collides with itself the second time the tool runs in a session.

@augur.tool is polymorphic on the wrapped function too: async def tools get an async wrapper (so frameworks that require inspect.iscoroutinefunction(handler) == True, like the Claude Agent SDK, accept the result unchanged).

augur.set_reasoning

Record the agent’s explanation for the next write. The reasoning carries over to the next action only and is cleared once the write materializes. Calling it outside a session is a no-op, so the same agent code stays unit-testable without the SDK.

def set_reasoning(self, text: str) -> None: ...
augur.set_reasoning("Vendor verified; routing matches master record.")
pay_invoice(invoice) # the write materializes with this reasoning attached

What the gate records

Every action carries a reasoning status beside the text, so a silence says which silence it was. The status follows from what you staged and from the session’s reasoning_profile:

You calledThe session declaredThe action records
set_reasoning("…")either profileprovided, with your text
set_reasoning("")either profileprovided, and the text is blank. The agent explained itself and what it said was nothing.
nothingreasoning_profile="provided"missing. The agent declares it reasons and this write carried none.
nothingreasoning_profile="unavailable"unavailable. This agent produces no reasoning at all.

A staged value always wins, so an unavailable session that does explain one write records that write as provided.

missing is not a verdict and does not block anything on its own. It reads on the operator’s card as “Reasoning missing — the agent declares it reasons and supplied none”. One signal reads the agent’s reasoning: agent_reasoning_drift_score. A rule that lists it among the signals it reads is marked not evaluable on an action carrying no reasoning, and the judge is told not to cite it there. A rule that lists no signals is judged as written.

Actions recorded before this release carry no status of their own. They read as “Reasoning not recorded (pre-upgrade action)” on the operator’s card and “Reasoning not recorded (action predates reasoning-status recording)” in the audit packet.

Durable runtimes: attach and suspend

A durable-execution runtime — Cloudflare Workflows, Temporal, Inngest, Step Functions — resumes a function by re-executing it with completed steps memoized. Two pieces of surface let one session span that resume, so a single trajectory can hold across a human wait that runs for days and still be judged as a whole.

Reach for these only under such a runtime. An ordinary agent needs neither.

augur.suspend

Park the active session on exit instead of completing it. The caller keeps ownership of completion and finishes the session later by attaching.

def suspend(self) -> None: ...

It is a call, not a decorator argument, because whether a run parks is decided inside the body: a run that inspects its work and finds nothing to do exits without a write and must complete normally. Calling it outside a session is a no-op, matching set_reasoning.

Parking is idempotent, and re-parking is the refresh — see augur.refresh_park below, which is how a wait longer than the idle cutoff says it is still there. A parked session shows as PARKED on the operator surface and counts toward ACTIVE, because a run waiting on a human is still in flight.

augur.refresh_park

Keep a parked session out of the idle reaper, without re-entering it.

def refresh_park(
session_id: str,
*,
server_url: str | None = None,
timeout_s: float = 30.0,
) -> None: ...

suspend() parks a session once, on the way out. The reaper then takes it on the ordinary idle cutoff — there is no longer, separate parked cutoff, because a fixed one is wrong in both directions: short enough and a real multi-day wait times out, long enough and a run that died five minutes in sits parked for the rest of it. So a wait that outlives the cutoff has to say it is still there.

Call it from whatever your wait already wakes on:

while not released:
time.sleep(300)
augur.refresh_park(workflow_id)
released = check_release(...)

It takes an explicit session_id and opens its own client because there is no active session to read — by then the decorated function has returned. Attaching just to park again would clear parked and re-set it on every tick, publishing a status round trip to the operator feed for the length of the wait.

A refresh that succeeds is not evidence the session is still live. The server answers 204 even when it declined the park because an operator killed the session or it already completed. Attach is what checks: it refuses, and that becomes a fail-closed kill. Treat this as liveness bookkeeping and treat the attach as the gate.

It raises NovarchKillError when the request itself fails, so a caller under a runtime with a retry policy gets the retry. Tolerating one missed refresh is your call, and there is room to: the cutoff is many multiples of any sane poll interval.

attach=True

Bind to an existing session rather than creating one. The second half of a resumed run cannot create the session it is already inside — POST /sessions answers 409 on a reused id, and any 4xx on open is a fail-closed kill.

@augur.session(
tenant_id="acme", team_id="ap-ops", agent_id="ap-bot",
session_id=workflow_id,
)
def start(...) -> None:
... # gate the first half
augur.suspend() # park instead of completing
# ...the runtime waits, possibly for days, then resumes:
@augur.session(
tenant_id="acme", team_id="ap-ops", agent_id="ap-bot",
session_id=workflow_id, attach=True,
)
def finish(...) -> None:
... # same session, same trajectory

attach=True requires session_id and refuses without one, raising NovarchKillError with a [fail_closed_config] prefix before any request goes out — creating a fresh session in its place would split one trajectory in two with nothing recording that it happened.

The server validates the full identity tuple on attach: tenant_id, team_id and agent_id must match the stored session, or the attach is refused. A session that is already completed or killed cannot be attached.

If the body raises after calling suspend(), the session still parks. That is deliberate: a durable runtime retries a failed step, and a completed session would refuse the retry’s attach.

The resumable gate: park and resume

A nightly batch runs at 2am and the people who decide arrive at 8. Blocking for six hours is not something a batch process can do, so the gate can pause without waiting: your run records the item as deferred and exits, a person decides in the morning, and a later run picks the answer up and executes the write once.

New in novarch 0.4.0.

Parking takes two declarations. The deployment’s hold window has to be indefinite (an admin sets that in ADMIN → SETTINGS), and your session has to declare resumable=True. On a finite window the session blocks on a hold rather than parking — but everything below about resuming still applies, so a run that re-attaches across a durable-runtime step owes the same obligations under either window.

from novarch import augur, NovarchPausedError, NovarchAlreadyExecutedError
@augur.tool(
kind="write",
action_type="create_purchase_order",
context_builder=lambda po: GenericContext(record_id=po["id"], amount=po["amount"]),
action_id_builder=lambda po: po["id"],
)
def stage_po(po: dict) -> str:
return erp.stage(po["id"])
@augur.session(
tenant_id="hallway", team_id="field-ops", agent_id="po-agent",
session_id="nightly-2026-09-03",
resumable=True,
max_hold_s=augur.ADOPT_DEPLOYMENT_WINDOW,
)
def nightly() -> None:
try:
stage_po(po)
except NovarchPausedError as e:
log.info("deferred %s for a person", e.action_id)
augur.suspend()

NovarchPausedError is a separate exception from NovarchKillError so a batch can tell a person is deciding from the gate refused. It carries session_id, action_id, decision_id, rule_id_cited, rationale and the full decision.

The run that comes back re-enters the same session and asks for the standing answer:

@augur.session(
tenant_id="hallway", team_id="field-ops", agent_id="po-agent",
session_id="nightly-2026-09-03",
attach=True, resume=True,
resumable=True, max_hold_s=augur.ADOPT_DEPLOYMENT_WINDOW,
)
def resume_nightly() -> None:
stage_po(po) # approved overnight → the write runs, exactly once

resume=True requires attach=True, and every write in a resumed session must supply its own action_id through action_id_builder. Both are refused before any request goes out, raising NovarchKillError with a [fail_closed_config] prefix.

What you have to keep across runs

Novarch stores no list of pending work and offers no endpoint to discover one. The resumed run brings three things itself:

  • the same session_id,
  • the same action_id for each work item, and
  • enough state to rebuild the same action payload.

A run that opens a new session presents a different continuation key. It finds no standing verdict, is judged fresh, and the protection against paying twice does not reach it. Derive both ids from your own work item (po-1234, nightly-2026-09-03) so a re-run produces the same ones.

The context builder is a safety surface

The standing verdict is bound to a fingerprint of action_type plus the context your context_builder produced, and a resume presenting different content is refused. Leave the destination account out of that context and two writes differing only there fingerprint identically, so the resume path honours an approval given for the other one. Put every field that decides what the write does into the context.

The judge reads more than the context: tool results and the agent’s reasoning reach it, and AP-2 is decided on exactly those. A resume carries fresh tool results and fresh reasoning, and a matching fingerprint hands back the standing verdict without looking at either. The hold window is what bounds how long a verdict stays honourable.

A second write is a second commitment

An approval is consumed once. A later run presenting the same work item raises NovarchAlreadyExecutedError, carrying session_id, action_id and executed_at. To write again, present a new action_id: it is judged on its own merits and recorded as its own commitment. A resumed run also has no access to the earlier run’s return value, so re-fetch what you need from your own system.

When the gate cannot tell you whether the write landed

The SDK claims the right to execute, runs your write, then acknowledges it. If the process dies in between, the gate cannot know whether the write landed, and a later resume raises NovarchKillError with verdict="execution_unconfirmed" rather than guessing. An operator settles it in the portal: confirm executed closes it, confirm not executed clears the claim so a later run can take it again. Your own idempotency is what covers a deliberate retry.

The four resume refusals

Each arrives as a NovarchKillError whose message leads with the token.

TokenMeansRetry?
[resume_fingerprint_mismatch]The submitted content differs from the approved commitment.No — submit under a new action_id.
[resume_commitment_version_changed]A release changed what a commitment means, so approvals given under the old meaning no longer apply.No — new action_id, decided again.
[resume_commitment_unversioned]The action was recorded before the gate fingerprinted commitments.No — new action_id.
[resume_no_standing_verdict]Nothing has been decided for this action yet.Yes — retry the resume.

The last one is also what a resume sees while a first attempt is still in front of the judge, which a durable-runtime retry overlapping a live run hits routinely. Minting a new action_id there pays twice. On that one ground the session parks on the way out instead of completing, so the retry can attach to it.

novarch.schema

Action, ToolCall, and Decision are the wire contract between the SDK and novarch_server:

from novarch.schema import Action, ToolCall, Decision
  • Action: one write commitment. Carries the buffered ToolCalls and the agent’s reasoning.
  • ToolCall: one buffered read invocation. Audit-surface only.
  • Decision: the Novarch engine’s verdict (pass | hold | kill) plus the cited rule, signals, and rationale.

See Concepts for how the Novarch engine evaluates an Action across the full session.