Skip to content

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.

Terminal window
npm install @novarch/sdk

The 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 throw NovarchKillError.
  • kill (buyer-facing: BLOCKED): throws 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.

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

OptionDefaultWhat it does
tenantId, teamId, agentIdnone (required)The hierarchy keys stamped on every action in the session.
serverUrlNOVARCH_SERVER_URL, then http://localhost:8000The deployment this client talks to.
sdkKeyNOVARCH_SDK_KEYSent as X-Novarch-Key; a configured server rejects writes without it.
sessionIdserver mints onePin an explicit id to correlate with your own run id.
pollTimeoutMs300000The longest this runtime can block on a PAUSED action. The deployment’s hold window can shorten the wait this session actually gets.
pollIntervalMs1000Backoff between retries when a verdict poll hits a transient error. Not the poll cadence.
maxHoldMspollTimeoutMsWhat 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.
resumablefalseWhether 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.
resumefalseThis 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.
evidencenoneDeclares 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.
timeoutMs30000Per-request deadline. Must exceed the gate’s 25s long-poll hold.
signalnoneCaller cancellation, composed with the deadline on the operator long poll.
attachfalseRe-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): void

Stages 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 calledThe session declaredThe action records
setReasoning('…')either profileprovided, with your text
setReasoning('')either profileprovided, and the text is blank. The agent explained itself and what it said was nothing.
nothingreasoningProfile: 'provided'missing. The agent declares it reasons and this write carried none.
nothingreasoningProfile: '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(): void

Parks 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 actionId 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 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.

TokenMeansRetry?
[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 aroundUse
session.writeTool(...) — the hand-rolled floore instanceof NovarchKillError
agent.invoke(...) — the createAgent seamasNovarchKillError(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:

ExportWhat it is
FAIL_CLOSED_SENTINELThe rule_id_cited value on a fail-closed kill.
SEAM_BLOCKED_PREFIXLeads the blocked message a seam adapter hands back to the agent.
SEAM_FAIL_CLOSED_PREFIXLeads a seam adapter’s fail-closed block message.
CONFIG_FAIL_CLOSED_PREFIXLeads a kill caused by a misconfigured call, refused before any request goes out.
READ_RAISED_SUMMARYThe result summary buffered for a read that threw.
LONG_POLL_HOLD_MSHow long the server holds a verdict poll open.
SDK_VERSIONSent 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 ToolNode adapter. createAgent is gated through @novarch/sdk/langchain; a hand-built StateGraph uses the floor above. See LangGraph for why.

Next steps