npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

The 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/core

Use 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 actionId for 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