TypeScript SDK
@novarch/sdk is the TypeScript client. It implements the same wire contract
and the same fail-closed behavior as the Python SDK, pinned by a conformance
corpus both test suites run.
npm install @novarch/sdkThe core has zero runtime dependencies and uses Web-standard fetch only, so
it runs on Node 20.3+, Cloudflare Workers, Deno and Bun. It is ESM only —
require('@novarch/sdk') throws ERR_REQUIRE_ESM. The Node floor is 20.3
rather than 20.0 because that is where AbortSignal.any landed, which the
request timeout composes with a caller-supplied signal.
import { withSession, NovarchKillError, asNovarchKillError } from '@novarch/sdk'Named exports, no namespace object. Python groups its surface under augur
because that object holds decorators; TypeScript has none, so the object would
exist only for symmetry and would defeat tree-shaking.
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 waits until an operator decides. Approve runs the write; deny / skip / timeout throwNovarchKillError.kill(buyer-facing: BLOCKED): throwsNovarchKillError.
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.
withSession
Opens a session, runs your body with it, and completes the session on every exit path.
function withSession<T>( config: SessionConfig, body: (session: NovarchSession) => Promise<T>,): Promise<T>The session is an explicit value, not ambient state. Python discovers its
session from a ContextVar; this SDK cannot, because node:async_hooks is a
Node-only API and the SDK has to run on Workers and Deno too. Passing the
session is also the better anchor when a framework already gives you a context
object to hang it on.
A failed open — backend down, bad SDK key, a 409 on a reused sessionId —
comes back as a fail-closed NovarchKillError, the same single exception the
write path throws. The private NovarchAPIError never escapes.
SessionConfig
| Option | Default | What it does |
|---|---|---|
tenantId, teamId, agentId | none (required) | The hierarchy keys stamped on every action in the session. |
serverUrl | NOVARCH_SERVER_URL, then http://localhost:8000 | The deployment this client talks to. |
sdkKey | NOVARCH_SDK_KEY | Sent as X-Novarch-Key; a configured server rejects writes without it. |
sessionId | server mints one | Pin an explicit id to correlate with your own run id. |
pollTimeoutMs | 300000 | The longest this runtime can block on a PAUSED action. The deployment’s hold window can shorten the wait this session actually gets. |
pollIntervalMs | 1000 | Backoff between retries when a verdict poll hits a transient error. Not the poll cadence. |
maxHoldMs | pollTimeoutMs | What this runtime declares it can wait when an action is PAUSED. Omit to declare the resolved pollTimeoutMs; pass null to declare no ceiling of your own; pass a number for that ceiling. See The hold window. |
resumable | false | Whether 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 throws NovarchPausedError instead of blocking. A finite window polls either way. See The resumable gate. |
resume | false | This run is coming back for commitments that were already decided. Every write then presents its actionId as a continuation key and the gate answers the standing verdict rather than judging again. Requires attach. See The resumable gate. |
reasoningProfile | 'provided' | What this agent’s silence means. 'provided' 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. |
evidence | none | Declares the fields this agent stamps on its own actions, by action type. An invalid declaration is refused before any request reaches the server. A name the deployment already computes, or a name this agent already declared under a different kind, comes back on the open response as a warning and the session still opens. Sent on create and on attach, only when there is something to declare. See Evidence your agent reports. |
timeoutMs | 30000 | Per-request deadline. Must exceed the gate’s 25s long-poll hold. |
signal | none | Caller cancellation, composed with the deadline on the operator long poll. |
attach | false | Re-enter an existing session instead of creating one. Requires sessionId. See Durable runtimes. |
session.readTool
Wrap a read so its invocation buffers into the next Action.
readTool<A extends unknown[], R>( name: string, fn: (...args: A) => Promise<R> | R,): (...args: A) => Promise<R>The read runs regardless of any verdict — reads are evidence, not commitments.
A read that throws is still buffered, with outcome fail and a <raised>
summary, and then re-thrown: a failed lookup never disappears from the audit
trail just because it failed.
session.writeTool
Wrap a write so it materializes an Action and gates on the verdict.
writeTool<A extends [Record<string, unknown>, ...unknown[]], R>( spec: WriteSpec, fn: (...args: A) => Promise<R> | R,): (...args: A) => Promise<R>
interface WriteSpec { actionType: string contextBuilder: (args: Record<string, any>) => ActionContext actionIdBuilder?: (args: Record<string, any>) => string | null | undefined}actionIdBuilder returns your id for this work item (po-1234). It 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. See The resumable
gate.
The context is built from the first argument, matching
WriteSpec.contextBuilder — one context-builder contract across the floor and
every adapter. An optional reasoning key on that same object overrides staged
reasoning.
writeTool takes a spec where readTool takes a name, and the asymmetry is
deliberate: a write is named by spec.actionType, which is the name the judge,
the rules and the audit trail all use. A second name here would be a name
nothing reads.
session.setReasoning
setReasoning(text: string): voidStages the agent’s rationale for the next write. It travels with the Action and appears on the operator’s triage card next to the cited rule.
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 reasoningProfile:
| You called | The session declared | The action records |
|---|---|---|
setReasoning('…') | either profile | provided, with your text |
setReasoning('') | either profile | provided, and the text is blank. The agent explained itself and what it said was nothing. |
| nothing | reasoningProfile: 'provided' | missing. The agent declares it reasons and this write carried none. |
| nothing | reasoningProfile: '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.
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.
session.suspend
suspend(): voidParks the session on exit instead of completing it, handing completion to a later attach. A method rather than a config flag, because whether a run parks is decided inside the body: a run that finds nothing to do exits without a write and must complete normally.
Parking is idempotent, and re-parking is the refresh — see
refreshPark 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.
refreshPark
Keeps a parked session out of the idle reaper, without re-entering it.
import { refreshPark } from '@novarch/sdk'
await refreshPark({ sessionId: workflowId, serverUrl: env.NOVARCH_SERVER_URL, sdkKey: env.NOVARCH_SDK_KEY,})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:
for (let i = 0; i < MAX_POLLS; i++) { await step.sleep(`wait-${i}`, '5 minutes') await step.do(`check-${i}`, async () => { await refreshPark({ sessionId: workflowId, serverUrl, sdkKey }) return checkRelease(...) })}It is a bare function taking an explicit sessionId, not a method: by the
time the wait is running, withSession has returned and the NovarchSession
is gone. 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 throws 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
Binds 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.
// First pass: gate the early writes, then park.await withSession({ ...cfg, sessionId: workflowId }, async (session) => { // ... session.suspend()})
// The runtime waits, possibly for days, then re-executes:await withSession( { ...cfg, sessionId: workflowId, attach: true }, async (session) => { // same session, same trajectory },)attach: true requires sessionId and refuses without one, throwing
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: tenantId, teamId
and agentId must match the stored session, or the attach is refused. A
session that is already completed or killed cannot be attached.
If the body throws after 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/sdk 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.
import { withSession, NovarchPausedError } from '@novarch/sdk'
const spec = { actionType: 'create_purchase_order', contextBuilder: (args) => ({ record_id: args.poId, amount: args.amount }), actionIdBuilder: (args) => args.poId,}
await withSession( { tenantId: 'hallway', teamId: 'field-ops', agentId: 'po-agent', sessionId: 'nightly-2026-09-03', resumable: true, maxHoldMs: null, // adopt the deployment's window }, async (session) => { const stagePo = session.writeTool(spec, (a) => erp.stage(a.poId)) try { await stagePo({ poId: 'po-1234', amount: 5000 }) } catch (e) { if (e instanceof NovarchPausedError) log.info(`deferred ${e.actionId}`) else throw e } session.suspend() },)NovarchPausedError is a separate class from NovarchKillError so a batch can
tell a person is deciding from the gate refused. It carries sessionId,
actionId, decisionId, ruleIdCited, rationale and the full decision.
The run that comes back re-enters the same session and asks for the standing answer:
await withSession( { tenantId: 'hallway', teamId: 'field-ops', agentId: 'po-agent', sessionId: 'nightly-2026-09-03', attach: true, resume: true, resumable: true, maxHoldMs: null, }, async (session) => { const stagePo = session.writeTool(spec, (a) => erp.stage(a.poId)) await stagePo({ poId: 'po-1234', amount: 5000 }) // runs, exactly once },)resume: true requires attach: true, and every write in a resumed session
must supply its own actionId through actionIdBuilder. Both are refused
before any request goes out, throwing 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
sessionId, - the same
actionIdfor 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 actionType plus the context
your contextBuilder 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 some rules are 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 throws
NovarchAlreadyExecutedError, carrying sessionId, actionId and
executedAt. To write again, present a new actionId: 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 throws 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.
| Token | Means | Retry? |
|---|---|---|
[resume_fingerprint_mismatch] | The submitted content differs from the approved commitment. | No — submit under a new actionId. |
[resume_commitment_version_changed] | A release changed what a commitment means, so approvals given under the old meaning no longer apply. | No — new actionId, decided again. |
[resume_commitment_unversioned] | The action was recorded before the gate fingerprinted commitments. | No — new actionId. |
[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 actionId there pays twice. On that one ground the
session parks on the way out instead of completing, so the retry can attach to
it.
Putting it together
import { withSession } from '@novarch/sdk'
await withSession( { tenantId: 'acme', teamId: 'ap', agentId: 'po-agent' }, async (session) => { const lookupVendor = session.readTool('lookup_vendor', (id: string) => vendorDb.lookup(id), ) const submitPayment = session.writeTool( { actionType: 'process_invoice', contextBuilder: (args) => buildApContext(args.invoice), }, async (args: { invoice: Invoice }) => paymentsApi.submit(args.invoice), )
await lookupVendor(invoice.vendorId) // buffers a read session.setReasoning('Vendor known; bank verified.') await submitPayment({ invoice }) // gated write },)Catching a blocked write
import { NovarchKillError, asNovarchKillError } from '@novarch/sdk'Which one you use depends on what you are catching around.
| You are catching around | Use |
|---|---|
session.writeTool(...) — the hand-rolled floor | e instanceof NovarchKillError |
agent.invoke(...) — the createAgent seam | asNovarchKillError(e) |
try { await agent.invoke({ messages: [{ role: 'user', content: question }] })} catch (e) { const kill = asNovarchKillError(e) if (kill) { // Terminal for this write. Retrying is a governance violation, not a retry. console.error(kill.ruleIdCited, kill.rationale) }}Fields are camelCase here and snake_case in Python — sessionId, actionId,
ruleIdCited, rationale, decision, verdict. Meanings are identical; see
NovarchKillError.
Exported sentinels
Exported so your code and your log pipeline match on the same spellings the gate emits, rather than on string literals that can drift:
| Export | What it is |
|---|---|
FAIL_CLOSED_SENTINEL | The rule_id_cited value on a fail-closed kill. |
SEAM_BLOCKED_PREFIX | Leads the blocked message a seam adapter hands back to the agent. |
SEAM_FAIL_CLOSED_PREFIX | Leads a seam adapter’s fail-closed block message. |
CONFIG_FAIL_CLOSED_PREFIX | Leads a kill caused by a misconfigured call, refused before any request goes out. |
READ_RAISED_SUMMARY | The result summary buffered for a read that threw. |
LONG_POLL_HOLD_MS | How long the server holds a verdict poll open. |
SDK_VERSION | Sent as X-Novarch-SDK-Version on every request. |
Differences from the Python SDK
Both SDKs implement the same wire contract and the same fail-closed behavior, pinned by a shared conformance corpus that both test suites run. Three documented differences:
- The session is a value, not a ContextVar (no
node:async_hooks). - There is no sync API. Every gated call is async.
- No raw LangGraph
ToolNodeadapter.createAgentis gated through@novarch/sdk/langchain; a hand-builtStateGraphuses the floor above. See LangGraph for why.
Next steps
- Quickstart: wire this into a running agent.
- LangChain: gate every tool with one middleware.
- NovarchKillError: every fail-closed condition, and the field table.
- Configuration: environment variables and resolution order.