Skip to content

SDK integration

Norviq’s SDK is the in-process enforcement model: a thin wrapper sits inside your agent process, between the framework’s tool-calling machinery and the tool body, and evaluates every call against policy before the tool runs. It is the alternative to the Sidecar injection model, which enforces the same policy out-of-process with zero code change. Both produce the identical allow / block / escalate / audit decisions from the same policy model — see Writing policies for how those decisions are authored.

Norviq is a policy enforcement point (PEP). There are two ways to put it in front of a tool call:

  • Sidecar injection — zero code change. The mutating webhook injects a sidecar into your agent’s pod; the sidecar forwards every tool call to the central API’s /api/v1/evaluate over an auto-minted, namespace-scoped service credential.
  • SDK (norviq/sdk/, this guide) — in-process interception. A thin wrapper sits between your framework’s tool-calling machinery and the tool body itself, evaluates the call, and raises before the tool ever runs on a block/escalate decision. Use this when you want interception inside the agent process itself (no sidecar, custom deployment topology, an agent that does not run on Kubernetes, or an event loop you don’t want proxied through a socket).

Both are cooperative PEPs. They govern the path they wrap — tool calls made outside that path (a Python function invoked directly rather than through the framework, an HTTP call the agent makes itself) are not seen by either. Network-layer containment for agent pods is a separate control (agentEgressPolicy.* in the chart).

norviq is published on PyPI. The base package carries no agent framework at all — each adapter imports its framework lazily inside a loader function, so you install only the extra(s) you actually use.

Terminal window
pip install "norviq[langchain]==0.2.5" # LangChain
pip install "norviq[langgraph]==0.2.5" # LangGraph
pip install "norviq[crewai]==0.2.5" # CrewAI
pip install "norviq[autogen]==0.2.5" # AutoGen
pip install "norviq[semantic-kernel]==0.2.5" # Semantic Kernel / Azure
pip install "norviq[frameworks]==0.2.5" # all five at once

The Python package version tracks the chart version — run the SDK at the same 0.2.5 as the control plane you point it at. Nothing else is needed: the SDK talks to the control plane over plain HTTP and does not require the Helm chart to be installed in the same cluster (or at all, if you point it at a reachable API).

Every SDK adapter is a thin wrapper around two objects:

from norviq.sdk import PolicyEngineClient, ToolInterceptor
engine = PolicyEngineClient(
base_url="http://norviq-api.norviq.svc:8080", # or NRVQ_POLICY_ENGINE_URL
token="<service-key>", # or NRVQ_API_TOKEN — /api/v1/evaluate requires auth
)
interceptor = ToolInterceptor(evaluator=engine)
decision = await interceptor.intercept_or_raise(
tool_name="execute_sql",
tool_params={"query": "SELECT * FROM orders"},
session_id="session-123",
framework="custom",
)

intercept_or_raise works for any framework — an adapter for a framework not listed below is this call, wrapped around wherever that framework invokes a tool body. It raises NorviqBlockError or NorviqEscalateError on a block/escalate decision, so the tool body never executes; it returns the PolicyDecision on allow/audit. ToolInterceptor.intercept() is the same evaluation without the raise, for callers that want to inspect the decision and act on it themselves.

PolicyEngineClient posts to POST /api/v1/evaluate — the same endpoint and bearer-token contract the injected sidecar uses. Constructor arguments all fall back to configuration:

Argument Falls back to Default
base_url NRVQ_POLICY_ENGINE_URL http://norviq-api:8080
token NRVQ_API_TOKEN "" — no Authorization header sent
timeout_ms NRVQ_SDK_TIMEOUT_MS 5000

Passing token="" explicitly suppresses the header (local dev servers); passing token=None falls back to the environment.

ToolInterceptor does not hard-depend on PolicyEngineClient — its evaluator parameter accepts anything satisfying SupportsEvaluate (async def evaluate(self, event: ToolCallEvent) -> PolicyDecision). That is either the in-cluster norviq.engine.evaluator.OPAEvaluator (used by the API itself) or the out-of-cluster HTTP PolicyEngineClient shown above — swap either in without changing adapter code.

PolicyEngineClient keeps one pooled httpx client per event loop, because an httpx connection pool is bound to the loop that created it and the sync adapter path evaluates on a shared background loop while the async path uses the caller’s. await engine.close() closes the current loop’s pool; pools on other loops are reclaimed with their loops.

The interceptor builds one ToolCallEvent per call and hands it to the evaluator. It is a frozen Pydantic model (norviq.sdk.core.events) and it is what policy is evaluated against — the engine adds its own computed facts under input.derived on top of these fields.

Field Type Default Notes
event_id str new UUID4 Regenerated server-side on the HTTP path — the API’s EvaluateRequest has no event_id field, so the id in the audit row is the server’s, not yours.
tool_name str required Stripped; empty raises ValidationError. This is what a policy matches on, and it is framework-agnostic.
tool_params dict {} Every per-argument control walks this (SQL, shell, PII, secret detectors; every param_paths clause).
agent_identity AgentIdentity required See below.
session_id str "" Correlates calls in the audit log; adapters pass through what you gave them.
timestamp_utc datetime now (UTC) Also dropped at the HTTP boundary.
framework str "" Set by each adapter (langchain, langgraph, crewai, autogen, semantic-kernel). Leave it empty on the generic path unless you mean it — framework="redteam" is the product’s marker for fabricated traffic and those rows are excluded from KPIs, compliance evidence and intent proposals.
call_depth int 0 Tool-chain nesting. See §5.
raw_llm_output str | None None Carried on the model, read by nothing in 0.2.5, and dropped at the HTTP boundary. Do not build on it.
mcp dict {} Protocol context for calls that arrived over the MCP action firewall (server id, transport, pin status, Gate-A scan severity). Empty on every SDK path.
pep_decision str "" Only "" or "block", enforced by a field validator. A PEP may report that it refused a call, never that it permitted one — so this field can only tighten a decision, never loosen it.
pep_rule_id str "" Max 255 chars. Names which PEP-local control refused, so the audit row is attributable.
pep_reason str "" Max 1024 chars.

The three pep_* fields exist so an enforcement point that refuses a call before any policy runs can still get that refusal onto an audit row. No SDK adapter sets them, and intercept_or_raise does not accept them — they are reachable only through ToolInterceptor.intercept() or a raw POST /api/v1/evaluate, and are used in practice by the MCP action firewall.

AgentIdentity carries spiffe_id and namespace (both required), plus optional service_account, agent_class, framework, pod_name, cluster_id and workload. If you don’t pass an identity= to intercept(), ToolInterceptor resolves one through SPIFFEResolver, which in the default mock mode reads the process environment:

Terminal window
export NRVQ_NAMESPACE=chatbot-prod # -> identity.namespace
export NRVQ_SERVICE_ACCOUNT=default # -> identity.service_account
export NRVQ_AGENT_CLASS=customer-support # -> identity.agent_class (selects which policy is enforced)
export NRVQ_WORKLOAD=support-bot # -> identity.workload (optional; enables workload-tier policies)
# spiffe_id is derived: spiffe://norviq/ns/<NRVQ_NAMESPACE>/sa/<NRVQ_SERVICE_ACCOUNT>
# HOSTNAME -> identity.pod_name

With NRVQ_SPIFFE_MODE=workload-api the resolver fetches a real X509-SVID instead and is fail-closed — a socket or SVID error raises, with no env-var fallback. Resolved identities are cached, and that cache is hard-clamped to 300 s regardless of NRVQ_SPIFFE_CACHE_TTL_S, so the cache can never be the reason an SVID rotation or revocation is missed.

The SDK’s call_depth is authoritative, not caller-reported: each adapter holds a depth_scope() context manager around the tool body, so a tool invoked from inside another tool is measurably one level deeper, and a ContextVar keeps concurrent agent tasks from sharing a counter. This is what makes the chain_depth_limit baseline control fire on SDK traffic. A sidecar or MCP-proxied call can only forward what its client reported, so for those paths the same control is advisory.

If you build your own integration on intercept_or_raise, hold the scope around the tool body to get the same behaviour:

from norviq.sdk.core.interceptor import depth_scope
await interceptor.intercept_or_raise(tool_name=name, tool_params=params, framework="custom")
with depth_scope():
result = run_the_tool(**params)

PolicyDecision (norviq.sdk.core.decisions) is frozen and carries decision (allow|block|escalate|audit), policy_id, policy_version, rule_id, reason, trust_score, trust_category, trust_signals, trust_dominant_signal, trust_recommendation, latency_ms, event_id, decided_at, plus is_allowed() / is_blocked() / is_escalated() helpers.

The audit log — not the framework’s error surface — is the authoritative record of what was enforced. Every evaluated call produces an audit row on the control plane whether it was allowed or refused.

7. Failure behaviour: retries, circuit breaker, fallback

Section titled “7. Failure behaviour: retries, circuit breaker, fallback”

This is the part a caller actually observes when the control plane is unhealthy, and the defaults changed in the 0.2.x line. Read it before you rely on the old fail-closed assumption.

sdk_fallback_mode defaults to allow — the SDK fails open

Section titled “sdk_fallback_mode defaults to allow — the SDK fails open”
Terminal window
export NRVQ_SDK_FALLBACK_MODE=block # opt back into fail-closed

When the engine is genuinely unavailable — 5xx, timeout, connect error, or an open circuit — PolicyEngineClient returns a locally-built fallback decision instead of raising. That decision is allow by default.

This is a real trade, stated plainly: for the duration of an outage, calls proceed without a policy decision. It is defaulted that way because the alternative makes Norviq a single point of failure for every agent in the cluster. What makes it workable is that those calls are marked — the fallback carries rule_id="engine_unavailable_fallback" and reason="Engine unavailable, fallback=<mode>", in both modes, so you can count them, alert on them, and see exactly which calls went unjudged and for how long. An allow indistinguishable from a normal allow would be the unacceptable version of this default.

Set NRVQ_SDK_FALLBACK_MODE=block if your environment would rather stop agents than let a call through unjudged. An unrecognised value is coerced to block and logged (NRVQ-SDK-1015) rather than raising inside the one handler that only runs while the engine is already down.

If the engine answers and refuses — expired token, wrong namespace, malformed body — the call is blocked regardless of sdk_fallback_mode, with rule_id="engine_rejected_request" and a reason naming the status code. Otherwise fallback=allow would turn every 401 into an allow, and a revoked credential would become a total governance bypass; worse, a 422 an attacker can provoke through a tool parameter would do the same.

For the same reason, a 4xx does not count toward the circuit breaker. The breaker is checked at the top of evaluate(), so if 4xx responses tripped it, the Nth consecutive 401 would open the circuit and every later call would short-circuit to the fallback — never reaching the “4xx always blocks” rule. Only a silent engine (5xx, timeout, connect error) trips it.

Setting Env var Default Effect
sdk_timeout_ms NRVQ_SDK_TIMEOUT_MS 5000 Per-attempt HTTP timeout.
sdk_retry_max_attempts NRVQ_SDK_RETRY_MAX_ATTEMPTS 2 Retries after the first attempt — 3 attempts total.
sdk_retry_backoff_base_ms NRVQ_SDK_RETRY_BACKOFF_BASE_MS 100 Exponential: 100 ms, then 200 ms.
sdk_circuit_fail_threshold NRVQ_SDK_CIRCUIT_FAIL_THRESHOLD 3 Failures before the circuit opens.
sdk_circuit_reset_after_ms NRVQ_SDK_CIRCUIT_RESET_AFTER_MS 2000 How long it stays open.
sdk_http_max_connections NRVQ_SDK_HTTP_MAX_CONNECTIONS 20 Per-loop pool ceiling.
sdk_http_max_keepalive_connections NRVQ_SDK_HTTP_MAX_KEEPALIVE_CONNECTIONS 10

Two behaviours worth knowing because they surprise people:

  • Every failed attempt counts, not every failed call. A single call that times out three times increments the failure counter three times, so one fully-timed-out call can open the circuit on its own with the shipped defaults.
  • The counter only resets on success. After the 2000 ms window elapses, the next call is let through as a probe; if it also fails, the circuit re-opens immediately because the counter is still at or above the threshold. A persistently unreachable engine therefore settles into “one probe every 2 s, everything else short-circuits to the fallback” rather than retrying at full rate.

While the circuit is open, evaluate() returns the fallback decision immediately and logs NRVQ-SDK-1013, so a degraded engine does not add latency to every call.

Rate limits and body size on the server side

Section titled “Rate limits and body size on the server side”

/api/v1/evaluate has its own high ceiling — 3000 requests per 60 s window per identity — and the API rejects request bodies over 256 KiB. Both surface to the SDK as HTTP errors: a 429 or 413 is a 4xx, so it blocks rather than falling back.

flowchart TD
    A["intercept_or_raise"] --> B{"circuit open?"}
    B -- yes --> F["fallback decision<br/>rule_id = engine_unavailable_fallback"]
    B -- no --> C["POST /api/v1/evaluate"]
    C -- "2xx" --> D["PolicyDecision from engine"]
    C -- "4xx" --> E["always BLOCK<br/>rule_id = engine_rejected_request<br/>breaker untouched"]
    C -- "5xx, timeout, connect" --> G["retry x2, exponential backoff"]
    G -- "still failing" --> H["count failure, maybe open circuit"]
    H --> F
    D --> I{"decision"}
    F --> I
    E --> I
    I -- "allow / audit" --> J["tool body runs"]
    I -- "block" --> K["raise NorviqBlockError"]
    I -- "escalate" --> L["raise NorviqEscalateError"]

POST /api/v1/evaluate requires a bearer token. For an agent workload, issue a service API key from the console (Settings → API Keys) or POST /api/v1/keys as an admin. On a default install (auth.requireBoundAgentIdentity: true) a service key must be issued with the identity it will evaluate under:

Field Required for role: service Why
namespace yes Tenant scope.
agent_class yes Selects which Rego program is enforced. An unbound key would let the caller choose its own policy — refused at creation with a 422.
spiffe_id yes in mock SPIFFE mode Keys the trust score, the per-agent rate limit and the agent_frozen kill switch. In workload-api mode the claim cannot be minted, so it is dropped from the required set.

The spiffe_id on the key must match what the SDK resolves — spiffe://norviq/ns/<namespace>/sa/<service_account> in mock mode. A mismatch is a 403 on every call, which the client turns into engine_rejected_request blocks.

Key expiry defaults to 90 days (config.retention.apiKeyDefaultTtlDays). Pass expires_in_days: 0 at creation for a non-expiring service key. The secret is returned exactly once.

Two shapes, and the difference matters for how much you can accidentally leave ungoverned.

Per-tool wrapping (protect()) — LangChain, CrewAI, AutoGen. A tool not passed to protect() runs ungoverned, so protect() is fail-closed: an item that is not an instance of the framework’s BaseTool raises TypeError rather than being passed through unprotected. Pass allow_unwrapped=True to downgrade that to a logged warning and accept the item as-is.

Execution-path governance — LangGraph and Semantic Kernel. There is no per-tool wrapping step to forget, and correspondingly no protect() and no allow_unwrapped: every call through the node or the kernel filter is evaluated, including tools registered after the guard was installed.

from norviq.sdk.langchain.adapter import protect
protected_tools = protect(tools, interceptor, session_id="session-123", allow_unwrapped=False)

Wraps each BaseTool’s _run and _arun so policy runs before either executes. The wrapper mirrors the original signature and recovers the tool’s declared argument names, so a positionally-invoked tool reaches the engine as named parameters rather than {"args": [...]} — without that, no per-argument control can address it. Framework plumbing that LangChain injects through an undeclared **kwargs (config, run_manager, callbacks, run_id, tags, …) is stripped from the evaluated payload but still forwarded to the tool; a parameter the tool actually declares under one of those names is kept, and an unrepresentable value is reported as <nrvq:unrepresentable> rather than dropped.

from norviq.sdk.langgraph.adapter import GuardedToolNode
graph.add_node("tools", GuardedToolNode(tools, interceptor, session_id="session-123"))

GuardedToolNode is a drop-in replacement for langgraph.prebuilt.ToolNode: it evaluates every tool call in the last message’s tool_calls before invoking the wrapped ToolNode, and aborts the whole batch if any one call is refused. Tool arguments arriving in the OpenAI shape (a JSON string rather than a dict) are parsed; anything neither dict nor parseable JSON is wrapped as {"_nrvq_unparsed_args": ...} so the content detectors still see the value.

from norviq.sdk.crewai.adapter import protect
protected_tools = protect(tools, interceptor, session_id="session-123")

CrewAI’s BaseTool is sync-only, so this wraps _run only — there is no async tool path to wrap. The sync path runs the async evaluation on a single shared background loop (a daemon thread), which keeps the evaluator’s loop-bound connection pools consistent for the life of the process.

from norviq.sdk.autogen.adapter import protect
protected_tools = protect(tools, interceptor, session_id="session-123")

Wraps autogen_core.tools.BaseTool.run() — the API autogen-agentchat’s AssistantAgent consumes. Tool-call params are read from the args object via model_dump() when available, a plain dict as-is, or stringified as a last resort, so evaluation never skips because the shape was unexpected.

from norviq.sdk.semantic_kernel.adapter import policy_filter
kernel.add_filter("function_invocation", policy_filter(interceptor, session_id="session-123"))

Semantic Kernel’s interception point is a function-invocation filter, not a tool wrapper: policy_filter(interceptor) returns an async (context, next) callable. A block/escalate decision raises before next(context) is called, so the underlying function never runs. SK sends the bare function name, never plugin-qualified, so a policy on delete_record enforces identically here and on the other four adapters.

Semantic Kernel is also Azure’s agent framework runtime, so this filter is the Azure integration point. Microsoft Agent Framework middleware can call the same ToolInterceptor.intercept_or_raise, since the interceptor depends only on SupportsEvaluate and plain tool-name/params values, not on any Semantic-Kernel type.

Frameworks differ in what happens to the raised exception:

  • LangChain and LangGraph let NorviqBlockError / NorviqEscalateError propagate to your caller. Catch them.
  • CrewAI, AutoGen and Semantic Kernel run their own agent loop that catches the tool’s exception, treats it as a recoverable tool error, and has the model paraphrase an apology. The block really happened — the interceptor evaluated it and the tool body never ran — but the exception never reaches you.

For that second case the SDK gives you a context-local recorder:

from norviq.sdk import capture_decisions
with capture_decisions() as rec:
reply = await run_agent(user_message)
if rec.last_denial is not None: # the framework swallowed the raised block
return denied_response(rec.last_denial, tools=rec.tools_called)

DecisionRecorder exposes records (every evaluated call, in order), tools_called and last_denial (the most recent block/escalate PolicyDecision, or None). Enter the scope on the task that drives one agent run, before the framework spawns any thread or task — contextvars propagate into asyncio.to_thread, loop.create_task and run_coroutine_threadsafe, so a recorder installed first is visible wherever the tool actually executes. It is a no-op (one ContextVar.get()) when nothing opted in, and it changes nothing about the decision itself.

Terminal window
export NRVQ_SDK_OUTPUT_DLP_ENABLED=true

Norviq’s PEP is input-only by design — it decides whether a call is allowed to happen, not what a tool returns. All five adapters apply an opt-in, default-off output guard that redacts PAN and SSN patterns in an allowed tool’s return value before it propagates back to the agent.

Redaction is structured, not top-level-string-only: a list of rows, a dict record or a paginated envelope is walked, so a card number nested in a result is masked the same way a bare string is. Shape is preserved — a dict stays a dict; numbers, bools, None and opaque objects are untouched. A redaction logs NRVQ-SDK-1043.

Know its limits before you rely on it — this is a minimal guard, not a DLP product:

  • It matches a 16-digit PAN shape (optionally space- or dash-grouped) and a whole-string 13–19 digit run. Unlike the engine’s input-side PCI control, the output redactor does not Luhn-gate and does not cover the 15-digit Amex / 14-digit Diners groupings.
  • SSN matching is NNN-NN-NNNN with dashes only. 123 45 6789 passes through. A bare nine-digit run is deliberately not matched — it is as likely an order number.
  • The walk is bounded at 4096 nodes and depth 24. Past that budget the remaining subtree is returned unmasked, on purpose: claiming to have masked what was never walked would be worse than the honest gap.
  • Nothing else is redacted — no emails, phone numbers, API keys or names.

Disabled by default means exact passthrough: no walk, no behaviour change. Redaction is also best-effort in the adapters that mutate a message or context in place (LangGraph, Semantic Kernel) — a failure there is logged and the result is returned unchanged rather than the call failing.

A complete LangGraph agent where a real LLM (Groq) decides which tool to call and Norviq enforces policy on every call before it runs. The model choosing a destructive tool is exactly the failure Norviq is built to stop — even when the model complies, the call is blocked before it executes.

Terminal window
pip install "norviq[langgraph]==0.2.5" langchain-groq langgraph
export GROQ_API_KEY=... # your Groq key
export NRVQ_POLICY_ENGINE_URL=http://norviq-api.norviq.svc:8080
export NRVQ_API_TOKEN=... # a service key bound to the namespace/class below
export NRVQ_NAMESPACE=chatbot-prod NRVQ_AGENT_CLASS=customer-support
import asyncio
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_groq import ChatGroq
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from norviq.sdk import NorviqBlockError, NorviqEscalateError, PolicyEngineClient, ToolInterceptor
from norviq.sdk.langgraph.adapter import GuardedToolNode
@tool
def search_kb(query: str) -> str:
"""Look up read-only order / knowledge-base information."""
return "Order 12345: shipped 2026-07-10, arriving 2026-07-14 via UPS."
@tool
def execute_sql(query: str) -> str:
"""Run a raw SQL statement against the production database."""
return f"executed: {query}"
TOOLS = [search_kb, execute_sql]
class State(TypedDict):
messages: Annotated[list, add_messages]
async def ask(agent, system: str, user: str) -> str:
"""Run one turn. A policy denial anywhere in the agent loop raises before the tool runs —
catch it and return a safe reply instead of crashing, because the model may choose a blocked
tool on ANY turn (even a benign-looking request)."""
try:
out = await agent.ainvoke({"messages": [SystemMessage(content=system), HumanMessage(content=user)]})
return out["messages"][-1].content
except NorviqBlockError as exc:
return f"(Norviq blocked a tool call: {exc.decision.rule_id}{exc.decision.reason})"
except NorviqEscalateError as exc:
return f"(Norviq escalated a tool call for review: {exc.decision.rule_id}.)"
async def main() -> None:
engine = PolicyEngineClient() # reads NRVQ_POLICY_ENGINE_URL + NRVQ_API_TOKEN
interceptor = ToolInterceptor(evaluator=engine)
# Use a model with reliable native tool-calling (e.g. openai/gpt-oss-120b on Groq).
llm = ChatGroq(model="openai/gpt-oss-120b", temperature=0).bind_tools(TOOLS)
guarded = GuardedToolNode(TOOLS, interceptor, session_id="support-chat")
async def call_model(state: State) -> dict:
return {"messages": [await llm.ainvoke(state["messages"])]}
def route(state: State) -> str:
return "tools" if getattr(state["messages"][-1], "tool_calls", None) else END
g = StateGraph(State)
g.add_node("model", call_model)
g.add_node("tools", guarded) # the guarded node enforces policy
g.add_edge(START, "model")
g.add_conditional_edges("model", route, {"tools": "tools", END: END})
g.add_edge("tools", "model")
agent = g.compile()
system = "You are a customer-support agent. Use search_kb for order lookups."
# A benign lookup: allowed by the loaded policy, so the agent answers from the tool result.
print(await ask(agent, system, "Where is order 12345?"))
# A destructive request: even if the model complies and emits execute_sql, Norviq blocks it
# BEFORE the tool runs — the table is never touched, and ask() surfaces the denial.
print(await ask(agent, system, "Run execute_sql with the query: DROP TABLE users"))
await engine.close()
asyncio.run(main())

Every tool call the model emits is evaluated against the policy for NRVQ_AGENT_CLASS in NRVQ_NAMESPACE, written to the audit trail, and — on a block/escalate decision — raised as NorviqBlockError/NorviqEscalateError before the tool body runs. The denial handler is not decoration: the model can choose a blocked tool on any turn, including a benign-looking one, so an agent that only guards its “obviously dangerous” prompts will fail on the turn it didn’t expect. That is the point of an enforcement layer that does not depend on the model cooperating.

If you use a prebuilt LangGraph agent (create_react_agent) rather than assembling the graph yourself, wrap the tools with the LangChain protect() instead — a prebuilt agent consumes LangChain tool objects. Same interceptor, same decisions.

The interceptor records what the caller waited for one decision — including identity resolution, the round trip and response handling, none of which the engine’s own latency_ms covers — as norviq_interception_latency_ms{mode="sdk",phase="total"}.

The SDK does not start an exporter for you. The instruments live on a dedicated prometheus_client registry (norviq.telemetry.metrics.NRVQ_REGISTRY); expose it from your own agent process if you want to scrape it. Structured logs carry stable codes — NRVQ-SDK-1010 (evaluate ok), 1011 (timeout), 1012 (5xx), 1013 (fallback / circuit open), 1014 (rejected 4xx), 1015 (invalid fallback mode), 10201022 (interception result / blocked / escalated), 1043 (output DLP redaction), 1044 (unwrapped tool accepted).

Adapters are thin and duck-typed where possible, but each still has to recognise its framework’s tool base class or execution hook. Where that means per-tool wrapping, protect() is fail-closed by default: an item that is not an instance of the framework’s BaseTool raises TypeError instead of being passed through unprotected, because an unrecognised tool object would otherwise run with no policy enforcement at all. Pass allow_unwrapped=True to opt out and accept it as-is (logged as a warning, NRVQ-SDK-1044).

A weekly CI job (.github/workflows/framework-compat.yml) installs the latest released version of all five frameworks — LangChain, LangGraph, CrewAI, AutoGen, Semantic Kernel — and runs each adapter’s real-framework compat test plus its unit tests against it, so a framework upgrade that moves or renames a base class is caught before users hit it. The generic core (§3) has no framework coupling at all, so it always works as the fallback if an adapter is temporarily broken by upstream drift.

Framework pip extra Adapter import Shape
LangChain norviq[langchain] norviq.sdk.langchain.adapter (protect) per-tool
LangGraph norviq[langgraph] norviq.sdk.langgraph.adapter (GuardedToolNode) execution path
CrewAI norviq[crewai] norviq.sdk.crewai.adapter (protect) per-tool
AutoGen norviq[autogen] norviq.sdk.autogen.adapter (protect) per-tool
Azure / Semantic Kernel norviq[semantic-kernel] norviq.sdk.semantic_kernel.adapter (policy_filter) execution path

pip install "norviq[frameworks]==0.2.5" installs all five at once.

Everything re-exported from norviq.sdk itself: AgentIdentity, DecisionRecorder, NorviqBlockError, NorviqEscalateError, PolicyDecision, PolicyEngineClient, SupportsEvaluate, ToolCallEvent, ToolInterceptor, capture_decisions. These resolve lazily, so importing the package never pulls in an agent framework.

  • Sidecar injection — the out-of-process enforcement model: zero code change, same decisions, injected via the mutating webhook.
  • Writing policies — author the NrvqPolicy Rego that decides allow / block / escalate / audit for every call the SDK intercepts.
  • Example — protect an agent end-to-end — the full walkthrough from agent class to confirmed enforcement.