@novarch/sdk
v0.5.0
Published
Runtime enforcement layer for AI agents in production — TypeScript client SDK.
Readme
@novarch/sdk
Runtime enforcement layer for AI agents in production — TypeScript client SDK.
Novarch sits in the path of an agent's actions. Before a consequential write executes, it checks the action against rules the workflow owner wrote in plain English: clear violations are blocked autonomously, borderline actions are paused for a human operator to decide.
This package is the client. It talks to a novarch-server deployment; it does
not make judgments locally.
Install
npm install @novarch/sdkThe core SDK genuinely has zero runtime dependencies — the line above is
everything a hand-rolled loop (below) needs. Runs on Node 20.3+, Cloudflare
Workers, Deno and Bun — Web-standard fetch only, no node: imports.
ESM only. import works; require('@novarch/sdk') throws
ERR_REQUIRE_ESM. On Node the 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.
The LangChain createAgent path below needs LangChain too — it's an optional
peer dependency, not bundled, so install it alongside:
npm install @novarch/sdk langchain @langchain/coreUse it with LangChain createAgent
import { withSession } from '@novarch/sdk'
import { novarchGateMiddleware } from '@novarch/sdk/langchain'
import { createAgent } from 'langchain'
await withSession(
{ tenantId: 'hallway', teamId: 'ops', agentId: 'revbudget', serverUrl: process.env.NOVARCH_SERVER_URL },
async (session) => {
const agent = createAgent({
model,
tools,
middleware: [
novarchGateMiddleware({
session,
toolKinds: { lookup_po: 'read', stage_revenue_line: 'write' },
writeSpecs: {
stage_revenue_line: {
actionType: 'stage_revenue_line',
contextBuilder: (args) => ({ record_id: args.lineId, amount: args.amount }),
},
},
}),
],
})
return agent.invoke({ messages: [{ role: 'user', content: 'reconcile PO-1' }] })
},
)One registration gates every tool the agent has — not just the ones listed in
toolKinds. A tool absent from toolKinds is not passed through unchecked; it
is permanently blocked, so an agent that quietly stops doing most of its
job is what a missing entry looks like. List every tool the agent can call, not
only the ones you care about. Put the middleware last in the list so it
sees the request that will actually execute.
Use it with a hand-rolled loop
await withSession({ tenantId: 't', teamId: 'ops', agentId: 'a' }, async (session) => {
const lookupPo = session.readTool('lookup_po', (id: string) => db.po(id))
const stageLine = session.writeTool(
{ actionType: 'stage_revenue_line', contextBuilder: (args) => ({ record_id: args.lineId }) },
async (args: { lineId: string }) => erp.stage(args.lineId),
)
await lookupPo('PO-1')
session.setReasoning('budget line matches PO-1 within tolerance')
await stageLine({ lineId: 'L-1' })
})Reads buffer. The next write drains them, materializes an Action, and gates on the verdict.
Verdicts
There are two taxonomies for what happens to a write the gate does not let
through, depending on which path materialized it — the hand-rolled
floor (session.writeTool, above) or a framework seam
(novarchGateMiddleware and friends). Everything upstream of that point —
buffering, the judge call, the audit trail — is identical either way. Source
of truth: contract/scenarios/05a-floor-deny-raises.json vs
05b-seam-deny-blocks.json.
| Verdict | Floor (writeTool) | Seam (novarchGateMiddleware) |
|---|---|---|
| pass | write runs | write runs |
| hold → approve | write runs | write runs |
| hold → deny | throws NovarchKillError | does not throw — agent.invoke() resolves; the tool call resolves to a [novarch_blocked] message and the agent keeps going |
| hold → skip / timeout | throws | throws |
| kill (judge said kill outright) | throws | throws |
A session that declared it can park does not block on hold at all: it throws
NovarchPausedError on both paths, and a later run collects the answer. See
A paused write can outlive the process that made
it.
The seam also resolves-with-a-block, never throws, for cases the floor has no
way to express at all: a tool with no toolKinds entry, a write dispatched
alongside other gated calls in the same model turn (fan-out — the gate cannot
attribute evidence correctly), and an internal adapter error. All three are
permanent for that call.
A paused write can outlive the process that made it
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.
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 say it can park:
import { withSession, NovarchPausedError } from '@novarch/sdk'
await withSession(
{
tenantId: 'hallway', teamId: 'field-ops', agentId: 'po-agent',
sessionId: 'nightly-2026-09-03', // your own id for this run
resumable: true,
maxHoldMs: null, // adopt the deployment's window
},
async (session) => {
const stagePo = session.writeTool(
{
actionType: 'create_purchase_order',
contextBuilder: (args) => ({ record_id: args.poId, amount: args.amount }),
actionIdBuilder: (args) => args.poId, // your own id for this work item
},
async (args: { poId: string; amount: number }) => erp.stage(args.poId),
)
try {
await stagePo({ poId: 'po-1234', amount: 5000 })
} catch (e) {
if (e instanceof NovarchPausedError) log.info(`deferred ${e.actionId} for a person`)
else throw e
}
session.suspend() // park the session for the next run
},
)A paused action now throws NovarchPausedError instead of blocking. It is a
separate class from NovarchKillError so your batch can tell a person is
deciding from the gate refused, and it carries sessionId, actionId,
ruleIdCited and rationale.
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, handler) // the same spec as above
// approved overnight → the write runs, exactly once
await stagePo({ poId: 'po-1234', amount: 5000 })
},
)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, with a [fail_closed_config] prefix.
A third run presenting the same work item throws
NovarchAlreadyExecutedError, carrying the time the write was acknowledged.
Around agent.invoke(), unwrap with asNovarchPausedError /
asNovarchAlreadyExecutedError for the same reason asNovarchKillError exists.
What you have to keep across runs
Novarch stores no list of pending work and offers no endpoint to discover one. The resumed run has to bring 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 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.
What the context builder puts in is what binds the approval
The standing verdict is bound to a fingerprint of actionType plus the context
your contextBuilder produced. A resume presenting different content is
refused. That makes the builder a safety surface: leave the destination account
out of the context and two writes that differ only there fingerprint
identically, so the resume path will honour 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 window an admin sets is what bounds how long a verdict stays honourable.
A second write is a second commitment
An approval is consumed once. 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 rather than expecting it back.
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.
Four refusals can come back on a resume. Three are terminal and want a new
actionId: the content no longer matches ([resume_fingerprint_mismatch]),
the action predates the fingerprint ([resume_commitment_unversioned]), or a
release changed what a commitment means and invalidated the approvals given
under the old meaning ([resume_commitment_version_changed]). The fourth,
[resume_no_standing_verdict], means nothing has been decided yet — retry
the resume. It also happens when a first attempt is still in front of the
judge, and minting a new 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.
Fail closed
On the floor, any uncertainty throws NovarchKillError with a greppable
prefix: [fail_closed_network], [fail_closed_judge_error],
[fail_closed_operator_timeout]. A writer whose audit pipe is broken does not
write. On the seam, the same is true for everything except an operator
deny — see the table above.
import { NovarchKillError } from '@novarch/sdk'
try {
await stageLine({ lineId: 'L-1' })
} catch (e) {
if (e instanceof NovarchKillError) {
// Terminal for this write. Retrying is a governance violation, not a retry.
console.error(e.toString())
}
}That bare instanceof is correct for the hand-rolled writeTool loop above.
It is silently wrong around createAgent: LangChain's JS runtime wraps
any exception a middleware throws in its own error type, copying .name from
the original — so a wrapped kill looks like a NovarchKillError (e.name
still reads 'NovarchKillError') while instanceof is false and the catch
never fires. Use asNovarchKillError instead when catching around
agent.invoke():
import { asNovarchKillError } from '@novarch/sdk'
try {
await agent.invoke({ messages: [{ role: 'user', content: 'reconcile PO-1' }] })
} catch (e) {
const kill = asNovarchKillError(e)
if (kill) console.error(kill.toString())
}Configuration
| Option | Default |
|---|---|
| serverUrl | NOVARCH_SERVER_URL, then http://localhost:8000 |
| sdkKey | NOVARCH_SDK_KEY |
| pollTimeoutMs | 300000 |
| pollIntervalMs | 1000 |
| timeoutMs | 30000 (must exceed the 25s long-poll hold) |
| reasoningProfile | 'provided' |
| evidence | none |
On Cloudflare Workers there is no process.env — pass serverUrl and sdkKey
explicitly from the request-scoped env object.
reasoningProfile says what your agent's silence means. Under the default the
gate reads a write that staged no reasoning as missing: the agent explains
itself and this one did not. An agent that produces no rationale at all says so
once, and its writes record unavailable instead:
await withSession(
{
tenantId: 'acme', teamId: 'ops', agentId: 'nightly-sweep',
reasoningProfile: 'unavailable', // a rules engine with no rationale to record
},
async (session) => { ... },
)Staging a value still wins for that write, so the one write this agent can
explain records provided.
evidence declares the fields your agent stamps on its own actions, by
action type: a fact only the agent knows (a variance it calculated, a
decision it made):
await withSession(
{
tenantId: 'acme', teamId: 'ops', agentId: 'po-agent',
evidence: {
accept_purchase_order: [
{ name: 'variance_cents', kind: 'int', constraints: { min: 0 } },
],
},
},
async (session) => { ... },
)Put the field itself on the context you build for that write; the
declaration only names it, its kind, and its bounds. An invalid declaration
is refused before any request. A declaration the deployment will not accept
never refuses the session: a name a signal Novarch already computes owns, a
name your agent already declared under a different kind, or one that would
exceed a quota comes back as a warning, and that write runs with the field
unbound. Whether the judge is actually offered a declared field as evidence
is a deployment setting; it ships at shadow, where a declaration is
recorded but not offered to the judge as evidence and cannot be cited.
Parity with 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
(contract/scenarios/). Documented differences: the Python SDK discovers its
session from a ContextVar, this one takes it as a value (there is no
node:async_hooks dependency); and this SDK has no sync API.
Docs: https://docs.novarch.ai · Apache-2.0
