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.
1. When to use the SDK
Section titled “1. When to use the SDK”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/evaluateover 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).
2. Install
Section titled “2. Install”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.
pip install "norviq[langchain]==0.2.5" # LangChainpip install "norviq[langgraph]==0.2.5" # LangGraphpip install "norviq[crewai]==0.2.5" # CrewAIpip install "norviq[autogen]==0.2.5" # AutoGenpip install "norviq[semantic-kernel]==0.2.5" # Semantic Kernel / Azurepip install "norviq[frameworks]==0.2.5" # all five at onceThe 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).
3. The framework-agnostic core
Section titled “3. The framework-agnostic core”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.
4. What a ToolCallEvent carries
Section titled “4. What a ToolCallEvent carries”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:
export NRVQ_NAMESPACE=chatbot-prod # -> identity.namespaceexport NRVQ_SERVICE_ACCOUNT=default # -> identity.service_accountexport 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_nameWith 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.
5. Tool-chain depth
Section titled “5. Tool-chain depth”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)6. What comes back
Section titled “6. What comes back”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”export NRVQ_SDK_FALLBACK_MODE=block # opt back into fail-closedWhen 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.
A 4xx is never a fallback
Section titled “A 4xx is never a fallback”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.
Retries and the breaker
Section titled “Retries and the breaker”| 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"]
8. Credentials
Section titled “8. Credentials”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.
9. Framework adapters
Section titled “9. Framework adapters”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.
LangChain
Section titled “LangChain”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.
LangGraph
Section titled “LangGraph”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.
CrewAI
Section titled “CrewAI”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.
AutoGen
Section titled “AutoGen”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.
Azure / Semantic Kernel
Section titled “Azure / Semantic Kernel”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.
10. How a refusal surfaces, per framework
Section titled “10. How a refusal surfaces, per framework”Frameworks differ in what happens to the raised exception:
- LangChain and LangGraph let
NorviqBlockError/NorviqEscalateErrorpropagate 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.
11. Output DLP (opt-in, default off)
Section titled “11. Output DLP (opt-in, default off)”export NRVQ_SDK_OUTPUT_DLP_ENABLED=trueNorviq’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-NNNNwith dashes only.123 45 6789passes 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.
12. End-to-end example
Section titled “12. End-to-end example”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.
pip install "norviq[langgraph]==0.2.5" langchain-groq langgraphexport GROQ_API_KEY=... # your Groq keyexport NRVQ_POLICY_ENGINE_URL=http://norviq-api.norviq.svc:8080export NRVQ_API_TOKEN=... # a service key bound to the namespace/class belowexport NRVQ_NAMESPACE=chatbot-prod NRVQ_AGENT_CLASS=customer-supportimport asynciofrom typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage, SystemMessagefrom langchain_core.tools import toolfrom langchain_groq import ChatGroqfrom langgraph.graph import END, START, StateGraphfrom langgraph.graph.message import add_messages
from norviq.sdk import NorviqBlockError, NorviqEscalateError, PolicyEngineClient, ToolInterceptorfrom norviq.sdk.langgraph.adapter import GuardedToolNode
@tooldef 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."
@tooldef 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.
13. Observability in your own process
Section titled “13. Observability in your own process”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), 1020–1022 (interception result / blocked /
escalated), 1043 (output DLP redaction), 1044 (unwrapped tool accepted).
14. Version compatibility
Section titled “14. Version compatibility”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.
15. Adapter import paths
Section titled “15. Adapter import paths”| 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.
Next steps
Section titled “Next steps”- Sidecar injection — the out-of-process enforcement model: zero code change, same decisions, injected via the mutating webhook.
- Writing policies — author the
NrvqPolicyRego that decidesallow/block/escalate/auditfor every call the SDK intercepts. - Example — protect an agent end-to-end — the full walkthrough from agent class to confirmed enforcement.