# Compile an action rule from natural language
Source: https://docs.visiqlabs.com/api/action-rules/compile-an-action-rule-from-natural-language
/api-reference/openapi.yaml post /allow/rules/compile
Compile a plain-language policy request into Rego. Rate limited to 10 requests per minute per account. Set `?stream=true` (or send `Accept: text/event-stream`) for a streamed response. When no AI provider is configured this facet returns a pre-canned fallback (HTTP 200 with a `warning`), not an error.
Requires permission `allow_rules:create`.
# Create an action rule
Source: https://docs.visiqlabs.com/api/action-rules/create-an-action-rule
/api-reference/openapi.yaml post /allow/rules
Create an action-governance rule from Rego source. Requires permission `allow_rules:create`.
# Delete an action rule
Source: https://docs.visiqlabs.com/api/action-rules/delete-an-action-rule
/api-reference/openapi.yaml delete /allow/rules/{id}
Delete an action rule. Requires permission `allow_rules:delete`.
# Get an action rule
Source: https://docs.visiqlabs.com/api/action-rules/get-an-action-rule
/api-reference/openapi.yaml get /allow/rules/{id}
Fetch a single action rule including its Rego source. Requires permission `allow_rules:view`.
# List action rules
Source: https://docs.visiqlabs.com/api/action-rules/list-action-rules
/api-reference/openapi.yaml get /allow/rules
Paginated list of action-governance rules. Requires permission `allow_rules:view`.
# Update an action rule
Source: https://docs.visiqlabs.com/api/action-rules/update-an-action-rule
/api-reference/openapi.yaml put /allow/rules/{id}
Partially update an action rule. At least one field is required. Requires permission `allow_rules:update`.
# Create an agent
Source: https://docs.visiqlabs.com/api/agents/create-an-agent
/api-reference/openapi.yaml post /allow/agents
Register a new agent. The response includes a one-time plaintext `api_key` that is unrecoverable afterwards — store it securely. If you omit `api_key` in the request, one is generated. Requires permission `allow_agents:create`.
# Delete an agent
Source: https://docs.visiqlabs.com/api/agents/delete-an-agent
/api-reference/openapi.yaml delete /allow/agents/{id}
Delete an agent. Requires permission `allow_agents:delete`.
# Get an agent
Source: https://docs.visiqlabs.com/api/agents/get-an-agent
/api-reference/openapi.yaml get /allow/agents/{id}
Fetch a single agent by its UUID. Requires permission `allow_agents:view`.
# List agents
Source: https://docs.visiqlabs.com/api/agents/list-agents
/api-reference/openapi.yaml get /allow/agents
Paginated list of registered agents. Requires permission `allow_agents:view`.
# Register agent environment
Source: https://docs.visiqlabs.com/api/agents/register-agent-environment
/api-reference/openapi.yaml post /allow/agents/register
The idempotent registration handshake an SDK/harness performs on startup: it provisions the agent if needed and records environment metadata (OS, hostname, IP, username). It returns only an acknowledgement — no API key. Requires scope `rules:evaluate` (or `allow:write`) and permission `allow_agents:create`.
# Update an agent
Source: https://docs.visiqlabs.com/api/agents/update-an-agent
/api-reference/openapi.yaml put /allow/agents/{id}
Partially update an agent. Setting `mode` to `null` clears the per-agent override so it inherits the account default. At least one field is required. Requires permission `allow_agents:update`.
# Get a single action decision
Source: https://docs.visiqlabs.com/api/audit-log/get-a-single-action-decision
/api-reference/openapi.yaml get /v1/allow/decisions/{id}
Fetch one action decision by ID, including the linked approval item when the decision was `approval_required`. Requires permission `allow_audit_log:view` and scope `rules:read` (or `allow:read`).
# Query the action decision log
Source: https://docs.visiqlabs.com/api/audit-log/query-the-action-decision-log
/api-reference/openapi.yaml get /v1/allow/audit-log
Paginated, filterable log of action-governance decisions. Requires permission `allow_audit_log:view` and scope `allow:read`.
# Query the record event log
Source: https://docs.visiqlabs.com/api/audit-log/query-the-record-event-log
/api-reference/openapi.yaml get /v1/record/audit-log
Paginated, filterable log of recorded events across all records. Requires permission `record_audit_log:view` and scope `record:read`.
# Query the retrieval decision log
Source: https://docs.visiqlabs.com/api/audit-log/query-the-retrieval-decision-log
/api-reference/openapi.yaml get /v1/recall/audit-log
Paginated, filterable log of retrieval-governance decisions. Requires permission `recall_audit_log:view` and scope `recall:read`.
# Query captured cognition sessions
Source: https://docs.visiqlabs.com/api/cognition/query-captured-cognition-sessions
/api-reference/openapi.yaml get /v1/allow/cognition/sessions
Paginated list of Agent Cortex cognition sessions (capture is default-OFF and server-gated per agent). Sorted by `last_event_at` descending. Sessions are identified by their natural `session_id` key. Requires permission `allow_cognition:view` and the dedicated scope `cognition:read` (never granted by `allow:read`/`rules:read`).
# Query cognition beats (events)
Source: https://docs.visiqlabs.com/api/cognition/query-cognition-beats-events
/api-reference/openapi.yaml get /v1/allow/cognition/events
Paginated cognition beats. Under a `session_id` filter, beats are ordered `seq` ascending (the client-authoritative interleave order); otherwise `created_at` descending. `limit` above 200 is a 400, never a silent clamp. Stored content is floor-redacted; the encrypted raw is NEVER served here (reveal is the audited unmask endpoint). Beat `cost` carries raw token buckets only — price at read time. Requires permission `allow_cognition:view` and scope `cognition:read`.
# Accept a delegation grant
Source: https://docs.visiqlabs.com/api/delegation/accept-a-delegation-grant
/api-reference/openapi.yaml post /orchestrate/grants/{id}/accept
The child agent accepts a pending grant and receives a signed `grant_token` to present on subsequent delegated evaluations. The child identity must be supplied in the `X-Agent-ID` header. Requires scope `allow:write`.
# Create a delegation grant
Source: https://docs.visiqlabs.com/api/delegation/create-a-delegation-grant
/api-reference/openapi.yaml post /orchestrate/grants
A parent agent creates a scoped, time-boxed delegation to a child agent. The grant starts `pending` and must be accepted within the accept window. Requires scope `allow:write`.
# Get grant status
Source: https://docs.visiqlabs.com/api/delegation/get-grant-status
/api-reference/openapi.yaml get /orchestrate/grants/{id}
Poll a grant's lifecycle status. Requires scope `allow:write` or `allow:read`.
# Revoke a delegation grant
Source: https://docs.visiqlabs.com/api/delegation/revoke-a-delegation-grant
/api-reference/openapi.yaml post /orchestrate/grants/{id}/revoke
Revoke a grant and eagerly cascade the revocation to every descendant grant. Returns the number of grants revoked. Requires scope `allow:write`.
# Enforce a delegated action
Source: https://docs.visiqlabs.com/api/evaluation/enforce-a-delegated-action
/api-reference/openapi.yaml post /orchestrate/evaluate
A delegated (child) agent checks a single action against the scope of its signed grant token. A `deny` outcome is a governance result and is still returned with HTTP 200; the hand-off is recorded either way.
Requires scope `allow:write`.
# Evaluate a governed event (unified)
Source: https://docs.visiqlabs.com/api/evaluation/evaluate-a-governed-event-unified
/api-reference/openapi.yaml post /evaluate
The single, operation-native evaluation endpoint. Declare which operations the event performs via `operations[]` and receive the union decision vocabulary. Hybrid events (e.g. `["retrieval","action"]`) are evaluated on both facets and combined fail-closed to the most restrictive outcome. A legacy `{ kind: "action" | "retrieval", ... }` shape is also accepted for compatibility.
Requires scope `rules:evaluate` (dual-accepts the legacy `allow:write` / `recall:write`).
# Evaluate a retrieval
Source: https://docs.visiqlabs.com/api/evaluation/evaluate-a-retrieval
/api-reference/openapi.yaml post /recall/evaluate
Evaluate a retrieval, tool call, or prompt render against your retrieval-governance rules and return the enforced decision (with mask directives when the decision is `redact`).
Requires scope `rules:evaluate` (or the legacy `recall:write`).
# Evaluate an agent action
Source: https://docs.visiqlabs.com/api/evaluation/evaluate-an-agent-action
/api-reference/openapi.yaml post /allow/evaluate
Evaluate a single agent action against your action-governance rules and return the enforced decision. This is the agent-facing hot path.
Requires scope `rules:evaluate` (or the legacy `allow:write`).
# Create an approval request
Source: https://docs.visiqlabs.com/api/human-in-the-loop/create-an-approval-request
/api-reference/openapi.yaml post /allow/hitl/queue
Directly enqueue an approval request. Requires permission `allow_hitl:respond`.
# List the approval queue
Source: https://docs.visiqlabs.com/api/human-in-the-loop/list-the-approval-queue
/api-reference/openapi.yaml get /allow/hitl/queue
Paginated approval queue. Defaults to `pending` items (FIFO). Filter by `status` and `category`. Requires permission `allow_hitl:view`.
# Respond to an approval request
Source: https://docs.visiqlabs.com/api/human-in-the-loop/respond-to-an-approval-request
/api-reference/openapi.yaml post /allow/hitl/queue/{id}
Resolve a pending approval item. The accepted request body depends on the item's `category`: `enduser` items take `{ decision: "approved" | "rejected", responded_by }`; all other categories take `{ action: "dismiss" | "create_rule", responded_by, linked_rule_id? }` (`linked_rule_id` is required when `action` is `create_rule`). Requires permission `allow_hitl:respond`.
# Finalize a record
Source: https://docs.visiqlabs.com/api/records/finalize-a-record
/api-reference/openapi.yaml patch /record/records/{id}/finalize
Seal a record so no further events can be appended. Idempotent — a record that is already finalized is returned unchanged. Requires permission `record_records:finalize`.
# Get a checkpoint
Source: https://docs.visiqlabs.com/api/records/get-a-checkpoint
/api-reference/openapi.yaml get /record/checkpoints/{seq}
Fetch a single checkpoint by its batch sequence. Requires permission `record_records:view` and scope `record:read`.
# Get a record
Source: https://docs.visiqlabs.com/api/records/get-a-record
/api-reference/openapi.yaml get /record/records/{id}
Fetch a single record. Requires permission `record_records:view`.
# Ingest a record envelope
Source: https://docs.visiqlabs.com/api/records/ingest-a-record-envelope
/api-reference/openapi.yaml post /record/envelopes
Append a signed event (with optional artifacts and attestations) to the tamper-evident audit trail. `tenantId` must match the authenticated account. Requires permission `record_records:create` and scope `record:write`.
# List record artifacts
Source: https://docs.visiqlabs.com/api/records/list-record-artifacts
/api-reference/openapi.yaml get /record/records/{id}/artifacts
The artifacts attached to a record. Requires permission `record_records:view`.
# List record attestations
Source: https://docs.visiqlabs.com/api/records/list-record-attestations
/api-reference/openapi.yaml get /record/records/{id}/attestations
The attestations attached to a record. Requires permission `record_records:view`.
# List record events
Source: https://docs.visiqlabs.com/api/records/list-record-events
/api-reference/openapi.yaml get /record/records/{id}/events
The ordered event log for a record. Requires permission `record_records:view`.
# List records
Source: https://docs.visiqlabs.com/api/records/list-records
/api-reference/openapi.yaml get /record/records
Paginated, filterable list of records. Requires permission `record_records:view`.
# List transparency-log checkpoints
Source: https://docs.visiqlabs.com/api/records/list-transparency-log-checkpoints
/api-reference/openapi.yaml get /record/checkpoints
The tenant-neutral transparency-log checkpoint feed (signed Merkle roots and counts). Paginated by sequence cursor. Requires permission `record_records:view` and scope `record:read`.
# Verify a record envelope
Source: https://docs.visiqlabs.com/api/records/verify-a-record-envelope
/api-reference/openapi.yaml get /record/envelopes/{id}/verify
Run the full cryptographic verification chain (leaf signature, Merkle inclusion, signed root, and RFC-3161 timestamp) for a record. Requires permission `record_records:view` and scope `record:read`.
# Verify hash-chain consistency
Source: https://docs.visiqlabs.com/api/records/verify-hash-chain-consistency
/api-reference/openapi.yaml get /record/chain/consistency
Return a consistency proof over the checkpoint hash-chain for a sequence range. An unverifiable result is reported as `verified: false` in a 200 body (fail-closed), not an error. Requires permission `record_records:view` and scope `record:read`.
# Activate emergency bypass
Source: https://docs.visiqlabs.com/api/retrieval-rules/activate-emergency-bypass
/api-reference/openapi.yaml post /recall/rules/{id}/bypass
Temporarily deactivate a single retrieval rule (a time-boxed emergency bypass). The activation is written to the audit trail. Requires permission `recall_rules:bypass`.
# Compile a retrieval rule from natural language
Source: https://docs.visiqlabs.com/api/retrieval-rules/compile-a-retrieval-rule-from-natural-language
/api-reference/openapi.yaml post /recall/rules/compile
Compile a plain-language retrieval-governance request into Rego. Rate limited to 10 requests per minute per account; SSE streaming supported. This facet is fail-closed: if no AI provider is configured it returns HTTP 503 (unlike action compilation, which returns a fallback).
Requires permission `recall_rules:create`.
# Create a retrieval rule
Source: https://docs.visiqlabs.com/api/retrieval-rules/create-a-retrieval-rule
/api-reference/openapi.yaml post /recall/rules
Create a retrieval-governance rule from Rego source. Requires permission `recall_rules:create`.
# Deactivate emergency bypass
Source: https://docs.visiqlabs.com/api/retrieval-rules/deactivate-emergency-bypass
/api-reference/openapi.yaml delete /recall/rules/{id}/bypass
Clear an active emergency bypass and re-enforce the rule. Requires permission `recall_rules:bypass`.
# Delete a retrieval rule
Source: https://docs.visiqlabs.com/api/retrieval-rules/delete-a-retrieval-rule
/api-reference/openapi.yaml delete /recall/rules/{id}
Delete a retrieval rule. Requires permission `recall_rules:delete`.
# Get a retrieval rule
Source: https://docs.visiqlabs.com/api/retrieval-rules/get-a-retrieval-rule
/api-reference/openapi.yaml get /recall/rules/{id}
Fetch a single retrieval rule including its Rego source. Requires permission `recall_rules:view`.
# List retrieval rules
Source: https://docs.visiqlabs.com/api/retrieval-rules/list-retrieval-rules
/api-reference/openapi.yaml get /recall/rules
Paginated list of retrieval-governance rules. Requires permission `recall_rules:view`.
# Update a retrieval rule
Source: https://docs.visiqlabs.com/api/retrieval-rules/update-a-retrieval-rule
/api-reference/openapi.yaml put /recall/rules/{id}
Partially update a retrieval rule. At least one field is required. Requires permission `recall_rules:update`.
# Get the action rule bundle
Source: https://docs.visiqlabs.com/api/rule-bundles/get-the-action-rule-bundle
/api-reference/openapi.yaml get /allow/rules/bundle
The compiled action-governance bundle for one agent, used by the SDK runtime. Responds with an `ETag`; send `If-None-Match` to get a `304`. A shutdown agent returns a fail-closed shutdown bundle. Requires permission `allow_rules:view`.
# Get the retrieval rule bundle
Source: https://docs.visiqlabs.com/api/rule-bundles/get-the-retrieval-rule-bundle
/api-reference/openapi.yaml get /recall/rules/bundle
The compiled retrieval-governance bundle for the SDK runtime. Responds with an `ETag`; send `If-None-Match` to get a `304`. Requires permission `recall_rules:view`.
# Get the unified rule bundle
Source: https://docs.visiqlabs.com/api/rule-bundles/get-the-unified-rule-bundle
/api-reference/openapi.yaml get /rules/bundle
The single compiled bundle covering both facets for one agent, carrying the resolved agent attributes and dialect version. Responds with an `ETag`; send `If-None-Match` to get a `304`. Requires permission `allow_rules:view`.
# Get governance settings
Source: https://docs.visiqlabs.com/api/settings/get-governance-settings
/api-reference/openapi.yaml get /allow/settings
Fetch the account's governance defaults. Returns `200` if a row exists, or `201` when defaults are first initialized. Requires permission `allow_settings:view`.
# Update governance settings
Source: https://docs.visiqlabs.com/api/settings/update-governance-settings
/api-reference/openapi.yaml put /allow/settings
Partial update of the account's governance defaults. Only the supplied fields change; at least one is required. Requires permission `allow_settings:update`.
# Create a rule
Source: https://docs.visiqlabs.com/api/unified-rules/create-a-rule
/api-reference/openapi.yaml post /rules
Create a rule tagged with the operations it governs. `operations[]` is mapped to `applies_to`. Requires permission `allow_rules:create`.
# Delete a rule
Source: https://docs.visiqlabs.com/api/unified-rules/delete-a-rule
/api-reference/openapi.yaml delete /rules/{id}
Delete a rule. Requires permission `allow_rules:delete`.
# Get a rule
Source: https://docs.visiqlabs.com/api/unified-rules/get-a-rule
/api-reference/openapi.yaml get /rules/{id}
Fetch a single rule including its Rego source. Requires permission `allow_rules:view`.
# List rules
Source: https://docs.visiqlabs.com/api/unified-rules/list-rules
/api-reference/openapi.yaml get /rules
Paginated list of rules across both facets. Filter by facet with `?operations=action,retrieval`. Requires permission `allow_rules:view`.
# Update a rule
Source: https://docs.visiqlabs.com/api/unified-rules/update-a-rule
/api-reference/openapi.yaml put /rules/{id}
Partially update a rule. At least one field is required. Requires permission `allow_rules:update`.
# Authentication
Source: https://docs.visiqlabs.com/authentication
The canonical guide to VisIQ credentials — the two key audiences (harness vs management), permissions and scopes, rotation, self-revocation, the agent device flow, and MCP.
Every call into VisIQ authenticates with an API key sent as a bearer token.
There are exactly two kinds of key, and picking the right one is the first
decision. This page is the map; each section links to the deep reference.
```bash theme={null}
Authorization: Bearer vq_prod_...
```
***
## The two key audiences
VisIQ issues keys in two **audiences**. The audience is fixed when the key is
created and determines what the key can reach.
| | Harness keys | Management keys |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| **Who uses it** | The SDK / harness / agents at runtime | Your scripts, CI, and back-office tooling |
| **What it reaches** | The SDK operational endpoints only — evaluation, rule bundles, HITL decision polling, telemetry, record ingestion, agent registration, discovery reporting | The management API — rules, agents, audit log, settings, and more |
| **How it's scoped** | Not scoped: full power within its route allowlist, denied everywhere else | An explicit `resource:action` permission list you choose at creation |
| **Where to create one** | **Settings → Harness Keys** (or minted for you by the SDK install studios and agent registration) | **Settings → API Keys**, or the agent device flow |
The two audiences are asymmetric. A harness key that calls a management endpoint
is rejected with `403 harness_key_not_permitted`, no matter what else it can do.
A management key is a **superset** — it may also reach the runtime endpoints its
permissions cover.
**The `vq_prod_` / `vq_test_` prefix encodes the *environment*, not the
audience.** Whether a key is a harness or a management key is fixed at creation
and shown in the dashboard — it is **not** derivable from the key string. A
`vq_prod_…` key can be either audience; read the audience from the dashboard key
table, not from the prefix.
**`test` keys are a labeling convention, not an isolated sandbox.** A `vq_test_`
key authenticates against the same tenant and the same data at
`https://api.visiqlabs.com` as a `vq_prod_` key — the prefix only helps you tell
credentials apart in logs and secret stores. There is no separate test tenant or
sandboxed dataset behind it.
If you are integrating the `@visiq/harness` SDK, you want a **harness key** — see
the [Quickstart](/quickstart). Everything below the audiences is about management
keys and the flows shared by both.
***
## Permissions & scopes
A management key carries an **explicit list of permissions** — the same
`resource:action` catalogue that governs your team members (for example
`allow_rules:view`, `allow_agents:create`, `allow_audit_log:view`). Enforcement is
exact and fail-closed: a request is allowed only when the route's required
permission is in the key's list, with no wildcard expansion and no implication
between permissions. Anything else returns `403 insufficient_permission`, naming
both the required permission and what the key holds.
You can only grant permissions you hold yourself — a request for anything beyond
your own effective permissions is rejected with `403` and an `exceededPermissions`
list, so a narrowly-permissioned caller can never bootstrap a stronger key.
**Legacy scoped keys** created before explicit permissions shipped carry coarse
**scopes** instead of a permission list (for example `rules:read` / `rules:write`
/ `rules:evaluate`, or `full_access`). They are still honored, and a scope denial
returns `403 insufficient_scope`. New keys always use explicit permissions. Full
model: [The permission model](/automation/introduction#the-permission-model).
***
## Lifecycle: create, rotate, revoke
The complete lifecycle — creation dialog, key format, expiry, rotation with grace
windows, rate limits, and the error reference — lives in
[Managing API keys](/automation/api-keys). The essentials:
Name it, pick environment and expiry, select permissions. The plaintext key
is shown **exactly once** — VisIQ stores only a SHA-256 hash.
Issue a new secret for the same logical key with a configurable grace window
so in-flight callers cut over with zero downtime.
Immediate and irreversible — the next request with that key gets `401`. Every
create, rotate, and revoke is written to your audit log.
A key can always revoke **itself** — no permission required, authenticated by
the presenting key. The clean way for an agent to end its own session.
Self-revocation is authenticated by the presenting key itself, so the only key it
can ever target is the caller's own — cross-key revocation is impossible through
that path ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009) spirit):
```bash theme={null}
# Works for a harness OR a management key — make it the last call with that key
curl -X POST https://api.visiqlabs.com/allow/self/revoke \
-H "Authorization: Bearer vq_prod_..."
```
***
## Agents that mint their own key: the device flow
An AI agent can obtain its own credential without a human pasting one in, through
the human-approved `agent_auth` **device flow**
([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)-shaped). The agent registers,
shows an operator a short `user_code`, the operator reviews the requested audience
and permissions and approves, and the agent polls for the key — issued **exactly
once**. Issuance always requires a human; there is no anonymous or instant
credential, and the grant can never exceed the approver's own permissions.
By default the flow requests a **harness** key; pass
`"requested_key_type": "management"` with an explicit `requested_permissions` list
to request the management surface. See
[Agent self-registration](/automation/agent-device-flow) for the full ceremony,
and the machine-readable [agent authentication guide](https://visiqlabs.com/auth.md)
served at `visiqlabs.com/auth.md`.
***
## Operating over MCP
Once an agent holds a **management** key it can operate VisIQ through the
authenticated [Platform MCP server](/automation/mcp) — the same management
surface as the web app, with every tool gated by the identical RBAC permission
its web route requires. Point any MCP client at
`https://app.visiqlabs.com/api/mcp` with an `Authorization: Bearer` header, then
call `whoami` first to confirm the permissions your key resolves to. A harness
key is **not** accepted there; the server is fail-closed.
***
## Where each audience is documented
The runtime credential the `@visiq/harness` SDK uses. Minted by the install
studios or under **Settings → Harness Keys**.
Automation keys for scripts and CI, their permission model, and guardrails.
Full lifecycle: creation, format, expiry, rotation, rate limits, errors.
The one-line definitions of harness vs management keys.
# Agent self-registration
Source: https://docs.visiqlabs.com/automation/agent-device-flow
How an AI agent obtains its own VisIQ credential through the human-approved agent_auth device flow — register, get a user code, have an operator approve, poll for the key.
Instead of a human minting a key up front and pasting it into your agent, the
agent can **register itself** and have a human approve it. VisIQ implements this
as an [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)-shaped **device
authorization flow** (the `agent_auth` grant). Issuance still always requires a
human to approve — there is no anonymous or instant credential.
This is the machine-readable counterpart to the
[agent authentication guide](https://visiqlabs.com/auth.md) served for agents at
`visiqlabs.com/auth.md`. The flow is advertised in the `agent_auth` block of
[`/.well-known/oauth-authorization-server`](https://visiqlabs.com/.well-known/oauth-authorization-server).
## When to use it
* Your agent runs somewhere an operator cannot pre-provision a key, but a human
can approve a one-time request.
* You want the credential **bound to the approving human's account** and scoped
to exactly what they agree to — never broader than their own permissions.
If an operator can simply create a key in the dashboard, use
[Managing API keys](/automation/api-keys) instead — this flow exists for the
agent-initiated case.
## The flow at a glance
1. **Agent → VisIQ:** `POST /api/agent/identity` to register.
2. **VisIQ → Agent:** returns a `claim_token`, a short `user_code`, and a
verification URL.
3. **Agent → Human:** show the operator the `user_code` /
`verification_uri_complete`.
4. **Human → VisIQ:** the operator signs in, opens the URL, reviews the
requested audience and permissions, and approves.
5. **Agent → VisIQ (poll):** `POST /api/agent/identity/claim` every `interval`
seconds — `400 authorization_pending` until approved.
6. **VisIQ → Agent:** once approved, the `api_key` is returned **exactly once**.
## Step 1 — Register
`POST https://app.visiqlabs.com/api/agent/identity`
```json theme={null}
{ "type": "anonymous", "agent_name": "my-agent" }
```
By default this requests a **harness** key (least privilege — see
[key audiences](/automation/introduction)). To request the **full product
surface**, ask for a management key, optionally with an explicit permission
list:
```json theme={null}
{
"type": "anonymous",
"agent_name": "my-agent",
"requested_key_type": "management",
"requested_permissions": [
"allow_rules:view",
"allow_rules:create",
"allow_agents:view",
"allow_audit_log:view"
]
}
```
| Field | Required | Notes |
| ------------------------------ | -------- | ----------------------------------------------------------------------------------------------- |
| `type` | yes | `anonymous`, or `identity_assertion` to assert a verified identity |
| `agent_name` | no | A label the operator sees on the approval screen (≤ 200 chars) |
| `assertion_type` / `assertion` | no | With `type: identity_assertion` — e.g. `verified_email` + the address |
| `requested_key_type` | no | `harness` (default) or `management` |
| `requested_permissions` | no | For a management key: the exact RBAC grants you need (≤ 64). Omit for the default product grant |
Omitting `requested_permissions` requests the default product grant (rules,
agents, audit/outcomes, action schemas, HITL responses, settings) — but **never
account administration**: a device-flow key cannot manage API keys, the team, or
billing.
The response is a device-flow ceremony:
```json theme={null}
{
"registration_id": "...",
"claim_token": "vqac_...",
"claim": {
"user_code": "WXYZ-2345",
"verification_uri": "https://app.visiqlabs.com/agent/claim",
"verification_uri_complete": "https://app.visiqlabs.com/agent/claim?user_code=WXYZ-2345",
"expires_in": 1800,
"interval": 5
}
}
```
Keep the `claim_token` private — it is what you exchange for the key in Step 3.
Show the **human** the `user_code` / `verification_uri`.
## Step 2 — Have a human approve
Show your operator the `verification_uri_complete` — a single clickable link
with the code already embedded (RFC 8628 one-click); the code survives the
sign-in / MFA detour and the approval page auto-loads your pending registration.
Fall back to the bare `verification_uri` plus the typed `user_code` if a
one-click link is not usable.
They sign in to VisIQ, open the URL, **review the requested audience and
permissions**, and approve. Approval mints a governed key bound to their account
— harness by default, management if you requested it and they agreed. They can
narrow the grant, and can never grant beyond their own permissions.
## Step 3 — Poll for the credential
`POST https://app.visiqlabs.com/api/agent/identity/claim`, every `interval`
seconds, with your `claim_token`:
```json theme={null}
{ "claim_token": "vqac_..." }
```
While the human has not approved yet, you get:
```json theme={null}
{ "error": "authorization_pending" }
```
(HTTP `400`). Keep polling at `interval` seconds — do not poll faster. Once
approved, you receive the credential **exactly once**:
```json theme={null}
{
"token_type": "api_key",
"api_key": "vq_prod_...",
"agent_id": "...",
"api_base_url": "https://api.visiqlabs.com",
"key_type": "harness",
"permissions": null
}
```
* `key_type` tells you which audience was actually granted (`harness` or
`management`).
* For a management key, `permissions` is the exact grant list your credential
holds; it is `null` for a harness key.
The `api_key` is shown **once**. Store it in your secret manager immediately —
VisIQ keeps only a SHA-256 hash and cannot show it again. If you lose it,
[revoke it](#revoking) and register again.
Claim tickets expire after **30 minutes**. If the ceremony lapses (the human
never approves in time), register again for a fresh `user_code`.
Then use the key as a Bearer token — see
[Use a key](/automation/api-keys#use-a-key).
## Revoking
You can retire a device-flow credential yourself, at any time:
* **Cancel or revoke the device-flow credential** —
`POST https://app.visiqlabs.com/api/agent/identity/revoke` with
`{ "token": "vq_prod_..." }` (an issued key) or `{ "claim_token": "vqac_..." }`
(the pending ticket, which also revokes an already-issued key).
* **Self-revoke any key** you hold — `POST /allow/self/revoke`, authenticated by
the key itself, no permission required. See
[Revoke your own key](/automation/api-keys#revoke-your-own-key-self-revocation).
## Prefer MCP?
Once you hold a **management** key you can operate VisIQ through the
[Platform MCP server](/automation/mcp) instead of raw REST — including retiring
the key with the `revoke_self` tool when you are done.
# Managing API Keys
Source: https://docs.visiqlabs.com/automation/api-keys
Create, rotate, and revoke automation keys — key format, expiry, grace windows, rate limits, and error codes.
Automation keys authenticate your scripts and CI against the VisIQ management
API. This page covers their full lifecycle. For what automation keys are and
how the permission model works, start with the
[Platform Automation introduction](/automation/introduction).
**Agents can obtain automation keys too.** Besides the dashboard flow below,
an AI agent can self-register through the `agent_auth` device flow with
`"requested_key_type": "management"` and an explicit permission list; a human
reviews the exact grant at **`/agent/claim`** and approves or narrows it. The
grant can never exceed the approver's own permissions. See the
[agent authentication guide](https://visiqlabs.com/auth.md).
***
## Create a key
1. In the dashboard, open **Settings → API Keys**.
2. Click **Create New Key** (requires the `api_keys:create` permission).
3. Fill in the dialog:
* **Name** — a label for the key (1–50 characters), e.g. `ci-audit-export`.
* **Environment** — `production` or `test`. This only determines the key
prefix (`vq_prod_` vs `vq_test_`) so you can tell credentials apart.
* **Expires** — `30 days`, `90 days` (default), `180 days`, `1 year`, or
`No expiry`. Prefer an expiry; expired keys stop authenticating
automatically.
* **Permissions** — pick the explicit permissions the key needs, grouped
the same way as the team Roles matrix. Each group has a select-all
checkbox, and **Grant all** selects everything — use it sparingly.
4. The full key is displayed **exactly once** — copy it into your secrets
manager immediately.
You can only grant permissions you hold yourself. A request for anything
beyond your own effective permissions is rejected with `403` and an
`exceededPermissions` list — no key can be more powerful than its creator.
Every creation is recorded in your audit log as `API_KEY_CREATED`, and the
operation aborts if the audit write fails.
The plaintext key is shown only at creation (and rotation). VisIQ stores a
SHA-256 hash — verified with a timing-safe comparison — never the key itself.
If you lose it, rotate or re-create the key. Never commit keys to source
control.
### Key format
```text theme={null}
vq_prod_f3a91c0e5b27d8146a0c9e3f71b52d80e4c6a1f97d3b08e25c41f6a890b7d213
vq_test_0d8e2a71c45f9b36e810d72c4a95f3081b6e0d49c27a85f1e3b09c64d2a7f581
```
A `vq_prod_` / `vq_test_` prefix followed by 64 hex characters. The dashboard
key table shows only the first 16 characters (`vq_prod_f3a91c0e****`) so you
can identify a key without exposing it.
**The prefix encodes the *environment*, not the audience.** `vq_prod_` vs
`vq_test_` only tells you which environment a key was labeled for — whether it is
a **harness** key or a **management** key is fixed at creation and shown in the
dashboard, not derivable from the string. A `vq_prod_…` key can be either
audience. See [Authentication](/authentication#the-two-key-audiences).
**`test` keys are a labeling convention, not an isolated sandbox.** A `vq_test_`
key authenticates against the **same tenant and the same data** at
`https://api.visiqlabs.com` as a `vq_prod_` key — the prefix only helps you keep
credentials apart in logs and secret stores. There is no separate test tenant or
sandboxed dataset behind it, so treat a `vq_test_` key with the same care as a
production one.
***
## Use a key
Send it as a bearer token to `https://api.visiqlabs.com`:
```bash theme={null}
curl https://api.visiqlabs.com/v1/allow/audit-log \
-H "Authorization: Bearer vq_prod_..."
```
The request succeeds when the route's required permission is in the key's
permission list — this example needs `allow_audit_log:view` — otherwise it is
denied (see [Errors](#errors)).
Every authenticated request updates the key's **Last Used** timestamp, shown
in the dashboard key table. Use it to spot stale keys and revoke them before
they become forgotten liabilities.
***
## Rotate a key
Rotation issues a **new secret** for the same logical key — name, environment,
permissions, audience, and expiry all carry over — and retires the old one.
In the key table, open the row's actions menu and choose **Rotate Key**
(requires `api_keys:rotate`).
The rotation dialog asks how long to **keep the old key valid** — the grace
window: `Revoke immediately`, `1 hour`, `24 hours` (default), or `7 days`.
* The new key is returned **once**, exactly like at creation.
* The **old key keeps authenticating until the grace window lapses**, so
in-flight callers can cut over with zero downtime. The table shows the
retiring row as `Grace ends in …`.
* With **Revoke immediately**, the old key stops authenticating at once.
* Once the grace window lapses the old key returns `401 API key has expired`.
* The retired key is linked to its replacement (`replaced_by_key_id`), so the
rotation chain is auditable, and the rotation itself is logged as
`API_KEY_ROTATED`. If the audit write fails, the new key is rolled back —
a rotation never leaves an unaudited live secret behind.
Rotate-with-grace means **two** valid secrets exist for the duration of the
window. Pick **Revoke immediately** when you suspect the old key is
compromised — grace is for routine credential hygiene, not incident response.
***
## Revoke a key
Revocation is available today, including for existing automation keys. Choose
**Delete Key** in the row's actions menu (requires `api_keys:delete`) and
confirm. Revocation is **immediate** — the very next request with that key
receives `401` — and is logged as `API_KEY_REVOKED`. There is no grace window
and no undo; create a new key if you revoked the wrong one.
***
## Revoke your own key (self-revocation)
Beyond the dashboard, **the holder of a key can always revoke that key itself** —
no dashboard, no human, and **no special permission** ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)
spirit). This is the clean way for an automation or agent to end its own session
or retire a credential it believes is compromised, and it is the only revoke
path a permission-scoped key can rely on (such a key may not hold
`api_keys:delete`).
The endpoint is authenticated by the **presenting key itself** — the credential
is resolved from its own bearer token, so the only key it can ever target is the
caller's own. There is no request body naming another key, which makes cross-key
revocation impossible through this path.
```bash theme={null}
# Works for a harness OR a management key
curl -X POST https://api.visiqlabs.com/allow/self/revoke \
-H "Authorization: Bearer vq_prod_..."
```
```json theme={null}
{ "revoked": true, "api_key_id": "...", "key_type": "management" }
```
The same operation is exposed on the web JSON API at
`POST https://app.visiqlabs.com/api/allow/self/revoke`, and as the
[`revoke_self`](/automation/mcp) tool on the Platform MCP server (no arguments).
Self-revocation is **immediate, idempotent, and irreversible**. After it
succeeds the key stops authenticating at once, so it must be the **last call**
you make with that key. A key that is already revoked, unknown, or expired
returns `401`; a dashboard user session (not an API key) returns `400
not_an_api_key`. Legacy vendor root keys have no `api_keys` row and are not
self-revocable — rotate them from the dashboard instead.
Agents that obtained their key through the
[device flow](/automation/agent-device-flow) can also tear it down (including a
still-pending registration) via `POST /api/agent/identity/revoke`.
***
## Expiry
A key with an expiry stops authenticating the moment `expires_at` passes —
requests receive `401 API key has expired`. The dashboard shows expiry as
`in 12d` / `Never` / `Expired`. Expiry cannot be edited after creation, and a
rotated key's replacement **inherits the original expiry** — to extend a key's
life, create a new key.
***
## Rate limits
API-key requests are rate limited per key with a sliding window — **600
requests per minute** by default. Every authenticated response carries the
current state:
| Header | Meaning |
| ----------------------- | ---------------------------------------- |
| `X-RateLimit-Limit` | The per-window request limit |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
Exceeding the limit returns `429 Too Many Requests` with a `Retry-After`
header (seconds until the window frees up):
```json theme={null}
{
"error": "rate_limited",
"detail": "API key rate limit exceeded.",
"retryAfter": 42
}
```
Back off for `Retry-After` seconds before retrying. Dashboard sessions are not
subject to this limit — it applies to API-key traffic only. The limiter is
fail-closed: if it cannot be evaluated, the request is denied rather than
waved through.
***
## Permission ↔ endpoint matrix
Each management route requires one explicit permission (a few also require a coarse scope). Grant a key exactly the permissions its endpoints need. `full_access` (or a legacy scope that covers the route) satisfies any of these; owners hold every permission.
### Action governance
| Permission | Endpoints |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow_agents:view` | `GET /allow/agents`, `GET /allow/agents/:id`, `GET /allow/agents/me/stream` |
| `allow_agents:create` | `POST /allow/agents`, `POST /allow/agents/register`, `POST /allow/agents/tools` |
| `allow_agents:update` | `PUT /allow/agents/:id`, `POST /allow/agents/:id/regenerate-naming` |
| `allow_agents:delete` | `DELETE /allow/agents/:id` |
| `allow_rules:view` | `GET /allow/rules`, `GET /allow/rules/:id`, `GET /allow/rules/bundle` |
| `allow_rules:create` | `POST /allow/rules`, `POST /allow/rules/compile` |
| `allow_rules:update` | `PUT /allow/rules/:id` |
| `allow_rules:delete` | `DELETE /allow/rules/:id` |
| `allow_hitl:view` | `GET /allow/hitl/queue` |
| `allow_hitl:respond` | `POST /allow/hitl/queue`, `POST /allow/hitl/queue/:id` |
| `allow_audit_log:view` | `GET /v1/allow/audit-log`, `GET /v1/allow/decisions/:id` |
| `allow_cognition:view` | `GET /v1/allow/cognition/sessions`, `GET /v1/allow/cognition/events` (dedicated `cognition:read` scope — never granted by `allow:read`/`rules:read`) |
| `allow_settings:view` | `GET /allow/settings` |
| `allow_settings:update` | `PUT /allow/settings` |
| `payloads:unmask` | `POST /allow/cognition/reveal` |
### Retrieval governance
| Permission | Endpoints |
| ----------------------- | ------------------------------------------------------------------------ |
| `recall_rules:view` | `GET /recall/rules`, `GET /recall/rules/:id`, `GET /recall/rules/bundle` |
| `recall_rules:create` | `POST /recall/rules`, `POST /recall/rules/compile` |
| `recall_rules:update` | `PUT /recall/rules/:id` |
| `recall_rules:delete` | `DELETE /recall/rules/:id` |
| `recall_rules:bypass` | `POST /recall/rules/:id/bypass`, `DELETE /recall/rules/:id/bypass` |
| `recall_audit_log:view` | `GET /v1/recall/audit-log` |
| `recall_receipts:view` | `GET /recall/receipts/:id` |
| `payloads:unmask` | `GET /recall/decisions/:id/unmask` |
### Unified rules
The unified `/rules` CRUD reuses the action-facet rule permissions, so no new grants are needed:
| Permission | Endpoints |
| -------------------- | --------------------------------------------------- |
| `allow_rules:view` | `GET /rules`, `GET /rules/:id`, `GET /rules/bundle` |
| `allow_rules:create` | `POST /rules` |
| `allow_rules:update` | `PUT /rules/:id` |
| `allow_rules:delete` | `DELETE /rules/:id` |
### Audit trail
| Permission | Scope | Endpoints |
| ------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `record_records:create` | `record:write` | `POST /record/envelopes` |
| `record_records:view` | — (scope `record:read` on the checkpoint, consistency, and verify routes) | `GET /record/records`, `GET /record/records/:id`, `GET /record/records/:id/events`, `GET /record/records/:id/artifacts`, `GET /record/records/:id/attestations`, `GET /record/envelopes/:id/verify`, `GET /record/checkpoints`, `GET /record/checkpoints/:seq`, `GET /record/chain/consistency` |
| `record_records:finalize` | — | `PATCH /record/records/:id/finalize` |
| `record_audit_log:view` | `record:read` | `GET /v1/record/audit-log` |
### Delegation governance
The `/orchestrate/*` delegation endpoints are gated by the coarse `allow:write` scope (the read-only grant status also accepts `allow:read`) rather than a fine-grained permission — see the [delegation API reference](/rules/delegation/api-reference). A management key with `allow:write` (or `full_access`) reaches them; a harness key is rejected as `harness_key_not_permitted`.
### Evaluation & operational routes
The runtime routes an SDK calls — `POST /evaluate`, `POST /allow/evaluate`, `POST /recall/evaluate`, the rule bundles, `POST /allow/telemetry`, `POST /record/envelopes` — are **scope**-gated (`rules:evaluate`, `allow:write`, `recall:write`, `record:write`), not permission-gated, and a harness key is full-power within that operational allowlist. See the per-facet API references for the exact scope on each. (Delegation is reached operationally through the unified `POST /evaluate` with a `delegation` operation — the dedicated `POST /orchestrate/evaluate` is management-audience and rejects harness keys, as noted above.)
***
## Errors
| Status | Error | Cause |
| ------ | --------------------------- | ----------------------------------------------------------------------------- |
| `401` | `Invalid API key or token` | The key is unknown, malformed, or revoked |
| `401` | `API key has expired` | The key passed its expiry, or its rotation grace window lapsed |
| `403` | `insufficient_permission` | The key's permission list does not contain the permission this route requires |
| `403` | `insufficient_scope` | The key's coarse scopes do not cover this scope-gated route |
| `403` | `harness_key_not_permitted` | A harness key was used against a management endpoint — use an automation key |
| `429` | `rate_limited` | Per-key rate limit exceeded — honor `Retry-After` |
A `403 insufficient_permission` response tells you exactly what was missing:
```json theme={null}
{
"error": "insufficient_permission",
"detail": "This management API key is not granted the requested permission.",
"requiredPermission": "allow_rules:update",
"grantedPermissions": ["allow_rules:view", "allow_audit_log:view"]
}
```
Fix it by creating a key that includes the `requiredPermission` — permissions
cannot be edited on an existing key.
`403 insufficient_scope` comes from the coarse scope layer. You will see it
most often on legacy keys created before explicit permissions shipped, but any
key whose (derived) scopes do not cover a scope-gated route — the evaluation
endpoints, for example — receives it too. Permission denials on
permission-gated routes return `insufficient_permission` instead.
# Platform Automation
Source: https://docs.visiqlabs.com/automation/introduction
Automate the VisIQ management API from scripts and CI with permission-scoped automation keys.
**Automation keys are live.** Mint them under **Settings → API Keys**, or let
an AI agent request one through the human-approved `agent_auth` device flow
(`"requested_key_type": "management"` — see the
[agent authentication guide](https://visiqlabs.com/auth.md)). Every grant is
permission-scoped and can never exceed its approver's own permissions.
VisIQ issues API keys in two **audiences**. Which one you need depends on what
the key is for:
| | Harness keys | Automation keys |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Who uses it** | The SDK / harness / agents at runtime | Your scripts, CI pipelines, and back-office tooling |
| **What it can reach** | The SDK operational endpoints only (evaluation — unified and per-facet — rule bundles, HITL decision polling, telemetry, record envelope ingestion, agent registration, discovery sensor reporting) | The management API — rules, agents, audit log, settings, and more |
| **How access is scoped** | Not scoped — full power within its route allowlist, denied everywhere else | An explicit permission list you choose at creation |
| **Where to create one** | **Settings → Harness Keys** (or minted automatically by the SDK install studios and agent registration) | **Settings → API Keys**, or agent self-registration via the `agent_auth` device flow |
If you are integrating the `@visiq/harness` SDK, you want a
[harness key](/quickstart) — the dashboard studios mint one for you. This
section is about the other audience: **automation keys** (also called
management keys), which let your own automation call the VisIQ management API
at `https://api.visiqlabs.com`.
***
## What an automation key is
An automation key is a bearer credential for the management API:
```bash theme={null}
curl https://api.visiqlabs.com/allow/rules \
-H "Authorization: Bearer vq_prod_..."
```
Use it to script anything you could do in the dashboard — manage
action-governance rules, register and update agents, query the audit log,
respond to HITL items, or adjust settings — without a browser session.
The two audiences are asymmetric. A harness key that tries to call a
management endpoint is rejected with `403 harness_key_not_permitted`, no
matter what it is otherwise allowed to do. An automation key, by contrast, may
call any endpoint its permissions allow — including the SDK operational
endpoints.
***
## The permission model
Every automation key carries an **explicit list of permissions** — the same
`resource:action` catalogue that governs your team members (for example
`allow_rules:view`, `allow_agents:create`, `allow_audit_log:view`). You pick
the permissions when you create the key, grouped exactly as they appear in the
team Roles matrix.
Enforcement is **exact and fail-closed**:
* A request is allowed only when the permission required by that route is
**in the key's list**. There is no wildcard expansion and no implication
between permissions — `allow_rules:view` does not grant
`allow_rules:update`.
* Anything not explicitly granted is denied with `403 insufficient_permission`.
The response names the permission the route required and the permissions the
key actually holds, so a denial is always diagnosable:
```json theme={null}
{
"error": "insufficient_permission",
"detail": "This management API key is not granted the requested permission.",
"requiredPermission": "allow_rules:update",
"grantedPermissions": ["allow_rules:view", "allow_audit_log:view"]
}
```
Grant the minimum set the job needs. A nightly job that pulls the
action-governance audit log needs `allow_audit_log:view` — not **Grant all**.
**Operational SDK routes.** A management key is a superset of a harness key,
so it can also reach the runtime endpoints. The evaluation legs — the unified
`POST /evaluate` plus the per-facet `POST /allow/evaluate` and
`POST /recall/evaluate` — are gated by coarse product scopes: a management key
holding a `…:write`-class permission for a product derives the matching write
scope and can evaluate through it. Two runtime routes require an explicit
permission on top: record envelope ingestion (`POST /record/envelopes`) also
requires `record_records:create`, and writing agent attribution
(`POST /allow/agents/register`) also requires `allow_agents:create`. If you
only want a key to reach the management API (rules, agents, audit) and never
the evaluate path, grant read-class permissions — they do not derive the write
scopes that open the operational routes.
**Legacy scoped keys still work.** Keys created before explicit permissions
shipped carry coarse scopes instead of a permission list. The scope
vocabulary is the per-product pairs `allow:read`/`allow:write`,
`recall:read`/`recall:write`, `record:read`/`record:write`, the unified
`rules:read`/`rules:write`/`rules:evaluate` (the unified read and write scopes
subsume their per-product counterparts; `rules:evaluate` is evaluation-only
and never satisfies a rule-management gate), and `full_access`. Scoped keys
are still honored under the original scope-mapping rules, and a scope denial
returns `403 insufficient_scope`. New automation keys always use explicit
permissions.
***
## Built-in guardrails
Automation keys ship with the security properties a credential system should
have, on by default:
* **Privilege-bound minting** — a key can never be created with permissions
its creator does not hold. The request is rejected with `403` and the list
of exceeded permissions, so a narrowly-permissioned caller cannot bootstrap
a stronger key.
* **Audited lifecycle** — every create, rotate, and revoke is written to your
tenant audit log; key creation and rotation **abort (rolling back the new secret) if the audit write fails**.
There is no unaudited key event.
* **Hashed at rest** — VisIQ stores only a SHA-256 hash of each key and
verifies it with a timing-safe comparison. The plaintext is shown exactly
once, at creation.
* **Fail-closed authentication** — expired keys (including a lapsed rotation
grace window) stop authenticating immediately, and every key is
[rate limited](/automation/api-keys#rate-limits) with a per-key sliding
window.
***
## Where to create one
Open **Settings → API Keys** and click **Create New Key** — naming the key,
picking its environment and expiry, and selecting its permissions. The full
key is shown **exactly once**. AI agents can instead self-register via the
`agent_auth` device flow and receive a management key after human approval.
See the [managing keys guide](/automation/api-keys) for the complete flow,
rotation, rate limits, and error reference.
Creation flow, key format, expiry, rotation with grace windows, rate
limits, and error codes.
The management endpoints for rules, agents, audit log, HITL, and settings.
# Platform MCP server
Source: https://docs.visiqlabs.com/automation/mcp
Operate VisIQ from any MCP client — the authenticated Model Context Protocol server exposing rules, agents, decisions, HITL, settings, and natural-language queries as tools.
VisIQ exposes an authenticated **Model Context Protocol (MCP)** server so an AI
agent or MCP-capable client can operate the product with tool calls instead of
raw REST. It is the same management surface as the web app and the
[automation API](/automation/introduction) — every tool enforces the identical
RBAC permission its web route requires.
There are **two** VisIQ MCP servers, both listed in
[`/.well-known/mcp/server-cards.json`](https://visiqlabs.com/.well-known/mcp/server-cards.json):
* **Public MCP** — `https://visiqlabs.com/mcp` — read-only discovery, **no
credentials**. Public information about VisIQ only.
* **Platform MCP** (this page) — `https://app.visiqlabs.com/api/mcp` —
**authenticated**; operates your tenant.
## Endpoint & transport
| | |
| ------------- | --------------------------------------------------------- |
| **URL** | `https://app.visiqlabs.com/api/mcp` |
| **Transport** | Streamable HTTP |
| **Auth** | `Authorization: Bearer ` on every request |
| **Protocol** | MCP `2025-06-18` |
You need a **management-audience** API key. Get one from **Connectors → API
Keys** in the dashboard, or have your agent
[self-register](/automation/agent-device-flow) with
`"requested_key_type": "management"`. A harness key is confined to the SDK
operational surface and is **not** accepted here.
The server is **fail-closed**: a missing or invalid key is rejected, and a tool
whose required permission the key does not hold returns an error naming the
missing permission (never a silent partial result). Nothing is anonymously
accessible.
## Connect a client
Point any MCP client at the endpoint with a bearer header. For example, a
generic `mcpServers` configuration:
```json theme={null}
{
"mcpServers": {
"visiq": {
"type": "http",
"url": "https://app.visiqlabs.com/api/mcp",
"headers": { "Authorization": "Bearer vq_prod_..." }
}
}
}
```
Then **start with `whoami`** to confirm which vendor and permissions your key
resolves to — every other tool is gated by those permissions.
## Tool catalog
The server advertises the tools below via `tools/list`. Each is permission-gated
exactly like its web/API counterpart.
**Discovery**
* `whoami` — the vendor and effective permissions your key holds. Call this first.
* `describe_chat` — how the in-product natural-language query tools work.
**Action governance (rules)**
* `list_rules`, `get_rule` — read action rules
* `create_rule`, `update_rule`, `delete_rule` — author action rules
* `compile_rule` — compile natural language into a rule (requires `allow_rules:create` **or** `recall_rules:create`)
**Retrieval governance (rules)**
* `list_recall_rules`, `create_recall_rule` — read and author retrieval rules
**Agents**
* `list_agents`, `get_agent` — inventory
* `register_agent`, `update_agent` — register and manage agents across frameworks
**Decisions & schemas**
* `get_decision_outcomes` — decision/audit outcomes
* `list_action_schemas` — the action schemas agents report
**Human-in-the-loop**
* `list_hitl_queue` — pending approvals
* `respond_hitl` — approve or deny a queued action (authenticated approver identity)
**Settings**
* `get_allow_settings`, `update_allow_settings` — enforcement mode and settings
**Natural-language queries**
* `chat_rules_table`, `chat_events_log`, `chat_agents_inventory`, `chat_hitl_queue`, `chat_data_table` — ask questions over each surface in plain language
**Key lifecycle**
* `revoke_self` — revoke **this** key (no arguments, no permission required). It
stops authenticating immediately, so make it your **final** call. See
[self-revocation](/automation/api-keys#revoke-your-own-key-self-revocation).
## Related
* [Agent authentication](https://visiqlabs.com/auth.md) — the agent-facing auth guide (device flow + self-revoke).
* [Managing API keys](/automation/api-keys) — key lifecycle, formats, errors.
* [Agent self-registration](/automation/agent-device-flow) — obtain a key without a pre-provisioned credential.
# Changelog
Source: https://docs.visiqlabs.com/changelog
Release notes for the @visiq/harness SDK — what changed in each version, with every breaking change and removal called out. Read this before upgrading.
Release notes for the `@visiq/harness` SDK (and its Python peer `visiq`). The
SDK is **pre-1.0 (0.x): there is no wire-compatibility guarantee yet, and
breaking changes may ship in any minor release until v1.0 GA.** Pin an exact
version and read the `Breaking` / `Removed` entries below before upgrading — see
[SDK versioning & compatibility](/versioning) for the full posture.
**Canonical source.** The registry version history is the authoritative record of
what is live: [npmjs.com/package/@visiq/harness](https://www.npmjs.com/package/@visiq/harness?activeTab=versions).
Check it rather than this page for the current build — these notes are
hand-maintained and have run behind the registry before (`0.2.7` and `0.2.8`
published with no entry here). The package's own `CHANGELOG.md` lives in the repo
and is **not** shipped inside the tarball, whose `files` allowlist is
`dist`, `README.md` and `LICENSE`.
**Every minor may break at 0.x.** A `0.x → 0.x` minor bump is allowed to remove
or rename a field, change a default, or tighten a shape. Don't float a version
range across a minor boundary in an unattended pipeline — pin, then upgrade
deliberately after reading the entries below.
***
## 0.2.10
**Metadata only — no runtime behaviour changed.** The package description was
rewritten onto one shape shared across the whole VisIQ harness/SDK family (npm,
PyPI, Maven Central, RubyGems), so every package now describes itself in the
current action-governance / retrieval-governance / audit-trail vocabulary. No
API, decision, or wire change — a republish is required only because registry
metadata is frozen at publish time.
**Why this is `0.2.10` and not `0.2.9`.** The rewrite was authored against
`0.2.9`, but a concurrent promotion published `0.2.9` from a tree that did not
yet contain it. A published version is immutable, so `0.2.9` on the registry
carries the OLD description permanently and the rewrite needed the next number.
The same collision hit `@visiq/openclaw-plugin` (`0.1.10` → **`0.1.11`**) and
`@visiq/claude-code-harness` (`0.1.5` → **`0.1.6`**). `NPM Content Freshness`
is the gate that caught it: it rebuilds each would-be-published tarball and
compares its whole content hash against the registry artifact at the same
version, so "edited but not bumped" fails a PR instead of silently no-op'ing at
publish time.
`0.2.7` and `0.2.8` shipped without an entry on this page. Their contents are in
the git history; this note records the gap rather than reconstructing them.
***
## 0.2.6
**Republish note.** The security and compatibility changes below were merged
after `0.2.5` was published (2026-07-12) but no version bump followed, so `0.2.5`
on the registry does **not** contain them. `0.2.6` is the first published build
that ships them. (Versions `0.2.1`–`0.2.5` are backfilled separately.)
### Changed
* **Fail-closed on an unrecognized retrieval outcome (security).** The retrieval
application layer now **withholds content** — drops the document or returns the
blocked sentinel — on any outcome verb this build does not recognize, instead
of passing it through. This is defense-in-depth behind the engine's own
fail-closed mapping; `allow` / `escalate` passthrough is unchanged. An unaware
upgrader gets strictly **safer** behavior.
* **Every control-plane request now carries `X-VisIQ-SDK` (version) and
`X-VisIQ-Dialect` (wire-contract capability) headers.** Telemetry only — not
load-bearing for any decision.
* **MUST-UNDERSTAND bundle refuse.** A rule bundle that declares a `min_dialect`
newer than this SDK speaks is refused wholesale (fail-closed, deny-all) rather
than partially applied. Dormant today (`min_dialect` is `1` everywhere); it is
the forward-compatibility safety net for the first wire-contract bump. The
refuse also lives in the shared engine (`@visiq/core-wasm ^0.1.3`) so every
language binding enforces it uniformly.
### Removed
* **Breaking — the `@visiq/harness/record` subpath export is removed.** The audit
layer is now a server-side proof layer only: every action and retrieval
decision is automatically signed (Ed25519) and persisted by the backend. No
SDK import is required to produce or read receipts — query the dashboard or the
REST API instead. The previous types (`RecordEnvelope`, `RecordSource`,
`RecordDecisionPayload`, `SigningResult`, `ReceiptVerificationInput`) have been
deleted from the SDK.
### Deprecated
* The `recall_receipts` Postgres view is deprecated in favor of querying
`decision_receipts` with `source = 'recall'` for complete, consistent views
across all decision sources. The view is retained for backward compatibility
and will be removed in a future release.
***
## 0.2.0
### Added
* `AgentVendor` union type (`'visiq' | 'crowdstrike' | 'intune' | 'sentinelone' | 'unknown'`).
* `FleetInstance.last_scanned: string | null` — ISO timestamp of the last
per-instance heartbeat.
* `FleetInstance.agent_vendor: AgentVendor` — vendor of the agent reporting
heartbeats for the instance.
* `HeartbeatInput { instanceId: string; vendor: AgentVendor }` input type for
`fleet.heartbeat()`.
* `HeartbeatResponse.instance_id` — echoes the heartbeated instance id back to
the caller.
### Breaking
* `fleet.heartbeat()` now requires a `HeartbeatInput` argument
(`{ instanceId, vendor }`). Callers that previously invoked `fleet.heartbeat()`
with no arguments must iterate `fleet.status().instances` and call once per
instance.
***
## Where to look next
The full pre-1.0 posture, the invariants held even at 0.x, and the v1.0
compatibility window.
The `visiq()` surface, options, framework detection, and error behavior.
# Stream logs to Datadog
Source: https://docs.visiqlabs.com/connectors/datadog
Continuously ship your governance decision and audit logs to your own Datadog organization.
VisIQ can forward your governance **decision** and **audit** logs to your own Datadog organization as they happen, so your existing dashboards, monitors and retention policy cover AI governance the same way they cover everything else.
Set it up at **Connectors → Log Streaming → Datadog**. It takes about three minutes.
***
## Before you start
* A Datadog organization and permission to create an **API key** in it.
* A VisIQ account with the **`settings:update`** permission — the log-destination endpoints are guarded by it.
Streaming starts from the moment you save. There is **no historical backfill** — logs recorded before the destination existed stay in VisIQ's audit trail and are not replayed into Datadog.
***
## Step 1 — Create a Datadog API key
In Datadog, go to **Organization Settings → API Keys → New Key** and copy the key value.
An **API key** is all VisIQ needs, and it is deliberately the least-privileged option:
* Log submission authenticates with the `DD-API-KEY` header and nothing else.
* VisIQ never asks for an **Application key**, which is the credential that can read your Datadog data and change configuration. If you are about to paste something that starts with your user's app key, you have the wrong one.
***
## Step 2 — Pick your site
Datadog runs several independent regions and your API key only works against yours. VisIQ asks you to pick it from a fixed list rather than accepting a URL:
| Site | Region |
| ------------------- | ----------------- |
| `datadoghq.com` | US1 — the default |
| `us3.datadoghq.com` | US3 |
| `us5.datadoghq.com` | US5 |
| `datadoghq.eu` | EU1 |
| `ap1.datadoghq.com` | AP1 · Japan |
| `ap2.datadoghq.com` | AP2 · Australia |
| `uk1.datadoghq.com` | UK1 |
| `ddog-gov.com` | US1-FED |
| `us2.ddog-gov.com` | US2-FED |
To find yours, match your browser's Datadog URL (`app.datadoghq.com` is US1, `us5.datadoghq.com` is US5, `app.datadoghq.eu` is EU1, and so on), or read it at the top of **My Preferences**.
Note that EU1 is `datadoghq.eu` — **not** `eu1.datadoghq.com` — and US1 has no region prefix at all.
### Why a list and not a URL
VisIQ **derives** every Datadog host from the site you pick and never accepts a customer-supplied URL for this connector:
* logs go to `https://http-intake.logs./api/v2/logs`
* the key check goes to `https://api./api/v1/validate`
That removes a whole class of risk: because there is no URL field, there is nothing to point at an internal host, and a typo produces "wrong site" rather than a silent redirection of your compliance logs.
***
## Step 3 — Choose what to stream
Pick at least one stream. Both are on by default.
| Stream | Contents |
| ------------- | -------------------------------------------------------------------- |
| **Decisions** | Action, retrieval, and Human-in-the-Loop governance decision events. |
| **Audit** | Platform configuration and access audit log. |
You can also set an optional **source name** (Datadog's `source` attribute, e.g. `visiq`) to make the events easy to filter alongside your other log sources.
***
## Step 4 — Verify and save
There is no **Test connection** button here. As soon as your key is entered and the configuration is complete, VisIQ sends a test event to your Datadog intake automatically, and **Save** unlocks once it is verified. If it fails, fix the key or the site and VisIQ re-verifies on its own.
Once saved, the connector card shows delivered and failed counts so you can see the stream's health at a glance.
***
## How delivery behaves
Understanding the retry model matters when you are reconciling counts.
* Datadog's intake returns **202** for an accepted batch. That is what VisIQ counts as delivered.
* A **bad key, wrong site, or oversized batch** is a permanent rejection. VisIQ **buffers and backs off** rather than discarding the batch, so you can fix the configuration without losing logs.
* **Rate limits, timeouts, server errors and network failures** are retried.
* Datadog **silently drops** logs older than roughly its 18-hour intake window while still answering 202. VisIQ will not report those as delivered: a batch that has aged past the window is counted as dropped and skipped, so a backlog can never be falsely attested as shipped.
***
## The API behind the card
`GET` needs `settings:view`; `POST` needs `settings:update`.
```json POST /api/log-destinations theme={null}
{
"name": "Datadog — production",
"type": "datadog",
"datadog_site": "us5.datadoghq.com",
"streams": ["decisions", "audit"],
"auth_config": { "api_key": "…" },
"source_name": "visiq",
"enabled": true
}
```
Note the shape: Datadog carries `datadog_site` and **no** `endpoint_url`, because the host is derived. `streams` must be a non-empty subset of `decisions` and `audit`. On a later update, omitting `auth_config` keeps the stored key unchanged.
***
## Troubleshooting
Almost always the wrong **site**. A key issued in EU1 does not authenticate against US1. Match the site to the Datadog URL you use in the browser, then let VisIQ re-verify.
Open the destination — the recorded error names the cause. A permanent status (a rejected key, a site mismatch) means VisIQ is buffering rather than dropping: correct the setting and the buffered batches deliver on the next attempt.
Datadog discards logs older than about 18 hours at intake. VisIQ counts anything that has aged past that window as dropped instead of reporting it delivered — the stream keeps moving rather than wedging on undeliverable history.
Log submission takes an organization **API key**, not an Application key. Create one under Organization Settings → API Keys.
***
## Related
The same streams into your own Elastic deployment, with a least-privilege API key.
InsightIDR Custom Logs, where the webhook URL is the whole credential.
# Stream logs to Elasticsearch
Source: https://docs.visiqlabs.com/connectors/elastic
Continuously ship your governance decision and audit logs to your own Elastic deployment with a least-privilege API key.
VisIQ can forward your governance **decision** and **audit** logs into your own Elasticsearch deployment as they happen, indexed as ordinary log documents you can search, alert on and retain under your existing policy.
Set it up at **Connectors → Log Streaming → Elasticsearch**. It takes about three minutes.
***
## Before you start
* An Elasticsearch deployment reachable over **https from the public internet**, and Kibana access to create an API key.
* A VisIQ account with the **`settings:update`** permission — the log-destination endpoints are guarded by it.
Streaming starts from the moment you save. There is **no historical backfill** — logs recorded before the destination existed stay in VisIQ's audit trail and are not replayed into Elastic.
***
## Step 1 — Create a least-privilege API key
In Kibana, go to **Stack Management → Security → API keys → Create API key**. Turn on **Control security privileges** and paste this role descriptor:
```json Elastic role descriptor theme={null}
{
"visiq_ingest": {
"cluster": [
"monitor"
],
"indices": [
{
"names": [
"logs-*-*"
],
"privileges": [
"auto_configure",
"create_doc"
]
}
]
}
}
```
Then copy the **Base64 encoded** value — Kibana shows it only once.
This is the whole grant, and it is narrow on purpose:
| Privilege | What it allows | What it cannot do |
| ------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------- |
| `create_doc` on `logs-*-*` | Add new log documents to your `logs-*` data streams. | Update, overwrite, read, or delete anything already in your cluster. |
| `auto_configure` on `logs-*-*` | Let the data stream create itself on first write. | Touch indices outside the pattern. |
| `cluster: monitor` | Let the connection check confirm the cluster is reachable. | Any write or admin operation. |
If your organisation prefers to provision the key another way, the only requirement is that it can create documents under `logs-*-*`. A key with more privilege than this works but is not needed.
***
## Step 2 — Point VisIQ at your deployment
Enter your **Elasticsearch endpoint URL** — the deployment's base URL, for example `https://my-deployment.es.us-central1.gcp.cloud.es.io`. Then paste the encoded API key.
The endpoint has to clear two checks:
* **In the browser**, a shape check: it must be `https`, and it must not carry embedded credentials (`https://user:pass@host` is refused, because such a URL would be stored in plain text and breaks the HTTP client anyway).
* **On the server**, the authoritative one: the host is DNS-resolved and rejected if it lands in a private, reserved or link-local range. This runs at the route boundary **and again immediately before every single send**, so an endpoint that later resolves inward cannot become an egress path.
A self-hosted cluster therefore has to be reachable at a public address; an endpoint on a private network cannot be used.
**Changing the endpoint host requires re-entering your API key.** VisIQ refuses to ship a stored credential to a host it was not issued for, so editing the endpoint to a different host locks Save until you paste a fresh key and it verifies against the new host. Changing the path or port of the same host does not trigger this.
***
## Step 3 — Choose what to stream
Pick at least one stream. Both are on by default.
| Stream | Contents |
| ------------- | -------------------------------------------------------------------- |
| **Decisions** | Action, retrieval, and Human-in-the-Loop governance decision events. |
| **Audit** | Platform configuration and access audit log. |
You can also set an optional **dataset override** (for example `logs-visiq.decisions-default`) if you want the documents to land somewhere other than the default target.
***
## Step 4 — Verify and save
There is no **Test connection** button. Once the endpoint and key are in, VisIQ writes a test event automatically and **Save** unlocks when it is verified. If it fails, correct the endpoint or the key and VisIQ re-verifies on its own.
Once saved, the connector card shows delivered and failed counts.
***
## How delivery behaves
VisIQ writes through Elasticsearch's `_bulk` API with `create` operations and a **deterministic document id** per event. That is what makes retries safe: a redelivered document comes back as a `409` version conflict, which VisIQ counts as already-delivered rather than inserting a duplicate.
The response handling is deliberate, because `_bulk` can return `200` while individual documents failed:
| Outcome | What VisIQ does |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Document accepted, or `409` conflict | Counted delivered. |
| Per-document `429` | Retried — the batch is not advanced past. |
| Per-document `400` (mapping or parse error) | Counted as a permanent drop, so one poison document cannot wedge the stream forever. |
| Whole-request `401` / `403` / `413` | Buffered with back-off, never dropped — a fixable credential or sizing problem must not cost you logs. |
| Whole-request `429` / `5xx` / network failure | Retried. |
| `200` with a body VisIQ cannot parse | **Retried, not counted delivered.** An unparseable `200` usually means a proxy answered instead of Elastic, and VisIQ will not attest delivery it cannot confirm. |
***
## The API behind the card
`GET` needs `settings:view`; `POST` needs `settings:update`.
```json POST /api/log-destinations theme={null}
{
"name": "Elastic — production",
"type": "elasticsearch",
"endpoint_url": "https://my-deployment.es.us-central1.gcp.cloud.es.io",
"streams": ["decisions", "audit"],
"auth_config": { "api_key": "…" },
"index_name": "logs-visiq.decisions-default",
"enabled": true
}
```
Note that the persisted type is `elasticsearch` even though the connector card reads "Elasticsearch". `streams` must be a non-empty subset of `decisions` and `audit`. On a later update, omitting `auth_config` keeps the stored key unchanged.
***
## Troubleshooting
The API key is wrong, or it lacks `create_doc` on `logs-*-*`. Re-create it with the role descriptor above and make sure you copied the **encoded** value, not the key id.
The host resolved to a private, reserved or link-local address. VisIQ only streams to publicly-resolvable endpoints, and it re-checks before every send rather than trusting the value that was validated at save time.
You changed the host, so a fresh API key is required. This is intentional — a key issued for one cluster is never shipped to another.
Check the recorded error on the destination. Documents rejected with a mapping or parse error are counted as dropped rather than retried forever; everything else is buffered and retried, so the shortfall is usually temporary.
***
## Related
The same streams into Datadog, with the intake host derived from your site.
InsightIDR Custom Logs, where the webhook URL is the whole credential.
# Email approvals
Source: https://docs.visiqlabs.com/connectors/email
Set the catch-all address that receives Human-in-the-Loop approvals for agents with no registered owner.
Email is the simplest connector VisIQ has: one address, no credential, about a minute of work. It is also the safety net that keeps an approval from going nowhere.
Configure it at **Connectors → Human-in-the-Loop → Email**.
***
## What this address is for
Email is your organisation's **catch-all**, not your only delivery path.
When you connect an agentic framework or tool, you choose that agent's approval pathway and register its owner. Approvals for an agent with an owner go straight to that person. This address is where an approval lands when the agent has **no owner registered** — so an admin can see what is running, decide it, and assign someone.
| Situation | Where the approval goes |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **Claimed agent** — an owner is registered | Directly to that owner, over the pathway chosen for the agent. |
| **Unclaimed agent** — no owner yet | To this catch-all address, flagged with the agent's id and reported environment so you can attribute and claim it. |
The dashboard queue is always active regardless of what you configure here, so an approval is never lost because a channel is unset.
***
## Set it up
1. Open **Connectors → Human-in-the-Loop → Email**.
2. Enter the **catch-all address** — a shared mailbox such as `approvals@yourcompany.com` works well, because unclaimed-agent approvals are an admin task rather than one person's.
3. **Save.**
There is no API key, no webhook, and no credential of any kind: VisIQ sends these from its own outbound mail infrastructure. The connector is treated as configured as soon as it holds a non-empty address, which is why the card flips to **Connected** immediately after saving.
Once saved, **Send test** delivers a short plain-text probe — subject `VisIQ connector test`, body `✅ VisIQ connector test — this channel is wired up correctly.` — so you can confirm mail from VisIQ arrives and is not caught by a spam filter or a mailbox rule. It is deliberately **not** a rendered approval card: it carries no decision buttons and no signed links, so it proves *deliverability*, not the approval round-trip.
***
## What the email contains
Each approval email renders, in order: a branded header, the rule or policy reason that routed the call to approval, an AI-generated risk summary, an action-details table, and the call-to-action links. For an unclaimed agent the message also names the agent id and the host it reported from, which is the information you need to assign an owner.
| Link | What happens |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Approve** | The tool call runs with its full arguments. |
| **Deny** | The tool never runs; the agent receives the standard block message as the tool's output. |
| **Investigate** | Opens the pending item in the VisIQ dashboard — an auth-gated, read-only deep link that takes no governance action. Always present. |
| **Let VisIQ decide** | Hands the pending item to VisIQ's automated reviewer. Shown only when that is available for the agent. |
The links carry signed, single-use, expiring tokens. Clicking one after somebody else has already resolved the item is safe — the decision is not applied twice.
***
## The API behind the card
Both endpoints require a session and the `connectors:manage` permission.
| Method | Path | Body | Purpose |
| -------- | ------------------------------------------ | -------------------------------------- | ------------------------------------------------ |
| `PUT` | `/api/connectors/hitl-channels/email` | `{ enabled?, display_label?, config }` | Create or update the connector. |
| `DELETE` | `/api/connectors/hitl-channels/email` | — | Remove the connector. |
| `POST` | `/api/connectors/hitl-channels/email/test` | — | Send a test message through the saved connector. |
Unlike Slack, `config` is **required** on an email upsert, and it holds exactly one field:
```json PUT /api/connectors/hitl-channels/email theme={null}
{
"enabled": true,
"display_label": "Approvals mailbox",
"config": {
"address": "approvals@yourcompany.com"
}
}
```
The body is validated strictly: `address` must be a valid email address, and any key VisIQ does not recognise is rejected rather than ignored. `display_label` is capped at 120 characters.
***
## Troubleshooting
Email is marked connected as soon as a valid address is stored — that is a statement about configuration, not about deliverability. Use **Send test** to prove delivery end to end, then check the mailbox's spam folder and any routing rules on a shared mailbox.
That is the intended behaviour for an agent that has an owner registered. This address only receives approvals for agents nobody owns yet. To change where a specific agent's approvals go, set its owner and pathway on the Agents page.
The approval window is capped at 120 seconds and an unanswered request fails closed, so email suits review-after-the-fact and low-volume gates better than time-critical ones. For interactive, in-the-moment approvals use [Slack](/connectors/slack).
***
## Related
Interactive Approve and Deny buttons in a channel, with owner direct messages.
How approvals pause a tool call, owner routing, and the timeout model.
# Stream logs to Rapid7
Source: https://docs.visiqlabs.com/connectors/rapid7
Continuously ship your governance decision and audit logs to a Rapid7 InsightIDR Custom Logs event source — with no Rapid7 API key.
VisIQ can forward your governance **decision** and **audit** logs to a Rapid7 InsightIDR **Custom Logs** event source, so AI governance events sit in Log Search alongside the rest of your detection estate.
Set it up at **Connectors → Log Streaming → Rapid7**. It takes about three minutes, and the last step needs you to check something in Rapid7 yourself — that is deliberate, and explained below.
***
## Before you start
* A Rapid7 InsightIDR account where you can create an event source.
* A VisIQ account with the **`settings:update`** permission — the log-destination endpoints are guarded by it.
Streaming starts from the moment you save. There is **no historical backfill** — logs recorded before the destination existed stay in VisIQ's audit trail and are not replayed into Rapid7.
***
## VisIQ never asks for a Rapid7 API key
This connector has no API key field, and that is a security decision rather than a limitation.
A Rapid7 platform key would be **more** privileged than this integration needs, not less. A read-only platform key can list every log in the account **together with its ingest tokens** — handing over every write credential in your estate — and an Organization key is documented as a super key across all products. A Custom Logs webhook URL is strictly narrower: it can append events to **one** event source and do nothing else.
So the URL is the whole credential. Treat it exactly like a password:
* It is stored **envelope-encrypted** at rest and is never returned to the browser.
* The copy VisIQ keeps in its plain-text display column has **every path segment masked**, so no part of the credential reaches any surface that merely reads destination metadata.
* To revoke it, generate a new Webhook URL for the event source in InsightIDR. The old one stops working immediately.
***
## Step 1 — Create the InsightIDR event source
In InsightIDR: **Data Collection → Setup Event Source → Add Event Source → Add Raw Data → Custom Logs**.
1. Set the collection method to **Webhook**.
2. Name the event source. That name becomes the log name you will select in Log Search, so make it something you will recognise — `VisIQ Governance`, for example.
3. **Save**, then click **Copy** on the Webhook URL it generates.
There is no key to create and nothing else to configure on the Rapid7 side.
***
## Step 2 — Paste the Webhook URL
Back in VisIQ, paste the URL into **InsightIDR Webhook URL** and pick at least one stream.
| Stream | Contents |
| ------------- | -------------------------------------------------------------------- |
| **Decisions** | Action, retrieval, and Human-in-the-Loop governance decision events. |
| **Audit** | Platform configuration and access audit log. |
The URL is checked before it is accepted, and again before every send:
* It must be `https`.
* It must not carry an embedded username or password.
* Its host must be `insight.rapid7.com` or a subdomain of it — nothing else, ever. VisIQ also refuses to follow redirects, so a tampered URL cannot bounce your compliance logs to another host.
Copy the URL rather than typing it. A host like `evil-insight.rapid7.com` is **not** a subdomain of the Rapid7 apex and is rejected, as is anything with the apex buried in the middle of another domain.
**Do not re-paste the masked URL the interface shows you.** A configured destination displays the redacted form. It is a structurally valid Rapid7 URL, so pasting it back would look accepted — VisIQ rejects it with an explicit error instead, because saving it would silently replace a working webhook with one that can never deliver. Leave the field blank to keep the stored URL, or re-copy a real one from InsightIDR.
***
## Step 3 — Confirm the test event actually arrived
This is the step other destinations do not have.
Rapid7 issues **write-only** webhook URLs with no read-back, so VisIQ cannot check that an accepted event was indexed. Worse, Rapid7's sibling ingest endpoint returns a success code for a token that does not exist — which means "the POST succeeded" does not even prove the URL is real. Showing you a green *Connected* on that basis would be a lie about a compliance stream.
So VisIQ says only that Rapid7 **accepted** the test event, hands you the unique marker it carried, and keeps **Save locked** until you confirm you found it:
1. In Rapid7, open **Log Search** and select the event source you created.
2. Run the query VisIQ shows you — it is of the form `event_id = ""`.
3. When the event is there, tick **I found the test event in Rapid7 Log Search**. Save unlocks.
If you change the URL or the streams after confirming, VisIQ re-runs the test with a **new** marker and clears your confirmation — the old attestation was about an event that is no longer the one in flight.
***
## The API behind the card
`GET` needs `settings:view`; `POST` needs `settings:update`.
```json POST /api/log-destinations theme={null}
{
"name": "Rapid7 — InsightIDR",
"type": "rapid7",
"streams": ["decisions", "audit"],
"auth_config": { "webhook_url": "https://us.api.insight.rapid7.com/…" },
"enabled": true
}
```
Note the shape, which differs from the other destinations: Rapid7 sends **no** `endpoint_url` at all. The URL is the credential, so it travels inside `auth_config` where it is encrypted; the endpoint value you see on list surfaces is the redacted display form VisIQ derives server-side. On a later update, omitting `auth_config` keeps the stored URL unchanged.
Events are posted as newline-delimited JSON.
***
## Troubleshooting
Copy the Webhook URL from InsightIDR with its **Copy** button rather than retyping it. Every Rapid7 Insight ingest host is a subdomain of `insight.rapid7.com`; a look-alike host is refused by design.
Confirm you are searching the event source you just created — the name you gave it is the log name — and that the collection method is **Webhook** rather than one of the other Custom Logs methods. Do not tick the confirmation box until you have actually seen the marker; that checkbox is the only evidence VisIQ has that delivery works.
For Rapid7, a passing test alone does not unlock Save. You also need at least one stream selected and the delivery confirmation ticked.
That was the masked display form, not the credential. Re-copy the real URL from InsightIDR, or leave the field blank to keep streaming with the one already stored.
***
## Related
The same streams into Datadog, with the intake host derived from your site.
The same streams into your own Elastic deployment, with a least-privilege API key.
# Slack approvals
Source: https://docs.visiqlabs.com/connectors/slack
Connect Slack so Human-in-the-Loop approvals arrive as an interactive message with working Approve and Deny buttons.
Slack is the richest approval channel VisIQ ships: an `approval_required` decision posts a message into a channel you choose, and a reviewer resolves it with **Approve** or **Deny** without leaving Slack.
Setup takes about two minutes and produces three values you paste into VisIQ. The dashboard walks you through it at **Connectors → Human-in-the-Loop → Slack**; this page is the same flow written down, plus the one setting people most often miss.
**Do not skip the Interactivity Request URL.** It is the only thing that tells Slack where to send a button click. Without it, VisIQ's message still posts and the buttons still render — but pressing one never reaches VisIQ, the approval sits unresolved, and the agent's call times out and fails closed. The manifest below sets it for you; if you build the app by hand instead, set it yourself.
***
## Before you start
* A Slack workspace where you can **create and install an app**. Installing grants workspace-admin OAuth consent, so if you are not an admin you will need one to approve the install.
* A VisIQ account with the **`connectors:manage`** permission — the save and delete endpoints are guarded by it.
* A channel for approvals. A public channel is easiest; the app can post to a public channel without being invited.
***
## Step 1 — Create the Slack app from VisIQ's manifest
Go to [api.slack.com/apps](https://api.slack.com/apps?new_app=1), choose **Create New App**, and pick **From an app manifest**. Select your workspace, then paste this:
```json Slack app manifest theme={null}
{
"display_information": {
"name": "VisIQ Approvals",
"description": "Human-in-the-loop approvals from VisIQ",
"background_color": "#4a154b"
},
"features": {
"bot_user": {
"display_name": "VisIQ",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"chat:write",
"chat:write.public",
"users:read.email",
"users:read",
"im:write"
]
}
},
"settings": {
"interactivity": {
"is_enabled": true,
"request_url": "https://app.visiqlabs.com/api/integrations/slack/interactions"
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}
```
The manifest pre-fills the permissions **and** the Interactivity Request URL in one step, which is why it is the recommended path.
**Self-hosted or non-production VisIQ?** Replace the `request_url` host with your own VisIQ origin, keeping the path exactly `/api/integrations/slack/interactions`. The dashboard's setup panel renders the correct URL for the environment you are signed in to — copy it from there rather than typing it.
Create the app, then **install it to your workspace** and approve the consent screen.
### What each permission is for
Every scope in the manifest is there for a specific delivery behaviour. None of them can read your channel history, your DMs, or your files.
| Scope | What it does | Why VisIQ asks for it |
| ------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `chat:write` | Send messages as the VisIQ bot. | Posts each approval request into your chosen channel. |
| `chat:write.public` | Post to a public channel without joining it first. | Saves you from inviting the bot to every channel by hand. |
| `users:read.email` | Resolve a teammate's Slack account from their email. | Lets VisIQ DM an agent's owner directly instead of the shared channel. |
| `users:read` | Read a teammate's basic Slack profile (the id and name behind an email). | Slack requires it alongside `users:read.email` — the email lookup reads the base user profile first. |
| `im:write` | Open a direct-message channel with a teammate. | Lets VisIQ open the owner's DM (`conversations.open`) after resolving them by email. |
Two of these are worth calling out:
* **`users:read` is not optional if you want `users:read.email`.** Slack documents that the two must be requested together, so a hand-built app that asks only for `users:read.email` is incomplete.
* **A missing `im:write` degrades silently.** Owner direct messages need both `users:read.email` (to find the person) and `im:write` (to open the DM). Drop either one and approvals for a claimed agent quietly fall back to the shared channel and the owner's email instead of failing loudly. VisIQ's connection test reads the granted scopes back from Slack and warns you when this is the case — it is a warning, not an error, because channel-only installs are fully supported.
***
## Step 2 — Collect the three values VisIQ needs
Open **Connectors → Human-in-the-Loop → Slack** in the VisIQ dashboard and fill in these three fields. They are the only inputs the connector takes.
| Field | Where to find it in Slack | What VisIQ accepts |
| ------------------------ | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Bot User OAuth Token** | **OAuth & Permissions → Bot User OAuth Token** | Must begin with `xoxb-`. Stored encrypted; never shown again. |
| **Signing Secret** | **Basic Information → App Credentials → Signing Secret** (click *Show*) | At least 16 characters. Stored encrypted; never shown again. |
| **Channel ID** | Open the channel → **View channel details** → copy the **Channel ID** at the bottom | The channel id itself, e.g. `C0A1B2C3D4E` — not the channel name. |
For a **private** channel, invite the bot first with `/invite @VisIQ`. Public channels are already covered by `chat:write.public`.
Both secrets are envelope-encrypted at rest and are never returned by any VisIQ API — the connector list endpoint projects rows to a secret-free shape.
***
## Step 3 — Run **Test connection**, then save
**Test connection** is a required step on first setup, not a nicety: the Save button stays disabled until a test passes. It does four things, and the third is the one that makes the buttons work.
1. Calls Slack's `auth.test` with your bot token to confirm the token is valid, and reports the workspace name back to you.
2. Posts a real test message to your channel, so `chat:write` and channel reachability are proven by delivery rather than assumed.
3. **Reads your workspace's team id from Slack and stores it with the connector.** This is how inbound button clicks find your tenant — see below.
4. Checks that your app was granted `users:read.email` and `im:write`, and warns (without blocking) if owner direct messages will be unavailable.
Two things it deliberately cannot check, and says so rather than claiming otherwise:
* **The signing secret** is only format-checked. Slack signs requests *to* your Request URL, so there is no API call that validates a signing secret — it is proven live the first time someone clicks Approve or Deny.
* **Whether the Interactivity Request URL is set** cannot be read back from Slack at all. Confirm it yourself in the app's **Interactivity & Shortcuts** page. This is exactly why the manifest path is recommended.
Once the test passes, click **Save**. The card shows **Connected**.
### Why the team id matters
An approval message posted by VisIQ carries buttons. When a reviewer clicks one, Slack posts the interaction to your Interactivity Request URL — a public endpoint that carries no VisIQ session. VisIQ therefore identifies the tenant by the **workspace team id** in Slack's payload, looks up the matching connector, decrypts *that tenant's* signing secret, and verifies the request signature before it will resolve anything.
That chain only works if a team id is stored against your connector, and **Test connection is what puts it there**. A connector saved without a passing test has no team id, so every button click is answered with an opaque `401` and the approval is never recorded.
***
## How VisIQ authenticates a button click
The interactions endpoint is public by design — it is a webhook, and Slack does not carry a user session. Authenticity comes from cryptography instead, in this order:
1. The raw request bytes are read **before** any parsing, because Slack signs the exact bytes it sent.
2. The workspace team id in the payload resolves the tenant's connector.
3. That connector's signing secret verifies the `X-Slack-Signature` HMAC, and a request whose timestamp is more than **5 minutes** off is rejected as a replay.
4. Only after the signature verifies does VisIQ check that the signed button value names this connector's own tenant.
An unknown workspace and an invalid signature return a byte-identical `401`, so the endpoint cannot be used to discover which workspaces have a VisIQ connector.
***
## The buttons a reviewer sees
| Button | What happens |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Approve** | The tool call runs with its full arguments. The Slack message is replaced with a confirmation. |
| **Deny** | The tool never runs; the agent receives the standard block message as the tool's output. |
| **Let VisIQ decide** | Hands the pending item to VisIQ's automated reviewer, which either resolves it or leaves it pending for a human. It can never manufacture an independent approver. This third button is added to the message **only** when automated review is enabled for the agent — otherwise it is not rendered at all. |
Clicking a button on an item somebody already resolved is safe — Slack shows *"This request was already resolved"* rather than double-applying a decision.
***
## Routing approvals to an agent's owner
By default approvals go to the shared channel you configured. Set `owner_email` and a `hitl_pathway` of `slack` on an agent and its approvals are direct-messaged to that person instead, with the shared channel kept as the fail-safe if the DM cannot be delivered. Owner DMs are the feature that needs `users:read.email` and `im:write`; without them the approval still arrives, just in the channel.
***
## The API behind the card
The dashboard is a client of the same endpoints you can drive yourself. All three require a session and the `connectors:manage` permission.
| Method | Path | Body | Purpose |
| -------- | -------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------- |
| `POST` | `/api/connectors/hitl-channels/slack/verify` | `{ config, secret }` | Test candidate credentials. Persists nothing. |
| `PUT` | `/api/connectors/hitl-channels/slack` | `{ enabled?, display_label?, config?, secret? }` | Create or update the connector. |
| `DELETE` | `/api/connectors/hitl-channels/slack` | — | Remove the connector. |
| `POST` | `/api/integrations/slack/interactions` | Slack's signed payload | Slack's callback for button clicks. Public; HMAC-authenticated. |
The `PUT` body is validated strictly — an unrecognised key is rejected, not ignored:
```json PUT /api/connectors/hitl-channels/slack theme={null}
{
"enabled": true,
"display_label": "Security approvals",
"config": {
"team_id": "T0A1B2C3D4E",
"default_channel_id": "C0A1B2C3D4E"
},
"secret": {
"slack_bot_token": "xoxb-…",
"slack_signing_secret": "…"
}
}
```
Two separate conditions are checked before the row is written, and **both** answer `400`:
* **A secret on first configuration.** `secret` may be omitted on a later update to keep the stored credentials, but the first save of a Slack connector is rejected unless a secret is present.
* **A routing key on the effective config.** The write is refused unless the *effective* config — the stored config patched by this body — carries `team_id`, the only key an inbound Approve/Deny click is resolved by. (A legacy one-way `legacy_webhook_url` row passes the same check instead: it has no inbound click path at all, so there is nothing for a team id to route.) The dashboard fills `team_id` in for you when **Test connection** passes; if you drive the API yourself, send it. Omitting it succeeds only when a previous save already stored one — the check runs on the merged config, never on this body alone.
`display_label` is capped at 120 characters.
***
## Troubleshooting
Slack is not reaching VisIQ, or VisIQ cannot identify your workspace. Check, in order: the app's **Interactivity & Shortcuts** page has interactivity **on** with the Request URL pointing at your VisIQ origin's `/api/integrations/slack/interactions`; and that you ran **Test connection** successfully, which is what stores the workspace team id the callback needs.
That is a warning for a **private** channel — run `/invite @VisIQ` in it. Public channels do not need the invite because `chat:write.public` covers them.
The **Channel ID** field wants the id (something like `C0A1B2C3D4E`), not the `#channel-name`. Copy it from the bottom of the channel's details panel.
Your installed app is missing `users:read.email`, `im:write`, or both. Re-install or re-consent the app from the current manifest to pick them up. Approvals keep working in the meantime — they deliver to the channel and the owner's email instead of a DM.
The approval window is capped at 120 seconds, and an unanswered request fails closed. That ceiling is a property of the harness, not of Slack — see [Human-in-the-Loop](/rules/action/hitl) for the pause-and-resume model and the mask fallback.
***
## Related
How approvals pause a tool call, what the queue contains, and the timeout model.
The catch-all channel for agents that have no registered owner.
# Install the Deployed Sensor
Source: https://docs.visiqlabs.com/discovery/deployed-sensor
Install the visiq-discovery sensor persistently on a host or across a managed fleet, with an authenticated download and a verified checksum.
The **Deployed Sensor** is the `visiq-discovery` binary installed to stay: it lives on the host, re-scans on a schedule, and reports what it finds to your Discover dashboard. It is the recommended way to run [Discovery](/discovery/introduction) — on a single machine or across a managed fleet.
It is distinct from an **On-Demand Scan**, which runs the same binary once through a device-management tool's own execution channel and leaves nothing installed. The mnemonic: *the sensor stays, the scan vanishes.*
***
## Before you start
* A **harness key** for your tenant (**Settings → Harness Keys**, or the Connectors page). The same key both authorizes the download and links the scan's findings to your tenant. Without a key the sensor still runs, but only prints a local report.
* Administrative access on the target host — root on Linux and macOS, an elevated shell or SYSTEM on Windows.
**The binary is not code-signed yet.** Apple notarization and Windows Authenticode signing are being provisioned. Until they land the **SHA-256 checksum is the integrity control**, which is why every command below verifies the digest *before* executing the binary — never skip that step. On Defender-hardened Windows fleets you may also need to allowlist the binary by SHA-256 or path, or grant it managed-installer trust: the opt-in ASR prevalence rule and Defender cloud reputation can block a fresh binary regardless of signature. (This is not SmartScreen, which does not fire for a SYSTEM-context download — and Authenticode signing alone would not satisfy the prevalence rule either.)
***
## Install on one host
The dashboard generates this command with your key already spliced in — copy it from **Connectors → Discovery Agent** rather than transcribing it. The shape is:
```bash Linux / macOS theme={null}
export VISIQ_API_KEY=""
sh <<'VISIQ_INSTALL'
set -eu
# Registers what this host discovers to your VisIQ tenant. Everything between
# the markers is plain text to your shell, read by sh - so a failed integrity
# check aborts the INSTALL, not your terminal session.
# Resolve THIS host's release slug; refuse to run the wrong platform's command.
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) plat=linux-x64 ;;
Linux-aarch64|Linux-arm64) plat=linux-arm64 ;;
Darwin-arm64) plat=darwin-arm64 ;;
Darwin-x86_64) plat=darwin-x64 ;;
*) echo "VisIQ: unsupported platform $(uname -s)/$(uname -m) — aborting" >&2; exit 1 ;;
esac
# Resolve a short-lived download URL + its integrity digest (your tenant key authorizes it)
hdr="$(mktemp)"; trap 'rm -f "$hdr"' EXIT
url=$(curl -fsSL -D "$hdr" -H "Authorization: Bearer $VISIQ_API_KEY" "https://api.visiqlabs.com/api/discovery/download/$plat")
sha=$(awk 'tolower($1)=="x-visiq-sha256:"{gsub(/\r/,"",$2);print $2}' "$hdr"); rm -f "$hdr"
# FAIL CLOSED: the locator 503s rather than answer without a digest, so a missing
# URL or digest means the response was tampered with — never fetch or run unverified.
case "$url" in https://*) ;; *) echo "VisIQ: locator did not return an https download URL — aborting" >&2; exit 1;; esac
[ -n "$sha" ] || { echo "VisIQ: no integrity digest (X-Visiq-Sha256 missing) — aborting" >&2; exit 1; }
curl -fsSL -o visiq-discovery "$url"
# Verify the binary BEFORE running it; abort on a checksum mismatch.
# Pick ONE sha256 tool up front, then run exactly one check.
if command -v sha256sum >/dev/null 2>&1; then
printf '%s %s\n' "$sha" visiq-discovery | sha256sum -c - || { echo "VisIQ: checksum verification FAILED — aborting" >&2; exit 1; }
elif command -v shasum >/dev/null 2>&1; then
printf '%s %s\n' "$sha" visiq-discovery | shasum -a 256 -c - || { echo "VisIQ: checksum verification FAILED — aborting" >&2; exit 1; }
else
echo "VisIQ: no sha256 tool (sha256sum/shasum) available — aborting" >&2; exit 1
fi
chmod +x visiq-discovery
./visiq-discovery scan
VISIQ_INSTALL
```
The verification **fails closed**: it aborts if the locator returns no digest, if the download URL is not `https:`, if no SHA-256 tool is present, or if the bytes do not match. A `200` from the locator always carries the digest — it answers `503` rather than serve without one — so a 200 with no digest means the response was tampered with.
On **macOS** add `xattr -d com.apple.quarantine visiq-discovery 2>/dev/null || true` before `chmod`. A binary fetched with `curl` never carries the quarantine attribute, so Gatekeeper does not block it — the command strips the attribute defensively anyway, which matters if the file ever arrives by another route.
On **Windows**, use the PowerShell form the dashboard generates. It enables TLS 1.2 first (Windows PowerShell 5.1 on older images omits it and every HTTPS call fails before anything else), passes `-UseBasicParsing` (required under the SYSTEM/Server Core hosts that device-management tools run), verifies the digest with `Get-FileHash`, and runs `Unblock-File` before the scan.
**Pass the key as an environment variable, never as a command-line flag.** Anything on the command line is visible in the process list and lands in shell history. Every command VisIQ generates exports `VISIQ_API_KEY`; there is no `--key` option, on purpose.
Confirm it worked: `./visiq-discovery --version` prints the sensor version, and a successful tenant report ends with `visiq-discovery: reported to tenant (id …)`. If that line is absent the scan stayed local — the key did not reach the process.
***
## Pick the right platform
The download path ends in a platform slug. Requesting one VisIQ does not publish returns `400` with the valid list.
| Slug | Binary |
| -------------- | --------------------------------- |
| `linux-x64` | `visiq-discovery-linux-x64` |
| `linux-arm64` | `visiq-discovery-linux-arm64` |
| `darwin-x64` | `visiq-discovery-darwin-x64` |
| `darwin-arm64` | `visiq-discovery-darwin-arm64` |
| `windows-x64` | `visiq-discovery-windows-x64.exe` |
***
## Roll it out to a fleet
The same command hardens into a script your device-management console pushes. It installs to a stable path, runs as root or SYSTEM, and is **idempotent** — safe to re-run on every check-in, because it downloads only when the binary is absent and re-scans every time.
The dashboard carries console-specific walkthroughs for Microsoft Intune, Jamf Pro, Kandji, Group Policy, Configuration Manager, Workspace ONE, Ansible, JumpCloud and NinjaOne, each with the exact field to paste into and that console's own pitfalls. Two rules hold across all of them:
* **Assign to a device group, never a user group.** A root/SYSTEM install targeted at users silently no-ops.
* **Exit 0 and stay idempotent.** Consoles read a non-zero exit as a failed deployment and will retry.
### Microsoft Intune, the persistent way
On Windows the robust path is a **Win32 managed app** — the app model is what makes the sensor persistent and self-healing, rather than a one-shot script:
1. **Package it.** Wrap `install.ps1` and `uninstall.ps1` with the Microsoft Win32 Content Prep Tool (`IntuneWinAppUtil.exe`) into a single `.intunewin`.
2. **Create the Win32 app** under **Apps → Windows → Add → Windows app (Win32)**. Install command `powershell.exe -ExecutionPolicy Bypass -File install.ps1`, uninstall command the same with `uninstall.ps1`, and on the Program page set **Install behavior = System**.
3. **Add a detection rule** — for example the presence of the run-scan wrapper under `%ProgramData%\VisIQ\discovery`, or the *VisIQ Discovery Sensor* scheduled task. Without one, Intune re-offers the installer in a roughly 24-hour loop.
4. **Assign it to an Entra device security group as Required**, starting with a pilot ring. The sensor installs on the next device check-in and self-heals on every scheduled scan after that.
The `.intunewin` should be Authenticode-signed or managed-installer-trusted before a broad rollout — see the signing caveat above.
For **macOS and Linux**, Intune runs the bootstrap script instead, and the details differ per platform in ways that bite:
| Platform | Where it lives in Intune | Run context | Cadence |
| ------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Windows** (script path) | Devices → Manage devices → Scripts and remediations → Platform scripts → Windows 10 and later | Set *Run this script using the logged on credentials* = **No**. The default is Yes, so you must change it. | Runs once, with retries — not on every check-in. |
| **macOS** | Devices → macOS → Manage devices → Scripts (its own node, **not** under Platform scripts) | *Run script as signed-in user* = No is already the default, so it runs as root. | Runs once unless you set a script frequency. Check-in is roughly every 8 hours. |
| **Linux** | Devices → Manage devices → Scripts and remediations → Platform scripts → Linux | Set *Execution context* = **Root**. The default is User, which only runs when somebody signs in. | Recurring only — the default frequency is every 15 minutes, so idempotency matters. |
Both script forms must begin with a `#!` shebang on macOS and Linux.
Intune's Linux support is desktop-only and narrow — corporate-enrolled Ubuntu Desktop and RHEL on x86-64, GNOME, no servers and no ARM. For Linux servers or an agentic-AI fleet, install the Deployed Sensor directly with a configuration-management tool instead.
***
## How the authenticated download works
The binary lives in a **private bucket**. There is no public download and no public GitHub release — the same pattern commercial endpoint vendors use.
```http theme={null}
GET /api/discovery/download/
Authorization: Bearer
```
A successful call returns **`200` with a short-lived pre-signed URL as the plain-text body**, plus these headers:
| Header | Meaning |
| ------------------------- | ---------------------------------------------------------------------------------- |
| `X-Visiq-Sha256` | The digest of the binary the URL will serve. Verify against this before executing. |
| `X-Visiq-Filename` | The released asset name for the platform. |
| `X-Visiq-Version` | The exact version being served. |
| `X-Visiq-Release-Channel` | The channel the version was resolved from. |
| `X-Visiq-Url-Ttl-Seconds` | How long the pre-signed URL stays valid. |
The heavy bytes come straight from object storage and are never proxied through VisIQ, and your `Authorization` header is never forwarded to the storage host — the pre-signed URL is the only credential it sees.
**The digest and the bytes cannot disagree.** VisIQ resolves the channel pointer to a version, then reads both the checksum manifest and the binary from that *immutable* version prefix. A release published mid-download cannot leave you verifying one build and running another.
Two optional, validated query parameters let a self-updating agent fetch exactly what its policy targets:
* `?version=` — pin an immutable version.
* `?channel=` — take a specific channel's head instead of the default.
Anything path-unsafe is rejected with a `400` rather than reaching storage.
| Status | Meaning |
| ------ | ---------------------------------------------------------------------------------------------------------- |
| `200` | Pre-signed URL in the body, digest in the headers. |
| `400` | Unknown platform, or an invalid `version` / `channel`. |
| `401` | Missing or invalid harness key. |
| `503` | The release could not be resolved. VisIQ fails closed here — it never returns a fabricated or partial URL. |
***
## What happens after the scan
The sensor performs a read-only scan and posts one structured report per host to your tenant, where it appears under **Discover → Findings**. Re-run `visiq-discovery scan` any time to refresh a host's inventory; a fleet install does this on its own schedule.
See [Discovery](/discovery/introduction) for what each scanner detects and the read-only guarantees the sensor holds to.
# Discovery
Source: https://docs.visiqlabs.com/discovery/introduction
Discover every custom AI agent, MCP server, AI framework, and tool in your environment — plus local models, coding agents, and shadow AI — before you decide what to govern.
Discovery is evolving — the sensor and findings model are still changing, and surfaces may change between releases.
You can't govern what you can't see. Discovery answers the first question of any AI governance program — *what agentic AI is actually running in my environment?* — with a lightweight endpoint sensor that scans each host and reports what it finds to your Discover dashboard.
***
## What the sensor finds
Each scan runs seven scanners and emits one structured report:
| Scanner | What it detects |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Agent frameworks** | npm and pip installs classified against a curated agentic taxonomy — orchestrators (LangGraph, CrewAI, AutoGen, Haystack, DSPy, smolagents), agent SDKs (OpenAI Agents SDK, Mastra, Pydantic AI, Claude Agent SDK, LlamaIndex Agents), agent runtimes, memory backends, and tool routers. |
| **MCP servers** | Servers declared in client configs (Claude Desktop, Cursor, Windsurf, VS Code, Zed) and visible in the process list, with their tool surface read by static introspection. |
| **Local model runtimes** | The Ollama daemon and binary, plus HTTP probes for LM Studio, vLLM, llama.cpp, GPT4All, and text-generation-webui. |
| **Coding & CLI agents** | Claude Code, Cursor, Windsurf, Aider, and OpenClaw installed on the host. |
| **Shadow AI** | Stray provider API keys in environment files (OpenAI, Anthropic, Google, Groq, Hugging Face), AI browser extensions, and AI IDE extensions and plugins. |
| **Data-store reachability** | Which vector stores (Chroma, Pinecone, Weaviate, Qdrant, Milvus, LanceDB, FAISS), SQL and NoSQL databases, caches, and object stores the host's code can reach — from SDK dependencies, config files, and connection-string environment variables. |
| **Code projects** | Which individual code projects on the host build agents, and which of those already have the VisIQ harness installed — so governance coverage is measured per project, not just per machine. This is the one scanner that looks inside developer directories; see [Code-project scanning](#code-project-scanning) below for exactly what it reads and reports. |
The framework taxonomy separates signal from noise: a bare LLM SDK or a local model runtime only counts as agentic when it's linked to a genuine agent anchor on the same host, so a plain RAG dependency doesn't light up your fleet as autonomous agents.
Static scanning of model artifacts and pickle opcodes is **not** part of any scan. It is deferred roadmap work, and the scanner is unwired from the sensor — it never runs.
***
## Code-project scanning
The **Code projects** scanner is the only one that traverses developer directories, so it is worth being precise about what it does. It is **on by default** and can be turned off per organization (**Settings → Discovery → Project-level governance detection**) or at deploy time.
**Where it looks.** Under each scanned user's home directory: the conventional code roots `code`, `git`, `work`, `projects`, `src`, `dev`, `repos`, `Developer`, and `www` (to a depth of 6), plus the home directory itself (to a depth of 4). The traversal is bounded — at most 12,000 directories per scan and a 20-second deadline — and stops early rather than running long. If it runs out of budget, the report records the truncation instead of silently claiming full coverage.
**What it reads.** Dependency manifests only — `package.json`, `requirements.txt`, `pyproject.toml`, `go.mod`, `Gemfile`, `Cargo.toml`, `pom.xml`, Gradle files — and the installed package metadata beside them. **Your source code is never read, parsed, or transmitted.**
**What it reports.** A project is included only if it has at least one anchor-class agent framework or the VisIQ harness. A project that merely imports an LLM SDK is not reported at all. For each included project the sensor sends:
* the project directory path and the paths of the manifests it matched,
* the ecosystems in use (npm, pip, maven, gradle, go, gem, cargo),
* **only** the dependency names that match the agentic-framework taxonomy, and **only** the VisIQ harness package names — no other dependency name is ever serialized,
* whether the VisIQ harness is declared and whether it is actually installed,
* the owning local account (uid/SID and username) and install scope.
**Turning it off.** Toggle **Project-level governance detection** off in Discovery settings, or set `VISIQ_PROJECT_SCANNING=false` in the sensor's environment at deploy time — the environment variable is a hard opt-out that wins over the tenant setting. One caveat worth knowing: because this scanner is read-only detection, the sensor resolves an unreachable or unanswered settings lookup as **enabled**, the opposite of the harness auto-install gate, which fails closed. If you need the setting to hold on an endpoint regardless of connectivity, use the environment variable rather than the tenant toggle.
***
## Read-only by design
The sensor observes; it never runs what it finds.
* **Discovered MCP servers are never executed.** Their tool surface is read by static analysis of the on-disk package. When a server's tools can't be read statically (HTTP transport, dynamic registration, minified code), it's reported as an *unreadable tool surface* — a visibility finding — rather than launched to find out.
* **Package managers are never invoked.** Framework detection is a static walk of `node_modules` and Python `site-packages` directories — no `npm` or `pip` subprocesses.
* **Secrets never leave the host.** A detected API key is reported as a SHA-256 fingerprint of its value, never the key itself.
* **Coverage is honest.** The report records per-scanner coverage, so "zero findings" is distinguishable from "couldn't look." A degraded scan surfaces as *Needs coverage* — it never silently reads as safe.
***
## Two ways to deploy
The sensor ships as a single self-contained binary per platform — Linux (x64, arm64), macOS (Intel, Apple silicon), and Windows (x64) — with zero runtime dependencies. No Node, Python, or agent framework is required on the endpoint.
**Recommended.** The sensor installed persistently as a managed app through your device-management platform. It scans on the schedule you set in Discovery settings and can update itself in place with `visiq-discovery update`. **[Install it →](/discovery/deployed-sensor)**
The same binary run once through your existing endpoint tooling. Nothing stays installed on the host, and the scan schedule is whatever your own tooling defines.
Both approaches deliver the identical payload and produce the identical report. To get started, open **Integration → Connectors** in the dashboard and pick the **VisIQ Discovery Sensor** card — it's included with every plan and offers a fleet-rollout walkthrough, a single-host CLI install, or a copy-paste prompt your coding agent can run for you. The generated commands resolve a short-lived, authenticated download URL for your platform and verify the binary's published SHA-256 digest before it ever runs.
The sensor authenticates to your tenant with a harness key: with `VISIQ_API_KEY` and `VISIQ_ENDPOINT` set it reports each scan to your dashboard; without them (or with `--no-report`) it runs local-only and prints the JSON report.
***
## Where results land
Scan results power the **Discover** section in the dashboard sidebar:
* **Reach Map** — every agentic surface in your fleet, arranged by how far VisIQ governance can reach it today.
* **Inventory** — one row per scanned host, with the custom AI agents, MCP servers, AI frameworks, and tools found on each.
* **Findings** — one row per detected risk factor per host, with severity, evidence, and remediation status.
***
## How risk is scored
Every host gets a 0–100 risk score from an **additive point budget**: each exposure signal the sensor looks for is a catalog factor with a fixed weight, the weights sum to exactly 100, and a host's score is simply the sum of the factors detected on it. No opaque formula — the score always reconciles against the factor grid you see in the dashboard.
| Factor | Dimension | Points |
| ------------------------------------------------------ | -------------- | ------ |
| Live tool surface (MCP tools the agent can invoke) | Action surface | 30 |
| Agentic host (an agent framework installed and active) | Autonomy | 18 |
| Coding / CLI agent | Action surface | 14 |
| Running agent (live right now, not latent) | Autonomy | 14 |
| Framework concentration (3+ frameworks stacked) | Reach | 10 |
| Unreadable tool surface | Visibility | 8 |
| Incomplete coverage | Visibility | 6 |
Scores band as **Low** (0–39), **Medium** (40–64), **High** (65–84), and **Critical** (85–100). A host the sensor couldn't fully assess lands in a fifth lane, **Needs coverage**, which sorts above Low — degraded visibility is itself a finding, never a clean bill of health.
Findings map 1:1 to these factors, and remediation is derived automatically from scan history: when a factor stops appearing in a host's latest scan, its finding flips to *remediated* on its own. Nobody has to close tickets by hand.
***
## Settings
Discovery is configured per organization under **Settings → Discovery**:
* **Auto-install harness plugin** — off by default. When enabled, deployed sensors may automatically install the VisIQ harness plugin into supported agent tools they detect (such as OpenClaw), wiring up runtime governance with no manual step. VisIQ never modifies your agents unless you opt in.
* **Project-level governance detection** — **on by default.** Enables the Code projects scanner described in [Code-project scanning](#code-project-scanning): a bounded walk of conventional developer directories that reads dependency manifests (never source code) to report which projects build agents and which already have the VisIQ harness. Turn it off here, or set `VISIQ_PROJECT_SCANNING=false` at deploy time for an opt-out that does not depend on the endpoint reaching the control plane.
* **Scan frequency** — how often the Deployed Sensor scans each endpoint: hourly, every 6 hours, every 12 hours, daily (the default), or weekly. This governs only the Deployed Sensor; an On-Demand Scan runs on your own tooling's schedule.
* **Harness status** — how many discovery-installed harnesses are registered for your tenant and when one last reported.
***
## Next steps
Put the agents Discovery found under governance with one `visiq()` call.
How tool-call authorization works once an agent is harnessed.
# API Examples — Notebooks
Source: https://docs.visiqlabs.com/examples/notebooks
Runnable Jupyter notebooks that do real data science over your governance data — including an independent, from-scratch re-verification of the transparency log.
Two standalone, **off-platform** notebooks built on the VisIQ management API — the kind of
cross-cutting, statistical, cryptographic analysis you would deliberately *not* bake into the
product UI. They run against the read-only **sandbox showcase** org, so you can explore real seeded
governance data (or, for the transparency notebook, run with **no credentials at all**).
Re-derive VisIQ's transparency log **from scratch** — recompute every Merkle root, hash-chain
link and Ed25519 signature yourself, trusting none of the product's own flags. **Runs with zero
credentials** (demo mode).
Exploratory data science over the harness corpus — a business-function atlas, a rule-attribution
meta-audit, an egress graph and volume seasonality. Needs a sandbox-reader key.
## Run them
A fully static **JupyterLite** build runs the notebooks entirely in your browser via Pyodide
(WASM Python) — no install, no server, no account, and for the transparency notebook **no
credentials**. It is live below — give it a few seconds to boot, then choose **Run → Run All
Cells** to re-derive every Merkle root, hash-chain link and Ed25519 signature yourself:
The scientific stack (`numpy`, `pandas`, `matplotlib`, `networkx`, `cryptography`) loads on first
run; the notebook's Ed25519 known-answer self-test passes in-browser. Prefer a full-screen tab, or
want the exploratory notebook?
The transparency-log verifier in a full JupyterLab tab — the complete Ed25519 verification, still with zero credentials.
The exploratory notebook — set a sandbox-reader key inside to pull real seeded data.
The embed runs everything client-side (Pyodide/WASM) — nothing you type or run leaves your
browser. Nothing to install; or use **Run locally** below.
Download both notebooks straight from the public build — no repo access needed:
```bash theme={null}
curl -O https://visiq-notebooks.vercel.app/files/visiq_transparency_log_verification.ipynb
curl -O https://visiq-notebooks.vercel.app/files/visiq_agent_governance_eda.ipynb
```
```bash theme={null}
pip install -r requirements.txt
```
```bash theme={null}
jupyter lab visiq_transparency_log_verification.ipynb
```
The transparency notebook runs immediately in **demo mode**. To point either notebook at real
seeded data, set a sandbox-reader key first (below).
## Get a sandbox-reader key
A **read-only sandbox-reader** key resolves to the shared exemplar showcase tenant and is clamped
read-only — it can never reach a real tenant. Mint one, then:
```bash theme={null}
export VISIQ_API_KEY="vq_prod_…" # a read-only sandbox-reader key
# optional — pick a specific exemplar org (default: Northwind, the ~1M-event whale)
export VISIQ_SANDBOX_VENDOR_ID="00000000-0000-0000-0000-00000000d301"
```
With a key set, the transparency notebook verifies the **real** VisIQ ledger and the EDA notebook
reads the real seeded decision logs. Without one, the transparency notebook falls back to a
cryptographically self-consistent demo ledger.
***
## Transparency-log verification
VisIQ commits every governed decision into an append-only [transparency log](/record/introduction):
a Merkle-batched, hash-chained, Ed25519-signed ledger. This notebook **ignores every `verified` flag
the API returns** and re-derives the guarantees itself with \~40 lines of `hashlib` + `cryptography`.
What it checks, independently of the server:
* **Hash-chain linkage** — recompute `chain[k] = SHA256(0x02 ‖ chain[k−1] ‖ root[k])`, verify `prevRoot`
links and gap-free `batchSeq`. A single altered or deleted batch is caught.
* **Root signatures** — Ed25519-verify each checkpoint's root under its returned key.
* **Receipt inclusion** — replay each retrieval receipt's Merkle proof **and require its root to be one we
signature-verified** (so a forged "proof" to an unsigned root is rejected).
**Honest trust boundary.** The notebook is explicit about what a read client *cannot* verify and
records as server attestation, never as proven: the **identity** of the signing key (pin VisIQ's
published Ed25519 key out-of-band), the **RFC-3161 timestamps** (the token bytes are never exposed),
and the **KMS / RECORD-inclusion** legs (they need credentials or data a read client lacks). It also
ships a known-answer self-test and a tamper demonstration that proves the verifier genuinely checks.
## Governance exploratory analysis
Exploratory data science over the harness corpus: a business-function → action atlas, a
rule-attribution **meta-audit** (which honestly flags that seeded `rule_id`s are noise), an egress
graph, and STL volume seasonality. It reads the paginated decision logs
(`/v1/allow/audit-log`, `/v1/recall/audit-log`) plus `/allow/agents` and `/rules`, so it needs a
sandbox-reader (or management) key — set `VISIQ_API_KEY` as above.
This is a **research** notebook over **deterministic synthetic** sandbox data. Each analysis flags
what is genuinely learnable (business-function ↔ action correlation, action-risk ↔ outcome skew,
volume seasonality) versus what is noise-by-construction (`rule_id`, `latency_ms`, `target_app`).
There are no token/cost/model fields — cost analytics are not viable on this schema.
# Glossary
Source: https://docs.visiqlabs.com/glossary
One place for VisIQ's vocabulary — governance outcomes, operation facets, agent modes, trust tiers, and the audit-trail primitives (record envelopes, Merkle checkpoints, the transparency log) and key audiences.
VisIQ's docs use a dense, precise vocabulary. This page defines every load-bearing
term in one place. Each entry links to the reference where the concept is used in
depth.
***
## Governance outcomes
A single rule engine evaluates every event and resolves it to exactly one
outcome. The same verb set spans both operation facets, though a few verbs only
make sense on one facet (a document can't pause a human; a tool call can't be
"redacted" the way a document is). See the full behavior in the
[SDK Reference](/reference#governance-outcomes).
| Outcome | Applies to | Meaning |
| ------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `permit` | action | The tool call runs unchanged. On the retrieval facet the passthrough verb surfaces as `allow` — the document enters the context window untouched. |
| `deny` | action · retrieval | The tool call never runs (a block message is returned as its output), or the document is silently excluded from results. |
| `approval_required` | action | The call pauses while a human approves or rejects it over Slack or email (Microsoft Teams coming soon). Times out fail-closed. |
| `mask` | action | The tool runs, but the named sensitive arguments are redacted first (outbound argument masking). |
| `redact` | retrieval | The document passes through with its sensitive fields and patterns masked in place. |
| `escalate` | retrieval | The access is recorded for human review. Retrieval is synchronous and never pauses, so the rule chooses whether the document passes through or comes back masked in the meantime. |
Two verbs name the same idea per facet: an action that proceeds is `permit`; a
document that proceeds is `allow`. The unified engine treats them as the one
"passed unchanged" outcome, split only by which facet produced it.
***
## Operation facets
Every event carries a multi-valued `operations[]` tag describing what the agent
is trying to do. A rule can target one facet or several, and a single hybrid tool
(one that both reads and writes) carries more than one facet at once.
| Facet | What it governs |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `action` | What an agent can **do** — every tool call is gated before it executes. |
| `retrieval` | What an agent can **see** — every retrieved document is filtered before it reaches the model's context window. |
| `delegation` | When an agent hands work to another agent or sub-agent — the hand-off itself is a governable event. |
A tool that both retrieves and mutates (for example `retrieve_and_archive`) is a
hybrid: one decision tagged `['retrieval', 'action']` governs both legs at once —
the action side gates the call, the retrieval side filters what comes back.
***
## Agent modes
Every agent runs in exactly one mode. The mode is **server-authoritative** —
resolved on the backend and shipped to the SDK inside the rule bundle, where a
running agent picks up a change within seconds. Set it on the **Harness → Agents**
page, not in SDK config. See [Agent modes](/reference#agent-modes).
| Mode | Behavior |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `monitor` | **The default.** Every event is evaluated and the would-be decision is recorded, but nothing is blocked, masked, or paused. Observe first, enforce when confident. |
| `enforce` | Decisions are enforced — denials block, masks redact, approvals pause. |
| `off` | Evaluation is skipped entirely: no enforcement and no telemetry. |
A new agent is auto-provisioned in `monitor` on first contact. Each agent's mode
can **inherit an org-wide default** or be **overridden per agent** — and even
pinned per operation facet via `mode_by_operation` (for example, enforce actions
while keeping retrievals in monitor).
***
## Trust tiers
Every agent is assigned a **trust tier** that, combined with its **business
function**, drives the seeded need-to-know defaults (a curated catalog of
35 default rules). Tiers run from most to least trusted:
| Tier | Meaning |
| ------- | ----------------------------------------------------------------------- |
| `tier1` | Highest trust — the broadest need-to-know. |
| `tier2` | Medium trust — access to sensitive categories is escalated or masked. |
| `tier3` | Restricted — no access to categories the agent has no need-to-know for. |
The matrix is need-to-know: an agent that needs a data category for its function
gets it at its tier; an agent with no need-to-know never sees raw values. See
[Retrieval Governance](/rules/retrieval/introduction).
***
## Audit-trail primitives
Every decision emits tamper-evident evidence automatically — no SDK import, no
extra call. These terms describe how a raw decision becomes independently
verifiable. See [Audit Trail](/record/introduction) and
[Decision Receipts](/record/receipts).
| Term | Definition |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Record envelope** | The canonical record of one decision — its `source` (`action` / `retrieval` / `hitl`), the `event` that happened, and the `attestation` metadata. Persisted server-side off the request path, then attested. |
| **Ed25519 receipt** | The envelope's `event` is canonicalized, hashed, and asynchronously signed with an Ed25519 key. The receipt (signature, public key, payload hash) is stored separately from the envelope it attests. |
| **Merkle batch / checkpoint** | A background worker gathers unbatched receipts and commits their leaf hashes to a single **Merkle root**. Each signed batch is a **checkpoint** that commits to the previous batch's root, forming an append-only chain. |
| **Transparency log** | The hash-chained sequence of checkpoints, so deleting, reordering, or backdating any batch breaks the chain detectably for anyone holding an earlier checkpoint. Publishing those checkpoints to write-once (WORM) object storage as an external witness — the leg that would extend "detectably" to *even for VisIQ* — is supported but **off by default and not enabled on the hosted service today**. |
| **RFC 3161 timestamp** | An independent timestamp authority (DigiCert) countersigns each Merkle root, attesting *when* the batch existed — a third-party clock the operator does not control. |
| **Content hash** | A hash over the envelope's `event`, pinned by the database in a generated column the application cannot write at ingest time. Proves the record was not edited even in the window between ingest and signing. |
***
## Key audiences
VisIQ issues API keys in two **audiences**. Audience is fixed at creation and is
not derivable from the key string — the `vq_prod_` / `vq_test_` prefix encodes
the **environment**, not the audience. See
[Authentication](/authentication) and [Managing API keys](/automation/api-keys).
| Term | Definition |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Harness key** | A runtime credential for the SDK / harness / agents. Confined to the SDK operational endpoints (evaluation, rule bundles, HITL polling, telemetry, record ingestion, agent registration, discovery reporting); not permission-scoped. Minted under **Settings → Harness Keys**. |
| **Management key** | Also called an **automation key** — a bearer credential for the management API (rules, agents, audit log, settings). Carries an explicit `resource:action` permission list chosen at creation. Minted under **Settings → API Keys**, or via the agent device flow. |
A harness key that calls a management endpoint is rejected with
`403 harness_key_not_permitted`; a management key is a superset and may also reach
the runtime endpoints its permissions cover.
***
## See also
Outcomes, framework detection, agent modes, and local evaluation in depth.
Key audiences, permissions, rotation, self-revoke, device flow, and MCP.
# VisIQ — AI Agent Governance SDK
Source: https://docs.visiqlabs.com/introduction
Control what your AI agents can do, see, and prove they did. One import, one function call.
AI agents call tools and retrieve context with no runtime governance. VisIQ fixes that in two lines of code.
```diff theme={null}
+ import { visiq } from "@visiq/harness";
- const executor = new AgentExecutor({ agent, tools });
+ const executor = visiq(new AgentExecutor({ agent, tools }));
```
That's the entire integration. Your `executor.invoke()` calls work unchanged — but every tool call and every retrieved document now flows through policy evaluation, and every decision lands in a signed audit trail.
The SDK ships for **TypeScript** (`@visiq/harness`) and **Python** (`visiq`).
From a language without an SDK, call the REST API directly — see the
[action governance API reference](/rules/action/api-reference).
***
## What activates
Controls what agents can **do**. Every tool call is evaluated against your rules before it executes — permit it, deny it, mask sensitive arguments and proceed, or route it to a human for approval over Slack or email (Microsoft Teams coming soon).
Controls what agents can **see**. Every retrieved document is evaluated before it reaches the agent's context window — allowed, denied, redacted field-by-field, or escalated to a human.
Proves what agents **did**. Every decision emits a signed record envelope — Ed25519 receipts, Merkle-batched into a hash-chained, checkpoint-signed transparency log with RFC 3161 timestamps.
All three activate from the single `visiq()` call. There is no additional code, configuration, or per-tool wrapping — and you're covered before you write your first rule: every tenant starts with a curated catalog of 35 default rules built on a business-function × trust-tier need-to-know matrix.
**Monitor first, enforce when confident.** A new agent is auto-provisioned in
**monitor** mode on its first contact: every decision is evaluated and
audited, but nothing is blocked yet. Each agent's mode **inherits an org-wide
default** (`monitor` out of the box) or is overridden per agent — even per
operation — on the dashboard's **Harness → Agents** page, and the SDK picks up
the change within seconds. Zero-disruption rollout is the default, not an
afterthought.
***
## How it works
The harness inspects the target object. It recognizes LangChain `AgentExecutor` and LangGraph `CompiledGraph`, plus agent instances from the **Vercel AI SDK**, **Mastra**, the **OpenAI Agents SDK**, **LlamaIndex.TS**, **VoltAgent**, and a **Semantic Kernel** `Kernel` (the community npm JavaScript port; Microsoft's official Python SK is governed by the [`visiq` PyPI package](https://pypi.org/project/visiq/), .NET is not covered) — or a standalone tool object. CLI agents are covered by dedicated harnesses for [OpenClaw](/quickstart/openclaw) and [Claude Code](/quickstart/claude-code).
Framework callbacks can't block — in LangChain, a throwing callback is logged and the tool runs anyway. So VisIQ wraps each tool's dispatch method (`invoke`/`call`/`_call`, or `execute`) in place, where a deny actually stops execution — the streaming path is governed identically. Each proposed call is evaluated in-process against a locally cached rule bundle: once the first bundle loads, the decision path makes no network round-trip — waiting on a human approval is the only exception.
The harness finds your retrievers and document-returning tools and instruments them. Every returned document passes through policy evaluation — content and metadata — before the agent sees it.
Each decision emits a record envelope. Receipts are Ed25519-signed asynchronously, Merkle-batched into a hash-chained checkpoint log, and timestamped by an RFC 3161 authority. Verify any envelope via `GET /record/envelopes/{id}/verify`.
***
## Install
```bash theme={null}
npm install @visiq/harness
```
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
```
Mint a harness key in the dashboard under **Settings → Harness Keys** (`vq_prod_...` for production, `vq_test_...` for everything else). The harness reads `VISIQ_API_KEY`, `VISIQ_ENDPOINT`, and (optionally) `VISIQ_AGENT_ID` from environment variables automatically; options passed to `visiq()` take precedence. With just a `VISIQ_API_KEY` the harness reaches the managed SaaS backend, loads a rule bundle, and governs automatically. If you don't set an agent id, the SDK derives one from your package name (then hostname), and the backend auto-provisions it in monitor mode on first contact.
The endpoint defaults to `https://api.visiqlabs.com`, so a bare
`VISIQ_API_KEY` is enough to reach SaaS, load a bundle, and be governed.
Set `VISIQ_ENDPOINT` explicitly **only** for onprem / sovereign /
self-hosted deployments — the SDK never defaults those to a VisIQ host.
(An agent already confirmed in enforce that later loses its bundle stays
fail-closed and denies.)
***
## Next steps
Full working example with tools and a retriever — 5 minutes.
Complete `visiq()` API — options, framework detection, error behavior.
# OEM Partner API
Source: https://docs.visiqlabs.com/partners/oem-api
Provision, meter, and manage the sub-tenant orgs embedded in your product — authentication, endpoints, usage reconciliation, lifecycle, and webhooks.
The OEM Partner API lets an embedding partner provision and operate the VisIQ
sub-tenant organizations inside their own product, entirely programmatically.
It is the machine surface behind the OEM engagement model: create a governed
tenant per end customer, read reconciliation-grade usage, manage keys /
entitlement / branding, and offboard cleanly.
**Base URL** — `https://api.visiqlabs.com/v1/partners/oem`
Every response carries the header **`X-VisIQ-Partner-Api-Version: 2026-07-11`**.
See [versioning & deprecation](/partners/oem-versioning) for the compatibility
contract. To exercise the whole flow without touching billing, use a test-mode
key — see the [sandbox guide](/partners/oem-sandbox).
## Authentication
Authenticate every request with your partner **provisioning key** as a bearer
token:
```http theme={null}
Authorization: Bearer vqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
A provisioning key (`vqp_…`) is **not** a vendor API key and never authenticates
the governance data plane. It is scoped to `oem:provision`, tied to your active
OEM partner account, and carries a **mode** (`live` or `test`). Keys are stored
hashed at rest and shown once at mint time.
Failures are fail-closed and give no oracle:
| Condition | Response |
| --------------------------------------------------- | ----------------------------------------------------------------------- |
| Missing / malformed / revoked / wrong-scope key | `401 { "error": "unauthorized" }` |
| Rate limit exceeded | `429 { "error": "rate_limited", "retryAfter": }` |
| An id that is not one of your (mode-scoped) tenants | `404 { "error": "not_found" }` |
| Invalid body / query | `400 { "error": "invalid_request" \| "invalid_period", "detail": "…" }` |
Rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`)
accompany throttled responses.
## Provisioning
### Create a sub-tenant
```http theme={null}
POST /tenants
{ "companyName": "Acme Robotics", "externalRef": "crm-8842" }
```
Returns `201` with the new tenant id and the sub-tenant's operational harness
API key **once** (`apiKey`, never retrievable again). `externalRef` is echoed
back for your bookkeeping. A duplicate company name (per partner, per mode)
returns `409 tenant_exists`; the per-partner tenant cap returns
`409 tenant_cap_reached`.
```json theme={null}
{ "tenantId": "…", "apiKey": "vq_prod_…", "apiKeyId": "…", "isSandbox": false }
```
### List sub-tenants
```http theme={null}
GET /tenants
```
Returns your sub-tenants (mode-scoped) with lifecycle `status`
(`active` · `suspended` · `closed`).
## Usage & reconciliation
Usage is counted with the **same rules the royalty statement generator uses**
(governed action and retrieval audit events), valued at your current
contract-year list snapshot when present, else the platform list rate.
```http theme={null}
GET /usage?period=2026-07
GET /tenants/{id}/usage?period=2026-Q3
```
`period` accepts `YYYY-MM` or `YYYY-Qn`, or pass an explicit `start` & `end`
ISO pair (window bounded to 400 days). The aggregate response also reports your
commit-pool drawdown state:
```json theme={null}
{
"period": "2026-07",
"tenants": [
{ "tenantId": "…", "companyName": "Acme Robotics",
"events": { "allow": 812345, "recall": 40122, "total": 852467 },
"listValueCents": 10656 }
],
"totals": { "events": { "allow": …, "recall": …, "total": … }, "listValueCents": … },
"commit": { "commitTotalCents": 2500000, "drawnDownCents": 640000,
"poolRemainingCents": 1860000, "royaltyRatePct": 12 }
}
```
## Statements
```http theme={null}
GET /statements # list (newest first, paginated: limit/offset)
GET /statements/{id} # full JSON incl. per-tenant lines + drawdown columns
GET /statements/{id}/csv # a per-tenant CSV (one row per sub-tenant + a TOTAL row)
```
The JSON is the machine-readable contract. The CSV is a flat per-tenant export
with the header row:
```
tenant,usage_events,usage_value_cents,royalty_cents
```
one row per sub-tenant plus a final `TOTAL` row (whose `royalty_cents` is the
statement's metered royalty). All statement money fields — in both the JSON and
the CSV — are integer cents.
## Lifecycle
```http theme={null}
PATCH /tenants/{id} { "action": "suspend" | "resume" | "close" }
```
`suspend` reversibly blocks the sub-tenant's traffic; `resume` clears it.
`close` is **terminal** (Twilio suspend → close → purge pattern): it stamps
`closedAt`, sets a 30-day `purgeAfter` grace window, and suspends the tenant if
it was not already. Any action on a closed tenant — and any *create* against it
(keys, simulated usage) — returns `409 tenant_closed`. After the grace window a
nightly reaper hard-deletes the sub-tenant and its audit rows.
### Offboarding export
Extract the governance evidence before purge:
```http theme={null}
GET /tenants/{id}/export?limit=10000
GET /tenants/{id}/export?cursor=a:
```
Streams newline-delimited JSON (`application/x-ndjson`): a `tenant` metadata
line (first page only), then `allow_audit` and `recall_audit` rows in stable id
order. Follow the **`X-VisIQ-Export-Cursor`** response header until it is empty.
Page cap is 10,000 rows.
## Sub-tenant keys
```http theme={null}
POST /tenants/{id}/keys # mint an additional harness key (returned once)
GET /tenants/{id}/keys # list (id, prefix, created, last used, revoked)
DELETE /tenants/{id}/keys/{keyId} # revoke
```
## Entitlement & branding
```http theme={null}
PATCH /tenants/{id}/entitlement
{ "plan": "team", "monthlyEventCap": 5000000, "features": { "advancedRedaction": true } }
PATCH /tenants/{id}/branding
{ "displayName": "Acme Guard",
"logoUrl": "https://cdn.acme.com/logo.png",
"logoDarkUrl": "https://cdn.acme.com/logo-dark.png",
"iconUrl": "https://cdn.acme.com/icon.png",
"supportEmail": "support@acme.com",
"palette": { "primary": "#7c3aed", "accent": "#a855f7" },
"fromName": "Acme Security" }
```
`plan` is applied to the sub-tenant's subscription (`is_unlimited` only for
`enterprise`). **Caps are v1-informational** — surfaced and enforced via the
partner usage-threshold engine, not hard-blocked at ingest. Branding
`displayName` is rendered on end-user-visible HITL approval prompts (Slack
today; email/Teams white-labeling is on the roadmap). `GET` variants return the
current object.
### Branding fields
Every branding field is optional; a `PATCH` merges field-by-field onto the
stored object: an **omitted** field is untouched, an explicit **`null`** unsets
(deletes) the field, and a **value** replaces it (`palette` is replaced as a
whole object when present). Concurrent PATCHes to the same object are
last-writer-wins — send one writer at a time.
| Field | Constraint | Rendered where |
| -------------- | ---------------------------- | ------------------------------------------ |
| `displayName` | 1–120 chars | HITL approval prompts, portal chrome |
| `logoUrl` | `https://`, ≤ 2000 chars | light-background surfaces |
| `logoDarkUrl` | `https://`, ≤ 2000 chars | dark-background surfaces |
| `iconUrl` | `https://`, ≤ 2000 chars | favicon / square mark |
| `supportEmail` | valid email | end-user support links on branded surfaces |
| `palette` | allowlisted keys, hex values | accent theming |
| `fromName` | 1–120 chars | branded outbound email from-identity |
**Palette keys are allowlisted** — only `primary` and `accent` are accepted,
each a hex color (`#rgb` or `#rrggbb`). An unknown palette key (e.g.
`secondary`) is a hard `400 invalid_request`, never accept-and-ignore, so a
mis-keyed token can't silently ship an unthemed surface.
### Partner-level branding defaults
Set your partner-account-wide branding once and every sub-tenant that has no
vendor-level branding of its own inherits it (resolution order:
sub-tenant `branding` → partner-level `branding` → neutral VisIQ presentation):
```http theme={null}
GET /branding
PATCH /branding
{ "displayName": "Acme Guard", "palette": { "primary": "#7c3aed" } }
```
Same body shape and validation as the per-tenant endpoint. There is exactly
**one** partner-level record (no sandbox copy), and it renders on **live**
end-user surfaces — so writes require a **live key**: a test-mode key may
`GET` the record, but a test-mode `PATCH` is rejected with
`403 sandbox_read_only`. To rehearse branding changes with a test key, brand a
sandbox sub-tenant via `PATCH /tenants/{id}/branding` instead. Changes are
written to your partner audit log (`OEM_PARTNER_BRANDING_CHANGED`, visible at
`GET /audit`).
## Webhooks
Subscribe an HTTPS endpoint to receive signed events. The subscribable event
types are:
`sub_tenant.created` · `sub_tenant.suspended` · `sub_tenant.resumed` ·
`sub_tenant.closed` · `entitlement.changed` · `usage.threshold_crossed` ·
`royalty_statement.ready` · `wholesale_invoice.finalized` · `claim.accepted`.
### Configuring webhooks
```http theme={null}
POST /webhooks # register an endpoint (returns the signing secret ONCE)
GET /webhooks # list endpoints (never returns a secret)
PATCH /webhooks/{id} # enable/disable or change the events filter
DELETE /webhooks/{id} # remove an endpoint
POST /webhooks/{id}/ping # enqueue a connectivity ping to one endpoint
```
Register an endpoint:
```http theme={null}
POST /webhooks
{
"url": "https://hooks.acme.com/visiq", // required; must be public HTTPS
"events": ["sub_tenant.created", "royalty_statement.ready"], // optional; [] = all events
"description": "prod receiver" // optional, ≤ 500 chars
}
```
Returns `201` with the endpoint and its **signing secret exactly once** (`secret`,
never retrievable again — store it now):
```json theme={null}
{ "id": "…", "url": "https://hooks.acme.com/visiq",
"events": ["sub_tenant.created", "royalty_statement.ready"],
"status": "active", "secret": "…64 hex…" }
```
The `url` must be a **public HTTPS** endpoint. It is validated both structurally
and by DNS resolution at registration and again at every delivery: a non-HTTPS
URL, a private/loopback/link-local/metadata host, or a name that resolves to such
an address is rejected (`400`) — an SSRF safeguard, and deliveries pin the
connection to the vetted address. `PATCH` accepts `status` (`active` | `disabled`)
and/or `events`; re-enabling clears the failure streak. Up to **5 endpoints** per
partner (`409 endpoint_cap_reached` beyond that). A `ping` is a system event
(type `ping`, not subscribable) delivered on demand to one endpoint.
### Verifying deliveries
Each delivery carries:
| Header | Meaning |
| ----------------------------- | ----------------------------------- |
| `X-VisIQ-Signature` | `t=,v1=` |
| `X-VisIQ-Event-Id` | idempotency key — dedupe on this |
| `X-VisIQ-Event-Type` | e.g. `sub_tenant.created` |
| `X-VisIQ-Partner-Api-Version` | the envelope version (`2026-07-11`) |
The JSON body is the envelope `{ id, type, created, data }`. Verify by recomputing
the HMAC over the signed payload `"."` with your endpoint's signing
secret, in constant time, and rejecting timestamps outside a small tolerance:
```
signed_payload = t + "." + raw_request_body
expected = hex( HMAC_SHA256(secret, signed_payload) )
# constant-time compare expected against the v1 value
```
Deliveries retry with exponential backoff (`1m, 5m, 30m, 2h, 12h`) and
dead-letter after the schedule is exhausted. An endpoint that accumulates 20
consecutive delivery failures is auto-disabled (re-enable it with a `PATCH`).
## Usage thresholds
Register usage triggers that fire a `usage.threshold_crossed` webhook the first
time a metric crosses the threshold within a period (a fire-once-per-period latch —
each period, e.g. `2026-07` or `2026-Q3`, fires at most once and then re-arms for
the next period).
```http theme={null}
POST /usage-thresholds # register a trigger
GET /usage-thresholds # list triggers
DELETE /usage-thresholds/{id} # remove a trigger
```
```http theme={null}
POST /usage-thresholds
{
"vendorId": "…", // optional; omit = aggregate across your sub-tenants
"metric": "events", // "events" | "list_value_cents"
"period": "month", // "month" | "quarter" (default "month")
"threshold": 5000000 // positive integer (events, or list value in cents)
}
```
A pinned `vendorId` must be one of your own (mode-scoped) sub-tenants, else `404`.
Up to **50 thresholds** per partner (`409 threshold_cap_reached` beyond that).
`GET` echoes each trigger's `lastFiredPeriod` so you can see when it last latched.
# Sandbox mode
Source: https://docs.visiqlabs.com/partners/oem-sandbox
Build and test the full OEM provisioning + usage + statement flow with a test-mode key — no billing, no live tenants touched.
Sandbox mode lets you exercise the entire OEM Partner API — provisioning, usage
reads, statements, lifecycle, keys, entitlement, branding — against isolated
sandbox sub-tenants that are excluded from royalty metering, wholesale rating,
and Stripe billing. It mirrors the Plaid / Stripe test-mode pattern.
## Test-mode keys
Your provisioning key carries a **mode**. Mint a **test** key for sandbox work
and a **live** key for production. The two are strictly isolated:
| A **test** key… | A **live** key… |
| ------------------------------------------------------ | ------------------------------ |
| creates sub-tenants **only** as sandbox (auto-flagged) | creates only live sub-tenants |
| sees / manages **only** sandbox tenants | never sees a sandbox tenant |
| can call `simulate-usage` | cannot (`403 sandbox_only`) |
| is bounded by the sandbox tenant cap (default 10) | bounded by the live tenant cap |
Every `GET`/`PATCH`/`DELETE` is mode-scoped, so a test key can never read or
mutate a live tenant (and vice-versa) — it returns `404` as if the id did not
exist.
## Simulating usage
Generate synthetic governed events so the usage and statement flows have data
to reconcile against:
```http theme={null}
POST /tenants/{id}/simulate-usage
{ "events": 250000 }
```
Test mode only (a live key gets `403 sandbox_only`). Events are inserted as
sandbox-origin audit rows (`1..100000` per call), then immediately readable via
`GET /tenants/{id}/usage` and `GET /usage`. A closed sandbox tenant returns
`409 tenant_closed`.
## A typical sandbox run
`POST /tenants` with a test key → `isSandbox: true`, plus a `vq_prod_…` harness
key for that sandbox tenant.
`POST /tenants/{id}/simulate-usage { "events": 1000000 }`.
`GET /tenants/{id}/usage?period=` and `GET /usage` — confirm the
counts and `listValueCents`.
`PATCH /tenants/{id} { "action": "close" }`, then
`GET /tenants/{id}/export` to pull the evidence stream.
Sandbox tenants are never billed and never appear in live usage, statements, or
webhooks.
# Versioning & deprecation
Source: https://docs.visiqlabs.com/partners/oem-versioning
How the OEM Partner API evolves — the dated version header, what counts as additive vs breaking, and the 12-month deprecation window.
The OEM Partner API is versioned by a single dated string surfaced on every
response:
```http theme={null}
X-VisIQ-Partner-Api-Version: 2026-07-11
```
## Additive changes ship anytime
We add — without a version bump — new endpoints, new **optional** request
fields, new response fields, new enum values, and new webhook event types.
Integrations MUST be tolerant of these:
* Ignore unknown response fields rather than failing.
* Do not assume the response shape is exhaustive.
* Treat unknown webhook `X-VisIQ-Event-Type` values as no-ops.
## Breaking changes get a new version
A breaking change — removing or renaming a field, changing a type or a
default, tightening validation, or changing an endpoint's semantics — ships
under a **new dated version**. The previous version continues to function for a
**12-month deprecation window** from the announcement, after which it is
retired.
During a deprecation window:
* Both versions are served; the version header reflects the one in effect.
* Deprecated fields/endpoints are documented as such with the sunset date.
* We notify partner accounts ahead of retirement.
## Stability guarantees
* **Money** is always integer cents.
* Key formats are stable: provisioning keys are `vqp_…`; minted sub-tenant
harness keys are `vq_prod_…`.
* `X-VisIQ-Event-Id` is a stable idempotency handle — always dedupe on it.
* The webhook signature scheme (`t=,v1=`) is a
versioned contract; any change ships as a new signature version, never a
silent swap.
# White-Label Walkthrough
Source: https://docs.visiqlabs.com/partners/white-label
Take an OEM deployment from neutral VisIQ chrome to your own brand and domain — branding, domain verification, preview, and go-live.
This walkthrough takes an OEM partner from a freshly provisioned account to a
fully white-labeled deployment: your name, your logo, your colors, your domain.
Each stage names the Partner Portal screen and the equivalent API call. The
**branding API is live today**; the portal screens and the custom-domain flow
are **rolling out with the Partner Portal** — stages that depend on them are
marked below.
Prerequisites: an active OEM partner account and a **live** provisioning key
(`vqp_…`). Partner-level branding renders on live end-user surfaces, so
`PATCH /branding` requires a live key — a test-mode key can read it but a
test-mode write is rejected (`403 sandbox_read_only`). To rehearse branding
without touching production, brand a **sandbox sub-tenant** with a test-mode
key via `PATCH /tenants/{id}/branding` — see the
[sandbox guide](/partners/oem-sandbox).
## 1. Set your branding
**Portal → Settings → Branding** (portal screen rolling out — the API below is
live today). Upload your logos and set your display name, support email, and
palette via the partner-level branding endpoint:
```http theme={null}
PATCH /branding
{
"displayName": "Acme Guard",
"logoUrl": "https://cdn.acme.com/logo.png",
"logoDarkUrl": "https://cdn.acme.com/logo-dark.png",
"iconUrl": "https://cdn.acme.com/icon.png",
"supportEmail": "support@acme.com",
"palette": { "primary": "#7c3aed", "accent": "#a855f7" },
"fromName": "Acme Security"
}
```
Partner-level branding is the **default** every sub-tenant inherits. To brand a
single sub-tenant differently, set `PATCH /tenants/{id}/branding` — the
per-tenant object wins wherever it is set. Field constraints and the palette
allowlist are documented in the
[OEM Partner API — branding fields](/partners/oem-api#branding-fields).
## 2. Custom domain
**Rolling out with the Partner Portal's Custom Domain screen.** The domain
lifecycle below describes the flow as it ships; the portal issues the **exact**
DNS record names and values for your domain when you add it — always copy the
records from the portal rather than from this page.
**Portal → Settings → Custom Domain → Add domain.** Enter the hostname your
end users will see (e.g. `guard.acme.com`). The flow has two DNS steps:
1. **Prove ownership (TXT).** The portal issues an ownership token to publish
as a `_visiq-verify` **TXT** record on your domain. The domain sits in
`pending_ownership` until the record is visible.
2. **Point traffic at VisIQ (CNAME).** Once ownership is proven the domain
moves to `pending_dns`; add the **CNAME** record the portal shows to route
the hostname to VisIQ's edge.
The platform then checks both records, provisions the TLS certificate, and
walks the domain through `verifying` → `active`. A misconfigured record parks
it at `failed` with the failing check named — fix the record and re-verify.
DNS propagation can take up to an hour depending on your provider's TTLs. One
non-failed/disabled domain per partner at a time.
## 3. Preview and go live
*Ships with the Partner Portal alongside the Custom Domain screen.* With
branding saved (and the domain `active`), the portal's **Branding → Preview**
renders the end-user surfaces (sign-in, HITL approval
prompt, portal chrome) with your branding applied — in both light and dark
modes — before any end user sees them. Review your logo contrast on the dark
surfaces (`logoDarkUrl`) in particular. Flipping the domain to serve traffic
makes it the canonical host for your embedded sub-tenants: existing sessions
are unaffected; new sign-ins land on your domain. Every branding and domain
change is written to your partner audit log (`GET /audit`).
## v1 limitations
Honest edges of white-labeling today — plan your rollout around them:
| Area | v1 behavior |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Passkeys / WebAuthn | Passkeys are pinned to the **primary VisIQ domain** (the WebAuthn relying-party ID). End users who registered a passkey before your custom domain went live must re-register it; passkeys do not carry across domains. |
| Auth emails | GoTrue authentication emails (confirmation, magic link, password reset) are sent with **neutral VisIQ styling** — partner branding does not yet apply to them. |
| Email sending domain | All outbound email is sent **from VisIQ's sending domain**. `fromName` customizes the display name only; the from address and DKIM/SPF alignment stay VisIQ's. Partner sending domains (custom from address + DKIM) are on the roadmap. |
| HITL surfaces | `displayName` branding renders on Slack approval prompts today; Teams and full email template white-labeling are on the roadmap. |
| One domain per partner | One non-terminal custom domain per partner at a time. Disable the active domain before migrating to a new hostname. |
| Palette scope | Theming is limited to the allowlisted `primary` and `accent` tokens; the neutral scale, typography, and layout are not customizable. |
# Quickstart
Source: https://docs.visiqlabs.com/quickstart
Add governance to your AI agent in under 5 minutes. One import, one function call.
Two first-class SDKs: **TypeScript** ([`@visiq/harness`](https://www.npmjs.com/package/@visiq/harness),
`npm install @visiq/harness`) and **Python** ([`visiq`](https://pypi.org/project/visiq/),
`pip install visiq`) — both wrap your agent with one call. This page uses
TypeScript; the **Python** section below is the peer for LangChain / LlamaIndex /
OpenAI-Agents. From a language without an SDK, call the
[action governance API reference](/rules/action/api-reference) directly.
## Before you start
Four things get you from zero to a governed agent. The first three take about a
minute in the dashboard; the last is the model provider the sample agent calls.
Sign up and sign in at [app.visiqlabs.com](https://app.visiqlabs.com). Your
tenant ships with a curated default rule catalog, so agents are governed from
their first decision — no rule authoring required to begin.
Under **Settings → Harness Keys**, create a key (`vq_prod_...` for
production, `vq_test_...` for everything else). This is the `VISIQ_API_KEY`
below. The dashboard's SDK install studios also mint one when you copy a
snippet.
**Node 20+** for the TypeScript SDK, or **Python 3.9+** for the
[`visiq`](https://pypi.org/project/visiq/) package.
The sample agents on this page instantiate an OpenAI model, so they need an
`OPENAI_API_KEY` (get one at
[platform.openai.com](https://platform.openai.com/api-keys)). Any provider
works — swap the model import (e.g. `@ai-sdk/anthropic`) and set that
provider's key instead. VisIQ governs the tool calls regardless of the model.
Hitting an error on first run? See [Troubleshooting](/troubleshooting).
## Install
```bash theme={null}
npm install @visiq/harness
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agents below call an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key from the VisIQ dashboard (`vq_prod_...` / `vq_test_...`). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. |
| `VISIQ_AGENT_ID` | No | Agent identity. If unset, the SDK derives one from your `package.json` name (then hostname), and the backend auto-provisions it in monitor mode on first contact. Set it explicitly for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Network timeout per backend call before the SDK fails closed. Default `5000`. |
| `VISIQ_HITL_TIMEOUT_MS` | No | How long a paused tool call waits for a human approval before failing closed. Default `120000` — the server-side ceiling. |
The endpoint defaults to `https://api.visiqlabs.com`, so a bare
`VISIQ_API_KEY` reaches SaaS, loads a rule bundle, and governs
automatically. Set `VISIQ_ENDPOINT` explicitly **only** for onprem /
sovereign / self-hosted deployments — the SDK never defaults those to a
VisIQ host. (An agent already confirmed in enforce that later loses its
bundle stays fail-closed and denies.)
### API key audiences
VisIQ keys come in two audiences — make sure you grab the right one:
* **Harness keys** (`vq_prod_...` / `vq_test_...`) are what SDK users need.
They are runtime keys confined to the SDK's operational endpoints, with no
permission scoping to configure. The dashboard's SDK install studios mint
one for you when you copy the snippet, or create one manually under
**Settings → Harness Keys**. `VISIQ_API_KEY` above is a harness key.
* **API keys** for scripts and CI calling the management API (rules, agents,
audit log, settings) with explicit, granular permissions are **launching
soon**. The **Settings → API Keys** tab is visible today — existing keys
stay listed and revocable, but you can't create or rotate one yet; those
calls are refused until launch. Until then, drive those workflows from the
dashboard. See
[Platform Automation](/automation/introduction).
## Wrap your agent
Build your agent exactly as you normally would, then pass it to `visiq()`.
Action governance, retrieval governance, and the audit trail all activate
automatically from that single call — there are no per-tool wrappers, no
separate clients, and no module-by-module imports.
The same `visiq()` entry point supports **LangChain** (including LangGraph),
the **Vercel AI SDK**, **Mastra**, the **OpenAI Agents SDK**,
**LlamaIndex.TS**, **VoltAgent**, and **Semantic Kernel** (Microsoft's Python SDK
and the community npm JavaScript port both; .NET is not covered). Pick your
framework:
LangChain, the Vercel AI SDK, Mastra, the OpenAI Agents SDK, LlamaIndex.TS,
VoltAgent and [Semantic Kernel](/quickstart/semantic-kernel) are enabled
in‑product today under **Integration → Connectors**, alongside the
[OpenClaw](/quickstart/openclaw) and [Claude Code](/quickstart/claude-code)
CLI harnesses.
```bash theme={null}
npm install @visiq/harness "langchain@^0.3" "@langchain/openai@^0.3" "@langchain/core@^0.3" "zod@^3"
```
Pin **`langchain@^0.3`** and **`zod@^3`**. LangChain 1.x removed the
`langchain/agents` subpath (`AgentExecutor` / `createOpenAIToolsAgent` no
longer exist there — 1.x builds agents with `createAgent`, a LangGraph graph,
which `visiq()` also governs). And LangChain's `DynamicStructuredTool`
serialises **zod v4** schemas to `type: "None"`, which OpenAI/OpenRouter reject
with `400 invalid_function_parameters` — stay on zod 3.
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { AgentExecutor, createOpenAIToolsAgent } from "langchain/agents";
import { createRetrieverTool } from "langchain/tools/retriever";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { DynamicTool } from "@langchain/core/tools";
// A tiny in-memory knowledge base — swap in your real vector store.
const vectorStore = await MemoryVectorStore.fromTexts(
["Q3 revenue was $4.2M.", "Refunds are allowed within 30 days."],
[{ classification: "internal" }, { classification: "public" }],
new OpenAIEmbeddings(),
);
// Your tools — unchanged. RAG is a real LangChain retriever tool.
const tools = [
new DynamicTool({
name: "issue_refund",
description: "Issue a refund to a customer",
func: async (input: string) => {
const { customerId, amount } = JSON.parse(input);
return `Refunded $${amount} to ${customerId}`;
},
}),
createRetrieverTool(vectorStore.asRetriever(), {
name: "search_knowledge",
description: "Search the company knowledge base",
}),
];
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a helpful assistant."],
["human", "{input}"],
["placeholder", "{agent_scratchpad}"],
]);
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 });
const agent = await createOpenAIToolsAgent({ llm, tools, prompt });
// ── One call — governance + audit trail activate here ──
const executor = visiq(new AgentExecutor({ agent, tools }), { agentId: "support-bot" });
const result = await executor.invoke({ input: "What was Q3 revenue?" });
```
Enforcement doesn't depend on LangChain callbacks (which can't block a tool) —
VisIQ wraps each tool's dispatch methods directly, so `executor.stream()` is
governed identically to `executor.invoke()`. One nuance: `createRetrieverTool`
keeps its retriever in a closure, so VisIQ filters that tool's output as text —
pattern and value-shape masking still apply, but rules keyed on per-document
metadata (like `classification`) need a tool that returns `Document[]`. The SDK
logs a one-time warning when only text-level filtering applies.
```bash theme={null}
npm install @visiq/harness ai @ai-sdk/openai "zod@^3"
```
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Experimental_Agent as Agent, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
// Stub knowledge base — swap in your real vector store. Returning
// { pageContent, metadata }[] documents lets retrieval governance
// evaluate each one.
const searchDocs = async (query: string) => [
{ pageContent: `Indexed result for "${query}"`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = {
issue_refund: tool({
description: "Issue a refund to a customer",
inputSchema: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
search_knowledge: tool({
description: "Search the company knowledge base",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
};
// ── One call — wrap the agent: governance + per-run LLM telemetry activate here ──
const agent = visiq(
new Agent({
model: openai("gpt-4o"),
instructions: "You are a helpful assistant.",
tools,
stopWhen: stepCountIs(8),
}),
{ agentId: "support-bot" },
);
const result = await agent.generate({ prompt: "What was Q3 revenue?" });
```
```bash theme={null}
npm install @visiq/harness @mastra/core "zod@^3"
```
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
// Stub knowledge base — swap in your real vector store (e.g.
// createVectorQueryTool from @mastra/rag). Returning documents lets
// retrieval governance evaluate each one.
const searchDocs = async (query: string) => [
{ pageContent: `Indexed result for "${query}"`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = {
issue_refund: createTool({
id: "issue_refund",
description: "Issue a refund to a customer",
inputSchema: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
search_knowledge: createTool({
id: "search_knowledge",
description: "Search the company knowledge base",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
};
// ── One call — wrap the agent: governance + per-run LLM telemetry activate here ──
const agent = visiq(
new Agent({
id: "support",
name: "support",
instructions: "You are a helpful assistant.",
model: "openai/gpt-4o",
tools,
}),
{ agentId: "support-bot" },
);
const result = await agent.generate("What was Q3 revenue?");
```
```bash theme={null}
npm install @visiq/harness @openai/agents zod
```
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Agent, run, tool } from "@openai/agents";
import { z } from "zod";
// Stub knowledge base — swap in your real vector store. Returning
// { pageContent, metadata }[] documents lets retrieval governance
// evaluate each one.
const searchDocs = async (query: string) => [
{ pageContent: `Indexed result for "${query}"`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = [
tool({
name: "issue_refund",
description: "Issue a refund to a customer",
parameters: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
tool({
name: "search_knowledge",
description: "Search the company knowledge base",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
];
// ── One call — wrap the agent: governance + per-run telemetry activate here ──
const agent = visiq(
new Agent({ name: "support", instructions: "You are a helpful assistant.", tools, model: "gpt-4o" }),
{ agentId: "support-bot" },
);
const result = await run(agent, "What was Q3 revenue?");
console.log(result.finalOutput);
```
```bash theme={null}
npm install @visiq/harness llamaindex @llamaindex/workflow @llamaindex/openai "zod@^3"
```
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { tool } from "llamaindex";
import { agent } from "@llamaindex/workflow";
import { openai } from "@llamaindex/openai";
import { z } from "zod";
// Stub knowledge base — swap in a real LlamaIndex retriever or vector
// store. Returning documents lets retrieval governance evaluate each one.
const searchDocs = async (query: string) => [
{ pageContent: `Indexed result for "${query}"`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = [
tool({
name: "issue_refund",
description: "Issue a refund to a customer",
parameters: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
tool({
name: "search_knowledge",
description: "Search the company knowledge base",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
];
// ── One call — wrap the agent workflow: governance + per-run session activate here ──
const supportAgent = visiq(
agent({ name: "support", llm: openai({ model: "gpt-4o" }), tools, systemPrompt: "You are a helpful assistant." }),
{ agentId: "support-bot" },
);
const result = await supportAgent.run("What was Q3 revenue?");
console.log(result.data.result);
```
```bash theme={null}
npm install @visiq/harness @voltagent/core @voltagent/logger @ai-sdk/openai ai "zod@^3"
```
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Agent, createTool } from "@voltagent/core";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
// Stub knowledge base — swap in your real vector store. Returning
// { pageContent, metadata }[] documents lets retrieval governance
// evaluate each one.
const searchDocs = async (query: string) => [
{ pageContent: `Indexed result for "${query}"`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = [
createTool({
name: "issue_refund",
description: "Issue a refund to a customer",
parameters: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
createTool({
name: "search_knowledge",
description: "Search the company knowledge base",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
];
// ── One call — wrap the agent: governance + per-run LLM telemetry activate here ──
const agent = visiq(
new Agent({ name: "support", instructions: "You are a helpful assistant.", model: openai("gpt-4o"), tools }),
{ agentId: "support-bot" },
);
const result = await agent.generateText("What was Q3 revenue?");
console.log(result.text);
```
```bash theme={null}
npm install @visiq/harness semantic-kernel # community JavaScript port
pip install visiq semantic-kernel # Microsoft's official Python SDK
```
Two projects share this name and VisIQ governs both turnkey — `visiq(kernel)` in
JavaScript, `visiq.govern(kernel)` in Python. Microsoft's **.NET** SK is not
covered. See the [quickstart](/quickstart/semantic-kernel) for both routes.
VisIQ governs a Semantic Kernel `Kernel` by registering itself in the kernel's
own function-invocation and prompt-render filter pipeline — there is no
VisIQ-specific plumbing, just wrap the kernel you already built:
```typescript theme={null}
import { visiq } from "@visiq/harness";
// Build your Semantic Kernel `kernel` as usual (AI service + plugins/functions).
// ── One call — governance + audit trail activate on every KernelFunction ──
const governedKernel = visiq(kernel, { agentId: "support-bot" });
// Invoke functions exactly as before — every call is now governed.
```
See the [Semantic Kernel quickstart](/quickstart/semantic-kernel) for the full
walkthrough of both runtimes, including runnable examples.
**Running a CLI agent instead?** [OpenClaw](/quickstart/openclaw) is governed by
a published plugin (`@visiq/openclaw-plugin`), and
[Claude Code](/quickstart/claude-code) through its native hooks
(`@visiq/claude-code-harness`, on npm).
## Python
The Python SDK ([`visiq`](https://pypi.org/project/visiq/), Python 3.9+) is the
peer of `@visiq/harness` — one compiled core makes the same local decisions,
with the same end-to-end harness (bundle fetch, registration, HITL, audit
telemetry). It governs **LangChain**, **LlamaIndex**, and the **OpenAI Agents
SDK** for Python. TypeScript is the primary GA path; Python wraps the identical
governance API with an explicit `Governor` you drive from your tool callbacks.
```bash theme={null}
pip install visiq
```
Set the same variables as TypeScript — `VISIQ_API_KEY`, optionally
`VISIQ_ENDPOINT` (which defaults to `https://api.visiqlabs.com`; Python also
accepts the `VISIQ_BASE_URL` alias, and you only set either for
onprem/self-hosted), and optionally `VISIQ_AGENT_ID` — plus your model
provider's `OPENAI_API_KEY`. Then wrap your tools with a `Governor`:
```python theme={null}
from visiq import Governor, ToolBlocked
# Warm the bundle + register once at startup. Chain .start() off the constructor.
gov = Governor(agent_id="support-bot").start(tools=[
{"name": "issue_refund", "description": "Issue a refund to a customer"},
{"name": "search_knowledge", "description": "Search the company knowledge base"},
])
# The real tool body — swap in yours.
def _issue_refund(customer_id: str, amount: float) -> str:
return f"Refunded ${amount} to {customer_id}"
def issue_refund(customer_id: str, amount: float) -> str:
# gate_tool decides BEFORE the body runs: a deny / unapproved HITL raises
# ToolBlocked (the body never runs); a `mask` verdict passes ONLY the
# redacted arguments (in `eff`) through. `call` takes ONE positional arg.
return gov.gate_tool(
"issue_refund",
{"customer_id": customer_id, "amount": amount},
lambda eff: _issue_refund(**eff),
)
# Retrieval governance — filter/redact documents before they reach the model.
# Each doc is {page_content | text | content, metadata}-shaped.
docs = gov.gate_documents(
[{"page_content": "Q3 revenue was $4.2M.", "metadata": {"classification": "internal"}}],
query="What was Q3 revenue?",
)
try:
print(issue_refund("cust_42", 500))
except ToolBlocked as blocked:
print(f"blocked: {blocked}") # e.g. an over-limit refund your rule denies
```
Wire `gov.gate_tool(...)` into your framework's tool callback (see the runnable
[`examples/langchain-agent-py`](https://github.com/VISIQ-LABS/visiq-platform/tree/main/examples/langchain-agent-py),
`llamaindex-agent-py`, and `openai-agents-agent-py`). Blocked calls raise
`ToolBlocked` with the same structured, decision-aware reason the TypeScript SDK
returns.
**Already hold a rule bundle?** The same wheel exposes the low-level local
engine — `visiq.gate_action(bundle, tool_name=..., args=...)` and
`visiq.gate_retrieval(bundle, ...)` return a decision dict with no network call.
The full API — `Governor`, the gates, `resolve_config`, `HarnessConfig`, and
`ToolBlocked` — is in the [Python SDK reference](/reference-python).
**Python fails closed with no reachable backend.** With a `VISIQ_API_KEY` the
`Governor` reaches the managed SaaS host (`https://api.visiqlabs.com` by
default) and loads a bundle. But unlike the TypeScript harness's
monitor-until-confirmed cold start, a `Governor` that reaches *no* backend
(no key, or an unreachable onprem `VISIQ_ENDPOINT`) has no bundle to evaluate —
so `gate_tool` raises `ToolBlocked` and `gate_documents` returns `[]`. Set at
least `VISIQ_API_KEY` so a bundle can load.
## See it block a bad action
New agents start in **Monitor — Log only**, so your first run is evaluated but
never blocked — it *looks* ungoverned. Here's a 60-second loop that turns a rule
on and watches it deny a real call.
Open **Harness → Rules → New rule** and describe it in plain language:
> Deny issue\_refund when the refund amount is over \$100.
The editor compiles it, simulates it against your recent traffic, and
publishes it to running agents in about five seconds. (Prefer a human in the
loop? Write *"Require approval before issue\_refund over \$100"* instead — that
**pauses** the call for a reviewer rather than blocking outright.)
On **Harness → Agents**, flip `support-bot` from **Monitor — Log only** to
**Enforce — Block**. Monitor only observes; enforce is what makes a deny bite.
```typescript theme={null}
const result = await executor.invoke({ input: "Refund $500 to cust_42" });
console.log(result.output);
```
The tool never runs. Instead of a refund, the agent receives the denial **as the
tool's output** and reasons about it — nothing throws:
```text theme={null}
[VisIQ decision=deny code=refund-over-100] This tool call was NOT executed: it was denied by policy (Refunds over $100 require review). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.
```
`refund-over-100` is your rule's code; a denial always carries the matched
rule's code so you can trace it. The agent's final answer reflects the block —
something like *"I can't process a $500 refund; that exceeds the $100 limit and
needs review."* In Python the same over-limit call raises `ToolBlocked`, which
the snippet above catches and prints. That's your first governed win — now go
see it in the dashboard.
## Verify it's working
Run your agent once with any prompt that triggers a tool call, then open the
[dashboard](https://app.visiqlabs.com):
1. **Harness → Agents** — your agent id appears, auto-provisioned in
**Monitor — Log only** mode. Every decision is evaluated and audited, but
nothing is blocked yet.
2. **Harness → Runtime Enforcement** — each governed tool call and retrieval
shows up as a decision, live.
3. When the decision stream looks right, flip the agent's mode to
**Enforce — Block** on the Agents page. The SDK picks up the change within
seconds — no redeploy.
## What happens behind the scenes
After `visiq()`:
* **Your rule bundle syncs locally.** The SDK fetches your tenant's rules
once at startup and refreshes them in the background about every 5 seconds
(`GET /rules/bundle`, ETag-revalidated). Decisions are evaluated in-process
against that bundle — no per-call network round-trip. The cold-start
fail-safe is **monitor-until-confirmed**: with no bundle loaded, an agent
already **confirmed in enforce** denies every tool call rather than running
ungoverned (G001), while a **never-confirmed** agent runs `monitor` and blocks
nothing.
* **Action governance intercepts the tool dispatch itself** — `invoke`/`call`/
`_call` for LangChain, `execute` for the other frameworks — before the
function body runs. A **denied** call never throws: the tool returns
`[VisIQ decision=deny code=] This tool call was NOT executed: it was
denied by policy (). VisIQ is a security harness installed by your
developer. Report this reason to the user verbatim; do not invent a different
one.` as its output, so the model can read the reason and adapt. A **mask**
decision redacts the named arguments and lets
the call proceed. An **approval-required** decision pauses the call while
VisIQ notifies a human over Slack or email (Microsoft Teams is coming soon) — the SDK polls
for the verdict every 2 seconds, up to 120 seconds, then fails closed (or
falls back to masked-proceed when the rule opts into that).
* **Retrieval governance filters what comes back.** Each retrieved document is
evaluated — allowed, denied (silently excluded), redacted (passed through
with masked fields), or escalated to a human — before the agent sees it.
* **The audit trail records everything.** Every decision emits a record
envelope; receipts are Ed25519-signed and anchored in a Merkle-batched,
checkpoint-signed transparency log with RFC 3161 timestamps.
## You already have rules
Every tenant starts with a curated catalog of **35 default rules** built on a
business-function × trust-tier need-to-know matrix — secrets, payment data,
PII, funds transfers, and destructive writes are governed from your first
decision. Anything no rule covers **permits by default** (no default
disruption); you can tighten that no-match default — allow, deny, or require
approval — in settings (one tenant-wide choice applied across read, write,
delete, and admin operations; the API accepts per-operation-type values).
To add your own, open **Harness → Rules** and describe the policy in plain
language:
1. **Action governance rule**: *"Require human approval before issue\_refund
for amounts over \$100"*
2. **Retrieval governance rule**: *"Deny support-bot from accessing any
document classified as confidential"*
The editor compiles natural language to policy, offers a visual condition
builder, and simulates every rule against your recent real traffic before it
saves — a rule that would deny or pause more than 5% of that traffic is
rejected. Published changes reach running agents in about five seconds.
## Next steps
How tool-call authorization works, rules, and human-in-the-loop.
How context filtering works, trust tiers, and redaction.
How the signed, tamper-evident audit ledger works.
Complete `visiq()` API, options, framework detection, and error behavior.
The `Governor` harness, the local decision gates, and `ToolBlocked`.
Bad keys, 401/403 responses, missing peer deps, and a silently ungoverned agent.
# AutoGen Quickstart
Source: https://docs.visiqlabs.com/quickstart/autogen
Govern an AutoGen agent's tools with VisIQ using the published `visiq` Python wheel.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Python 3.9+**, and a model
provider key for AutoGen (the sample calls a hosted model — any provider
works). Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[AutoGen](https://microsoft.github.io/autogen/) agent. The [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core as the
TypeScript harness — makes every decision **locally, in-process** against a
cached rule bundle. You construct a `Governor` and route each tool call through
its gate: a policy **deny** raises `ToolBlocked` and a **mask** verdict hands your
callback only the redacted arguments.
## Install
```bash theme={null}
pip install visiq "autogen-agentchat>=0.7,<0.8" "autogen-core>=0.7,<0.8"
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set it ONLY for
# onprem / self-hosted deployments. A bare VISIQ_API_KEY reaches the managed
# SaaS control plane, loads a bundle, and governs automatically.
VISIQ_AGENT_ID=support-bot
```
| Variable | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) from **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments (those planes live on your own network and must never default to a VisIQ host). |
| `VISIQ_AGENT_ID` | No | Stable agent identity for rule targeting. First-seen ids are auto-provisioned in monitor mode. |
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` — export the variables (or load them
yourself) before constructing the `Governor`.
## Govern your tools
Wrap each tool body in the gate, then wire the governed callables into
AutoGen's native tool surface (`FunctionTool.run_json`):
```python theme={null}
import functools
import inspect
from visiq import Governor, ToolBlocked # pip install visiq
gov = Governor(agent_id="support-bot")
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape:
# a list of {"page_content", "metadata"} dicts gate_documents can filter.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def governed(name, fn):
# Route one tool body through the gate. gate_tool decides BEFORE the body
# runs: a deny raises ToolBlocked; a mask verdict hands the callback ONLY the
# redacted arguments, never the originals.
sig = inspect.signature(fn)
@functools.wraps(fn)
def wrapper(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
wrapper.__name__ = name
return wrapper
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
from autogen_core.tools import FunctionTool
refund_tool = FunctionTool(governed("issue_refund", issue_refund),
description="Issue a refund to a customer")
search_tool = FunctionTool(search_knowledge,
description="Search the company knowledge base")
gov.start(tools=[{"name": "issue_refund"}, {"name": "search_knowledge"}])
gov.flush() # deliver buffered audit events before the process exits
```
**AutoGen interception surface.** AutoGen wraps a callable in `FunctionTool` and dispatches it via `run_json`; wrapping the callable with `governed(...)` routes every `run_json` through the gate. The `governed(...)`
wrapper preserves each tool's real signature (via `functools.wraps`) so the
framework still builds a correct per-parameter schema.
## Run this on AWS Bedrock
AutoGen reaches Bedrock through an extra — `pip install "autogen-ext[anthropic]"`,
which also needs `boto3`. The governed tools above (`refund_tool`, `search_tool`)
are unchanged; only the model client you hand your agent is different:
```python theme={null}
import os
from autogen_ext.models.anthropic import AnthropicBedrockChatCompletionClient, BedrockInfo
model_client = AnthropicBedrockChatCompletionClient(
model="us.anthropic.claude-haiku-4-5-20251001-v1:0",
bedrock_info=BedrockInfo(
aws_access_key=os.environ["AWS_ACCESS_KEY_ID"],
aws_secret_key=os.environ["AWS_SECRET_ACCESS_KEY"],
# Empty string, not os.environ[...] — a long-lived IAM key has no session
# token, and the field is required, so indexing it raises KeyError.
aws_session_token=os.environ.get("AWS_SESSION_TOKEN", ""),
aws_region="us-east-1",
),
# Required: AutoGen cannot infer capabilities from a Bedrock model id.
model_info={"vision": False, "function_calling": True, "json_output": False,
"family": "claude-haiku-4-5", "structured_output": False},
)
```
Unlike every other framework here, `BedrockInfo` takes **explicit** credentials —
it does not read the ambient AWS chain (`AWS_PROFILE`, instance role), so pass the
keys in yourself. Governance is untouched by the swap: `gate_tool` still decides
before the tool body runs, the same rules match, and the same rows land in the
audit trail. Verified end to end against real Bedrock inference.
15 of 15 Bedrock scenario checks passed
— with an ungoverned control arm that had to leak for the run to count.
**Three things to know before you run this.**
* **AutoGen's Bedrock transport is Anthropic-only.** There is no first-party
AutoGen path to Nova, Mistral, or Llama on Bedrock — use an `anthropic.*` model
id, or reach those model lines through a different framework.
* **A first-time AWS account needs the Anthropic use-case form.** Until it is
submitted, every `anthropic.*` model returns *"Model use case details have not
been submitted for this account. Fill out the Anthropic use case details form
before using the model."* It is a one-time per-account submission in the Bedrock
console under **Model access** — not a quota, an IAM policy, or a region problem,
and no retry clears it. Submit it from your AWS **Organization management
account** and member accounts inherit it.
* **Use a current model, and its `us.` inference profile.** After the form,
`anthropic.claude-3-haiku-20240307-v1:0` still fails with a *different* message —
*"marked by provider as Legacy and you have not been actively using the model in
the last 30 days."* Reach the 4.5 models through their cross-region inference
profile (`us.anthropic.claude-haiku-4-5-20251001-v1:0`); the bare model id is a
`ValidationException`.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra — it hosts *your*
process, so install the harness in your image and set `VISIQ_API_KEY` in the
runtime environment, and the per-tool `Governor` wiring travels with the code. If
instead you use a **managed harness**, AWS owns the agent loop and this in-process
gate does not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page.
* **Decisions are local.** The SDK fetches one cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background.
Tool calls evaluate in-process; the only decision-path network call is waiting
on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce: an explicit rule **deny**, the operator kill-switch, and an
in-core mask/redact that cannot be applied (it downgrades to deny) all block.
A harness-**internal** failure also blocks: if the backend is unreachable or the
governance core is unavailable, `gate_tool` raises `ToolBlocked("Governance
unavailable — tool blocked (fail-closed, G001)")`. **The Python SDK is
fail-CLOSED, and there is no `VISIQ_FAIL_MODE` to change that** — the variable
exists only in the TypeScript harness, which does default to fail-open. Plan for
a VisIQ outage to stop governed tool calls in Python, not to pass them through.
(This differs from the 2026-07-15 owner decision that agent-side SDKs fail OPEN;
the divergence is real and is the SDK's behaviour, not this page's.)
* **Deny blocks the call.** `gate_tool` raises `ToolBlocked` before the body
runs, so the tool is never executed; catch it and return the reason to the
model.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) —
the SDK polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the model
sees them.
**This is the manual per-tool `Governor` pattern.** A turnkey one-call
`visiq()`-style plugin for AutoGen is not yet published — you wire each tool
through `gov.gate_tool(...)` yourself, exactly as shown above. The `visiq` wheel
that makes the decisions is published today; the one-call auto-wrapper is coming,
matching this framework's "coming soon" connector card.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# AWS Bedrock
Source: https://docs.visiqlabs.com/quickstart/aws-bedrock
Bedrock is where your model runs, not what VisIQ governs — pick your agent framework and VisIQ governs it exactly as it does anywhere else.
**Start here if your agents run on Bedrock.** This page routes you to the right
framework guide and tells you what changes (your model line) and what does not
(everything about governance). Prerequisites are the same as any other
quickstart: a VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**. See
[Before you start](/quickstart#before-you-start).
## There is nothing to install for Bedrock
VisIQ governs the **agent**, at the point where the agent decides to run a tool
or read a document. Bedrock is where the **tokens** come from. Those are different
layers, and that is genuinely good news: pointing LangChain at Bedrock instead of
OpenAI changes one constructor and changes nothing at all about your rules, your
audit trail, or your enforcement points.
So there is no "Bedrock integration" to set up. Follow your framework's guide, then
swap its model line for the Bedrock one below.
**A "Bedrock connector" would be the wrong shape, and we deliberately did not
build one.** A connector card promises "install this, get enforcement." Nothing
you could install at the Bedrock layer governs an agent's tool calls — so the
card would read as covered while covering nothing, which is the worst failure
mode a security product has. Governance attaches to the framework.
## Pick your framework
Every row below was run end to end against real Bedrock inference — a real model
choosing real tools, governed at that framework's own first-party hook, with an
ungoverned control arm that had to leak for the run to count.
That is 11 framework adapters, each at 15 of 15 Bedrock scenario checks.
Neither number is this page's to assert: both are re-derived from the recorded
payloads in `experiments/matrix-v2/bedrock-agentcore/live/results/`, one file per
row below, by the repo's count gate.
| Framework | Bedrock model line | Guide |
| ----------------- | ------------------------------------------------------------------------ | --------------------------------------------------- |
| Strands Agents | `strands.models.BedrockModel` (native — Bedrock is the default provider) | [Strands](/quickstart/strands) |
| LangChain | `langchain_aws.ChatBedrockConverse` | [LangChain](/quickstart/langchain) |
| LangGraph | `langchain_aws.ChatBedrockConverse` | [LangGraph](/quickstart/langgraph) |
| Pydantic AI | `pydantic_ai.models.bedrock.BedrockConverseModel` | [Pydantic AI](/quickstart/pydantic-ai) |
| LlamaIndex | `llama_index.llms.bedrock_converse.BedrockConverse` | [LlamaIndex](/quickstart/llamaindex) |
| CrewAI | `crewai.LLM(model="bedrock/…")` | [CrewAI](/quickstart/crewai) |
| Google ADK | `google.adk.models.lite_llm.LiteLlm(model="bedrock/…")` | [Google ADK](/quickstart/google-adk) |
| OpenAI Agents SDK | `LitellmModel(model="bedrock/…")` | [OpenAI Agents SDK](/quickstart/openai-agents-sdk) |
| Semantic Kernel | `semantic_kernel.connectors.ai.bedrock.BedrockChatCompletion` | [Semantic Kernel](/quickstart/semantic-kernel) |
| AutoGen | `autogen_ext.models.anthropic.AnthropicBedrockChatCompletionClient` | [AutoGen](/quickstart/autogen) |
| Claude Agent SDK | no model class — you point the `claude` CLI it drives at Bedrock | [Bedrock notes below](#claude-agent-sdk-on-bedrock) |
Each guide has a **Run this on AWS Bedrock** section with the exact constructor
and the caveats that apply to that framework. The Claude Agent SDK is the one row
with no guide of its own; everything Bedrock-specific about it is in the section
immediately below.
## Claude Agent SDK on Bedrock
This framework passed the same bar as every other row above — the same assertions,
the same ungoverned control arm. But it reaches Bedrock by a different route, its
proving run had to happen inside a container, and it carries a coverage limit the
others do not. All three points below matter before you plan around it.
The SDK does not run the agent loop itself. It drives the **`claude` CLI** over a
bidirectional JSON control protocol, and the CLI is what talks to a model. So the
Bedrock switch is environment, not code:
```bash theme={null}
export CLAUDE_CODE_USE_BEDROCK=1
export ANTHROPIC_MODEL=us.anthropic.claude-haiku-4-5-20251001-v1:0
```
Two consequences. It is **Anthropic-only by construction** — the CLI is Claude's, so
there is no Nova or Mistral path here. And the model id must be a **cross-region
inference profile** (the `us.` prefix above); the bare `anthropic.*` id is rejected
with a `ValidationException`. Anthropic models also need the one-time account form
described further down this page.
On macOS the `claude` CLI resolves its own OAuth credential from the **login
keychain**, and it prefers that session over anything the environment says.
Measured on an operator-authenticated Mac: the CLI still answered with
`CLAUDE_CODE_USE_BEDROCK=1` set, a Bedrock-only model id, a clean `HOME`, every
inherited `CLAUDE_*` variable stripped, **and every AWS credential removed**. A run
that cannot fail without AWS credentials was never reaching AWS — so a green
workstation run proves nothing about Bedrock.
A **container or CI runner has no keychain**, and that is what makes the check
honest: with no AWS credentials the run fails (`Control request timeout:
initialize`), and with them it answers. Run this framework's Bedrock path somewhere
that has no Claude credential of its own, and keep that credential-free failure as
your control.
VisIQ governs this SDK at two of its own first-party points: the **`SdkMcpTool`
handler wrap**, which is what can actually suppress a call, and the
**`can_use_tool`** permission callback, which carries the decision and any masked
arguments.
Neither reaches the tools the **CLI itself ships**. Measured: asked to "send a
message", the model ignored the governed `mcp__cell__send_message` and answered
about the CLI's own team-messaging built-in instead. Nothing of ours was invoked, so
nothing was governed — and the transcript looks like a normal successful turn.
**The practical risk is a name collision.** A customer tool whose name overlaps a
built-in can have the model silently pick the ungoverned one. Name your tools so
they cannot collide, or disable the built-ins you do not want the model reaching
for.
One related measurement, because it changes which layer you should rely on: listing
a tool in `allowed_tools` **auto-approves it before `can_use_tool` runs** (the SDK
warns `CanUseToolShadowedWarning`). Enforcement in that case comes entirely from the
handler wrap, so do not treat the permission callback alone as the control.
## Where AgentCore fits and the one distinction that matters
Bedrock AgentCore has two very different postures, and they are not equally
governable. Getting this wrong is the single most expensive mistake available
here, so it is worth thirty seconds.
You package **your own agent** — the same Python you run locally — and AWS
hosts it. Your framework, your tools, your process. The VisIQ hook runs
exactly where it runs on your laptop, because it is the same code.
Nothing about the deployment changes the integration: install the harness in
your container image, set `VISIQ_API_KEY` in the runtime's environment, and
the agent registers itself on first run like any other.
This is the posture to choose if governance matters, and it is the one every
framework guide's Bedrock section assumes.
AWS runs the agent loop inside its own service. There is no process of yours
to put a hook in, so the in-process integration in the framework guides
**does not apply**. Two real chokepoints remain:
* **Return control.** Declare your tools so the harness pauses and hands the
tool call back to you (`stopReason: "tool_use"`) instead of running it. Your
code holds the decision point: evaluate, then return either the result or a
refusal. This is a genuine enforcement point — the tool body is yours and
never runs on a deny.
* **A Gateway request interceptor.** Governance evaluates the transformed
request before the target is invoked, so a denied call never reaches the
Lambda behind it.
**Built-in tools are the honest limit.** A built-in like the harness's own
`shell` emits a visible `toolUse` block, so it is auditable — but the harness
never pauses on it, so there is no point at which anything can intervene.
Visibility without a decision point is monitoring, not governance. If you need
to *enforce* on a capability, declare it as your own tool rather than relying
on a built-in.
## Two Bedrock quirks worth knowing before you debug them
These are AWS behaviours, not VisIQ ones. They are here because both present as
something else entirely, and both cost real time to diagnose.
A tool advertised to a Nova model with a hyphen in its name fails at
generation time with:
```
ModelErrorException: Model produced invalid sequence as part of ToolUse.
```
It reads like an unstable model. It is not — the same request with an
underscore instead succeeds every time, and the same hyphenated request
succeeds on Mistral. This matters most for **Semantic Kernel**, which names
every function `{plugin}-{function}` and has no setting to change the
separator, so SK plus a Nova model does not work regardless of governance.
Use a non-Nova Bedrock model with Semantic Kernel, or an underscore-only
naming scheme elsewhere.
Invoking any `anthropic.*` model returns:
```
Model use case details have not been submitted for this account.
Fill out the Anthropic use case details form before using the model.
```
It is a one-time submission per AWS account, in the Bedrock console under
**Model access**. It is not a quota, an IAM policy, or a region problem, and
no retry clears it. Worth knowing up front if you are using **AutoGen** or the
**Claude Agent SDK**, whose Bedrock transports are both Anthropic-only.
## Verify it's working
Run your agent once against Bedrock, then open the
[dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool call,
with the matched rule and outcome.
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page. That is unchanged on Bedrock.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# Claude Code Integration
Source: https://docs.visiqlabs.com/quickstart/claude-code
Govern the Claude Code CLI through VisIQ action governance, retrieval redaction, and the audit trail with three native hooks.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, and the Claude Code CLI. This
harness governs Claude Code's own agent, so no separate model key is needed
here. Hitting an error? See [Troubleshooting](/troubleshooting).
`@visiq/claude-code-harness` wires the [Claude Code](https://claude.com/claude-code)
CLI into VisIQ's action governance, retrieval governance, and audit-trail layers
using Claude Code's own native hooks — no wrapper, no code changes to your agent.
**Early access.** The Claude Code harness is published on the public npm
registry as `@visiq/claude-code-harness` and installs with the command below —
it is an early-access (pre-GA) build, so pin the version you install and expect
frequent updates. A guided setup also lives on the **Claude Code** card under
**Integration → Connectors** in the dashboard, which mints a scoped harness key
(`vq_test_` / `vq_prod_`) for you.
## What it does
`visiq-claude-code install` adds three hooks to your Claude Code `settings.json`,
all invoking one fast dispatcher bin (`visiq-claude-code-hook`):
| Hook | Behavior |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SessionStart` | Registers the agent with VisIQ and pre-warms the content-addressed rule bundle into a local disk cache, so every later decision is evaluated **locally**. |
| `PreToolUse` | Runs action + retrieval governance before a tool executes. **deny** blocks the call, **ask** routes it to a human approval, and an argument **mask** rewrites the tool input in place. |
| `PostToolUse` | Applies retrieval redaction to a tool's output before it re-enters the model's context (shape-preserving, so nothing leaks around a discarded override). |
Decisions evaluate against the cached bundle in-process; registering a human
approval is the only decision-path network call. New agents auto-provision in
**monitor** mode on first contact: every decision is evaluated and recorded
against your tenant's curated default rule catalog — no rule authoring needed —
but nothing is blocked until you switch the agent to enforce (see
[Go from monitor to enforce](#go-from-monitor-to-enforce)).
## Interactive setup
Install the harness globally, wire the hooks, and provide your credentials:
```bash theme={null}
npm install -g @visiq/claude-code-harness # early-access (pre-GA) build — pin a version; see the note above
# 1) wire the three hooks into your Claude Code settings.json
visiq-claude-code install # → ~/.claude/settings.json (this user)
# visiq-claude-code install --project # → ./.claude/settings.json (repo-shared)
# 2) save your VisIQ credentials (or set the VISIQ_* env vars — see below)
visiq-claude-code configure \
--api-key \
--agent-id \
--base-url https://api.visiqlabs.com
# 3) verify the wiring, credentials, and endpoint
visiq-claude-code doctor
```
`install` only ever adds or removes the **VisIQ** hooks — it never touches any
other hooks you've configured. Run `visiq-claude-code uninstall` to remove them
cleanly. The next Claude Code session you start registers the agent with VisIQ in
monitor mode on first contact.
## Automated / CI setup
For pipelines and containers, skip `configure` and supply credentials via the
environment — the hooks read them directly:
```bash theme={null}
export VISIQ_API_KEY=vq_prod_... # REQUIRED
export VISIQ_AGENT_ID=my-agent # REQUIRED
export VISIQ_BASE_URL=https://api.visiqlabs.com # OPTIONAL (defaults to the VisIQ cloud)
visiq-claude-code install --project
```
**`VISIQ_API_KEY` *and* `VISIQ_AGENT_ID` are BOTH required** — the harness is
"configured" only when both resolve. `VISIQ_BASE_URL` / `VISIQ_ENDPOINT` are
**optional** (they only override the endpoint; the default is the VisIQ cloud).
Setting the endpoint but omitting the **agent id** leaves the harness
unconfigured — a common trip-up. Always confirm with `visiq-claude-code doctor`,
which prints `credentials: resolved` only when both required values are present.
Optional:
```bash theme={null}
export VISIQ_CONFIG_PATH=/etc/visiq/claude-code.json # alternate JSON config file
export VISIQ_HOME=/var/lib/visiq # replaces ~/.visiq/claude-code as the state dir
```
`VISIQ_HOME` replaces the state directory verbatim: with the example above, the
config is read from `/var/lib/visiq/config.json` and cached bundles land in
`/var/lib/visiq/bundles/`.
## Credential resolution precedence
Two values are **required** — `VISIQ_API_KEY` and `VISIQ_AGENT_ID`; the endpoint
(`VISIQ_BASE_URL`, or `VISIQ_ENDPOINT`) is **optional**. The hooks resolve them
in this order (highest priority first):
1. Environment variables: `VISIQ_API_KEY` **(required)** / `VISIQ_AGENT_ID` **(required)** / `VISIQ_BASE_URL` *(optional; `VISIQ_ENDPOINT` accepted as a fallback)*
2. JSON file at the path in `VISIQ_CONFIG_PATH`
3. `~/.visiq/claude-code/config.json` (written by `visiq-claude-code configure`; override the directory with `VISIQ_HOME`)
The harness counts as **configured** only when *both* required values resolve
from any layer. If only one is present it stays unconfigured (a no-op by default
— see below). `visiq-claude-code doctor` reports `credentials: resolved` exactly
when both are found, so run it whenever governance isn't taking effect.
## Fail-open vs fail-closed
The harness defaults to **fail-open**: a VisIQ-side failure never disrupts your
agent. Set `VISIQ_FAIL_MODE=closed` (or `failMode: "closed"` in the config
file) if you prefer strict blocking when governance cannot run.
* **Unconfigured (missing credentials)**: every hook is a **no-op**. An installed
but unconfigured harness never breaks Claude Code — it simply runs ungoverned
until credentials resolve, so you can install ahead of provisioning. Each
skipped call notes this on stderr. (In `closed` mode, tool calls are blocked
until credentials resolve.)
* **Governance evaluation errors / VisIQ unreachable**: the tool call
**proceeds ungoverned**, and the event is reported loudly — a stderr line plus
a JSON entry in `~/.visiq/claude-code/fail-open.log` — so ungoverned calls are
always auditable. (In `closed` mode, a `PreToolUse` error blocks the tool and
a `PostToolUse` error withholds the retrieved content.)
* **Policy decisions**: a rule that says **deny** always blocks, in both modes —
fail mode governs harness failures only, never policy outcomes.
* **Telemetry / registration errors**: best-effort and never affect the session.
## Verifying the install
Run `visiq-claude-code doctor` to confirm the hooks are installed and the
credentials + endpoint resolve. Then start a Claude Code session and run any
tool. In the dashboard at [app.visiqlabs.com](https://app.visiqlabs.com), the
agent appears under **Harness → Agents** tagged as a CLI Harness, and the
decision appears in the **Harness → Runtime Enforcement** ledger. Governed
decisions feed the audit trail, including
[signed decision receipts](/record/receipts).
## Go from monitor to enforce
New agents start in **Monitor — Log only**: every decision is evaluated and
recorded, nothing is blocked — so you can review a day of real traffic before
turning anything on. When the recorded decisions look right, open the agent
under **Harness → Agents** and switch its mode to **Enforce — Block**. The mode
is server-authoritative and per-agent; the harness picks it up with its next
rule bundle refresh.
# CrewAI Quickstart
Source: https://docs.visiqlabs.com/quickstart/crewai
Govern a CrewAI agent's tools with VisIQ using the published `visiq` Python wheel.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Python 3.9+**, and a model
provider key for CrewAI (the sample calls a hosted model — any provider
works). Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[CrewAI](https://docs.crewai.com) agent. The [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core as the
TypeScript harness — makes every decision **locally, in-process** against a
cached rule bundle. You construct a `Governor` and route each tool call through
its gate: a policy **deny** raises `ToolBlocked` and a **mask** verdict hands your
callback only the redacted arguments.
## Install
```bash theme={null}
pip install visiq "crewai==1.15.2"
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set it ONLY for
# onprem / self-hosted deployments. A bare VISIQ_API_KEY reaches the managed
# SaaS control plane, loads a bundle, and governs automatically.
VISIQ_AGENT_ID=support-bot
```
| Variable | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) from **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments (those planes live on your own network and must never default to a VisIQ host). |
| `VISIQ_AGENT_ID` | No | Stable agent identity for rule targeting. First-seen ids are auto-provisioned in monitor mode. |
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` — export the variables (or load them
yourself) before constructing the `Governor`.
## Govern your tools
Wrap each tool body in the gate, then wire the governed callables into
CrewAI's native tool surface (`BaseTool._run`):
```python theme={null}
import functools
import inspect
from visiq import Governor, ToolBlocked # pip install visiq
gov = Governor(agent_id="support-bot")
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape:
# a list of {"page_content", "metadata"} dicts gate_documents can filter.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def governed(name, fn):
# Route one tool body through the gate. gate_tool decides BEFORE the body
# runs: a deny raises ToolBlocked; a mask verdict hands the callback ONLY the
# redacted arguments, never the originals.
sig = inspect.signature(fn)
@functools.wraps(fn)
def wrapper(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
wrapper.__name__ = name
return wrapper
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
from crewai.tools import BaseTool
class RefundTool(BaseTool):
name: str = "issue_refund"
description: str = "Issue a refund to a customer"
def _run(self, customer_id: str, amount: int) -> str:
return gov.gate_tool(
self.name, {"customer_id": customer_id, "amount": amount},
lambda effective: issue_refund(**effective),
)
gov.start(tools=[{"name": "issue_refund"}])
gov.flush() # deliver buffered audit events before the process exits
```
**CrewAI interception surface.** CrewAI tools subclass `BaseTool` and execute in `_run` — the framework's native execution seam; route `_run` through the gate. The `governed(...)`
wrapper preserves each tool's real signature (via `functools.wraps`) so the
framework still builds a correct per-parameter schema.
## Run this on AWS Bedrock
CrewAI reaches Bedrock through its own `LLM` wrapper, which routes to LiteLLM's
`bedrock/` provider — so there is no adapter package, and every governed tool
above is unchanged:
```python theme={null}
from crewai import LLM
# Only the model changes — RefundTool, gov.gate_tool(...) and gov.start(...)
# stay exactly as written above.
llm = LLM(model="bedrock/amazon.nova-micro-v1:0", temperature=0)
```
Hand that `llm` to the agents that call your governed tools. Note the `bedrock/`
**prefix** on the model id — that prefix is what selects the provider. Credentials
come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: the gate
fires at the same point, the same rules match, the same rows land in the audit
trail. Verified end to end against real Bedrock inference.
15 of 15 Bedrock scenario checks passed.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the `visiq` wheel in your image and set `VISIQ_API_KEY`
in the runtime environment. If instead you use a **managed harness**, AWS owns the
agent loop and these in-process `gate_tool` calls do not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page.
* **Decisions are local.** The SDK fetches one cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background.
Tool calls evaluate in-process; the only decision-path network call is waiting
on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce: an explicit rule **deny**, the operator kill-switch, and an
in-core mask/redact that cannot be applied (it downgrades to deny) all block.
A harness-**internal** failure also blocks: if the backend is unreachable or the
governance core is unavailable, `gate_tool` raises `ToolBlocked("Governance
unavailable — tool blocked (fail-closed, G001)")`. **The Python SDK is
fail-CLOSED, and there is no `VISIQ_FAIL_MODE` to change that** — the variable
exists only in the TypeScript harness, which does default to fail-open. Plan for
a VisIQ outage to stop governed tool calls in Python, not to pass them through.
(This differs from the 2026-07-15 owner decision that agent-side SDKs fail OPEN;
the divergence is real and is the SDK's behaviour, not this page's.)
* **Deny blocks the call.** `gate_tool` raises `ToolBlocked` before the body
runs, so the tool is never executed; catch it and return the reason to the
model.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) —
the SDK polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the model
sees them.
**This is the manual per-tool `Governor` pattern.** A turnkey one-call
`visiq()`-style plugin for CrewAI is not yet published — you wire each tool
through `gov.gate_tool(...)` yourself, exactly as shown above. The `visiq` wheel
that makes the decisions is published today; the one-call auto-wrapper is coming,
matching this framework's "coming soon" connector card.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# Google ADK Quickstart
Source: https://docs.visiqlabs.com/quickstart/google-adk
Govern a Google Agent Development Kit agent's tools with VisIQ using the published `visiq` Python wheel.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Python 3.9+**, and a model
provider key for Google ADK (the sample calls a hosted model — any provider
works). Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[Google ADK](https://google.github.io/adk-docs/) agent. The [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core as the
TypeScript harness — makes every decision **locally, in-process** against a
cached rule bundle. You construct a `Governor` and route each tool call through
its gate: a policy **deny** raises `ToolBlocked` and a **mask** verdict hands your
callback only the redacted arguments.
## Install
```bash theme={null}
pip install visiq "google-adk>=0.1"
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set it ONLY for
# onprem / self-hosted deployments. A bare VISIQ_API_KEY reaches the managed
# SaaS control plane, loads a bundle, and governs automatically.
VISIQ_AGENT_ID=support-bot
```
| Variable | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) from **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments (those planes live on your own network and must never default to a VisIQ host). |
| `VISIQ_AGENT_ID` | No | Stable agent identity for rule targeting. First-seen ids are auto-provisioned in monitor mode. |
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` — export the variables (or load them
yourself) before constructing the `Governor`.
## Govern your tools
Wrap each tool body in the gate, then wire the governed callables into
Google ADK's native tool surface (`before_tool_callback`):
```python theme={null}
import functools
import inspect
from visiq import Governor, ToolBlocked # pip install visiq
gov = Governor(agent_id="support-bot")
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape:
# a list of {"page_content", "metadata"} dicts gate_documents can filter.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def governed(name, fn):
# Route one tool body through the gate. gate_tool decides BEFORE the body
# runs: a deny raises ToolBlocked; a mask verdict hands the callback ONLY the
# redacted arguments, never the originals.
sig = inspect.signature(fn)
@functools.wraps(fn)
def wrapper(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
wrapper.__name__ = name
return wrapper
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
from google.adk.agents import Agent
def before_tool_callback(tool, args, tool_context):
# Gate BEFORE the tool runs; a deny raises ToolBlocked.
return gov.gate_tool(tool.name, dict(args), lambda effective: None)
agent = Agent(
name="support", model="gemini-2.0-flash",
tools=[issue_refund, search_knowledge],
before_tool_callback=before_tool_callback,
)
gov.start(tools=[{"name": "issue_refund"}, {"name": "search_knowledge"}])
gov.flush() # deliver buffered audit events before the process exits
```
**Google ADK interception surface.** Google ADK invokes `before_tool_callback` BEFORE each tool; routing it through `gov.gate_tool` lets a deny raise `ToolBlocked`, which ADK surfaces as the tool's failure so the body never runs. The `governed(...)`
wrapper preserves each tool's real signature (via `functools.wraps`) so the
framework still builds a correct per-parameter schema.
## Run this on AWS Bedrock
Google ADK has no native Bedrock provider — it reaches Bedrock through its
documented `LiteLlm` model wrapper, which ships in an **extra** rather than in the
base package:
```bash theme={null}
pip install "google-adk[extensions]"
```
Without that extra the import below raises
`ImportError: LiteLLM support requires: pip install google-adk[extensions]` — the
base `google-adk` install at the top of this page is not enough for Bedrock.
Then swap the `model=` string for a `LiteLlm` instance; everything else above is
unchanged:
```python theme={null}
from google.adk.models.lite_llm import LiteLlm
agent = Agent(
name="support",
model=LiteLlm(model="bedrock/amazon.nova-micro-v1:0", temperature=0),
tools=[issue_refund, search_knowledge],
before_tool_callback=before_tool_callback,
)
```
**The model id carries a `bedrock/` prefix.** It is
`bedrock/amazon.nova-micro-v1:0`, not the bare `amazon.nova-micro-v1:0` — the
prefix is what routes the call to Bedrock.
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched:
`before_tool_callback` fires at the same point, the same rules match, the same
rows land in the audit trail. Verified end to end against real Bedrock inference.
15 of 15 Bedrock scenario checks passed
— with an ungoverned control arm that had to leak for the run to count.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the `visiq` wheel in your image and set
`VISIQ_API_KEY` in the runtime environment.
If instead you use a **managed harness**, AWS owns the agent loop and this
in-process callback does not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page.
* **Decisions are local.** The SDK fetches one cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background.
Tool calls evaluate in-process; the only decision-path network call is waiting
on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce: an explicit rule **deny**, the operator kill-switch, and an
in-core mask/redact that cannot be applied (it downgrades to deny) all block.
A harness-**internal** failure also blocks: if the backend is unreachable or the
governance core is unavailable, `gate_tool` raises `ToolBlocked("Governance
unavailable — tool blocked (fail-closed, G001)")`. **The Python SDK is
fail-CLOSED, and there is no `VISIQ_FAIL_MODE` to change that** — the variable
exists only in the TypeScript harness, which does default to fail-open. Plan for
a VisIQ outage to stop governed tool calls in Python, not to pass them through.
(This differs from the 2026-07-15 owner decision that agent-side SDKs fail OPEN;
the divergence is real and is the SDK's behaviour, not this page's.)
* **Deny blocks the call.** `gate_tool` raises `ToolBlocked` before the body
runs, so the tool is never executed; catch it and return the reason to the
model.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) —
the SDK polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the model
sees them.
**This is the manual per-tool `Governor` pattern.** A turnkey one-call
`visiq()`-style plugin for Google ADK is not yet published — you wire each tool
through `gov.gate_tool(...)` yourself, exactly as shown above. The `visiq` wheel
that makes the decisions is published today; the one-call auto-wrapper is coming.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# LangChain Quickstart
Source: https://docs.visiqlabs.com/quickstart/langchain
Wrap a LangChain AgentExecutor or LangGraph graph with VisIQ governance in one function call.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+** (or **Python 3.9+**
for the [Python](#python) path), and an
`OPENAI_API_KEY` (the sample below calls an OpenAI model — any provider works).
Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[LangChain](https://js.langchain.com) agent by passing your `AgentExecutor` to
`visiq()`. The same call detects and wraps a LangGraph `CompiledGraph`. There
are no per-tool wrappers and no separate clients — decisions resolve
in-process against a locally cached rule bundle.
## Install
```bash theme={null}
npm install @visiq/harness "langchain@^0.3" "@langchain/openai@^0.3" "@langchain/core@^0.3" "zod@^3"
```
Pin LangChain to `^0.3`. LangChain **1.x** removed the `langchain/agents` entry point
(`AgentExecutor` / `createOpenAIToolsAgent`) used below in favor of `createAgent` and the graph
API — an unpinned install resolves to 1.x and the import in the next step throws. VisIQ governs all three per-tool:
the 0.3 `AgentExecutor` path, a LangGraph compiled graph, and 1.x's `createAgent` — the last is a
ReactAgent wrapper whose graph sits at `.graph`, and the SDK reads through it, so the tools inside
are governed individually rather than the agent being treated as one opaque tool. This quickstart
uses 0.3. Pin `zod@^3` too: zod 4 serializes tool parameters in a shape the evaluator rejects with
`400 invalid_function_parameters`.
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agent calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your agent
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { AgentExecutor, createOpenAIToolsAgent } from "langchain/agents";
import { createRetrieverTool } from "langchain/tools/retriever";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { DynamicTool } from "@langchain/core/tools";
// A small in-memory knowledge base so this snippet runs as pasted.
const vectorStore = await MemoryVectorStore.fromTexts(
["Q3 revenue was $4.2M.", "Refunds over $500 require manager approval."],
[{ classification: "internal" }, { classification: "public" }],
new OpenAIEmbeddings(),
);
const knowledgeRetriever = vectorStore.asRetriever();
// Your tools — unchanged.
const tools = [
new DynamicTool({
name: "issue_refund",
description: "Issue a refund to a customer",
func: async (input: string) => {
const { customerId, amount } = JSON.parse(input);
return `Refunded $${amount} to ${customerId}`;
},
}),
createRetrieverTool(knowledgeRetriever, {
name: "search_knowledge",
description: "Search the company knowledge base",
}),
];
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a helpful assistant."],
["human", "{input}"],
["placeholder", "{agent_scratchpad}"],
]);
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 });
const agent = await createOpenAIToolsAgent({ llm, tools, prompt });
// ── One call — governance + audit trail activate here ──
const executor = visiq(new AgentExecutor({ agent, tools }), { agentId: "support-bot" });
const result = await executor.invoke({ input: "What was Q3 revenue?" });
```
Enforcement wraps each tool's `invoke`/`call`/`_call` dispatch methods
directly — LangChain callbacks cannot block a tool call — so it holds for any
agent constructor, and `executor.stream()` is governed identically to
`invoke()`.
**Per-document RAG governance.** `createRetrieverTool` captures its retriever
in a closure and returns one joined string, so the harness falls back to
reduced-fidelity governance for that tool: pattern and value-shape masking
still apply to the string, but retrieval rules keyed on per-document
`metadata` (classification, source, …) cannot fire — the SDK prints a one-time
console warning when this happens. For full per-document governance, expose
the retriever on the tool as a reachable `.retriever` property, or use a tool
that returns a `Document[]`: any retriever the harness can reach (including a
nested `.retriever`) is instrumented so each document is evaluated with its
own metadata.
## Python
LangChain also has a Python SDK, and so does VisIQ: the [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core. The Python
API is **not** a `visiq()` wrapper. Instead you construct a `Governor` and route
each tool call through its gate, so a policy **deny** raises `ToolBlocked` and a
`mask` verdict hands your callback only the redacted arguments.
```bash theme={null}
pip install visiq "langchain>=0.3,<0.4" "langchain-openai>=0.2,<0.3" "langchain-core>=0.3,<0.4"
```
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` (that is the TypeScript harness) — so load it
yourself before constructing the `Governor`, or export the variables:
```python theme={null}
import functools
import inspect
from dotenv import load_dotenv # pip install python-dotenv
from langchain_core.tools import StructuredTool
from langchain.agents import AgentExecutor, create_openai_tools_agent
from visiq import Governor, ToolBlocked # pip install visiq
load_dotenv() # VISIQ_API_KEY / VISIQ_AGENT_ID (+ VISIQ_ENDPOINT for onprem)
gov = Governor(agent_id="support-bot")
def governed(name, fn):
# functools.wraps preserves the real signature so LangChain builds a correct
# per-parameter schema (a bare **kwargs wrapper would blind arg-matching rules).
sig = inspect.signature(fn)
@functools.wraps(fn)
def tool_fn(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
# gate_tool decides BEFORE the body runs; the callback receives the
# EFFECTIVE (redacted-on-mask) arguments, never the originals.
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
tool_fn.__name__ = name
return tool_fn
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape: a
# list of {"page_content", "metadata"} dicts that gate_documents can filter,
# drop, or redact before the model sees them.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
tools = [
StructuredTool.from_function(func=governed("issue_refund", issue_refund),
name="issue_refund", description="Issue a refund to a customer"),
StructuredTool.from_function(func=search_knowledge, name="search_knowledge",
description="Search the company knowledge base"),
]
# Report the tool surface once (drives blast-radius inference), then run the agent.
gov.start(tools=[{"name": t.name, "description": t.description} for t in tools])
# … build create_openai_tools_agent(llm, tools, prompt) + AgentExecutor as usual …
gov.flush() # deliver any buffered audit events before a short-lived process exits
```
A complete, runnable version of this agent — same 13 tools, same RAG corpus —
lives in
[`examples/langchain-agent-py`](https://github.com/VISIQ-LABS/visiq-platform/tree/main/examples/langchain-agent-py).
The [LlamaIndex](/quickstart/llamaindex) and
[OpenAI Agents SDK](/quickstart/openai-agents-sdk) quickstarts have Python
peers too.
## Run this on AWS Bedrock
LangChain reaches Bedrock through AWS's own integration package, so the governance
wiring does not change — `pip install langchain-aws`, then swap the model the
[Python](#python) agent above already builds:
```python theme={null}
from langchain_aws import ChatBedrockConverse
llm = ChatBedrockConverse(
model="amazon.nova-micro-v1:0",
region_name="us-east-1",
temperature=0,
)
agent = create_openai_tools_agent(llm, tools, prompt)
```
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: `gate_tool`
still decides before the tool body runs, `gate_documents` still filters retrieval,
the same rules match, the same rows land in the audit trail. Verified end to end
against real Bedrock inference.
15 of 15 Bedrock scenario checks passed
— with an ungoverned control arm that had to leak for the run to count.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the harness in your image and set `VISIQ_API_KEY` in the
runtime environment. If instead you use a **managed harness**, AWS owns the agent
loop and this in-process gate does not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Tool calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call hands the model
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
as the tool's output, so the agent reads it and adjusts course.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK polls
for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the
model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of 35 default rules.
Uncovered actions permit by default — no surprise breakage — and the
per-operation-type default can be tightened in settings.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# LangGraph Quickstart
Source: https://docs.visiqlabs.com/quickstart/langgraph
Govern a LangGraph agent's tools with VisIQ using the published `visiq` Python wheel.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Python 3.9+**, and a model
provider key for LangGraph (the sample calls a hosted model — any provider
works). Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[LangGraph](https://langchain-ai.github.io/langgraph/) agent. The [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core as the
TypeScript harness — makes every decision **locally, in-process** against a
cached rule bundle. You construct a `Governor` and route each tool call through
its gate: a policy **deny** raises `ToolBlocked` and a **mask** verdict hands your
callback only the redacted arguments.
## Install
```bash theme={null}
pip install visiq "langgraph>=0.2" "langchain-core>=0.3"
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set it ONLY for
# onprem / self-hosted deployments. A bare VISIQ_API_KEY reaches the managed
# SaaS control plane, loads a bundle, and governs automatically.
VISIQ_AGENT_ID=support-bot
```
| Variable | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) from **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments (those planes live on your own network and must never default to a VisIQ host). |
| `VISIQ_AGENT_ID` | No | Stable agent identity for rule targeting. First-seen ids are auto-provisioned in monitor mode. |
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` — export the variables (or load them
yourself) before constructing the `Governor`.
## Govern your tools
Wrap each tool body in the gate, then wire the governed callables into
LangGraph's native tool surface (the `@tool` callable surface):
```python theme={null}
import functools
import inspect
from visiq import Governor, ToolBlocked # pip install visiq
gov = Governor(agent_id="support-bot")
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape:
# a list of {"page_content", "metadata"} dicts gate_documents can filter.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def governed(name, fn):
# Route one tool body through the gate. gate_tool decides BEFORE the body
# runs: a deny raises ToolBlocked; a mask verdict hands the callback ONLY the
# redacted arguments, never the originals.
sig = inspect.signature(fn)
@functools.wraps(fn)
def wrapper(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
wrapper.__name__ = name
return wrapper
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
from langchain_core.tools import tool # LangGraph's native tool surface
from langgraph.prebuilt import create_react_agent
tools = [
tool(governed("issue_refund", issue_refund)),
tool(search_knowledge),
]
gov.start(tools=[{"name": "issue_refund"}, {"name": "search_knowledge"}])
agent = create_react_agent("openai:gpt-4o", tools)
print(agent.invoke({"messages": [("user", "What was Q3 revenue?")]}))
gov.flush() # deliver buffered audit events before the process exits
```
**LangGraph interception surface.** LangGraph tools are plain callables registered with `@tool` (from `langchain_core.tools`); wrap the callable with `governed(...)` first so every invocation routes through the gate. The `governed(...)`
wrapper preserves each tool's real signature (via `functools.wraps`) so the
framework still builds a correct per-parameter schema.
## Run this on AWS Bedrock
LangGraph drives LangChain model objects, so Bedrock needs one extra package —
`pip install langchain-aws` — and one changed argument. The governed `tools` list
built above is unchanged:
```python theme={null}
from langchain_aws import ChatBedrockConverse # pip install langchain-aws
agent = create_react_agent(
ChatBedrockConverse(
model="amazon.nova-micro-v1:0",
region_name="us-east-1",
temperature=0,
),
tools,
)
```
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: `gate_tool`
fires at the same point, the same rules match, the same rows land in the audit
trail. Verified end to end against real Bedrock inference, with no
LangGraph-specific caveat.
15 of 15 Bedrock scenario checks passed.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the `visiq` wheel in your image and set `VISIQ_API_KEY`
in the runtime environment. If instead you use a **managed harness**, AWS owns the
agent loop and the per-tool `Governor` calls above never run; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page.
* **Decisions are local.** The SDK fetches one cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background.
Tool calls evaluate in-process; the only decision-path network call is waiting
on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce: an explicit rule **deny**, the operator kill-switch, and an
in-core mask/redact that cannot be applied (it downgrades to deny) all block.
A harness-**internal** failure also blocks: if the backend is unreachable or the
governance core is unavailable, `gate_tool` raises `ToolBlocked("Governance
unavailable — tool blocked (fail-closed, G001)")`. **The Python SDK is
fail-CLOSED, and there is no `VISIQ_FAIL_MODE` to change that** — the variable
exists only in the TypeScript harness, which does default to fail-open. Plan for
a VisIQ outage to stop governed tool calls in Python, not to pass them through.
(This differs from the 2026-07-15 owner decision that agent-side SDKs fail OPEN;
the divergence is real and is the SDK's behaviour, not this page's.)
* **Deny blocks the call.** `gate_tool` raises `ToolBlocked` before the body
runs, so the tool is never executed; catch it and return the reason to the
model.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) —
the SDK polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the model
sees them.
**This is the manual per-tool `Governor` pattern.** A turnkey one-call
`visiq()`-style plugin for LangGraph is not yet published — you wire each tool
through `gov.gate_tool(...)` yourself, exactly as shown above. The `visiq` wheel
that makes the decisions is published today; the one-call auto-wrapper is coming.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# LlamaIndex.TS Quickstart
Source: https://docs.visiqlabs.com/quickstart/llamaindex
Wrap a LlamaIndex.TS agent workflow with VisIQ governance in one function call.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+** (or **Python 3.9+**
for the [Python](#python) path), and an
`OPENAI_API_KEY` (the sample below calls an OpenAI model — any provider works).
Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[LlamaIndex.TS](https://ts.llamaindex.ai) agent workflow by passing your
`agent` to `visiq()`. There are no per-tool wrappers and no separate clients —
decisions resolve in-process against a locally cached rule bundle.
## Install
```bash theme={null}
npm install @visiq/harness llamaindex @llamaindex/workflow @llamaindex/openai zod
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agent calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your agent
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { tool } from "llamaindex";
import { agent } from "@llamaindex/workflow";
import { openai } from "@llamaindex/openai";
import { z } from "zod";
// Any function returning document-shaped results works as a RAG source.
const searchDocs = async (query: string) => [
{ pageContent: `Q3 revenue was $4.2M. (matched: ${query})`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = [
tool({
name: "issue_refund",
description: "Issue a refund to a customer",
parameters: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
tool({
name: "search_knowledge",
description: "Search the company knowledge base",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
];
// ── One call — governance + per-run session activate here ──
const supportAgent = visiq(
agent({ name: "support", llm: openai({ model: "gpt-4o" }), tools, systemPrompt: "You are a helpful assistant." }),
{ agentId: "support-bot" },
);
const result = await supportAgent.run("What was Q3 revenue?");
console.log(result.data.result);
```
`runStream()` is governed identically to `run()`, and each run gets a fresh
session id so the dashboard correlates every decision in that run.
**Retrieval governance contract.** The harness governs a LlamaIndex workflow
at the tool boundary: each tool's `call()` is gated, and per-document
filtering applies to tools that return document-shaped results — array items
with a string `pageContent`, `text`, or `content` field, plus optional
`metadata` that retrieval rules match on (classification, data categories,
…). A retriever wired directly into the workflow (e.g. `index.asRetriever()`)
returns node objects the filter does not recognize — expose retrieval as a
tool that maps retrieved nodes to `{ text, metadata }` documents before
returning them, as in the snippet above.
## Python
LlamaIndex ships a Python framework, and so does VisIQ: the
[`visiq`](https://pypi.org/project/visiq/) wheel — published on PyPI, compiled
from the same governance core. The Python API is **not** a `visiq()` wrapper.
You construct a `Governor` and route each tool call through its gate, so a policy
**deny** raises `ToolBlocked` and a `mask` verdict hands your callback only the
redacted arguments.
```bash theme={null}
pip install visiq "llama-index-core>=0.11,<0.13" "llama-index-llms-openai>=0.2,<0.4"
```
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` (that is the TypeScript harness) — so load it
yourself before constructing the `Governor`, or export the variables:
```python theme={null}
import asyncio
import functools
import inspect
from dotenv import load_dotenv # pip install python-dotenv
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.core.tools import FunctionTool
from llama_index.llms.openai import OpenAI
from visiq import Governor, ToolBlocked # pip install visiq
load_dotenv() # VISIQ_API_KEY / VISIQ_AGENT_ID (+ VISIQ_ENDPOINT for onprem)
gov = Governor(agent_id="support-bot")
def governed(name, fn):
# functools.wraps preserves the real signature so LlamaIndex builds a correct
# per-parameter schema (a bare **kwargs wrapper would blind arg-matching rules).
sig = inspect.signature(fn)
@functools.wraps(fn)
def tool_fn(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
# gate_tool decides BEFORE the body runs; the callback receives the
# EFFECTIVE (redacted-on-mask) arguments, never the originals.
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
tool_fn.__name__ = name
return tool_fn
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape: a
# list of {"page_content", "metadata"} dicts that gate_documents can filter,
# drop, or redact before the model sees them.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
tools = [
FunctionTool.from_defaults(fn=governed("issue_refund", issue_refund),
name="issue_refund", description="Issue a refund to a customer"),
FunctionTool.from_defaults(fn=search_knowledge, name="search_knowledge",
description="Search the company knowledge base"),
]
async def main():
# Report the tool surface once (drives blast-radius inference).
gov.start(tools=[{"name": t.metadata.name, "description": t.metadata.description} for t in tools])
agent = FunctionAgent(tools=tools, llm=OpenAI(model="gpt-4o"),
system_prompt="You are a helpful assistant.")
print(await agent.run("What was Q3 revenue?"))
gov.flush() # deliver any buffered audit events before the process exits
asyncio.run(main())
```
A complete, runnable version of this agent — same 13 tools, same RAG corpus —
lives in
[`examples/llamaindex-agent-py`](https://github.com/VISIQ-LABS/visiq-platform/tree/main/examples/llamaindex-agent-py).
The [LangChain](/quickstart/langchain) and
[OpenAI Agents SDK](/quickstart/openai-agents-sdk) quickstarts have Python
peers too.
## Run this on AWS Bedrock
LlamaIndex ships its own first-party Bedrock integration, so moving the Python
agent above onto Bedrock is a provider swap — install the package and hand
`FunctionAgent` a `BedrockConverse` instead of `OpenAI`:
```bash theme={null}
pip install llama-index-llms-bedrock-converse
```
```python theme={null}
from llama_index.llms.bedrock_converse import BedrockConverse
async def main():
gov.start(tools=[{"name": t.metadata.name, "description": t.metadata.description} for t in tools])
agent = FunctionAgent(
tools=tools,
llm=BedrockConverse(model="amazon.nova-micro-v1:0", region_name="us-east-1", temperature=0),
system_prompt="You are a helpful assistant.",
)
print(await agent.run("What was Q3 revenue?"))
gov.flush()
```
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched:
`gate_tool` and `gate_documents` fire at the same points, the same rules match,
the same rows land in the audit trail. Verified end to end against real Bedrock
inference.
15 of 15 Bedrock scenario checks passed.
**Construct the agent inside the coroutine that runs it.** LlamaIndex binds its
workflow to the event loop that is alive when the agent is *constructed*. A
`FunctionAgent` built once at module scope and then driven by a fresh
`asyncio.run(...)` per turn dies with `RuntimeError: no running event loop` —
and because the tools then never run, tool-level assertions pass vacuously, so
the failure can read as a clean run. Build the agent inside `main()` as above,
or reuse one loop for the life of the process.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install `visiq` in your image and set `VISIQ_API_KEY` in the
runtime environment. The `Governor` and its locally cached bundle come along
unchanged.
If instead you use a **managed harness**, AWS owns the agent loop and these
in-process gates do not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Tool calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call hands the model
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
as the tool's output, so the agent reads it and adjusts course.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK polls
for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the
model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of 35 default rules.
Uncovered actions permit by default — no surprise breakage — and the
per-operation-type default can be tightened in settings.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# Mastra Quickstart
Source: https://docs.visiqlabs.com/quickstart/mastra
Wrap a Mastra agent with VisIQ governance in one function call.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+**, and an
`OPENAI_API_KEY` (the sample below calls an OpenAI model — any provider works).
Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[Mastra](https://mastra.ai) agent by passing your `Agent` to `visiq()`. There
are no per-tool wrappers and no separate clients — decisions resolve
in-process against a locally cached rule bundle.
## Install
```bash theme={null}
npm install @visiq/harness @mastra/core zod
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agent calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your agent
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
// Any function returning document-shaped results works as a RAG source.
const searchDocs = async (query: string) => [
{ pageContent: `Q3 revenue was $4.2M. (matched: ${query})`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = {
issue_refund: createTool({
id: "issue_refund",
description: "Issue a refund to a customer",
inputSchema: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
search_knowledge: createTool({
id: "search_knowledge",
description: "Search the company knowledge base",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
};
// ── One call — governance + per-run LLM telemetry activate here ──
const agent = visiq(
new Agent({
id: "support",
name: "support",
instructions: "You are a helpful assistant.",
model: "openai/gpt-4o",
tools,
}),
{ agentId: "support-bot" },
);
const result = await agent.generate("What was Q3 revenue?");
```
`stream()`, `generateVNext()`, and `streamVNext()` are governed identically to
`generate()`, and each run gets a fresh session id so the dashboard correlates
every decision in that run.
**Retrieval governance contract.** Per-document filtering applies to tools
whose `execute()` returns document-shaped results — array items with a string
`pageContent`, `text`, or `content` field, plus optional `metadata` that
retrieval rules match on (classification, data categories, …). Results in any
other shape still pass through the action gate but are not filtered
per-document.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Tool calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call hands the model
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
as the tool's output, so the agent reads it and adjusts course.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK polls
for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the
model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of 35 default rules.
Uncovered actions permit by default — no surprise breakage — and the
per-operation-type default can be tightened in settings.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# OpenAI Agents SDK Quickstart
Source: https://docs.visiqlabs.com/quickstart/openai-agents-sdk
Wrap an OpenAI Agents SDK agent with VisIQ governance in one function call.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+** (or **Python 3.9+**
for the [Python](#python) path), and an
`OPENAI_API_KEY` (the sample below calls an OpenAI model — any provider works).
Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to an
[OpenAI Agents SDK](https://openai.github.io/openai-agents-js) agent by passing
your `Agent` to `visiq()`. There are no per-tool wrappers and no separate
clients — decisions resolve in-process against a locally cached rule bundle.
## Install
```bash theme={null}
npm install @visiq/harness @openai/agents zod
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agent calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your agent
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Agent, run, tool } from "@openai/agents";
import { z } from "zod";
// Any function returning document-shaped results works as a RAG source.
const searchDocs = async (query: string) => [
{ pageContent: `Q3 revenue was $4.2M. (matched: ${query})`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = [
tool({
name: "issue_refund",
description: "Issue a refund to a customer",
parameters: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
tool({
name: "search_knowledge",
description: "Search the company knowledge base",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
];
// ── One call — governance + per-run telemetry activate here ──
const agent = visiq(
new Agent({ name: "support", instructions: "You are a helpful assistant.", tools, model: "gpt-4o" }),
{ agentId: "support-bot" },
);
const result = await run(agent, "What was Q3 revenue?");
console.log(result.finalOutput);
```
The harness wraps each function tool's model-facing `invoke` in place, so
every call the `run()` loop dispatches is gated — regardless of how you start
the run.
**Retrieval governance contract.** Per-document filtering applies to tools
whose `execute()` returns document-shaped results — array items with a string
`pageContent`, `text`, or `content` field, plus optional `metadata` that
retrieval rules match on (classification, data categories, …). Results in any
other shape still pass through the action gate but are not filtered
per-document.
## Python
The OpenAI Agents SDK has a Python edition, and so does VisIQ: the
[`visiq`](https://pypi.org/project/visiq/) wheel — published on PyPI, compiled
from the same governance core. The Python API is **not** a `visiq()` wrapper.
You construct a `Governor` and route each tool call through its gate, so a policy
**deny** raises `ToolBlocked` and a `mask` verdict hands your callback only the
redacted arguments.
```bash theme={null}
pip install visiq "openai-agents>=0.1,<1.0"
```
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` (that is the TypeScript harness) — so load it
yourself before constructing the `Governor`, or export the variables:
```python theme={null}
import asyncio
from dotenv import load_dotenv # pip install python-dotenv
from agents import Agent, Runner, function_tool
from visiq import Governor, ToolBlocked # pip install visiq
load_dotenv() # VISIQ_API_KEY / VISIQ_AGENT_ID (+ VISIQ_ENDPOINT for onprem)
gov = Governor(agent_id="support-bot")
def gate(name, fn, **kwargs):
try:
# gate_tool decides BEFORE the body runs; the callback receives the
# EFFECTIVE (redacted-on-mask) arguments, never the originals.
return gov.gate_tool(name, kwargs, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
def _issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
# The OpenAI Agents SDK derives each tool's JSON schema from the typed signature,
# so declare tools explicitly and route every body through the gate.
@function_tool
def issue_refund(customer_id: str, amount: int) -> str:
return gate("issue_refund", _issue_refund, customer_id=customer_id, amount=amount)
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape: a
# list of {"page_content", "metadata"} dicts that gate_documents can filter,
# drop, or redact before the model sees them.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
@function_tool
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
TOOLS = [issue_refund, search_knowledge]
async def main():
gov.start(tools=[{"name": t.name} for t in TOOLS]) # report the tool surface once
agent = Agent(name="support", instructions="You are a helpful assistant.",
model="gpt-4o", tools=TOOLS)
result = await Runner.run(agent, "What was Q3 revenue?")
print(result.final_output)
gov.flush() # deliver any buffered audit events before the process exits
asyncio.run(main())
```
A complete, runnable version of this agent — same 13 tools, same RAG corpus —
lives in
[`examples/openai-agents-agent-py`](https://github.com/VISIQ-LABS/visiq-platform/tree/main/examples/openai-agents-agent-py).
The [LangChain](/quickstart/langchain) and [LlamaIndex](/quickstart/llamaindex)
quickstarts have Python peers too.
## Run this on AWS Bedrock
Despite the name, the OpenAI Agents SDK is model-agnostic: its LiteLLM extension
reaches Bedrock. Install the extra (`pip install "openai-agents[litellm]"`) and
swap the `model=` you hand to `Agent` — the rest of the Python agent above is
unchanged:
```python theme={null}
from agents.extensions.models.litellm_model import LitellmModel
agent = Agent(
name="support",
instructions="You are a helpful assistant.",
model=LitellmModel(model="bedrock/amazon.nova-micro-v1:0"),
tools=TOOLS,
)
```
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: `gate()`
and `gate_documents` fire at the same points, the same rules match, the same rows
land in the audit trail. Ran against real Bedrock inference, with an ungoverned
control arm that had to leak for the run to count — and no framework-specific
caveat.
15 of 15 Bedrock scenario checks passed.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the harness in your image and set `VISIQ_API_KEY` in the
runtime environment. If instead you use a **managed harness**, AWS owns the agent
loop and this in-process gate does not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Tool calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call hands the model
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
as the tool's output, so the agent reads it and adjusts course.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK polls
for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the
model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of 35 default rules.
Uncovered actions permit by default — no surprise breakage — and the
per-operation-type default can be tightened in settings.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# OpenClaw Integration
Source: https://docs.visiqlabs.com/quickstart/openclaw
Gate every OpenClaw tool call with VisIQ action governance, retrieval redaction, and the audit trail — one plugin, six hooks.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, and **OpenClaw `2026.5.18`+**.
This CLI harness governs OpenClaw's own agent, so no separate model key is
needed here. Hitting an error? See [Troubleshooting](/troubleshooting).
`@visiq/openclaw-plugin` is an [OpenClaw](https://openclaw.com) plugin that routes
every tool call through VisIQ's action and retrieval governance and feeds the
audit trail. It installs from npm and requires OpenClaw `2026.5.18` or newer.
## What it does
The plugin registers six OpenClaw hooks:
| Hook | Behavior |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `before_tool_call` | Governs **every** tool call with one evaluation. **deny** blocks the call with a `[VisIQ decision=deny code=]` reason; **approval required** routes it to OpenClaw's native approval queue (allow-once / allow-always / deny); **mask** runs the tool with the named arguments redacted. Retrieval tools additionally get the retrieval facet: **deny** blocks, **redact** queues a redaction spec, **escalate** prompts a human approval. |
| `tool_result_persist` | Applies any queued redaction spec to the tool's output before it lands in the session transcript. |
| `before_message_write` | Defense-in-depth gate. If a redaction was queued but never applied, the message is dropped (fail-closed by design). |
| `llm_input` | Best-effort telemetry about each model call — metadata only (model, prompt length, message and tool counts), never prompt text. |
| `llm_output` | Best-effort telemetry about each model response — segment counts and token usage, never assistant text. |
| `session_end` | Clears the plugin's per-session redaction state. |
The retrieval facet is pre-seeded for ten tool names: `web_search`, `x_search`,
`web_fetch`, `tool_search`, `tool_describe`, `get_account`, `get_transactions`,
`get_devices`, `check_watchlist`, and `search_knowledge`. Namespaced tools
(`mcp__brave__web_search`) match on the segment after the last `__`. The set is
a fast-path default, not a governance boundary — tools outside it still get
full action governance on every call.
Decisions evaluate in-process against the agent's rule bundle, which the plugin
pre-warms before the first call — registering a human approval is the only decision-path network call. New agents
auto-provision in **monitor** mode on first contact: every decision is
evaluated and recorded, but nothing is blocked until you switch the agent to
enforce (see [Verifying the install](#verifying-the-install)).
## Interactive setup
You need a harness API key (`vq_test_` or `vq_prod_`). The guided setup on the
**OpenClaw** card under **Integration → Connectors** mints a scoped key for you;
you can also create one under **Settings → Harness Keys**. Then install and
configure the plugin:
```bash theme={null}
openclaw plugins install @visiq/openclaw-plugin --dangerously-force-unsafe-install
export VISIQ_API_KEY=
export VISIQ_AGENT_ID=
openclaw gateway run --auth none --bind loopback --port 18789
```
The plugin's `VISIQ_BASE_URL` / `VISIQ_ENDPOINT` defaults to
`https://api.visiqlabs.com` — set it only for cloud / onprem (sovereign)
deployments. On SaaS a bare API key is all you need.
The `--dangerously-force-unsafe-install` flag is required. OpenClaw's install
scanner blocks any plugin that reads environment variables **and** makes network
calls, flagging it as "possible credential harvesting". The VisIQ plugin does
exactly that **by design** — it reads `VISIQ_API_KEY` and sends governance
decisions to your VisIQ backend — so this is an expected false positive for a
governance plugin. Without the flag the install is hard-blocked.
`openclaw gateway run` runs the gateway in the foreground and serves the
Control UI at `http://localhost:18789`. To run it as a background service
(launchd / systemd / schtasks) instead:
```bash theme={null}
openclaw daemon install && openclaw daemon start
```
## Automated / CI setup
For pipelines, containers, and anywhere without an interactive TTY, supply
credentials via environment variables and run the gateway headless:
```bash theme={null}
VISIQ_API_KEY=vq_prod_... \
VISIQ_AGENT_ID=my-agent \
openclaw gateway run
```
Optional:
```bash theme={null}
VISIQ_CONFIG_PATH=/etc/visiq/config.json # alternate JSON file
```
## Persisting credentials (the durable channel)
An exported variable only reaches a gateway **you** launch from that same shell.
For a gateway that is already running — a service, a supervised process, anything
you did not start yourself — the credentials must live in
`~/.openclaw/openclaw.json`. `openclaw setup` has already created that file, so
**add** the plugin entry rather than replacing it:
```bash theme={null}
CFG="$HOME/.openclaw/openclaw.json"
mkdir -p "$(dirname "$CFG")"
[ -s "$CFG" ] || echo '{}' > "$CFG"
jq '.plugins.entries["@visiq/openclaw-plugin"] = {
"enabled": true,
"config": { "apiKey": "vq_prod_...", "agentId": "my-agent",
"baseUrl": "https://visiq.internal.example" },
"hooks": { "allowConversationAccess": true }
}' "$CFG" > "$CFG.new" && mv "$CFG.new" "$CFG"
```
(`baseUrl` is only needed for cloud / onprem — on SaaS the plugin defaults to the
managed control plane.)
The package also ships a `configure` helper that does the same merge for you —
`VISIQ_CONFIGURE_NONINTERACTIVE=1 VISIQ_API_KEY=… VISIQ_AGENT_ID=… npx -y
@visiq/openclaw-plugin configure` — and additionally registers the plugin on
`plugins.load.paths`. Versions **up to and including 0.1.15** exit 0
without writing anything when launched through a package manager (`npx`, `pnpm
exec`, a global bin): the entry check compared `import.meta.url` against
`process.argv[1]`, which is the `bin` shim, so `main()` never ran. Fixed in
**0.1.16**. On an older version, use the `jq` merge above, or invoke the module
directly: `node node_modules/@visiq/openclaw-plugin/dist/cli/configure.js`.
Verify with the same predicate the gateway uses — not by eyeballing the file:
```bash theme={null}
jq -e '.plugins.entries["@visiq/openclaw-plugin"].config
| .apiKey and .agentId and .baseUrl' "$HOME/.openclaw/openclaw.json"
```
The plugin entry lives **three levels down** — `plugins` → `entries` →
`"@visiq/openclaw-plugin"`. A config whose *top-level* key is
`"@visiq/openclaw-plugin"` is the most common hand-edit mistake, and the gateway
ignores it completely: the plugin starts credential-less and silently no-ops every
hook. A `cat` of such a file still shows the API key, the agent id and the base URL,
which is why the `jq -e` check above is the one to trust. After changing the file,
**restart** the gateway — a hot config reload does not pick up new credentials.
The resulting file looks like this (the helper writes it for you):
```json theme={null}
{
"plugins": {
"entries": {
"@visiq/openclaw-plugin": {
"enabled": true,
"config": {
"apiKey": "vq_prod_...",
"agentId": "my-agent",
"baseUrl": "https://visiq.internal.example"
},
"hooks": { "allowConversationAccess": true }
}
}
}
}
```
## Credential resolution precedence
The plugin resolves credentials in this order (highest priority first):
1. CLI flags: `--api-key` / `--agent-id` / `--base-url`
2. Environment variables: `VISIQ_API_KEY` / `VISIQ_AGENT_ID` / `VISIQ_BASE_URL`
(`VISIQ_ENDPOINT` is accepted as a base-URL fallback; `VISIQ_BASE_URL` wins
when both are set)
3. JSON file at the path in `VISIQ_CONFIG_PATH`
4. `~/.openclaw/openclaw.json` → `plugins.entries["@visiq/openclaw-plugin"].config`
5. Interactive prompt (only when both stdout and stdin are terminals)
## Fail-open vs fail-closed
The plugin defaults to **fail-open**: a VisIQ-side failure never disrupts
OpenClaw. Set `VISIQ_FAIL_MODE=closed` (in the environment or the plugin config)
to make those failures **block** instead.
* **Unconfigured (missing credentials)**: every hook no-ops. A single structured
warning is emitted to stderr explaining how to configure the plugin — the
plugin should never break OpenClaw on first install before credentials are
provisioned.
* **Governance evaluation errors** (an unreachable backend, the governance core
unavailable, a thrown evaluation): by **default** the tool call proceeds
**ungoverned** with a loud `[VisIQ] FAIL-OPEN` stderr report, so a VisIQ outage
never breaks the agent. Set `VISIQ_FAIL_MODE=closed` to **block** on those
errors instead. Real policy outcomes always enforce regardless of failMode: an
explicit rule **deny**, the operator kill-switch, and a queued redaction that
cannot be applied (it downgrades to deny — see `before_message_write` above)
all block.
* **Telemetry errors**: never affect agent behavior. Failures are logged to
stderr and swallowed — telemetry is best-effort.
## Verifying the install
Start the gateway and trigger a `web_search`. In the dashboard at
[app.visiqlabs.com](https://app.visiqlabs.com), the agent appears under
**Harness → Agents** tagged as a CLI Harness, and the decision appears in the
**Harness → Runtime Enforcement** ledger. Governed decisions feed the audit
trail, including [signed decision receipts](/record/receipts).
When the recorded decisions look right, open the agent on the Agents page and
switch its mode from **Monitor — Log only** to **Enforce — Block**. The mode is
server-authoritative and per-agent; the plugin picks it up with its next rule
bundle refresh.
# Pydantic AI Quickstart
Source: https://docs.visiqlabs.com/quickstart/pydantic-ai
Govern a Pydantic AI agent's tools with VisIQ using the published `visiq` Python wheel.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Python 3.9+**, and a model
provider key for Pydantic AI (the sample calls a hosted model — any provider
works). Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[Pydantic AI](https://ai.pydantic.dev) agent. The [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core as the
TypeScript harness — makes every decision **locally, in-process** against a
cached rule bundle. You construct a `Governor` and route each tool call through
its gate: a policy **deny** raises `ToolBlocked` and a **mask** verdict hands your
callback only the redacted arguments.
## Install
```bash theme={null}
pip install visiq "pydantic-ai>=0.0.14"
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set it ONLY for
# onprem / self-hosted deployments. A bare VISIQ_API_KEY reaches the managed
# SaaS control plane, loads a bundle, and governs automatically.
VISIQ_AGENT_ID=support-bot
```
| Variable | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) from **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments (those planes live on your own network and must never default to a VisIQ host). |
| `VISIQ_AGENT_ID` | No | Stable agent identity for rule targeting. First-seen ids are auto-provisioned in monitor mode. |
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` — export the variables (or load them
yourself) before constructing the `Governor`.
## Govern your tools
Wrap each tool body in the gate, then wire the governed callables into
Pydantic AI's native tool surface (`WrapperToolset.call_tool`):
```python theme={null}
import functools
import inspect
from visiq import Governor, ToolBlocked # pip install visiq
gov = Governor(agent_id="support-bot")
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape:
# a list of {"page_content", "metadata"} dicts gate_documents can filter.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
def governed(name, fn):
# Route one tool body through the gate. gate_tool decides BEFORE the body
# runs: a deny raises ToolBlocked; a mask verdict hands the callback ONLY the
# redacted arguments, never the originals.
sig = inspect.signature(fn)
@functools.wraps(fn)
def wrapper(*a, **kw):
bound = sig.bind(*a, **kw)
bound.apply_defaults()
args = dict(bound.arguments)
try:
return gov.gate_tool(name, args, lambda effective: fn(**effective))
except ToolBlocked as e:
return f"[BLOCKED BY POLICY] {e.reason}"
wrapper.__name__ = name
return wrapper
def issue_refund(customer_id: str, amount: int) -> str:
return f"Refunded ${amount} to {customer_id}"
def search_knowledge(query: str) -> str:
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o", system_prompt="You are a helpful assistant.")
agent.tool_plain(governed("issue_refund", issue_refund))
agent.tool_plain(search_knowledge)
gov.start(tools=[{"name": "issue_refund"}, {"name": "search_knowledge"}])
print(agent.run_sync("What was Q3 revenue?").output)
gov.flush() # deliver buffered audit events before the process exits
```
**Pydantic AI interception surface.** Pydantic AI dispatches every tool through a toolset, so `WrapperToolset.call_tool` is the framework's native interception seam; the equivalent shown here registers each governed callable as a plain tool. The `governed(...)`
wrapper preserves each tool's real signature (via `functools.wraps`) so the
framework still builds a correct per-parameter schema.
## Run this on AWS Bedrock
Bedrock is a **first-class** Pydantic AI model provider, so there is no
third-party adapter package — but its `boto3` dependency ships in an **extra**:
```bash theme={null}
pip install "pydantic-ai-slim[bedrock]"
```
Without it the import below raises ``ImportError: Please install `boto3` to use the
Bedrock model`` — the plain `pydantic-ai` install at the top of this page is not
enough for Bedrock.
Then swap the model string for a `BedrockConverseModel`; every line of the
governance wiring above is unchanged:
```python theme={null}
from pydantic_ai import Agent
from pydantic_ai.models.bedrock import BedrockConverseModel
agent = Agent(
BedrockConverseModel("amazon.nova-micro-v1:0"),
system_prompt="You are a helpful assistant.",
)
agent.tool_plain(governed("issue_refund", issue_refund))
agent.tool_plain(search_knowledge)
```
Bedrock support needs `boto3` available: the full `pydantic-ai` package already
ships it, and on the slim distribution install `pydantic-ai-slim[bedrock]`.
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: the gate
fires at the same point, the same rules match, the same rows land in the audit
trail. Verified against real Bedrock inference, with no framework-specific
caveat.
15 of 15 Bedrock scenario checks passed.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the `visiq` wheel in your image and set `VISIQ_API_KEY`
in the runtime environment. If instead you use a **managed harness**, AWS owns the
agent loop and this in-process `Governor` does not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page.
* **Decisions are local.** The SDK fetches one cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background.
Tool calls evaluate in-process; the only decision-path network call is waiting
on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce: an explicit rule **deny**, the operator kill-switch, and an
in-core mask/redact that cannot be applied (it downgrades to deny) all block.
A harness-**internal** failure also blocks: if the backend is unreachable or the
governance core is unavailable, `gate_tool` raises `ToolBlocked("Governance
unavailable — tool blocked (fail-closed, G001)")`. **The Python SDK is
fail-CLOSED, and there is no `VISIQ_FAIL_MODE` to change that** — the variable
exists only in the TypeScript harness, which does default to fail-open. Plan for
a VisIQ outage to stop governed tool calls in Python, not to pass them through.
(This differs from the 2026-07-15 owner decision that agent-side SDKs fail OPEN;
the divergence is real and is the SDK's behaviour, not this page's.)
* **Deny blocks the call.** `gate_tool` raises `ToolBlocked` before the body
runs, so the tool is never executed; catch it and return the reason to the
model.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) —
the SDK polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the model
sees them.
**This is the manual per-tool `Governor` pattern.** A turnkey one-call
`visiq()`-style plugin for Pydantic AI is not yet published — you wire each tool
through `gov.gate_tool(...)` yourself, exactly as shown above. The `visiq` wheel
that makes the decisions is published today; the one-call auto-wrapper is coming.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# Semantic Kernel Quickstart
Source: https://docs.visiqlabs.com/quickstart/semantic-kernel
Govern a Semantic Kernel agent with VisIQ in one function call — Microsoft's Python SDK or the npm JavaScript port.
**Which Semantic Kernel do you have?** Two different projects share this name.
VisIQ governs both in one call — pick your runtime:
| Your runtime | Package | One-call wrapper |
| --------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------- |
| **Microsoft Semantic Kernel — Python** | PyPI [`semantic-kernel`](https://pypi.org/project/semantic-kernel/) | `visiq.govern(kernel)` |
| **Semantic Kernel JS** (community port) | npm [`semantic-kernel`](https://github.com/afshinm/semantic-kernel-js) | `visiq(kernel)` |
| **Microsoft Semantic Kernel — Java** | Maven | **Not yet released** — see below |
| **Microsoft Semantic Kernel — .NET** | NuGet | **Not covered** — VisIQ ships no .NET SDK |
Both supported runtimes register through the kernel's **own filter pipeline**,
so there are no per-function wrappers: every `KernelFunction` on that kernel is
governed, including the ones a model chooses during auto-invocation.
Using Microsoft's **Java** SK? The adapter is built and its enforcement is
proven, but it is **not yet released**: it does not currently match the rest of
the VisIQ harness fleet (mask and redact outcomes block instead of running
redacted), and we do not ship a harness that governs a narrower range than its
siblings. Until it lands, wire the `com.visiqlabs:visiq-sdk` `gateAction` /
`gateRetrieval` primitives into your own tool layer.
## Python — Microsoft Semantic Kernel
```bash theme={null}
pip install visiq semantic-kernel
```
```python theme={null}
from semantic_kernel import Kernel
from semantic_kernel.functions import kernel_function
from visiq import govern
class Billing:
@kernel_function(name="issue_refund", description="Issue a refund")
def issue_refund(self, customer_id: str, amount: str) -> str:
return f"Refunded ${amount} to {customer_id}"
kernel = Kernel()
kernel.add_plugin(Billing(), "billing")
# ── One call — every function on this kernel is now governed ──
govern(kernel, agent_id="support-bot")
```
`govern()` installs Semantic Kernel's `FUNCTION_INVOCATION`,
`AUTO_FUNCTION_INVOCATION` and `PROMPT_RENDERING` filters. A **deny** means the
decorated python method is never called at all — the block message is returned as
the function's result so the model reads why and adjusts course. A **mask** hands
the function only the redacted arguments. Set `retrieval_functions={"search"}` to
govern a RAG function's *result* through the retrieval facet instead:
```python theme={null}
govern(kernel, agent_id="support-bot", retrieval_functions={"search_knowledge"})
```
Governance decisions are local and synchronous, but they run **off your event
loop** — a human-approval hold cannot freeze your agent's asyncio loop.
## JavaScript — the community `semantic-kernel` port
The rest of this guide covers the npm JavaScript port. The governance model is
identical; only the API differs.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+**, and an
`OPENAI_API_KEY` if you wire the kernel to an OpenAI model. Full setup and
fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[Semantic Kernel](https://github.com/afshinm/semantic-kernel-js) `Kernel` — the
JavaScript port — by passing it to `visiq()`. VisIQ registers itself through the
kernel's own **function-invocation** and **prompt-render** filter pipeline —
there are no per-function wrappers and no separate clients. Decisions resolve
in-process against a locally cached rule bundle, and every `KernelFunction` the
kernel runs is evaluated before it executes.
## Install
```bash theme={null}
npm install @visiq/harness semantic-kernel
```
This walkthrough uses the community **`semantic-kernel`** package (the
JavaScript/TypeScript port) — not Microsoft's Python/.NET SDK of the same name.
To drive the kernel with an OpenAI model, also install its service package (for
example `@semantic-kernel/openai`, which is part of the same JS project) — the
governance wiring below is identical regardless of which AI service you add.
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # only if the kernel calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your kernel
Build a `Kernel`, register your plugin functions as usual, then pass the kernel
to `visiq()`. The single call installs the governance filters; nothing else
about your kernel changes.
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Kernel, kernelFunction, KernelArguments } from "semantic-kernel";
// Your kernel — build it exactly as you normally would (add an AI service,
// register plugins/functions). Nothing here is VisIQ-specific.
const kernel = new Kernel();
// A tool the agent can call.
const issueRefund = kernelFunction(
({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
{
name: "issue_refund",
pluginName: "billing",
description: "Issue a refund to a customer",
schema: {
type: "object",
properties: {
customerId: { type: "string" },
amount: { type: "number" },
},
required: ["customerId", "amount"],
},
},
);
// A retrieval source — a function whose result is document-shaped.
const searchKnowledge = kernelFunction(
({ query }) => [
{ content: `Q3 revenue was $4.2M. (matched: ${query})`, metadata: { classification: "internal" } },
],
{
name: "search_knowledge",
pluginName: "kb",
description: "Search the company knowledge base",
schema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
);
// ── One call — governance + per-run audit trail activate here ──
const governedKernel = visiq(kernel, { agentId: "support-bot" });
// Invoke functions exactly as before — every call is now governed.
const result = await issueRefund.invoke(
governedKernel,
new KernelArguments({ customerId: "cust_42", amount: 500 }),
);
console.log(result.value);
```
`visiq()` returns the same kernel with its filters installed, so any
`KernelFunction` invoked through it — directly, or by a model that calls it
during `invokePrompt` / chat completion — is governed. Re-wrapping the same
kernel is a no-op; the filters install once.
## Connect a model
To let a model choose which functions to call, add an AI service to the kernel
before wrapping it — for example an OpenAI chat completion service from
`@semantic-kernel/openai`. Follow the
[Semantic Kernel docs](https://github.com/afshinm/semantic-kernel-js) for the
exact service setup; the VisIQ step is unchanged:
```typescript theme={null}
// After kernel.addService(...) and registering your plugins:
const governedKernel = visiq(kernel, { agentId: "support-bot" });
// Every KernelFunction the model invokes now runs through governance.
```
**Retrieval governance contract.** After each function runs, VisIQ evaluates its
result — the returned content together with the function and plugin name —
against your retrieval rules: a `redact` decision masks matching patterns in the
result before the model sees it, and a `deny` or `escalate` suppresses the result
entirely. The prompt-render filter applies the same evaluation to the rendered
prompt before it reaches the model. (Per-document `metadata` matching — e.g.
classification tiers on individual RAG documents — is surfaced by the
document-oriented adapters like Mastra and LlamaIndex; the Semantic Kernel filter
governs each function result as a whole.)
## Run this on AWS Bedrock
Bedrock is a **first-party** connector in Microsoft's Python Semantic Kernel, so
there is no adapter package — but the connector's `boto3` dependency ships in the
`aws` **extra**:
```bash theme={null}
pip install "semantic-kernel[aws]"
```
Without it the import below raises `ModuleNotFoundError: No module named 'boto3'` —
the plain `semantic-kernel` install at the top of this page is not enough for Bedrock.
Then add `BedrockChatCompletion` as the kernel's service; the `govern()` call from
the Python section above is unchanged:
```python theme={null}
from semantic_kernel.connectors.ai.bedrock import BedrockChatCompletion
kernel = Kernel()
kernel.add_service(
BedrockChatCompletion(model_id="mistral.mistral-large-2402-v1:0")
)
kernel.add_plugin(Billing(), "billing")
# ── Same one call — every function on this kernel is governed ──
govern(kernel, agent_id="support-bot")
```
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: the same
filters install at the same point, the same rules match, the same rows land in
the audit trail. Verified end to end against real Bedrock inference on
`mistral.mistral-large-2402-v1:0`.
15 of 15 Bedrock scenario checks passed.
**Semantic Kernel does not work with Amazon Nova models on Bedrock at all —
independently of governance.** Semantic Kernel addresses every function by its
fully qualified name, `{plugin}-{function}`, and the hyphen separator is not
configurable. Nova models reject a hyphenated tool name, failing at generation
time with `ModelErrorException: Model produced invalid sequence as part of
ToolUse`. Measured on the raw Bedrock Converse API with no Semantic Kernel and
no VisIQ in the request — 4 trials each, only the tool **name** changed — the
hyphenated spelling succeeded **0/4** and the underscore spelling **4/4**,
identically on `nova-micro` and `nova-lite`, while Mistral accepted both. Pick
a non-Nova Bedrock model; the full finding is on the
[AWS Bedrock](/quickstart/aws-bedrock) page.
`mistral-small` is a poor choice here too: it accepts the hyphenated names but
narrates the tool call in prose instead of emitting a tool-use block.
`mistral-large-2402` is what was verified.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the harness in your image and set `VISIQ_API_KEY` in
the runtime environment. If instead you use a **managed harness**, AWS owns the
agent loop and these in-process kernel filters do not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Function calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call replaces the function's
result with
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
so the model reads it and adjusts course — the function body never runs.
* **Approvals pause the call.** An `approval_required` decision holds the
function while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK
polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the function with the
named arguments redacted before it sees them; retrieval redaction masks
document fields (and rendered-prompt content) before the model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of
default rules. Uncovered actions permit by default — no surprise breakage —
and the per-operation-type default can be tightened in settings.
## Verify it's working
Run the kernel once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed
function call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# Strands Agents Quickstart
Source: https://docs.visiqlabs.com/quickstart/strands
Govern a Strands Agents agent's tools with VisIQ using the published `visiq` Python wheel.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Python 3.9+**, and a model
provider key for Strands Agents (the sample calls a hosted model — any provider
works). Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
**Strands is a framework, not a Bedrock feature — this page is not AWS-specific.**
[Strands Agents](https://strandsagents.com) is an independent Apache-2.0 agent framework
that AWS wrote and open-sourced. It is model- and cloud-agnostic and runs anywhere Python
does — your laptop, Lambda, ECS, Kubernetes, on-prem — and it is a peer of LangGraph,
CrewAI and Pydantic AI. Everything below governs a Strands agent running against
**any** model provider; a Strands agent talking to Anthropic or a local Ollama is governed
by exactly the same hook.
Strands separately happens to be the engine AWS runs inside Bedrock AgentCore's *managed
harness*. That is a coincidence of AWS choosing its own framework, **not** a dependency —
and it does not make this page's governance available there, because the managed harness
runs Strands in AWS's own process where no hook of yours can be registered. See
[the AgentCore governance map](https://docs.visiqlabs.com) for which Bedrock posture is
which.
Add action governance, retrieval governance, and a full audit trail to a
[Strands Agents](https://strandsagents.com) agent. The [`visiq`](https://pypi.org/project/visiq/)
wheel — published on PyPI, compiled from the same governance core as the
TypeScript harness — makes every decision **locally, in-process** against a
cached rule bundle. You construct a `Governor` and route each tool call through
its gate: a policy **deny** raises `ToolBlocked` and a **mask** verdict hands your
callback only the redacted arguments.
## Install
```bash theme={null}
pip install visiq "strands-agents>=0.1"
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set it ONLY for
# onprem / self-hosted deployments. A bare VISIQ_API_KEY reaches the managed
# SaaS control plane, loads a bundle, and governs automatically.
VISIQ_AGENT_ID=support-bot
```
| Variable | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) from **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments (those planes live on your own network and must never default to a VisIQ host). |
| `VISIQ_AGENT_ID` | No | Stable agent identity for rule targeting. First-seen ids are auto-provisioned in monitor mode. |
The `visiq` wheel reads its configuration from the **process environment** and
does not auto-load a project `.env` — export the variables (or load them
yourself) before constructing the `Governor`.
## Govern your tools
Register one `HookProvider` on Strands Agents's native interception surface
(`BeforeToolCallEvent`). Your tools stay plain functions — governance happens on the
event, so there is no wrapper to keep in sync with each tool's signature:
```python theme={null}
from strands import Agent, tool
from strands.hooks import HookProvider, HookRegistry
from strands.hooks.events import BeforeToolCallEvent
from visiq import Governor, ToolBlocked # pip install visiq
gov = Governor(agent_id="support-bot")
def scenario_search(query: str) -> list:
# A stand-in retrieval source — your real RAG store returns the same shape:
# a list of {"page_content", "metadata"} dicts gate_documents can filter.
return [
{"page_content": f"Q3 revenue was $4.2M. (matched: {query})",
"metadata": {"classification": "internal"}},
]
@tool
def issue_refund(customer_id: str, amount: int) -> str:
"""Refund a customer."""
return f"Refunded ${amount} to {customer_id}"
@tool
def search_knowledge(query: str) -> str:
"""Search the knowledge base."""
# Retrieval governance: drop / redact documents before the model sees them.
docs = gov.gate_documents(scenario_search(query), query=query)
return "\n\n".join(d["page_content"] for d in docs)
class VisiqHooks(HookProvider):
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(BeforeToolCallEvent, self.before_tool)
def before_tool(self, event: BeforeToolCallEvent) -> None:
args = dict(event.tool_use.get("input", {}))
try:
# `effective` is the governed argument set — the ORIGINALS on a permit,
# the REDACTED copy on a mask. It must be written back onto the event,
# because Strands passes event.tool_use["input"] to the tool, not
# anything the gate computed. Discarding it silently un-masks the call.
effective = gov.gate_tool(
event.tool_use["name"], args, lambda governed_args: governed_args)
except ToolBlocked as e:
# cancel_tool, NOT a raised exception. Strands honours cancel_tool by
# substituting a tool result the model can read and re-plan around. An
# exception escaping a hook aborts the whole invocation instead — and
# under the default ConcurrentToolExecutor a sibling tool dispatched in
# the same batch still completes its side effect.
event.cancel_tool = f"[BLOCKED BY POLICY] {e.reason}"
return
event.tool_use["input"] = effective
agent = Agent(tools=[issue_refund, search_knowledge], hooks=[VisiqHooks()])
gov.start(tools=[{"name": "issue_refund"}, {"name": "search_knowledge"}])
gov.flush() # deliver buffered audit events before the process exits
```
**Strands Agents interception surface.** Strands emits a `BeforeToolCallEvent` before
dispatch, and the hook governs the call by mutating that event — never by wrapping the
tool. Two details are load-bearing, and getting either wrong fails silently:
* **Write the governed arguments back** to `event.tool_use["input"]`. Strands passes
that dict to the tool, so a mask verdict whose redacted copy is computed and then
discarded lets the tool run with the ORIGINAL values.
* **Deny with `event.cancel_tool`, not a raised exception.** `cancel_tool` substitutes a
tool result the model can read and re-plan around. An exception escaping a hook aborts
the entire invocation instead, and under the default `ConcurrentToolExecutor` a sibling
tool dispatched in the same batch still completes its side effect.
Because tools stay unwrapped, the framework builds each per-parameter schema from the
real signature with nothing to keep in sync.
## Run this on AWS Bedrock
Bedrock is Strands Agents's **default** model provider, so there is no adapter
package — pass a `BedrockModel` and the code above is unchanged:
```python theme={null}
from strands.models import BedrockModel
agent = Agent(
tools=[issue_refund, search_knowledge],
hooks=[VisiqHooks()],
model=BedrockModel(model_id="amazon.nova-micro-v1:0", region_name="us-east-1"),
)
```
Credentials come from the standard AWS chain (`AWS_PROFILE`, instance role,
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). Governance is untouched: the hook
fires at the same point, the same rules match, the same rows land in the audit
trail. Verified end to end against real Bedrock inference, with an ungoverned
control arm that had to leak for the run to count.
Deploying to **Bedrock AgentCore Runtime** needs nothing extra either — it hosts
*your* process, so install the harness in your image and set `VISIQ_API_KEY` in the
runtime environment. If instead you use a **managed harness**, AWS owns the agent
loop and this in-process hook does not apply; see
[AWS Bedrock](/quickstart/aws-bedrock#where-agentcore-fits-and-the-one-distinction-that-matters)
for the two chokepoints that still work there.
## What happens at runtime
New agents start in **monitor** mode (observe-only) until you flip them to
**enforce** on the **Harness → Agents** page.
* **Decisions are local.** The SDK fetches one cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background.
Tool calls evaluate in-process; the only decision-path network call is waiting
on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce: an explicit rule **deny**, the operator kill-switch, and an
in-core mask/redact that cannot be applied (it downgrades to deny) all block.
A harness-**internal** failure also blocks: if the backend is unreachable or the
governance core is unavailable, `gate_tool` raises `ToolBlocked("Governance
unavailable — tool blocked (fail-closed, G001)")`. **The Python SDK is
fail-CLOSED, and there is no `VISIQ_FAIL_MODE` to change that** — the variable
exists only in the TypeScript harness, which does default to fail-open. Plan for
a VisIQ outage to stop governed tool calls in Python, not to pass them through.
(This differs from the 2026-07-15 owner decision that agent-side SDKs fail OPEN;
the divergence is real and is the SDK's behaviour, not this page's.)
* **Deny blocks the call.** `gate_tool` raises `ToolBlocked` before the body runs, so
the tool is never executed. Catch it in the hook and set `event.cancel_tool` to the
reason — Strands returns that to the model as the tool's result.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) —
the SDK polls for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the model
sees them.
**This is the manual per-tool `Governor` pattern.** A turnkey one-call
`visiq()`-style plugin for Strands Agents is not yet published — you wire each tool
through `gov.gate_tool(...)` yourself, exactly as shown above. The `visiq` wheel
that makes the decisions is published today; the one-call auto-wrapper is coming.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with a
live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
## Next steps
All supported frameworks and what happens behind the scenes.
The complete `Governor` API — gate\_tool, gate\_documents, fail modes.
# Vercel AI SDK Quickstart
Source: https://docs.visiqlabs.com/quickstart/vercel-ai-sdk
Wrap a Vercel AI SDK agent with VisIQ governance in one function call.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+**, and an
`OPENAI_API_KEY` (the sample below calls an OpenAI model — any provider works).
Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[Vercel AI SDK](https://ai-sdk.dev) agent by passing your `Agent` to `visiq()`.
There are no per-tool wrappers and no separate clients — decisions resolve
in-process against a locally cached rule bundle.
## Install
```bash theme={null}
npm install @visiq/harness ai @ai-sdk/openai zod
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agent calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your agent
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Experimental_Agent as Agent, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
// Any function returning document-shaped results works as a RAG source.
const searchDocs = async (query: string) => [
{ pageContent: `Q3 revenue was $4.2M. (matched: ${query})`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = {
issue_refund: tool({
description: "Issue a refund to a customer",
inputSchema: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
search_knowledge: tool({
description: "Search the company knowledge base",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
};
// ── One call — governance + per-run LLM telemetry activate here ──
const agent = visiq(
new Agent({
model: openai("gpt-4o"),
instructions: "You are a helpful assistant.",
tools,
stopWhen: stepCountIs(8),
}),
{ agentId: "support-bot" },
);
const result = await agent.generate({ prompt: "What was Q3 revenue?" });
```
`agent.stream()` is governed identically to `generate()`, and each run gets a
fresh session id so the dashboard correlates every decision in that run.
**Retrieval governance contract.** Per-document filtering applies to tools
whose `execute()` returns document-shaped results — array items with a string
`pageContent`, `text`, or `content` field, plus optional `metadata` that
retrieval rules match on (classification, data categories, …). Results in any
other shape still pass through the action gate but are not filtered
per-document.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Tool calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call hands the model
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
as the tool's output, so the agent reads it and adjusts course.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK polls
for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the
model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of 35 default rules.
Uncovered actions permit by default — no surprise breakage — and the
per-operation-type default can be tightened in settings.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# VoltAgent Quickstart
Source: https://docs.visiqlabs.com/quickstart/voltagent
Wrap a VoltAgent agent with VisIQ governance in one function call.
**Prerequisites.** A VisIQ account ([sign in](https://app.visiqlabs.com)) with a
harness key from **Settings → Harness Keys**, **Node 20+**, and an
`OPENAI_API_KEY` (the sample below calls an OpenAI model — any provider works).
Full setup and fixes: [Before you start](/quickstart#before-you-start) ·
[Troubleshooting](/troubleshooting).
Add action governance, retrieval governance, and a full audit trail to a
[VoltAgent](https://voltagent.dev) agent by passing your `Agent` to `visiq()`.
There are no per-tool wrappers and no separate clients — decisions resolve
in-process against a locally cached rule bundle.
## Install
```bash theme={null}
npm install @visiq/harness @voltagent/core @voltagent/logger @ai-sdk/openai ai zod
```
## Set environment variables
```bash .env theme={null}
VISIQ_API_KEY=vq_prod_...
# VISIQ_ENDPOINT defaults to https://api.visiqlabs.com — set only for onprem/self-hosted
# Optional — auto-derived from your package.json name when unset
VISIQ_AGENT_ID=support-bot
OPENAI_API_KEY=sk-... # the sample agent calls an OpenAI model
```
| Variable | Required | Description |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VISIQ_API_KEY` | Yes | Harness key (`vq_prod_...` or `vq_test_...`) — create one under **Settings → Harness Keys** in the [dashboard](https://app.visiqlabs.com). |
| `VISIQ_ENDPOINT` | Optional | Backend base URL — defaults to `https://api.visiqlabs.com`. Set it only for onprem / self-hosted deployments. With just a key the harness reaches SaaS, loads a bundle, and governs automatically (monitor until the first bundle confirms). |
| `VISIQ_AGENT_ID` | No | Agent identity. Auto-derives from your `package.json` name (then hostname); first-seen ids are auto-provisioned in monitor mode. Set it — or the `agentId` option on `visiq()` — for a stable, rule-friendly name. |
| `VISIQ_TIMEOUT_MS` | No | Per-evaluation network timeout in ms (default `5000`). |
| `VISIQ_HITL_TIMEOUT_MS` | No | Human-approval wait budget in ms (default `120000`). |
## Wrap your agent
```typescript theme={null}
import { visiq } from "@visiq/harness";
import { Agent, createTool } from "@voltagent/core";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
// Any function returning document-shaped results works as a RAG source.
const searchDocs = async (query: string) => [
{ pageContent: `Q3 revenue was $4.2M. (matched: ${query})`, metadata: { classification: "internal" } },
];
// Your tools — unchanged.
const tools = [
createTool({
name: "issue_refund",
description: "Issue a refund to a customer",
parameters: z.object({ customerId: z.string(), amount: z.number() }),
execute: async ({ customerId, amount }) => `Refunded $${amount} to ${customerId}`,
}),
createTool({
name: "search_knowledge",
description: "Search the company knowledge base",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => searchDocs(query),
}),
];
// ── One call — governance + per-run LLM telemetry activate here ──
const agent = visiq(
new Agent({ name: "support", instructions: "You are a helpful assistant.", model: openai("gpt-4o"), tools }),
{ agentId: "support-bot" },
);
const result = await agent.generateText("What was Q3 revenue?");
console.log(result.text);
```
`streamText()`, `generateObject()`, and `streamObject()` are governed
identically to `generateText()`, and each run gets a fresh session id so the
dashboard correlates every decision in that run.
**Retrieval governance contract.** Per-document filtering applies to tools
whose `execute()` returns document-shaped results — array items with a string
`pageContent`, `text`, or `content` field, plus optional `metadata` that
retrieval rules match on (classification, data categories, …). Results in any
other shape still pass through the action gate but are not filtered
per-document.
## What happens at runtime
Wrapping is safe to try immediately — new agents start in **monitor** mode
(observe-only) until you flip them to **enforce** on the **Harness → Agents**
page.
* **Decisions are local.** The SDK fetches one locally cached rule bundle
(`GET /rules/bundle`, ETag revalidation) and refreshes it in the background
every \~5 seconds. Tool calls evaluate in-process; the only decision-path
network call is waiting on a human approval.
* **Fail-open by default, loudly — strict deny is opt-in.** Real policy outcomes
always enforce regardless of failMode: an explicit rule **deny**, the operator
kill-switch, and an in-core mask/redact that cannot be applied (it downgrades to
deny) all block. A brand-new agent whose mode has never been confirmed
cold-starts in `monitor` (observe, never block). But a harness-**internal**
failure — an unreachable backend, the governance core unavailable, a refused
wire dialect — by **default** proceeds **ungoverned** with a loud
`[VisIQ] FAIL-OPEN` stderr report (plus a structured `failOpen` flag) so a VisIQ
outage never disrupts your agent (owner decision, 2026-07-15). Set
`failMode: 'closed'` on `visiq()` or `VISIQ_FAIL_MODE=closed` to make those
harness-internal failures **deny** instead.
* **Denials are returned, not thrown.** A blocked call hands the model
`[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.`
as the tool's output, so the agent reads it and adjusts course.
* **Approvals pause the call.** An `approval_required` decision holds the tool
while a human decides via Slack or Email (Microsoft Teams delivery is built server-side; its connector card is coming soon) — the SDK polls
for up to 120 seconds (`VISIQ_HITL_TIMEOUT_MS`), then fails closed.
* **Mask proceeds, redacted.** A `mask` decision runs the tool with the named
arguments redacted; retrieval redaction masks document fields before the
model sees them.
* **Covered from the first call.** Every workspace ships a curated catalog of 35 default rules.
Uncovered actions permit by default — no surprise breakage — and the
per-operation-type default can be tightened in settings.
## Verify it's working
Run the agent once, then open the [dashboard](https://app.visiqlabs.com):
* **Harness → Agents** — your agent appears automatically (monitor mode) with
a live last-seen heartbeat.
* **Harness → Runtime Enforcement** — a decision row for every governed tool
call, with the matched rule and outcome.
* **Harness → Escalations** — pending approvals. Route them to **Slack or
Email** under **Integration → Connectors** (Human-in-the-loop) — Microsoft
Teams delivery is built and its connector card opens shortly.
## Next steps
All supported frameworks and what happens behind the scenes.
Complete `visiq()` API, options, framework detection, and error behavior.
# API Reference
Source: https://docs.visiqlabs.com/record/api-reference
Complete REST API reference for the audit-trail record surface — envelope ingestion, record and sub-resource reads, finalization, the checkpoint feed, and the versioned audit log.
The audit-trail record endpoints are mounted under `/record/*`, with the versioned audit-log read at `/v1/record/audit-log`. The base URL is `https://api.visiqlabs.com`.
These endpoints manage the record surface behind the [Audit Trail](/record/introduction): every governance decision emits a record envelope, envelopes are grouped into records, and records carry events, artifacts, and attestations. For the cryptographic verification of a single envelope, see [Decision Receipts](/record/receipts).
## Authentication
All endpoints require a Bearer credential: `Authorization: Bearer `. Requests without a valid credential receive `401 Unauthorized`.
Two credential audiences exist:
* **Harness keys** — the operational credential your SDK or harness runs with. On this surface a harness key covers exactly `POST /record/envelopes` (the envelope ingestion the record SDK calls); every other endpoint here is management-only and returns `403 {"error": "harness_key_not_permitted"}` for a harness key.
* **Management keys** — general automation credentials governed by explicit permission grants. They can call every endpoint on this page. Management keys are **launching soon**: they are visible in the dashboard under **Settings → API Keys**, but creating one is not yet enabled. Until then, drive the management endpoints from the dashboard, which authenticates with your session.
Each endpoint below names the exact permission it requires. Permission-gated management keys must carry that permission; scope-gated routes additionally check the coarse `record:read` / `record:write` scope, and `full_access` satisfies everything.
### Rate limiting
Every API-key request passes a per-key sliding-window rate limit (default 600 requests per 60 seconds). Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers; exceeding the window returns `429` with a `Retry-After` header and body `{"error": "rate_limited", "detail": "API key rate limit exceeded.", "retryAfter": }`.
### List responses
Every list endpoint on this page returns the same envelope:
```json theme={null}
{
"data": [ ... ],
"total": 128,
"page": 1,
"limit": 50
}
```
The record list envelope names the page size field `limit` (echoing the request parameter), where the action and retrieval list endpoints name it `pageSize`. The values are the same; only the key differs.
Pagination is controlled by `page` (default `1`) and `limit` (default `50`, max `200`) query parameters.
***
## Ingestion
***
### POST /record/envelopes
Ingest a record envelope. The platform upserts the parent record by `correlationId` (creating it on first sight, appending to it thereafter), appends the event with the next sequence number and a SHA-256 content hash, and inserts any attached artifacts and attestations — atomically.
**Permission:** `record_records:create` · **Scope:** `record:write`
**Request body:**
```json theme={null}
{
"source": "action",
"tenantId": "b2e6d0c4-5a1f-4e8b-9c3d-7f2a1b4c5d6e",
"correlationId": "session-8f2b4c1d",
"actor": { "type": "agent", "id": "billing-agent" },
"event": {
"type": "tool_call_authorized",
"occurredAt": "2026-07-03T17:22:41.118Z",
"payload": { "target_app": "stripe", "action": "issue_refund" }
}
}
```
| Field | Type | Required | Description |
| ------------------ | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `source` | `string` | Yes | One of `execute`, `browser_proxy`, `session_events`, `action`, `isolate`, `app`, `retrieval` — determines the record's `kind` on first creation |
| `tenantId` | `string` | Yes | Must equal your authenticated vendor id, or the request is `403` (1–255 chars) |
| `correlationId` | `string` | Yes | Groups events into one record (1–255 chars) |
| `authorization` | `object` | No | `{ type?, principal?, scope? }` |
| `actor` | `object` | No | `{ type?, id?, name?, metadata? }` — `actor.id` seeds the record's `root_session_id` |
| `subject` | `object` | No | `{ type?, id?, name?, metadata? }` |
| `event.type` | `string` | Yes | Event type (1–255 chars) |
| `event.occurredAt` | `string` (ISO 8601) | Yes | When the event occurred |
| `event.payload` | `object` | Yes | The event payload — the content the signed receipt later attests |
| `event.signature` | `object` | No | `{ algorithm, value, keyId?, signedAt? }` |
| `artifacts` | `array` | No | Each `{ artifactType, contentHash, mimeType?, storageRef?, metadata? }` |
| `attestations` | `array` | No | Each `{ attestationType, issuerType, issuerId, statement, hash, signature? }` |
**Response (201):**
```json theme={null}
{
"record_id": "9f4c1a3e-8f2b-4c1d-9e5a-2b7c8d0f1a42",
"event_id": "4d7a2c1b-3e5f-4a8c-b1d2-9e0f3a6c7b8d"
}
```
**Status codes:** `201 Created`, `400 Bad Request` (invalid JSON or body), `401 Unauthorized`, `403 Forbidden` (harness-key on a management route, or `tenantId` does not match the authenticated vendor), `500 Internal Server Error`
***
## Records
Management endpoints — a harness key receives `403 harness_key_not_permitted` here. Each read is vendor-scoped; a record that isn't yours is `404`.
***
### GET /record/records
List records, newest first.
**Permission:** `record_records:view`
**Query parameters:**
| Parameter | Type | Description |
| ----------------------- | ------------------------------------- | ------------------------------------------ |
| `kind` | `string` | Filter by record kind |
| `status` | `open` \| `finalized` \| `superseded` | Filter by lifecycle status |
| `correlationId` | `string` | Filter by correlation id |
| `startDate` / `endDate` | ISO 8601 datetime | Bounds on `created_at` |
| `page` | `number` | Page index (default `1`) |
| `limit` | `number` | Records per page (default `50`, max `200`) |
**Response:**
```json theme={null}
{
"data": [
{
"id": "record-uuid",
"kind": "decision",
"status": "open",
"correlation_id": "session-8f2b4c1d",
"root_session_id": "billing-agent",
"integrity_state": "unverified",
"created_at": "2026-07-03T10:30:00Z",
"finalized_at": null
}
],
"total": 421,
"page": 1,
"limit": 50
}
```
**Status codes:** `200 OK`, `400 Bad Request` (invalid query), `401 Unauthorized`, `500 Internal Server Error`
***
### GET /record/records/:id
Get a single record by UUID.
**Permission:** `record_records:view`
**Path parameter:** `:id` — the record UUID (must be a valid UUID, else `400`)
**Response:** the record object (as in the list response).
**Status codes:** `200 OK`, `400 Bad Request` (invalid record ID), `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### PATCH /record/records/:id/finalize
Finalize a record — seal it so no further events are expected. Idempotent: finalizing an already-finalized record returns it unchanged.
**Permission:** `record_records:finalize`
**Path parameter:** `:id` — the record UUID
**Response:** the record object with `status: "finalized"` and a populated `finalized_at`.
**Status codes:** `200 OK`, `400 Bad Request` (invalid record ID), `401 Unauthorized`, `404 Not Found`, `409 Conflict` (the record is `superseded` and cannot be finalized), `500 Internal Server Error`
***
### GET /record/records/:id/events
List the events on a record, in sequence order.
**Permission:** `record_records:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `200`)
**Response:**
```json theme={null}
{
"data": [
{
"id": "event-uuid",
"event_type": "tool_call_authorized",
"event_source": "action",
"sequence_no": 1,
"occurred_at": "2026-07-03T17:22:41.118Z",
"payload_json": { "target_app": "stripe", "action": "issue_refund" },
"hash": "3f1c...",
"signature": null,
"created_at": "2026-07-03T17:22:41.200Z"
}
],
"total": 3,
"page": 1,
"limit": 50
}
```
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found` (unknown or non-owned record), `500 Internal Server Error`
***
### GET /record/records/:id/artifacts
List the artifacts attached to a record.
**Permission:** `record_records:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `200`)
**Response:** the standard list envelope whose items carry `id`, `artifact_type`, `mime_type`, `storage_ref`, `content_hash`, `metadata_json`, and `created_at`.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### GET /record/records/:id/attestations
List the attestations attached to a record.
**Permission:** `record_records:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `200`)
**Response:** the standard list envelope whose items carry `id`, `attestation_type`, `issuer_type`, `issuer_id`, `statement_json`, `hash`, `signature`, and `created_at`.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
## Verification
***
### GET /record/envelopes/:id/verify
Run a live, independent verification of one envelope's full attestation chain — integrity, Ed25519 leaf signature, Merkle inclusion, the AWS-KMS root signature (with a live KMS round-trip), and the RFC 3161 timestamp. The response carries a per-check result and evidence; [Audit Trail](/record/introduction#verify-a-record-in-one-call) documents the six checks.
**Permission:** `record_records:view` · **Scope:** `record:read`
**Path parameter:** `:id` — the envelope UUID (vendor-scoped; an envelope that isn't yours is `404`, indistinguishable from not-found to prevent enumeration)
**Status codes:** `200 OK`, `400 Bad Request` (invalid record ID), `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
## Checkpoints
The public transparency-log checkpoints — the signed, hash-chained Merkle roots. These are **tenant-neutral**: a checkpoint exposes only roots, signatures, counts, timestamp, and witness status, never any per-tenant data.
***
### GET /record/checkpoints/:seq
Fetch one checkpoint by its 1-based batch sequence number.
**Permission:** `record_records:view` · **Scope:** `record:read`
**Path parameter:** `:seq` — the checkpoint's `batch_seq` (integer ≥ 1)
**Query parameters:** `domain` — `production` (default) or `sandbox`, selecting the transparency chain
**Response:** the tenant-free checkpoint view (roots, signatures, counts, timestamp-authority time, and the derived external-witness status).
**Status codes:** `200 OK`, `400 Bad Request` (invalid sequence), `401 Unauthorized`, `404 Not Found` (unknown sequence), `500 Internal Server Error`
The sibling `GET /record/checkpoints` lists checkpoints newest-first, seq-cursor paginated via `before` and `limit` (default 50, max 200), under the same permission and scope — walk it to audit the published roots and detect a split view or rewrite.
***
## Audit Log
***
### GET /v1/record/audit-log
Query the record event log for your organization — the events across all your records, filterable by source, correlation id, event type, and date. The join is vendor-scoped server-side, so it never leaks another tenant's events.
**Permission:** `record_audit_log:view` · **Scope:** `record:read`
**Query parameters:**
| Parameter | Type | Description |
| ------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------- |
| `source` | `string` | Filter by event source (`execute`, `browser_proxy`, `session_events`, `action`, `isolate`, `app`, `retrieval`) |
| `correlation_id` | `string` | Filter by correlation id |
| `event_type` | `string` | Filter by event type |
| `start_date` / `end_date` | ISO 8601 datetime | Bounds on `occurred_at` |
| `page` | `number` | Page index (default `1`) |
| `limit` | `number` | Records per page (default `50`, max `200`) |
**Response:**
```json theme={null}
{
"data": [
{
"id": "event-uuid",
"record_id": "record-uuid",
"event_type": "tool_call_authorized",
"event_source": "action",
"sequence_no": 1,
"occurred_at": "2026-07-03T17:22:41.118Z",
"payload_json": { "target_app": "stripe", "action": "issue_refund" },
"hash": "3f1c...",
"signature": null,
"created_at": "2026-07-03T17:22:41.200Z"
}
],
"total": 1843,
"page": 1,
"limit": 50
}
```
**Status codes:** `200 OK`, `400 Bad Request` (invalid query), `401 Unauthorized`, `403 Forbidden` (insufficient permission or scope), `500 Internal Server Error`
***
## Errors & conventions
Every endpoint on this page follows the platform-wide REST conventions — the validation-error body shape, the `/v1/` vs unversioned split, and the API stability policy. See [REST API conventions](/reference/rest-conventions).
# Audit Trail
Source: https://docs.visiqlabs.com/record/introduction
A cryptographically signed, independently verifiable audit trail for every governance decision.
Every tool-call authorization and every human adjudication emits a **record envelope**; retrieval verdicts emit envelopes when artifact signing is enabled for your organization. There is no additional code; the audit trail activates automatically from the same `visiq()` call.
Envelopes are persisted server-side, off the request path, and then cryptographically attested: each one receives an asynchronous Ed25519 receipt, is committed to a Merkle batch whose root is signed and countersigned by an independent RFC 3161 timestamp authority, and takes its place in a hash-chained transparency log. [Decision Receipts](/record/receipts) covers the cryptography in depth.
***
## What gets recorded
Each decision produces one envelope. An action-governance denial looks like this:
```json theme={null}
{
"id": "9f4c1a3e-8f2b-4c1d-9e5a-2b7c8d0f1a42",
"source": "action",
"vendor_id": "b2e6d0c4-5a1f-4e8b-9c3d-7f2a1b4c5d6e",
"decision_id": "4d7a2c1b-3e5f-4a8c-b1d2-9e0f3a6c7b8d",
"actor_id": "support-agent",
"decision": "deny",
"event": {
"target_app": "stripe",
"action": "issue_refund",
"context": { "amount": 500, "currency": "USD" }
},
"attestation": {
"reason": "Refunds over $250 require human approval",
"rule_id": "8c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"mode": "enforce"
},
"emitted_at": "2026-07-03T17:22:41.118Z"
}
```
`source` tells you which subsystem produced the decision, and each source carries its own decision vocabulary:
| `source` | Emitted for | `decision` values |
| ----------- | ---------------------------------------------- | ----------------------------------------------- |
| `action` | Every tool-call authorization | `permit`, `deny`, `approval_required`, `mask` |
| `retrieval` | Every retrieval verdict | `allow`, `deny`, `redact`, `escalate` |
| `hitl` | Every human adjudication of a pending decision | `approved`, `rejected`, `dismissed`, `resolved` |
The `event` field holds what happened — the payload the cryptographic receipt later signs — and `attestation` holds the decision metadata, both shaped per source:
* **`action`** — `event: { target_app, action, context }`, `attestation: { reason, rule_id, mode }`. `mode` records the agent's mode at decision time — `enforce`, `monitor`, or `off` (an `off`-mode call is permitted without evaluation but still recorded).
* **`retrieval`** — `event: { operation, resource_type, resource_metadata }`, `attestation: { reason_code, reason, rule_id }`.
* **`hitl`** — `event: { hitl_item_id, category, target_app, action, context, allow_decision_id }`, `attestation: { responded_by, responded_at, ai_recommended_rule }`.
Human adjudications are first-class evidence. When a responder approves or rejects a held action, a separate `hitl` envelope records **who** responded and **when**, linked back to the originating decision via `decision_id` — and it receives its own cryptographic receipt like any other decision.
At the moment an envelope is ingested, the database also pins a **content hash** over its `event` in a generated column the application cannot write. Verification later recomputes it, proving the record was not edited even in the window between ingest and signing.
***
## How records become tamper-evident
Persisting the envelope is step one. An asynchronous pipeline — entirely off the decision hot path, adding zero latency to your agents — turns each envelope into independently verifiable evidence:
1. **Ed25519 leaf receipt** — the envelope's `event` is canonicalized, hashed, and signed. The receipt (signature, public key, payload hash) is stored separately from the envelope it attests.
2. **Merkle batching** — a background worker gathers unbatched receipts and commits their leaf hashes to a single Merkle root.
3. **Root signature** — the root is signed once. VisIQ supports holding that key in an HSM (AWS KMS), where the private key never leaves the HSM; that mode is opt-in per deployment and is **not enabled on the hosted service today**, which signs with an in-process key.
4. **RFC 3161 timestamp** — DigiCert's timestamp authority countersigns the root: an independent third party attests *when* the batch existed.
5. **Hash-chained checkpoints** — each signed batch commits to the previous batch's root, forming an append-only transparency log. Deleting, reordering, or backdating any batch breaks the chain detectably for anyone holding an earlier checkpoint. VisIQ additionally supports publishing checkpoints to write-once (WORM) object storage as an external witness — that is the leg that would make a rewrite detectable *even against VisIQ itself*, and it is **off by default and not enabled on the hosted service today**, so that stronger property is not one we currently claim.
This is the Certificate Transparency model: one HSM signature and one third-party timestamp attest an entire batch, so 100% of records are signed and independently timestamped at any decision volume.
***
## Verify a record in one call
You don't hand-roll any cryptography. One request re-derives and re-checks the full attestation chain:
```bash theme={null}
curl "https://api.visiqlabs.com/record/envelopes/9f4c1a3e-8f2b-4c1d-9e5a-2b7c8d0f1a42/verify" \
-H "Authorization: Bearer "
```
It runs six independent checks and returns per-check evidence:
| Check | Proves |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `integrity` | The envelope's `event` still hashes to the receipt's payload hash — the record was not altered since signing |
| `leafSignature` | The Ed25519 receipt signature verifies against the receipt's public key — this exact decision was signed |
| `merkleInclusion` | The receipt's leaf is committed to its batch's Merkle root via a recomputed inclusion proof |
| `kmsRootSignature` | The batch root's signature verifies — including a live AWS KMS round-trip confirming the HSM key produced it |
| `timestamp` | The RFC 3161 token verifies against DigiCert's certificate chain — a third party attests when the record existed |
| `ingestAnchor` | The at-ingest content hash still matches — the record was not edited between ingest and signing |
`verified` is `true` only when every applicable check passes. A freshly emitted record reports `attestationStatus: "timestamp_pending"` until the next batch sweep commits it — typically within a minute or two.
The dashboard runs this same verification for you: every governed event row (**Harness → Runtime Enforcement**) carries a record pill, and clicking through opens the record's verification page with the full per-check breakdown.
***
## What the audit trail proves
| Question | Evidence |
| ------------------------------------------------------- | -------------------------------------------------------------------------- |
| Did the agent attempt this action? | `event` — tool name, arguments, target app |
| Was the action authorized, and by what policy? | `decision` plus `attestation.rule_id` and `attestation.mode` |
| Who approved or rejected it? | The linked `hitl` envelope — `attestation.responded_by` and `responded_at` |
| When did it happen? | `emitted_at`, countersigned by an independent RFC 3161 timestamp |
| Has this record been tampered with? | The six-check verification above |
| Is the history complete — nothing deleted or backdated? | The hash-chained checkpoint log (`GET /record/chain/consistency`) |
***
## Consuming the trail
* **Dashboard** — **Harness → Runtime Enforcement** streams every governed event with its record attached; agent pages and the **Escalations** queue link to the same records.
* **Query APIs** — `GET /v1/allow/audit-log` and `GET /v1/recall/audit-log` return the paginated decision logs, filterable by agent, decision, and date range.
* **Log streaming** — stream decision envelopes and audit events to your SIEM. Datadog and Elasticsearch destinations are configured under **Settings → Log Streaming** (organization scope) or from the **Integration → Connectors** Log Streaming cards. Delivery is cursor-tracked: a failed batch is retried, never dropped.
* **Evidence packs** — export a signed, per-agent evidence bundle over a date range from the agent's dashboard page, sized for handing to an auditor.
Cryptographic receipt issuance is plan-dependent. On every plan, all decisions are still recorded in the queryable audit logs above.
***
## Next steps
The exact cryptography — canonicalization, signatures, Merkle proofs, timestamps — and how to verify offline.
How tool-call authorization decisions are made.
How retrieval verdicts govern what context your agents see.
Authenticating scripts and CI against the management API.
# Decision Receipts
Source: https://docs.visiqlabs.com/record/receipts
The cryptography behind the audit trail — Ed25519 receipts, Merkle batching, signed roots, RFC 3161 timestamps — and how to verify every layer, online or offline.
Every [record envelope](/record/introduction) — action, retrieval, and human-adjudication decisions alike — is attested by a **decision receipt**: an Ed25519 signature over the decision's canonical payload, stored separately from the envelope it proves. Receipts are then Merkle-batched under a signed root, timestamped by an independent authority, and chained into an append-only transparency log.
This page specifies the exact cryptography so you — or your auditor — can verify it independently.
Receipt issuance is plan-dependent. Decisions on every plan are still recorded in the queryable audit logs.
***
## What a receipt contains
| Field | Encoding | Meaning |
| ------------------- | -------------- | -------------------------------------------------------------- |
| `signature` | base64url | Ed25519 signature over the canonical payload bytes |
| `public_key` | base64url | The signing public key, as SPKI DER (44 bytes decoded) |
| `payload_hash` | hex (64 chars) | SHA-256 of the canonical payload bytes — the Merkle leaf input |
| `canonical_payload` | JSON | The exact alphabetically-key-sorted payload that was signed |
Once the receipt is committed to a batch it also carries its Merkle coordinates: the batch it belongs to, its leaf index, and its leaf hash. The dashboard's record verification page surfaces all of these fields for any record.
## When receipts are issued
Signing is **asynchronous** — scheduled immediately after the envelope persists, entirely off the decision hot path. Your agents never wait on cryptography; a decision's latency is identical with receipts on or off.
Asynchronous does not mean best-effort. Unbatched receipts *are* the durable batching queue: if a batching sweep fails, the next sweep picks up the same receipts. A record's verification result reports `attestationStatus: "timestamp_pending"` between signing and batch commitment — typically a minute or two — and `"signed"` once the full chain verifies.
***
## The exact cryptography
### Canonicalization
The signed payload is the envelope's `event` object, serialized deterministically:
1. Object keys are sorted **alphabetically, recursively** (arrays keep their order; primitives pass through unchanged).
2. The sorted structure is serialized with compact `JSON.stringify` — no whitespace.
3. The signature and hash operate on the **UTF-8 bytes** of that string.
### Hash and signature
* `payload_hash` = SHA-256 over the canonical bytes, hex-encoded.
* `signature` = Ed25519 over the **canonical payload bytes themselves** — not over the hash.
The most common verification mistake is signing-recipe drift: verifying the Ed25519 signature against the SHA-256 hash, decoding fields as standard base64 instead of **base64url**, or parsing `public_key` as a raw 32-byte key instead of **SPKI DER**. Any one of these makes every genuine receipt appear forged. The sample below matches the real recipe.
### Key management
In production the signing key is mandatory — the signer refuses to fall back to an ephemeral key, so a receipt that exists is always verifiable against the published key. Receipts produced by the hardened strategies record which one signed them (a receipt with no recorded strategy was signed by the default strategy), and an optional hardened mode signs every receipt inside the HSM itself so the private key never enters an application process (for those receipts, the signed message is the 32-byte `payload_hash` digest rather than the canonical bytes — batch roots are signed the same way). The receipt's stored `public_key` is authoritative for verification, so key rotation never invalidates history.
***
## Verify with one call (recommended)
Ask the platform to re-derive and re-check the entire attestation chain for a record:
```bash theme={null}
curl "https://api.visiqlabs.com/record/envelopes/9f4c1a3e-8f2b-4c1d-9e5a-2b7c8d0f1a42/verify" \
-H "Authorization: Bearer "
```
```json theme={null}
{
"recordId": "9f4c1a3e-8f2b-4c1d-9e5a-2b7c8d0f1a42",
"verified": true,
"attestationPending": false,
"attestationStatus": "signed",
"checks": {
"integrity": { "pass": true, "detail": "recomputed SHA-256 matches receipt (58a9c1d2e3f40516…)" },
"leafSignature": { "pass": true, "detail": "Ed25519 leaf signature valid for the canonical decision payload (env signer)" },
"merkleInclusion": { "pass": true, "detail": "leaf #142 proven in Merkle root via 8-step recomputed proof" },
"kmsRootSignature":{ "pass": true, "detail": "chain_hash signature confirmed LIVE by AWS KMS (HSM key active)", "liveKmsVerified": true },
"timestamp": { "pass": true, "detail": "RFC-3161 token verified against DigiCert chain; attested 2026-07-03T17:23:12Z", "source": "batch-root" },
"ingestAnchor": { "pass": true, "detail": "ingest anchor verified: event == content_hash == receipt (1f6b3c9a2d4e5f70…)" }
},
"evidence": {
"source": "action",
"decision": "deny",
"emittedAt": "2026-07-03T17:22:41.118Z",
"payloadHash": "58a9c1d2e3f40516…",
"merkleRoot": "b47e2f91c3a8d605…",
"leafIndex": 142,
"tsaGenTime": "2026-07-03T17:23:12Z",
"chainPosition": { "batchSeq": 3121, "prevRoot": "77d1a0be94c2f358…", "chainSigned": true }
}
}
```
Checks fail closed: a check that cannot be completed reports `pass: false` with a reason — never a silent pass — with one deliberate exception: the ingest anchor on legacy records that predate anchor recording. `verified` is `true` only when all applicable checks pass. The Merkle inclusion proof is **recomputed on demand** from the batch's retained leaf set — it must reproduce the signed root exactly — and the root check performs a live AWS KMS `Verify` round-trip, confirming the HSM key itself vouches for the signature.
These endpoints sit on the management surface: harness keys used by your agents are deliberately confined to the operational SDK routes and receive `403` here. The dashboard's record verification page runs the identical checks — click any record pill under **Harness → Runtime Enforcement**. For scripted access, see [Platform Automation](/automation/introduction).
***
## Verify offline (TypeScript)
Receipts also verify with no VisIQ dependency at all — Node's built-in crypto is enough. Copy the `event` and receipt fields from the record's verification page in the dashboard:
```typescript theme={null}
import { createHash, createPublicKey, verify } from "node:crypto";
// The envelope's event object, exactly as recorded.
const event = {
target_app: "stripe",
action: "issue_refund",
context: { amount: 500, currency: "USD" },
};
// The receipt fields, as shown on the record's verification page.
const receipt = {
signature: "",
public_key: "",
payload_hash: "<64-char hex SHA-256>",
};
// 1. Canonicalize: sort object keys alphabetically, recursively.
// Arrays keep their order; primitives pass through.
function canonicalize(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalize);
const sorted: Record = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = canonicalize((value as Record)[key]);
}
return sorted;
}
const canonicalBytes = Buffer.from(JSON.stringify(canonicalize(event)), "utf8");
// 2. The payload hash is SHA-256 over the canonical bytes.
const payloadHash = createHash("sha256").update(canonicalBytes).digest("hex");
if (payloadHash !== receipt.payload_hash) {
throw new Error("payload hash mismatch — the event was altered");
}
// 3. The Ed25519 signature covers the canonical bytes themselves.
// Ed25519 uses one-shot verify with algorithm null (no digest step).
const publicKey = createPublicKey({
key: Buffer.from(receipt.public_key, "base64url"),
format: "der",
type: "spki",
});
const ok = verify(
null,
canonicalBytes,
publicKey,
Buffer.from(receipt.signature, "base64url"),
);
console.log(ok ? "receipt verified" : "SIGNATURE INVALID — record tampered or forged");
```
This verifies the default signing strategy. A receipt whose recorded signing strategy is the HSM-per-receipt mode signs the 32-byte `payload_hash` digest instead — substitute `Buffer.from(receipt.payload_hash, "hex")` as the message in step 3. The one-call endpoint above handles both automatically.
***
## Merkle inclusion and the checkpoint chain
Receipts are batched into an RFC 6962-style Merkle tree with domain-separated hashing:
* **Leaf hash** = `SHA-256( 0x00 ‖ payload_hash_bytes )`
* **Node hash** = `SHA-256( 0x01 ‖ left ‖ right )`
* An unpaired trailing node is promoted unchanged to the next level — never hashed with a copy of itself.
The batch root is signed **once** (with an HSM-backed KMS key where a deployment enables that mode; the hosted service currently signs with an in-process key), and one RFC 3161 timestamp covers the root — so a single signature and a single timestamp attest every receipt in the batch via its inclusion proof. That is what makes 100% signing-and-timestamping coverage hold at any decision volume.
Batches then form an append-only **hash chain**: each batch carries a monotonic sequence number, a commitment to the previous batch's root, and a running accumulator
```
chain_hash[k] = SHA-256( 0x02 ‖ chain_hash[k-1] ‖ root_hash[k] )
```
with the batch signature covering `chain_hash` — so one trusted tip signature vouches for the entire history. Deleting a batch leaves a gap, reordering breaks the accumulator, and editing any root breaks the recomputation. Two management-API endpoints expose this to any authenticated caller (the feed itself is tenant-neutral — it contains only roots, signatures, and counts):
* `GET /record/checkpoints` — the transparency-log checkpoint feed: signed roots, signatures, leaf counts, and timestamp times, newest first. Also browsable in the dashboard under **Settings → Checkpoint feed** (organization scope).
* `GET /record/chain/consistency` — walks a chain segment and proves, purely by recomputation, that no batch was deleted, reordered, or tampered with.
VisIQ additionally supports publishing checkpoints to write-once (WORM) object storage under compliance-mode object locking — an externally held copy that, once written, neither VisIQ nor its cloud provider can alter or delete before retention expires. That is the leg that makes a silent rewrite of history detectable *even by us*, so that the audit trail does not ask you to trust the party that operates it.
**This leg is off by default and is not enabled on the hosted service today**, so no checkpoint has yet been WORM-published. Until it is turned on, what we claim is tamper-**evidence** — a rewrite is detectable to anyone holding an earlier checkpoint — and not tamper-proofness against VisIQ itself. If you need the stronger property, ask for the external witness to be enabled, or retain a checkpoint yourself.
***
## RFC 3161 trusted timestamps
Each batch root is countersigned by **DigiCert's timestamp authority**. The DER-encoded timestamp token binds the root hash to a time asserted by DigiCert — an independent third party — and verification checks the token against DigiCert's certificate chain. Combined with the chain above, this proves a decision existed *no later than* the attested time, on evidence that does not depend on VisIQ's clocks or word.
For long-horizon audits, batches can also carry frozen revocation evidence for the timestamp certificate chain, so tokens remain independently verifiable years later, after certificates expire or responders disappear.
***
## Next steps
What gets recorded, the envelope schema, and how to consume the trail.
Authenticate scripts and CI against the management API.
# SDK Reference
Source: https://docs.visiqlabs.com/reference
API reference for @visiq/harness — the visiq() function, VisiqOptions, framework detection, agent modes, local evaluation, and governance outcomes.
This page documents the core surface of `@visiq/harness`: the `visiq()` function and its `VisiqOptions`.
```typescript theme={null}
import { visiq, type VisiqOptions } from "@visiq/harness";
```
The SDK ships for TypeScript (Node 20+, `npm install @visiq/harness`) and Python (`pip install visiq`); both wrap the same governance API. Any language without an SDK can integrate directly over the REST API — see the [action governance](/rules/action/api-reference) and [retrieval governance](/rules/retrieval/api-reference) API references.
**The SDK is pre-1.0 (0.x).** Breaking changes may ship in any minor release until v1.0 GA — pin an exact version and read the [changelog](/changelog) before upgrading. See [SDK versioning & compatibility](/versioning) for the full posture and the invariants we hold even now.
***
## `visiq(target, options?)`
Inject governance into an agentic framework instance. Wraps the target's run and tool dispatch methods in place and returns `target`.
```typescript theme={null}
function visiq(target: T, options?: VisiqOptions): T;
```
### Behavior
1. **Detects the framework** by inspecting `target`
2. **Installs action governance** — wraps every tool's own dispatch methods (`invoke`/`call`/`_call` for LangChain, `execute` for the Vercel AI SDK / Mastra / VoltAgent, `invoke` for the OpenAI Agents SDK, `call` for LlamaIndex.TS) so a deny actually prevents execution. Framework callbacks are observational only — a throwing callback is logged and execution continues — so enforcement happens at the tool method itself.
3. **Installs retrieval governance** — finds retrievers and retriever-backed tools and wraps them so retrieved documents pass through policy before the model sees them
4. **Captures telemetry** — each top-level run gets a fresh session id, and LLM prompts, responses, and agent reasoning are attached to decisions so the dashboard shows the full run context around each one
5. **Returns the same `target` reference** — your code is unchanged
Every decision is recorded by the backend automatically — no extra SDK calls (see [Audit receipts](#audit-receipts)).
Pass each executor to `visiq()` exactly once. Tool and retriever wrapping is idempotent (guarded by internal symbol markers), but a LangChain executor's `invoke()`/`stream()` are re-patched on each call.
### Framework detection
`visiq()` detects what you pass and installs the right hooks:
| Target | Detection |
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| LangChain `AgentExecutor` | `target.invoke` + `target.agent` + `target.tools` array |
| LangGraph `CompiledGraph` | `target.invoke` + `target.nodes` + `target.edges` |
| Vercel AI SDK agent (`ToolLoopAgent` / `Experimental_Agent`) | `target.generate` + `target.settings.tools` (or `target.tools`) |
| Mastra `Agent` | `target.generate` + `target.getLLM` + `target.listTools` or `target.getTools` |
| VoltAgent `Agent` | `target.generateText` + `target.getTools` / `target.toolManager` |
| LlamaIndex.TS `AgentWorkflow` (`agent()`) | `target.run` + `target.runStream` + `target.agents` map |
| OpenAI Agents SDK `Agent` | `target.tools` array + `target.handoffs` + an AgentHooks emitter (`on`) |
| Semantic Kernel `Kernel` (npm JS port) | `target.useFunctionInvocation` + `target.usePromptRender` filter-registration methods (and no `execute` / `generate` / `run`) |
| Single tool | exposes `_call` / `execute` / `call` / `run` / `invoke` |
**VisIQ governs Semantic Kernel today, in both of its incarnations** — the
community npm JavaScript port via `visiq(kernel)` in this SDK, and Microsoft's
official **Python** SDK via `visiq.govern(kernel)` in the [`visiq` PyPI
package](https://pypi.org/project/visiq/). Each registers into the kernel's own
filter pipeline, so no per-function wrappers are needed. Microsoft's **.NET** SK
is not covered (VisIQ ships no .NET SDK). See the [Semantic Kernel
quickstart](/quickstart/semantic-kernel).
For the Vercel AI SDK, Mastra, VoltAgent, the OpenAI Agents SDK, and LlamaIndex.TS, RAG is a tool whose result is document-shaped — retrieval governance filters the documents that tool returns (a bare `Document[]`, or a document-shaped array nested inside the result object). Anything unrecognized throws:
```text theme={null}
[VisIQ] Cannot detect agentic framework. Pass a LangChain AgentExecutor, LangGraph CompiledGraph, or a tool with one of: _call, execute, call, run, invoke.
```
### Retriever detection
For LangChain targets, the harness checks each tool in `target.tools` against four patterns:
| Order | Pattern | Match condition | What gets patched |
| ----- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 1 | LangChain `BaseRetriever` | `tool._getRelevantDocuments` is a function | `_getRelevantDocuments()` wrapped; results filtered per document |
| 2 | Generic retriever | `tool.retrieve` is a function (no `_getRelevantDocuments`) | `retrieve()` wrapped and filtered |
| 3 | Nested retriever | `tool.retriever` is an object | Recurses into `tool.retriever` and re-applies patterns 1–3 |
| 4 | Retriever-backed tool | `tool.func` is a function, plus a reachable `.retriever` **or** a retrieval-suggesting name — an explicit `retriev*` token always counts; `search…doc`/`knowledge` count only when the name carries no mutation verb | `func` wrapped; a reachable retriever is filtered per document, otherwise the returned result is filtered |
A retriever-backed tool whose name also carries an unambiguous mutation verb (e.g. `retrieve_and_archive`) is treated as a hybrid: one combined decision governs both the call gate and the returned data, so the call is evaluated exactly once.
Tools that capture their retriever in a closure (e.g. LangChain's `createRetrieverTool`, which returns a joined string) cannot be governed per document — the harness detects them by name, still masks their output via value-shape and pattern rules, and logs a one-time `console.warn` explaining that per-document metadata rules cannot apply. For full per-document governance, use a tool that exposes its retriever as a `.retriever` property or returns a `Document[]`.
***
## `VisiqOptions`
```typescript theme={null}
interface VisiqOptions {
agentId?: string;
apiKey?: string;
endpoint?: string;
hitlTimeoutMs?: number;
timeoutMs?: number;
}
```
| Field | Type | Env var fallback | Default | Description |
| --------------- | -------- | ----------------------- | --------------------------- | ------------------------------------------------------------------------------------------ |
| `agentId` | `string` | `VISIQ_AGENT_ID` | auto-derived | Agent identity for every evaluation |
| `apiKey` | `string` | `VISIQ_API_KEY` | — | Harness API key (`vq_prod_…` / `vq_test_…`) from Settings → Harness Keys |
| `endpoint` | `string` | `VISIQ_ENDPOINT` | `https://api.visiqlabs.com` | Backend base URL — defaults to managed SaaS; set explicitly only for onprem/self-hosted |
| `hitlTimeoutMs` | `number` | `VISIQ_HITL_TIMEOUT_MS` | `120000` | Max wait (ms) for a human to resolve an `approval_required` decision before failing closed |
| `timeoutMs` | `number` | `VISIQ_TIMEOUT_MS` | `5000` | Network timeout (ms) per backend call on the decision path |
**`agentId` is optional.** Resolution order: explicit option → `VISIQ_AGENT_ID` → the nearest `package.json` name (npm scope stripped) → hostname → `"agent"`. The backend auto-provisions the first id it sees — in monitor mode — so setting just the API key works end-to-end. Set it explicitly when you want a stable, rule-friendly name.
**The `endpoint` defaults to `https://api.visiqlabs.com`.** With just an `apiKey` the harness reaches the managed SaaS backend, loads a rule bundle, and governs automatically. If `apiKey` is unset the harness has no backend at all and cold-starts in `monitor` (monitor-until-confirmed): every wrapped tool call and document is observed but nothing is blocked. **Set `endpoint` (or `VISIQ_ENDPOINT`) explicitly only for onprem / sovereign / self-hosted deployments** — the SDK never defaults those to a VisIQ host, by design (see [Onprem / self-hosted](#onprem-/-self-hosted)).
***
## Agent modes
Every agent runs in one of three modes. The mode is **server-authoritative** — resolved on the backend and shipped to the SDK inside the rule bundle, where running agents pick it up within seconds.
| Mode | Behavior |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `monitor` | **The default.** Every event is evaluated and the would-be decision is recorded, but nothing is blocked, masked, or paused — observe first, enforce when the dashboard shows your rules behaving as intended. |
| `enforce` | Decisions are enforced: denials block, masks redact, approvals pause. |
| `off` | Evaluation is skipped entirely — no enforcement, no telemetry. |
**Inheritance.** An agent's mode can **inherit an org-wide default** or be **overridden per agent**. Leave an agent's mode unset and it resolves to the org default `allow_settings.default_agent_mode` (which itself defaults to `monitor`); set it explicitly (`enforce` / `monitor` / `off`) to override for that one agent. Changing the org default moves every inheriting agent together.
**Per-operation overrides.** An agent can also pin a different mode per operation via `mode_by_operation` — for example `enforce` actions while keeping retrievals in `monitor`. Any operation left unset inherits the agent's resolved mode.
***
## Local evaluation
Decisions resolve **in-process** against a locally cached rule bundle — not via a per-tool-call network round-trip:
* The harness fetches `GET /rules/bundle` in the background when you wrap, then refreshes every 5 seconds with `If-None-Match` (a `304` keeps the cached bundle).
* Every evaluation runs locally against the cached bundle. The only decision-path network traffic is human approval: registering an `approval_required` decision, then polling for its resolution.
* If the backend becomes unreachable *after* the bundle has loaded, governance keeps working from the cached rules; refresh resumes when connectivity returns.
The fail-closed ladder:
1. **No bundle yet — monitor-until-confirmed.** The cold-start fail-safe is a *mode envelope*, not fail-open. With an `apiKey` the harness reaches SaaS (`https://api.visiqlabs.com` by default) and confirms a bundle automatically; monitor-until-confirmed is only the brief pre-first-bundle window (or the genuinely-no-key case). An agent whose mode the backend has **never confirmed** (that brief cold start, or no `apiKey`) runs `monitor` — it observes and records but never blocks. An agent that was **confirmed in `enforce`** and then loses its cached bundle stays **fail-closed to deny** (G001): every action is denied and every document suppressed until the bundle returns. A backend-confirmed `monitor`/`off` agent stays permissive.
2. **Uncovered actions** — an action no rule matches resolves via your no-match default. Out of the box uncovered actions proceed — "no default disruption", even in `enforce` mode — and you can switch the default to Deny or Require approval under Organization Settings → Security → "When no rule matches". A **per-agent fail-safe override** (the agent's `no_coverage` setting: "Fail open — permit" / "Fail closed — deny" on the agent page) takes precedence over the org-level default for that agent; "Fail closed" also overrides autopilot. Uncovered **retrieval** keeps its default-deny data-protection floor: an unmatched document is denied.
3. **Masking failure** — fail closed, never fail open: if argument masking itself fails the call is blocked, and if document redaction fails the document is excluded rather than returned raw.
***
## Onprem / self-hosted
The `endpoint` default (`https://api.visiqlabs.com`) applies to the managed SaaS product only. **Onprem, sovereign, and self-hosted deployments must set `VISIQ_ENDPOINT` (or the `endpoint` option) explicitly** — the SDK never defaults those to a VisIQ host, by design: the control plane runs on your own network and must never phone home to a VisIQ endpoint you didn't configure. Point it at your deployment's base URL (see [Sovereign Deployment](https://docs.visiqlabs.com)). No-key-at-all is unchanged everywhere: the harness stays a safe monitor no-op.
***
## Governance outcomes
Actions and retrieval each resolve to one of four decisions:
| Facet | Decision | What happens |
| --------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Action | `permit` | The tool runs unchanged |
| Action | `deny` | The tool never runs; a block message is returned as the tool's output |
| Action | `approval_required` | The call pauses while a human approves or rejects it |
| Action | `mask` | The tool runs with the named arguments redacted |
| Retrieval | `allow` | The document passes through unchanged |
| Retrieval | `deny` | The document is excluded from the results |
| Retrieval | `redact` | The document's fields/patterns are masked |
| Retrieval | `escalate` | Retrieval never pauses: the document passes through and the escalate decision is recorded in the decision stream — a rule with the mask fallback returns it masked instead |
### Denied tool calls
**Nothing is thrown.** On a deny, the tool's underlying function is never invoked; the harness returns the denial *as the tool's output*, so the agent reads it and keeps reasoning — your `invoke()` caller receives a normal completion:
```text theme={null}
[VisIQ decision=deny code=] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.
```
`` is the matched rule's human-readable code, or a synthetic policy code (e.g. `D-WRITE-DENY`) for uncovered actions. Don't wrap `invoke()` in try/catch to detect blocks — inspect the tool output, or the decision stream in the dashboard.
### Human approval (`approval_required`)
The call pauses. The harness registers the decision with the backend and polls `GET /v1/allow/decisions/:id` every 2 seconds until a human resolves it or `hitlTimeoutMs` (default 120s) elapses. Approved → the tool runs. Rejected, expired, timed out, or persistent polling failure → the call is blocked in the same shape as a denial (fail-closed). A rule configured with the `mask` fallback proceeds with masked arguments instead of blocking when approval never arrives. See [Human-in-the-loop](/rules/action/hitl).
### Denied documents
Denied documents are **silently excluded** from results and redacted documents are masked in place. No error is thrown — suppression is a normal policy outcome, not an exception. If every document is denied, the retriever returns an empty array.
***
## Audit receipts
Every decision — action, retrieval, and human-approval — is recorded server-side with no SDK import or extra call. Each recorded decision additionally receives an asynchronously signed (Ed25519) receipt; verify any receipt from the dashboard or via `GET /record/envelopes/:id/verify`. See [Audit Trail](/record/introduction).
# Go SDK Reference
Source: https://docs.visiqlabs.com/reference-go
Local-decision reference for the Go binding to the VisIQ governance core — build-from-repo GateAction / GateRetrieval over the same compiled Rust core the Python and TypeScript SDKs use.
The Go binding calls the **same compiled Rust core** the Python and TypeScript
SDKs use, in-process over a C ABI (cgo). One core, many languages — no
re-implementation, no drift.
**Internal / build-from-repo — Go is NOT published.** Go libraries distribute as
**source** (a public git repo + `proxy.golang.org`); there is no compiled-binary
Go *library* artifact. Publishing this binding would expose the engine call graph
in source, the same reason the Rust SDK stays off crates.io. So the Go binding is
`package main`, **non-importable**, consumed by building from the monorepo — not
`go get`. The published, installable bindings are **Python, TypeScript, and
Ruby** (compiled artifacts); **Java** publishes with the first `main` release —
build from source until then. Use those if you need a package manager install.
**Scope: local-decision layer only** — bundle auto-refresh, HITL and audit
streaming are the Governor harness (Python/TypeScript only). This binding makes
one thing fast and local: the policy DECISION. You fetch the rule bundle and
stream audit yourself.
## Build
Build the C-ABI core (no pyo3) once, then build/run the Go package from the repo:
```bash theme={null}
cd experiments/matrix-v2/visiq-core-rs && cargo build --release --target-dir target-capi
cd ../visiq-sdk-go && CGO_ENABLED=1 go test ./...
```
## Acquire a bundle
Every decision is made against a rule bundle you fetch from the control plane and
hold in memory. Fetch it over the [rules API](/rules/action/api-reference):
```bash theme={null}
curl -H "Authorization: Bearer $VISIQ_API_KEY" \
"$VISIQ_ENDPOINT/rules/bundle?agent_id=support-bot"
```
`VISIQ_ENDPOINT` defaults to `https://api.visiqlabs.com`; set it only for onprem.
Hold the returned JSON as a string (`bundleJSON`) and pass it to the gate helpers.
## Govern a tool call
`GateAction(bundleJSON, toolName, args, agentID)` decides one tool/action call.
Inspect `decision["allowed"]`; on a `mask` verdict apply
`decision["action"]["argRedactionRules"]` to the args before running the tool.
```go theme={null}
// guide:begin
decision, _ := GateAction(bundleJSON, "wire_transfer", map[string]any{"amount": 999}, "sdk-agent")
// A denied tool: decision["allowed"] is false — do NOT run it.
fmt.Printf("allowed=%v\n", decision["allowed"])
// guide:end
```
This exact snippet is executed as a proof (`guide_example_test.go`,
`ExampleGateAction`) against a bundle copied verbatim from the oracle-stamped
conformance corpus: the deny fixture blocks (`allowed=false`), the permit fixture
passes (`allowed=true`).
## Govern a retrieval
`GateRetrieval(bundleJSON, resourceMetadata, agentID)` decides one retrieval.
Inspect `decision["retrieval"]["action"]` — drop on `deny`/`escalate`, redact via
`decision["retrieval"]["redactionRules"]` — before content reaches the model.
```go theme={null}
decision, _ := GateRetrieval(bundleJSON, map[string]any{"classification": "restricted"}, "sdk-agent")
retrieval := decision["retrieval"].(map[string]any)
fmt.Printf("action=%v\n", retrieval["action"])
```
## Fail mode
A HARNESS-internal failure — the native core can't load, or `visiq_evaluate`
returns NULL — is routed by `VISIQ_FAIL_MODE` (owner G001 rescope): `open`
(**default**) returns a permit-equivalent decision plus a loud stderr report so a
VisIQ packaging bug never disrupts the agent; `closed` returns a deny-equivalent.
A real rule **deny** always blocks regardless of fail mode.
```bash theme={null}
VISIQ_FAIL_MODE=closed # strict: a core-load/ABI failure denies instead of proceeding
```
## Override the core library
The binding loads `libvisiq_core` from the build tree. Point it elsewhere with
`VISIQ_CORE_LIB` (an absolute path), matching the Java and Ruby bindings.
```bash theme={null}
VISIQ_CORE_LIB=/opt/visiq/libvisiq_core.dylib
```
## Next steps
The Java binding — `gateAction` over the same core.
The published Ruby gem — `gate_action` over the same core.
# Java SDK Reference
Source: https://docs.visiqlabs.com/reference-java
Local-decision reference for the Java binding to the VisIQ governance core — gateAction / gateRetrieval over the same compiled Rust core the Python and TypeScript SDKs use, via the Panama FFM API.
The Java binding calls the **same compiled Rust core** the Python and TypeScript
SDKs use, in-process via the Foreign Function & Memory API (Panama, finalized in
JDK 22) — no JNI shim. One core, many languages; every decision is local.
**Scope: local-decision layer only** — bundle auto-refresh, HITL and audit
streaming are the Governor harness (Python/TypeScript only). This binding makes
one thing fast and local: the policy DECISION. You fetch the rule bundle and
stream audit yourself.
## Install
The jar bundles the platform-matching compiled core (extracted at load time), so
there is no separate native install.
```xml theme={null}
com.visiqlabs
visiq-sdk
0.1.1
```
Requires **JDK 22+**, run with `--enable-native-access=ALL-UNNAMED` so the FFM
downcall to the core is permitted.
## Acquire a bundle
Every decision is made against a rule bundle you fetch from the control plane and
parse. Fetch it over the [rules API](/rules/action/api-reference):
```bash theme={null}
curl -H "Authorization: Bearer $VISIQ_API_KEY" \
"$VISIQ_ENDPOINT/rules/bundle?agent_id=support-bot"
```
`VISIQ_ENDPOINT` defaults to `https://api.visiqlabs.com`; set it only for onprem.
Parse the returned JSON into a Jackson `JsonNode` (`bundle`) and pass it in.
## Govern a tool call
`Visiq.gateAction(bundle, toolName, args, agentId)` decides one tool/action call.
Inspect `decision.get("allowed")`; on a `mask` verdict apply
`decision.get("action").get("argRedactionRules")` to the args first.
```java theme={null}
// guide:begin
JsonNode decision = Visiq.gateAction(bundle, "wire_transfer", args, "sdk-agent");
System.out.println("allowed=" + decision.get("allowed").asBoolean());
// guide:end
```
This exact snippet is executed as a proof (`GuideExampleTest`) against a bundle
copied verbatim from the oracle-stamped conformance corpus: the deny fixture
blocks (`allowed=false`), the permit fixture passes (`allowed=true`).
## Govern a retrieval
`Visiq.gateRetrieval(bundle, resourceMetadata, agentId)` decides one retrieval.
Inspect `decision.get("retrieval").get("action")` — drop on `deny`/`escalate`,
redact via `retrieval.get("redactionRules")` — before content reaches the model.
```java theme={null}
JsonNode decision = Visiq.gateRetrieval(bundle, metadata, "sdk-agent");
System.out.println("action=" + decision.get("retrieval").get("action").asText());
```
## Fail mode
A HARNESS-internal failure — the native core can't load, or `visiq_evaluate`
returns NULL/throws — is routed by `VISIQ_FAIL_MODE` (owner G001 rescope): `open`
(**default**) returns a permit-equivalent decision plus a loud stderr report so a
VisIQ packaging bug never disrupts the agent; `closed` throws a fail-closed
exception. A real rule **deny** always blocks regardless of fail mode.
```bash theme={null}
VISIQ_FAIL_MODE=closed # strict: a core-load/FFI failure denies instead of proceeding
```
## Override the core library
The jar extracts its bundled core to a temp file. Point it at a specific library
with `VISIQ_CORE_LIB` (an absolute path), matching the Go and Ruby bindings.
```bash theme={null}
VISIQ_CORE_LIB=/opt/visiq/libvisiq_core.so
```
## Next steps
The published Ruby gem — `gate_action` over the same core.
The full Python harness — Governor, gates, and audit streaming.
# Python SDK Reference
Source: https://docs.visiqlabs.com/reference-python
API reference for the visiq Python package — the Governor harness, the low-level local decision gates (gate_action / gate_retrieval / decide), config resolution, and the ToolBlocked exception.
The [`visiq`](https://pypi.org/project/visiq/) package is the Python peer of
`@visiq/harness`. One compiled Rust core makes the same local, in-process policy
decisions, and on top of it the package ships the same end-to-end harness the
TypeScript SDK does — bundle fetch, agent registration, human-in-the-loop, and
audit telemetry.
```bash theme={null}
pip install visiq
```
Requires **Python 3.9+** (the wheel is `abi3`). Set the same environment as the
TypeScript SDK: `VISIQ_API_KEY` (`vq_prod_…` / `vq_test_…`), `VISIQ_ENDPOINT`
(`https://api.visiqlabs.com`), and optionally `VISIQ_AGENT_ID`.
**Two layers, one wheel.** Use `Governor` for a governed agent end to end (it
fetches your bundle and enforces every outcome). Use the low-level
`gate_action` / `gate_retrieval` / `decide` functions when you already hold a
rule bundle and just want a local decision with no network.
**Pre-1.0 (0.x).** Breaking changes may ship in any minor release until v1.0.
Pin an exact version. See [SDK versioning](/versioning) for the posture.
## Public API
Everything exported from the package top level:
| Symbol | Kind | Purpose |
| ---------------- | --------- | ------------------------------------------------------------------- |
| `Governor` | class | End-to-end harness — governs tool calls and retrieval for one agent |
| `ToolBlocked` | exception | Raised by `Governor.gate_tool` on a deny or unapproved HITL |
| `gate_action` | function | Low-level: decide one action/tool call against a bundle |
| `gate_retrieval` | function | Low-level: decide one retrieval against a bundle |
| `decide` | function | Low-level: evaluate a raw event against a bundle |
| `resolve_config` | function | Build a `HarnessConfig` from the environment |
| `HarnessConfig` | class | Resolved transport configuration |
***
## `Governor`
One `Governor` per agent process. It warms the rule bundle, decides every tool
call and retrieval locally against the compiled core, blocks on human approval
when a rule requires it, applies retrieval redaction, and streams an audit event
per decision.
```python theme={null}
from visiq import Governor, ToolBlocked
gov = Governor(agent_id="support-bot").start(tools=[
{"name": "issue_refund", "description": "Issue a refund to a customer"},
{"name": "search_knowledge", "description": "Search the company knowledge base"},
])
```
### `Governor(agent_id=None, config=None)`
Construct a governor. `agent_id` follows the same precedence as the TypeScript
SDK: explicit argument → `VISIQ_AGENT_ID` → `"agent"`. Pass a `HarnessConfig`
for `config` to bypass environment resolution.
### `start(tools=None) -> Governor`
Warm the bundle and run the one-time registration handshakes (environment +
tool surface). Safe to call once at startup; returns `self` so you can chain it
onto the constructor. `tools` is a list of `{"name", "description"}` dicts
describing your tool surface.
### `gate_tool(tool_name, args, call) -> Any`
Govern one tool call: decide locally, enforce the outcome, and only then invoke
`call(effective_args)`.
* `call` **must accept exactly one positional argument** — the effective
argument mapping. On a `mask` verdict the gate hands `call` the **redacted**
arguments, so the tool never sees the originals.
* A `permit` runs the tool with the original args.
* An `approval_required` registers the decision and **blocks** on the HITL poll
until a human resolves it (or `hitl_timeout_ms` elapses → blocked).
* A `deny`, an unapproved/expired HITL, or an unredactable `mask` raises
`ToolBlocked` — **the tool body never runs** (G001).
```python theme={null}
def issue_refund(customer_id: str, amount: float) -> str:
return gov.gate_tool(
"issue_refund",
{"customer_id": customer_id, "amount": amount},
lambda eff: f"Refunded ${eff['amount']} to {eff['customer_id']}",
)
```
### `gate_documents(docs, query=None) -> list`
Filter retrieved documents through the retrieval facet: `deny` drops the
document, `redact` masks its fields, `allow`/`escalate` keep it. Each document
is `{page_content | text | content, metadata}`-shaped. Fails closed — a bundle
that has not loaded returns `[]`, and any per-document evaluation error drops
that one document rather than crashing the batch.
```python theme={null}
docs = gov.gate_documents(retrieved, query="What was Q3 revenue?")
```
### `gate_text(text, metadata=None) -> str`
Filter a single **string** tool result through the retrieval facet. Returns the
text unchanged on `allow`, a redacted copy on `redact`, or a short blocked
placeholder on `deny` / when governance is unavailable.
### `flush() -> None`
Flush buffered audit telemetry to the backend. Registered with `atexit`, so it
also runs at process exit — call it explicitly for long-lived processes.
### `agent_id` (property)
The resolved agent id this governor reports under.
**Offline is fail-closed.** With no `VISIQ_ENDPOINT` + `VISIQ_API_KEY` (or a
backend that never returns a bundle), `Governor` has no rules to evaluate, so
`gate_tool` raises `ToolBlocked` and `gate_documents` returns `[]`. This differs
from the TypeScript harness's monitor-until-confirmed cold start — set the
endpoint and key so a bundle can load.
***
## Low-level decision gates
Pure functions over a rule bundle you already hold. No network, no I/O — the
decision path is entirely local and fails closed on malformed input (G001).
Every gate returns the same `UnifiedDecision` dict.
### `gate_action(bundle, *, tool_name, args=None, agent_id="agent", target_resource=None, normalized=None) -> dict`
Decide one tool/action call.
```python theme={null}
import visiq
decision = visiq.gate_action(
bundle,
tool_name="issue_refund",
args={"customer_id": "cust_42", "amount": 500},
agent_id="support-bot",
)
if decision["action"]["decision"] == "deny":
... # block; on "mask", apply decision["action"]["argRedactionRules"] first
```
### `gate_retrieval(bundle, *, resource_type="document", resource_metadata=None, agent_id="agent", query=None) -> dict`
Decide one retrieval. Check `decision["retrieval"]["action"]` — drop on
`deny`/`escalate`, redact on `redact` via `retrieval.redactionRules`, keep on
`allow` — before the content reaches the model.
### `decide(event, bundle) -> dict`
Evaluate a raw event dict directly. `gate_action` and `gate_retrieval` are thin
wrappers over this.
### The decision dict
```json theme={null}
{
"decision": "deny",
"allowed": true,
"reason": "No matching rule and no no-coverage config — fail-closed (G001)",
"ruleId": null,
"ruleCode": null,
"enforced": false,
"agentMode": "monitor",
"action": { "decision": "deny", "allowed": false },
"retrieval": { "action": null }
}
```
* `action.decision` ∈ `permit` · `deny` · `approval_required` · `mask`.
* `retrieval.action` ∈ `allow` · `deny` · `redact` · `escalate`.
* `enforced` is `false` when the agent is in monitor/off mode — the would-be
verdict is still reported on `action.decision`, but top-level `allowed` stays
`true` because nothing is actually blocked. In an `enforce` bundle, `allowed`
reflects the real outcome.
**Where does `bundle` come from?** The full harness (`Governor`) fetches it for
you from `GET /rules/bundle`. If you drive the low-level gates yourself, fetch
that bundle over the [rules API](/rules/action/api-reference) and pass the JSON
object straight in.
***
## `resolve_config(agent_id=None) -> HarnessConfig`
Resolve transport configuration from the environment, matching the TypeScript
harness precedence:
* **endpoint** — `VISIQ_ENDPOINT`, then the `VISIQ_BASE_URL` alias.
* **api\_key** — `VISIQ_API_KEY`, then the `VISIQ_ALLOW_API_KEY` alias.
* **agent\_id** — explicit argument → `VISIQ_AGENT_ID` → `"agent"`.
`HarnessConfig(endpoint, api_key, agent_id, timeout_ms=10000, hitl_timeout_ms=120000)`
carries those values; `endpoint`/`api_key` may be `None`, in which case every
network call no-ops and the decision path is local-only (fail-closed on
enforce).
***
## `ToolBlocked`
```python theme={null}
class ToolBlocked(Exception): ...
```
Raised by `Governor.gate_tool` when a call is denied or a HITL approval is not
granted. The tool body is never executed (G001). Let it surface to your
framework as the tool's failure, or catch it to return a model-readable message:
```python theme={null}
try:
result = issue_refund("cust_42", 500)
except ToolBlocked as blocked:
result = f"[blocked] {blocked}"
```
## Next steps
The Python section of the quickstart — governed agent in a few lines.
The `visiq()` function, options, and framework detection.
# Ruby SDK Reference
Source: https://docs.visiqlabs.com/reference-ruby
Local-decision reference for the published Ruby gem binding to the VisIQ governance core — gate_action / gate_retrieval over the same compiled Rust core the Python and TypeScript SDKs use, via Fiddle FFI.
The Ruby gem calls the **same compiled Rust core** the Python and TypeScript SDKs
use, in-process via Ruby's stdlib `Fiddle` FFI — zero runtime gem dependencies.
One core, many languages; every decision is local.
**Scope: local-decision layer only** — bundle auto-refresh, HITL and audit
streaming are the Governor harness (Python/TypeScript only). This gem makes one
thing fast and local: the policy DECISION. You fetch the rule bundle and stream
audit yourself.
## Install
The gem bundles the platform-matching compiled core, so there is no separate
native install.
```bash theme={null}
gem install visiq
```
## Acquire a bundle
Every decision is made against a rule bundle you fetch from the control plane and
parse. Fetch it over the [rules API](/rules/action/api-reference):
```bash theme={null}
curl -H "Authorization: Bearer $VISIQ_API_KEY" \
"$VISIQ_ENDPOINT/rules/bundle?agent_id=support-bot"
```
`VISIQ_ENDPOINT` defaults to `https://api.visiqlabs.com`; set it only for onprem.
Parse the returned JSON into a Hash (`bundle`) and pass it in.
## Govern a tool call
`Visiq.gate_action(bundle, tool_name:, args:, agent_id:)` decides one tool/action
call. Inspect `decision["allowed"]`; on a `mask` verdict apply
`decision["action"]["argRedactionRules"]` to the args before running the tool.
```ruby theme={null}
# guide:begin
decision = Visiq.gate_action(bundle, tool_name: "wire_transfer", args: { "amount" => 999 }, agent_id: "sdk-agent")
puts "allowed=#{decision['allowed']}"
# guide:end
```
This exact snippet is executed as a proof (`guide_example.rb`) against a bundle
copied verbatim from the oracle-stamped conformance corpus: the deny fixture
blocks (`allowed=false`), the permit fixture passes (`allowed=true`).
## Govern a retrieval
`Visiq.gate_retrieval(bundle, resource_metadata:, agent_id:)` decides one
retrieval. Inspect `decision["retrieval"]["action"]` — drop on `deny`/`escalate`,
redact via `decision["retrieval"]["redactionRules"]` — before content reaches the
model.
```ruby theme={null}
decision = Visiq.gate_retrieval(bundle, resource_metadata: { "classification" => "restricted" }, agent_id: "sdk-agent")
puts "action=#{decision['retrieval']['action']}"
```
## Fail mode
A HARNESS-internal failure — the compiled core can't load, or an FFI error — is
routed by `VISIQ_FAIL_MODE` (owner G001 rescope): `open` (**default**) returns a
permit-equivalent decision plus a loud stderr report so a VisIQ packaging bug
never disrupts the agent; `closed` raises a fail-closed error. A real rule
**deny** always blocks regardless of fail mode.
```bash theme={null}
VISIQ_FAIL_MODE=closed # strict: a core-load/FFI failure denies instead of proceeding
```
## Override the core library
The gem loads its bundled core. Point it at a specific library with
`VISIQ_CORE_LIB` (an absolute path), matching the Go and Java bindings.
```bash theme={null}
VISIQ_CORE_LIB=/opt/visiq/libvisiq_core.dylib
```
## Next steps
The Java binding — `gateAction` over the same core.
The full Python harness — Governor, gates, and audit streaming.
# REST API conventions
Source: https://docs.visiqlabs.com/reference/rest-conventions
Conventions shared across every VisIQ REST endpoint — the validation-error body, the /v1 vs unversioned path split, list pagination, and the API stability policy.
Every VisIQ REST endpoint shares the same conventions for errors, versioning, and pagination. This page is the single reference the per-facet API references point back to. The base URL for all of them is `https://api.visiqlabs.com`.
***
## Validation errors
A request that fails schema validation returns **`400 Bad Request`** with a machine-readable body. The `error` field is always a short string; a `details` field carries the underlying diagnostics.
```json theme={null}
{
"error": "Invalid request body",
"details": { "...": "..." }
}
```
The `error` string names what failed to parse:
| `error` | When |
| ----------------------------------------------------------------------- | ------------------------------------------------------------- |
| `Invalid request body` | A request body failed schema validation |
| `Invalid query parameters` | A query string failed schema validation |
| `Invalid JSON body` | The body was not parseable JSON at all |
| `Invalid rule ID` / `Invalid record ID` / `Invalid checkpoint sequence` | A path parameter was not the expected shape (e.g. not a UUID) |
The `details` payload has one of three concrete shapes depending on the endpoint — all are derived from the same underlying validator, so all pinpoint the offending field(s):
* **Flattened** (the unified `POST /evaluate` and the `/orchestrate/*` endpoints): `{ "formErrors": [...], "fieldErrors": { "field": ["message"] } }`.
* **Raw issues** (the unified `/rules` CRUD): an array of issue objects, each `{ "code": "...", "path": ["field"], "message": "..." }`.
* **Mapped** (the `/record/*` endpoints): an array of `{ "field": "path.to.field", "message": "..." }`.
Path-parameter errors (`Invalid rule ID`, etc.) and `Invalid JSON body` carry no `details`.
Every write endpoint validates its entire body before touching the database (input is validated with a strict schema first), so a `400` means nothing was persisted.
***
## Versioned vs unversioned paths
Two path conventions coexist, and which one an endpoint uses is deliberate.
Cross-cutting and query endpoints live under a `/v1` prefix: the decision audit-log reads (`/v1/allow/audit-log`, `/v1/recall/audit-log`, `/v1/record/audit-log`), decision polling (`/v1/allow/decisions/:id`), and resources like credentials, events, and notifications. The prefix marks the stable, URL-versioned public surface.
The governance surfaces are mounted at the root without a version segment: `/allow/*`, `/recall/*`, `/orchestrate/*`, `/rules/*`, `/evaluate`, and `/record/*`. They are kept unversioned for product isolation, and their evolution is governed by the SDK **wire contract** below, not by a URL version.
Practically: a versioned read endpoint keeps its `/v1` path stable and adds fields additively; a governance-plane endpoint evolves under the bundle-dialect contract. The OEM Partner API is a third, separate surface with its own dated version header and a 12-month deprecation window — see [Versioning & deprecation](/partners/oem-versioning).
***
## List pagination
List endpoints accept `page` (default `1`) and `limit` query parameters and return a paginated envelope. The default and maximum `limit`, and the name of the page-size field in the response, vary by surface:
| Surface | Max `limit` | Response envelope |
| ------------------------------------------ | ----------- | --------------------------------- |
| Action, retrieval, unified rules | `100` | `{ data, total, page, pageSize }` |
| Record (records, sub-resources, audit log) | `200` | `{ data, total, page, limit }` |
Both fields hold the same value — the effective page size — under a different key. `total` is an accurate server-side count over the (vendor-scoped) query.
***
## Authentication & rate limits
All endpoints authenticate with a Bearer credential and are rate-limited per key (default 600 requests / 60 seconds). Credentials come in two audiences — operational **harness keys** confined to the SDK routes, and **management keys** governed by permission grants. The full model, error codes, and the permission ↔ endpoint matrix live in [Managing API Keys](/automation/api-keys).
***
## Stability policy
The platform is **pre-1.0** — there is no frozen wire-compatibility guarantee yet, and the SDK surface may change across a minor release until v1.0 GA. Two properties hold regardless of version, because a governance product cannot be casual about them:
The control plane never emits a bundle construct — a new outcome verb, field, or operation facet — that the reading SDK cannot already enforce. The SDK that understands a construct is published before the plane emits it. Write your clients as **tolerant readers**: ignore response fields you don't recognize rather than failing on them.
An SDK treats any construct it does not recognize as must-understand and resolves it to the fail-closed outcome for a confirmed-enforcing agent — never fail open. A version skew can degrade coverage but never safety.
For the full posture — the pre-1.0 stance, the invariants, and the compatibility window that takes effect at v1.0 — see [SDK versioning & compatibility](/versioning). For the separately-versioned partner surface, see [OEM Partner API versioning](/partners/oem-versioning).
# API Reference
Source: https://docs.visiqlabs.com/rules/action/api-reference
Complete REST API reference for the action-governance endpoints.
Action-governance endpoints are mounted under `/allow/*`, with versioned read endpoints under `/v1/allow/*`. The base URL is `https://api.visiqlabs.com`.
## Authentication
All endpoints require a Bearer credential: `Authorization: Bearer `. Requests without a valid credential receive `401 Unauthorized`.
Two credential audiences exist:
* **Harness keys** — the operational credential your SDK or harness runs with. Either a `vq_prod_...` / `vq_test_...` key minted in the dashboard under **Settings → Harness Keys**, or the `allow_...` key returned once by `POST /allow/agents`. Harness keys are full-power on the operational endpoints (evaluate, bundle, telemetry, execution events, decision polling, agent self-registration, the mode stream) and **route-confined**: calling any management endpoint with one returns `403 {"error": "harness_key_not_permitted"}`.
* **Management keys** — general automation credentials governed by explicit permission grants. They can call every endpoint on this page. Mint one in the dashboard under **Settings → API Keys**, scoped to the exact permission grants it needs; or drive the management endpoints from the dashboard, which authenticates with your session.
Scoped keys are additionally checked against their granted scopes. Evaluation endpoints require `rules:evaluate` (or the legacy `allow:write`); read endpoints require `rules:read` (or the legacy `allow:read`); `full_access` satisfies everything. A key without the required scope receives `403 {"error": "insufficient_scope"}` listing the required and granted scopes.
### Rate limiting
Every API-key request passes a per-key sliding-window rate limit (default 600 requests per 60 seconds). Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers; exceeding the window returns `429` with a `Retry-After` header and body `{"error": "rate_limited", "detail": "API key rate limit exceeded.", "retryAfter": }`.
### List responses
Every list endpoint on this page returns the same envelope:
```json theme={null}
{
"data": [ ... ],
"total": 128,
"page": 1,
"pageSize": 50
}
```
Pagination is controlled by `page` (default `1`) and `limit` (default `50`, max `100`) query parameters.
***
## Evaluation
The endpoints your SDK or harness calls at runtime. All of them accept a harness key.
***
### POST /allow/evaluate
Evaluate an action against your rule set and return an authorization decision. The SDK evaluates most actions locally from the cached rule bundle — this endpoint handles human-approval flows, uncovered-scenario fallback, and remote evaluation.
**Scope:** `rules:evaluate` or `allow:write`
**Request body:**
```json theme={null}
{
"agent_id": "billing-agent",
"target_app": "stripe.com",
"action": "POST /v1/charges",
"context": {
"amount": 5000,
"currency": "usd"
}
}
```
| Field | Type | Required | Description |
| ------------ | -------- | -------- | ----------------------------------------------------------------------------- |
| `agent_id` | `string` | Yes | Identifier of the agent making the request (1–255 chars) |
| `target_app` | `string` | Yes | Hostname or app identifier being called (1–255 chars) |
| `action` | `string` | Yes | The action string — typically HTTP method + path or a tool name (1–255 chars) |
| `context` | `object` | No | Key-value context for rule matching. Default `{}` |
| `telemetry` | `object` | No | Opaque client telemetry, attached to the decision's signed record envelope |
An `agent_id` the platform has never seen is **auto-provisioned in monitor mode** (observe-only) and the evaluation proceeds — there is no 404 for unregistered agents. The new agent appears on the dashboard Agents page, ready to be promoted to enforce.
**Response:**
```json theme={null}
{
"decision_id": "550e8400-e29b-41d4-a716-446655440000",
"decision": "permit",
"reason": "Matched rule: Allow Stripe reads",
"rule_code": "R-1042",
"enforced": true,
"agent_mode": "enforce",
"plane": "action",
"operation": "write",
"is_retrieval": false
}
```
| Field | Type | Description |
| --------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `decision_id` | `string` (UUID) | Unique ID for this decision — used for approval polling, execution events, and audit |
| `decision` | `permit` \| `deny` \| `approval_required` \| `mask` | The authorization outcome |
| `reason` | `string` | Human-readable explanation |
| `rule_code` | `string` \| `null` | The matched rule's `R-####` code, or a synthetic default-policy code (`D-READ-ALLOW`, `D-WRITE-DENY`, `D-DELETE-ASK`, …) when a no-coverage default decided |
| `enforced` | `boolean` | `true` when the agent runs in enforce mode. `false` means the decision is observational ("would have blocked") |
| `agent_mode` | `enforce` \| `monitor` \| `off` | The agent's server-authoritative mode at decision time |
| `plane` | `string` \| `null` | Classification of the action's schema (`action`, `retrieval`, …); `null` until the schema is mapped |
| `operation` | `string` \| `null` | Canonical operation verb from the schema mapping, when available |
| `is_retrieval` | `boolean` | Whether the event classifies as a retrieval |
| `arg_redaction_rules` | `array` | Present on a `mask` decision — see below |
| `hitl_fallback` | `deny` \| `mask` | Present on an `approval_required` decision when a matched rule routed the action to approval; absent on gates raised by the no-coverage `ask` default (treat absence as `deny`) — see below |
**The `mask` decision** is allow-with-transform: the action proceeds, but the harness must first redact the named arguments. The response carries the directives to apply:
```json theme={null}
{
"decision_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"decision": "mask",
"reason": "Card numbers are masked for this agent",
"rule_code": "R-1077",
"enforced": true,
"agent_mode": "enforce",
"plane": "action",
"operation": null,
"is_retrieval": false,
"arg_redaction_rules": [
{ "field": "card_number", "mode": "partial", "keepLast": 4, "maskChar": "*" }
]
}
```
Each redaction directive may include `field`, `pattern`, `replacement`, `mode` (`full` | `partial` | `email` | `custom`), `keepFirst`, `keepLast`, `maskChar`, and `keepPattern`. A `mask` decision whose rule resolves to no usable directives is downgraded to `deny` server-side — the platform never returns a mask that masks nothing.
**The `approval_required` decision** holds the action while a human approves or rejects it (see [Human-in-the-Loop](/rules/action/hitl)). The approval window is capped at **120 seconds** — an agent never blocks longer than 2 minutes. `hitl_fallback` tells the harness what to do if no human responds in time: `deny` (the default, fail-closed) or `mask` (proceed with the accompanying `arg_redaction_rules` applied).
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `429 Too Many Requests`, `500 Internal Server Error`
***
### GET /allow/rules/bundle
Fetch the compiled rule bundle for local evaluation. The SDK caches this bundle and revalidates it in the background, so decisions on the hot path never wait on the network.
**Query parameters:**
| Parameter | Required | Description |
| ---------- | -------- | --------------------------------------------------------------------------- |
| `agent_id` | Yes | The agent the bundle is compiled for — the bundle carries that agent's mode |
`agent_id` is required — calling this endpoint without it returns `400 Invalid query parameters`. A first bundle pull for an unknown `agent_id` auto-provisions the agent in monitor mode, exactly like `POST /allow/evaluate`.
**Request headers:**
| Header | Description |
| --------------- | ----------------------------------------------------------------------------------------------- |
| `If-None-Match` | ETag from a previous response. The server returns `304 Not Modified` if the bundle is unchanged |
**Response:**
```json theme={null}
{
"version": "a3b4c5d6e7f8...",
"agent_mode": "enforce",
"rules": [
{
"id": "rule-uuid",
"rule_code": "R-1042",
"name": "Allow Stripe reads",
"description": "Permit read-only Stripe API calls",
"effect": "allow",
"rego_source": "package allow\n\ndefault allow = false\n...",
"resource_type": "stripe.com",
"target_app": "stripe.com",
"action_pattern": "GET *",
"conditions": [
{ "field": "resource_type", "operator": "equals", "value": "stripe.com" },
{ "field": "action", "operator": "glob", "value": "GET *" }
],
"priority": 10
}
],
"no_coverage": {
"no_coverage_defaults": { "read": "approve", "write": "approve", "delete": "approve", "admin": "approve" },
"autopilot_enabled": false,
"enduser_hitl_enabled": true,
"hitl_timeout_seconds": 120
}
}
```
| Field | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | SHA-256 hash of the serialized payload. Changes whenever a rule, the agent's mode, or the no-coverage settings change |
| `agent_mode` | The agent's current mode, so the SDK branches on enforce / monitor / off without an extra round-trip |
| `rules` | Enabled action rules, sorted by priority descending. The SDK evaluates `rego_source` directly (with `target_app` / `action_pattern` as pre-filters) — the identical match loop the server runs. `conditions[]` is deprecated back-compat |
| `no_coverage` | Your organization's no-coverage policy, so the SDK resolves **uncovered** actions locally too — no round-trip for anything |
**Response headers:**
| Header | Value |
| --------------- | ------------------------------------------- |
| `ETag` | `""` (quoted, per RFC 7232) |
| `Cache-Control` | `private, max-age=60` |
**Status codes:** `200 OK`, `304 Not Modified`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### GET /v1/allow/decisions/:id
Poll the status of a decision by its ID. The SDK polls this every 2 seconds while a human approval is pending, until the decision resolves or the 120-second window lapses.
**Scope:** `rules:read` or `allow:read`
**Path parameter:** `:id` — UUID of the decision (from a `POST /allow/evaluate` response)
**Response:**
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"vendor_id": "vendor-uuid",
"agent_id": "billing-agent",
"target_app": "stripe.com",
"action": "POST /v1/charges",
"context": { "amount": 5000 },
"decision": "approval_required",
"reason": "High-value charge requires approval",
"rule_id": "rule-uuid",
"hitl_result": "approved",
"hitl_responded_at": "2026-07-03T10:35:00Z",
"hitl_responded_by": "reviewer@example.com",
"created_at": "2026-07-03T10:30:00Z",
"execution_result": null,
"execution_completed_at": null,
"execution_details": null,
"hitl": {
"id": "hitl-uuid",
"status": "approved",
"category": "enduser",
"expires_at": "2026-07-03T10:32:00Z",
"responded_at": "2026-07-03T10:31:12Z",
"responded_by": "reviewer@example.com",
"created_at": "2026-07-03T10:30:00Z"
}
}
```
The `hitl` field is `null` for decisions that did not require approval.
**Status codes:** `200 OK`, `400 Bad Request` (invalid UUID), `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### POST /allow/telemetry
Submit a batch of locally-evaluated decisions. The SDK calls this after local bundle evaluation so every decision — local or remote — lands in the audit trail, and uncovered scenarios surface for review.
**Request body:**
```json theme={null}
{
"decisions": [
{
"decision_id": "c1a7e2f0-3b4d-4c5e-8f90-123456789abc",
"agent_id": "billing-agent",
"target_app": "stripe.com",
"action": "GET /v1/customers",
"decision": "permit",
"reason": "Matched rule: Allow Stripe reads",
"evaluated_at": "2026-07-03T10:30:00.000Z",
"rule_id": "rule-uuid",
"rule_code": "R-1042",
"enforced": true,
"agent_mode": "enforce",
"evaluation_mode": "local",
"latency_ms": 1
}
]
}
```
1–100 decisions per request. Required per-item fields: `decision_id` (UUID, client-generated), `agent_id`, `target_app`, `action`, `decision` (`permit` | `deny` | `approval_required` | `mask`), `reason`, and `evaluated_at` (ISO 8601). Optional: `rule_id`, `rule_code`, `enforced`, `agent_mode`, `raw_event`, `evaluation_mode` (`local` | `remote`), `latency_ms`, and `context` (max 32 KB serialized). Decisions without a `rule_id` are uncovered scenarios and create engineer-category review items.
Items are validated **individually**: valid items are ingested, malformed ones are rejected per-item and reported back — one bad item never discards the rest of the batch. Only a batch where every item is invalid returns `400`.
**Response (202):**
```json theme={null}
{
"received": 5,
"uncovered": 1,
"new_schemas": 0,
"rejected": [
{ "index": 3, "issues": [ { "path": ["evaluated_at"], "message": "Invalid datetime" } ] }
]
}
```
**Status codes:** `202 Accepted`, `400 Bad Request` (envelope invalid or all items rejected), `401 Unauthorized`, `500 Internal Server Error`
***
### POST /allow/execution-events
Record the post-decision execution outcome for a permitted action, closing the feedback loop on the audit trail. Idempotent: a second submission for the same `decision_id` returns `409 Conflict`.
**Request body:**
```json theme={null}
{
"decision_id": "550e8400-e29b-41d4-a716-446655440000",
"result": "success",
"details": "Stripe charge created (ch_1234)"
}
```
| Field | Type | Required | Description |
| ------------- | --------------------------------- | -------- | ------------------------------------------------------------------------------ |
| `decision_id` | `string` (UUID) | Yes | ID of the decision (from `POST /allow/evaluate` or client-generated telemetry) |
| `result` | `success` \| `failure` \| `error` | Yes | Execution outcome |
| `details` | `string` | No | Free-text details (max 2000 chars) |
**Response:** `{ "updated": true }`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found` (decision not found), `409 Conflict` (already recorded), `500 Internal Server Error`
***
### POST /allow/agents/register
Registration handshake: the SDK calls this once at startup (fire-and-forget) to report the agent's captured environment. Idempotent — it provisions the agent if this arrives before the first evaluate call.
**Scope:** `rules:evaluate` or `allow:write` (harness keys); management keys additionally need the agent-management permission
**Request body:**
```json theme={null}
{
"agent_id": "billing-agent",
"os": "linux",
"hostname": "worker-7",
"ip": "10.0.4.12",
"username": "svc-agents",
"kind": "sdk"
}
```
All fields except `agent_id` are optional. `kind` is `sdk` (framework harness, the default) or `cli_harness` (CLI harness). When `ip` is omitted, the server records the request's source IP. Owner and approval-routing fields are deliberately **not** accepted here — those are set by humans via the dashboard or the agent management API.
**Response:** `{ "ok": true, "agent_id": "billing-agent" }`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### GET /allow/agents/me/stream
Server-Sent Events stream of mode changes for one agent, so the SDK flips enforcement instantly when you toggle a mode in the dashboard — no waiting for the next bundle poll.
**Query parameters:** `agent_id` (required)
**Events:**
* `mode_snapshot` — sent immediately on connect: `{ "agent_mode": "enforce" | "monitor" | "off" }`
* `mode_changed` — sent whenever the agent's mode changes: same payload
* `ping` — heartbeat every 25 seconds to keep idle proxies from closing the connection
**Status codes:** `200 OK` (stream), `400 Bad Request`, `401 Unauthorized`, `404 Not Found` (unknown agent), `500 Internal Server Error`
***
## Agents
Manage the agent registry. These are management endpoints — a harness key receives `403 harness_key_not_permitted` here.
***
### GET /allow/agents
List registered agents, newest first.
**Permission:** `allow_agents:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `100`)
**Response:**
```json theme={null}
{
"data": [
{
"id": "agent-row-uuid",
"agent_id": "billing-agent",
"name": "Billing Agent",
"description": "Handles subscription billing operations",
"mode": "enforce",
"owner_email": "owner@example.com",
"hitl_pathway": "slack",
"trust_tier": "tier2",
"categories": ["transactional", "internal"],
"business_function": "finance_accounting",
"business_function_source": "ai",
"no_coverage": null,
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z"
}
],
"total": 3,
"page": 1,
"pageSize": 50
}
```
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### POST /allow/agents
Register a new agent. Returns the plaintext API key **exactly once** — store it securely; it cannot be retrieved again.
**Permission:** `allow_agents:create`
**Request body:**
```json theme={null}
{
"agent_id": "billing-agent",
"name": "Billing Agent",
"description": "Handles subscription billing operations",
"mode": "monitor",
"owner_email": "owner@example.com",
"hitl_pathway": "slack",
"trust_tier": "tier2",
"categories": ["transactional", "internal"]
}
```
| Field | Type | Required | Description |
| -------------- | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_id` | `string` | Yes | Logical identifier used in SDK config (max 255 chars) |
| `name` | `string` | Yes | Display name (max 255 chars) |
| `description` | `string` | No | Description (max 1000 chars) |
| `mode` | `enforce` \| `monitor` \| `off` | No | Evaluation mode. Default `monitor` (observe-only) |
| `api_key` | `string` | No | Custom API key (16–256 chars). Auto-generated (`allow_` + 64 hex chars) if omitted |
| `owner_email` | `string` | No | The agent's human owner — receives approval requests for this agent |
| `agent_type` | `string` | No | The framework the agent runs on — a supported framework identifier such as `langchain`, `openclaw`, `llamaindex`, `autogen`, `crewai`, `vercel_ai`, `mastra`, `voltagent`, `openai_agents`, or `unknown` |
| `hitl_pathway` | `slack` \| `teams` \| `email` | No | Channel the owner is messaged through for approvals |
| `trust_tier` | `tier1` \| `tier2` \| `tier3` | No | Ordinal trust level (`tier1` = highest trust). Rules condition on it as `input.agent.trust_tier` |
| `categories` | `string[]` | No | Controlled multi-label vocabulary: capability labels (`read_only`, `transactional`, `data_processor`, `external_comms`, `code_exec`, `privileged`) and exposure labels (`internal`, `customer_facing`, `experimental`, `third_party`) |
| `no_coverage` | `open` \| `closed` \| `null` | No | Per-agent fail-safe when **no rule matches** an action (enforce mode). `open` permits every uncovered operation class; `closed` denies them all — an explicit lockdown that also takes precedence over autopilot; `null`/omitted inherits the org-level no-match defaults. Takes precedence over the org setting |
**Response (201):** the agent object (as in the list response) plus `api_key`:
```json theme={null}
{
"id": "agent-row-uuid",
"agent_id": "billing-agent",
"name": "Billing Agent",
"description": "Handles subscription billing operations",
"mode": "monitor",
"owner_email": "owner@example.com",
"hitl_pathway": "slack",
"trust_tier": "tier2",
"categories": ["transactional", "internal"],
"business_function": null,
"no_coverage": null,
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z",
"api_key": "allow_a1b2c3d4e5f6..."
}
```
The `api_key` field is only present in this response. Subsequent reads omit it entirely.
**Status codes:** `201 Created`, `400 Bad Request`, `401 Unauthorized`, `409 Conflict` (`agent_id` already registered), `500 Internal Server Error`
***
### GET /allow/agents/:id
Get a single agent by its row UUID. The response includes the machine-reported environment (`agent_os`, `agent_hostname`, `agent_ip`, `agent_username`) captured via `POST /allow/agents/register`, and never includes key material.
**Permission:** `allow_agents:view`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### PUT /allow/agents/:id
Update an agent. Only provided fields change; at least one field is required.
**Permission:** `allow_agents:update`
**Request body:** any subset of `name`, `description`, `mode`, `owner_email` (nullable), `hitl_pathway` (nullable), `trust_tier` (nullable), `categories`, `no_coverage` (`open` | `closed`, or `null` to clear — see the POST field table), and `business_function` — one of the twelve business-function identifiers (`finance_accounting`, `hr`, `engineering`, `it_security`, `legal`, `privacy_compliance`, `sales`, `marketing`, `operations`, `customer_support`, `procurement`, `generic`), or `null` to clear.
```json theme={null}
{
"mode": "enforce",
"trust_tier": "tier1"
}
```
Setting `name` or `business_function` pins it as human-owned — the platform's AI naming and classification never overwrite a value you set. A `mode` change is broadcast immediately to any live `GET /allow/agents/me/stream` subscriber.
**Response:** the updated agent object.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### POST /allow/agents/:id/regenerate-naming
Re-run AI naming for one agent now, from its observed and normalized action schemas. An explicit opt-in: it hands name ownership back to the AI even if the name was previously user-set. A naming failure returns `200` with `status: "skipped"` — it never errors the agent.
**Permission:** `allow_agents:update`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### DELETE /allow/agents/:id
Delete an agent from the registry.
**Permission:** `allow_agents:delete`
**Response:** `{ "deleted": true }`
Deleting an agent does not revoke API keys. Revoke the agent's key separately under **Settings → Harness Keys**.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
## Rules
Rule management. The recommended authoring path is the dashboard's rule editor (natural-language compile, visual condition builder, and a Simulate panel that checks a new rule against your recent traffic — rules projected to inhibit more than 5% of it are blocked). These endpoints are the programmatic equivalent.
***
### GET /allow/rules
List action rules, sorted by priority descending — the same order the engine evaluates them in (first match wins).
**Permission:** `allow_rules:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `100`)
**Response:**
```json theme={null}
{
"data": [
{
"id": "rule-uuid",
"name": "Allow Stripe reads",
"description": "Permit read-only Stripe API calls",
"natural_language": "Allow my billing agent to read from Stripe",
"priority": 10,
"enabled": true,
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z"
}
],
"total": 12,
"page": 1,
"pageSize": 50
}
```
The list omits `rego_source`; fetch a single rule to read the policy source.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### POST /allow/rules
Create a rule.
**Permission:** `allow_rules:create`
**Request body:**
```json theme={null}
{
"name": "Allow Stripe reads",
"description": "Permit read-only Stripe API calls",
"rego_source": "package allow\n\ndefault allow = false\n\nallow { input.action == \"GET /v1/customers\" }",
"natural_language": "Allow my billing agent to read from Stripe",
"priority": 10,
"enabled": true
}
```
| Field | Type | Required | Description |
| ------------------ | --------- | -------- | ---------------------------------------------------------------------- |
| `name` | `string` | Yes | Rule name, unique per organization (max 255 chars) |
| `description` | `string` | No | Description (max 1000 chars) |
| `rego_source` | `string` | Yes | Policy source (see note below) |
| `natural_language` | `string` | No | The plain-English intent the policy was compiled from (max 2000 chars) |
| `priority` | `number` | No | Evaluation priority — higher runs first. Default `0` |
| `enabled` | `boolean` | No | Whether the rule is active. Default `true` |
`rego_source` holds the rule's policy source in the platform's Rego-subset condition language (equality, inequality, set membership, `startswith`/`endswith`/`contains`, regex, counts, and numeric comparisons). The effect (permit, deny, approval, mask) is derived from the decisions in the policy body. Prefer `POST /allow/rules/compile` — the compiler drafts and validates the policy against the live evaluation engine.
**Response (201):** the created rule object, including `rego_source`.
**Status codes:** `201 Created`, `400 Bad Request`, `401 Unauthorized`, `409 Conflict` (a rule with this name already exists), `500 Internal Server Error`
***
### POST /allow/rules/compile
Compile a natural-language description into a rule. The compiler reads your existing rules for context, drafts the policy source, and validates it against the live evaluation engine.
**Permission:** `allow_rules:create`
**Rate limit:** 10 compile requests per minute per organization — exceeding it returns `429`.
**Query parameters:** `stream=true` — stream the compile over Server-Sent Events instead of a single JSON response (also triggered by `Accept: text/event-stream`).
**Request body:**
```json theme={null}
{
"prompt": "Allow my billing agent to read from Stripe but block all write operations"
}
```
| Field | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------------------------------------ |
| `prompt` | `string` | Yes | Plain-English rule description (max 4000 chars) |
| `nodeRef` | `string` | No | Rule-graph node reference for editor context (max 255 chars) |
**Response:**
```json theme={null}
{
"rego_source": "package allow\n\ndefault allow = false\n...",
"natural_language": "This rule permits read operations against stripe.com for the billing agent and denies writes...",
"name": "Stripe Read-Only Access",
"description": "Permits Stripe reads, denies Stripe writes",
"suggested_priority": 50
}
```
**Streaming response** (`?stream=true`): a `text/event-stream` of JSON events —
```
data: {"type":"text","content":"Drafting the policy..."}
data: {"type":"done","result":{"rego_source":"...","natural_language":"...","name":"...","description":"...","suggested_priority":50}}
```
On failure the stream emits `{"type":"error","message":"..."}` and closes.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `422 Unprocessable Entity` (the compiler could not produce a valid policy — refine the prompt), `429 Too Many Requests`, `500 Internal Server Error`
***
### GET /allow/rules/:id
Get a single rule by UUID, including `rego_source`.
**Permission:** `allow_rules:view`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### PUT /allow/rules/:id
Update a rule. Same fields as `POST /allow/rules`, all optional; at least one is required. Only provided fields change.
**Permission:** `allow_rules:update`
**Response:** the updated rule object.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### DELETE /allow/rules/:id
Delete a rule permanently.
**Permission:** `allow_rules:delete`
**Response:** `{ "deleted": true }`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
## Human-in-the-Loop Queue
Review items land here in two categories: **`enduser`** items are live approval gates (a rule routed the action to a human — the agent is waiting), while **`engineer`** and the other categories are coverage-gap reviews (the action was already decided by a no-coverage default; the item asks whether a rule should exist). The two categories take different response bodies.
These are management endpoints — approvals normally arrive as Slack or email prompts (Microsoft Teams coming soon), or through the dashboard's Escalations page. A harness key receives `403 harness_key_not_permitted` here.
***
### GET /allow/hitl/queue
List queue items. Defaults to `status=pending`, ordered oldest-first (FIFO for approvers); non-pending queries return newest-first.
**Permission:** `allow_hitl:view`
**Query parameters:**
| Parameter | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------- |
| `page`, `limit` | Pagination (defaults `1` / `50`, max `100`) |
| `status` | `pending` (default) \| `approved` \| `rejected` \| `timeout` \| `expired` \| `dismissed` \| `resolved` \| `all` |
| `category` | `enduser` \| `engineer` \| `finance` \| `security` \| `compliance` \| `other` \| `all` |
**Response:**
```json theme={null}
{
"data": [
{
"id": "hitl-uuid",
"decision_id": "decision-uuid",
"agent_id": "billing-agent",
"target_app": "stripe.com",
"action": "POST /v1/charges",
"context": { "amount": 5000 },
"category": "enduser",
"status": "pending",
"ai_recommended_rule": null,
"notified_via": ["slack"],
"expires_at": "2026-07-03T10:32:00Z",
"responded_at": null,
"responded_by": null,
"created_at": "2026-07-03T10:30:00Z"
}
],
"total": 2,
"page": 1,
"pageSize": 50
}
```
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### POST /allow/hitl/queue
Create an approval item directly, for a client that resolved a retrieval `escalate` decision locally and needs to queue the human gate without re-evaluating. Writes the decision record and the queue item together — never one without the other.
**Permission:** `allow_hitl:respond`
**Request body:**
```json theme={null}
{
"agent_id": "billing-agent",
"action": "read_customer_pii",
"context": { "record_count": 200 },
"reason": "Human approval required by retrieval escalate policy",
"category": "enduser",
"timeout_seconds": 120
}
```
| Field | Type | Required | Description |
| ----------------- | -------- | -------- | --------------------------------------------------------------------------- |
| `agent_id` | `string` | Yes | Must be a registered agent (unknown → `404`) |
| `action` | `string` | Yes | The action awaiting approval (max 255 chars) |
| `target_app` | `string` | No | Default `"openclaw"` |
| `context` | `object` | No | Default `{}` |
| `reason` | `string` | No | Why approval is required (max 1000 chars) |
| `category` | `string` | No | One of the categories above. Default `engineer` |
| `rule_code` | `string` | No | The rule code that routed this action to approval, recorded on the decision |
| `timeout_seconds` | `number` | No | 10–120. Default `120` |
**Response (201):** the created queue item.
**Status codes:** `201 Created`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found` (unknown agent), `500 Internal Server Error`
***
### POST /allow/hitl/queue/:id
Respond to a pending item. The request body depends on the item's category.
**Permission:** `allow_hitl:respond`
**`enduser` items** (live approval gates) — approve or reject:
```json theme={null}
{
"decision": "approved",
"responded_by": "reviewer@example.com"
}
```
| Field | Type | Required | Description |
| -------------- | ------------------------ | -------- | -------------------------------------------- |
| `decision` | `approved` \| `rejected` | Yes | The human's decision |
| `responded_by` | `string` | Yes | Who responded (email or name, max 255 chars) |
**All other categories** (coverage-gap reviews) — dismiss, or link the rule you created to cover the gap:
```json theme={null}
{
"action": "create_rule",
"responded_by": "reviewer@example.com",
"linked_rule_id": "rule-uuid"
}
```
| Field | Type | Required | Description |
| ---------------- | -------------------------- | ----------------- | ------------------------------------------- |
| `action` | `dismiss` \| `create_rule` | Yes | Dismiss the gap or record the covering rule |
| `responded_by` | `string` | Yes | Who responded |
| `linked_rule_id` | `string` (UUID) | For `create_rule` | The rule that now covers this scenario |
Sending the wrong body shape for the item's category returns `400`.
**Response:**
```json theme={null}
{
"id": "hitl-uuid",
"decision_id": "decision-uuid",
"status": "approved",
"responded_at": "2026-07-03T10:31:12Z",
"responded_by": "reviewer@example.com"
}
```
Every response also emits a signed record envelope, so the human decision itself is part of the verifiable audit trail.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `409 Conflict` (item already resolved — the current status is returned), `500 Internal Server Error`
***
## Audit Log
***
### GET /v1/allow/audit-log
Query the decision audit log. Every decision — local and remote, permit and deny — is recorded, including monitor-mode observations.
**Permission:** `allow_audit_log:view`
Tamper evidence comes from the signed record pipeline, not from the query API: each decision emits a record envelope that receives an asynchronous Ed25519 receipt, is Merkle-batched under a KMS-signed root with an RFC 3161 timestamp, and lands in a hash-chained checkpoint log. Verify any decision server-side via `GET /record/envelopes/:id/verify`.
**Scope:** `rules:read` or `allow:read` (management keys / dashboard session — harness keys cannot query the log)
**Query parameters:**
| Parameter | Type | Description |
| ------------- | --------------------------------------------------- | ------------------------------------------ |
| `agent_id` | `string` | Filter by agent logical ID |
| `target_app` | `string` | Filter by target application |
| `decision` | `permit` \| `deny` \| `approval_required` \| `mask` | Filter by decision outcome |
| `hitl_result` | `approved` \| `rejected` \| `timeout` | Filter by approval resolution |
| `start_date` | ISO 8601 datetime | Lower bound on `created_at` |
| `end_date` | ISO 8601 datetime | Upper bound on `created_at` |
| `page` | `number` | Page index (default `1`) |
| `limit` | `number` | Records per page (default `50`, max `100`) |
**Response:**
```json theme={null}
{
"data": [
{
"id": "decision-uuid",
"agent_id": "billing-agent",
"target_app": "stripe.com",
"action": "POST /v1/charges",
"context": { "amount": 5000 },
"decision": "permit",
"reason": "Matched rule: Allow Stripe reads",
"rule_id": "rule-uuid",
"hitl_result": null,
"hitl_responded_at": null,
"hitl_responded_by": null,
"created_at": "2026-07-03T10:30:00Z",
"execution_result": "success",
"execution_completed_at": "2026-07-03T10:30:02Z",
"execution_details": "Stripe charge created (ch_1234)"
}
],
"total": 843,
"page": 1,
"pageSize": 50
}
```
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `429 Too Many Requests`, `500 Internal Server Error`
***
## Settings
***
### GET /allow/settings
Get your organization's action-governance settings. If none exist yet, a defaults row is created and returned (`201`) — this endpoint always returns a valid settings object.
**Response:**
```json theme={null}
{
"id": "settings-uuid",
"no_coverage_defaults": { "read": "approve", "write": "approve", "delete": "approve", "admin": "approve" },
"default_agent_mode": "monitor",
"autopilot_enabled": false,
"hitl_timeout_seconds": 120,
"notification_channels": [],
"enduser_hitl_enabled": true,
"recall_retain_masked_original": false,
"auto_disable_high_interference": true,
"recall_floor_disabled_detectors": [],
"recall_floor_enabled_optin": [],
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z"
}
```
| Field | Type | Description |
| ------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `no_coverage_defaults` | `object` | Per-operation-type default when **no rule matches** in enforce mode: `read`, `write`, `delete`, `admin`, each `approve` \| `deny` \| `ask`. Defaults to all-`approve` (no default disruption — uncovered actions proceed until you tighten them) |
| `default_agent_mode` | `string` | Org-wide default enforcement mode — `enforce` \| `monitor` \| `off`, default `monitor`. Every agent whose own `mode` is unset inherits this; changing it moves all inheriting agents together, while an agent's explicit `mode` overrides it |
| `autopilot_enabled` | `boolean` | AI auto-drafts a rule for uncovered scenarios and permits the request while it awaits review |
| `hitl_timeout_seconds` | `number` | Approval wait window, 30–120 seconds. Default `120` — the hard ceiling; unapproved requests fall back per `hitl_fallback` |
| `notification_channels` | `array` | Organization-level approval notification channels (`{type, config}` entries). Configure channels under **Integration → Connectors** |
| `enduser_hitl_enabled` | `boolean` | When `false`, `ask` no-coverage defaults resolve without holding the agent (recorded as bypassed) |
| `recall_retain_masked_original`, `recall_floor_disabled_detectors`, `recall_floor_enabled_optin` | — | Retrieval-governance settings sharing this row; see the retrieval-governance docs |
| `auto_disable_high_interference` | `boolean` | Automatically disable a rule that inhibits more than 5% of recent evaluations. Default on |
**Status codes:** `200 OK`, `201 Created` (first access, defaults created), `401 Unauthorized`, `500 Internal Server Error`
***
### PUT /allow/settings
Update settings. Validated field ranges are as documented above — in particular `hitl_timeout_seconds` must be 30–120 (a higher value is rejected with `400`, and the evaluation path clamps to 120 regardless).
This is a **partial update** — only the fields you send are changed; any field you omit is left untouched (it is **not** reset to a default). The one exception is `no_coverage_defaults`: it is stored as a whole object, so sending a partial `no_coverage_defaults` replaces the entire column. Always send all four keys (`read`, `write`, `delete`, `admin`) together, not just the one you are changing.
**Request body:**
```json theme={null}
{
"no_coverage_defaults": { "read": "approve", "write": "ask", "delete": "deny", "admin": "deny" },
"default_agent_mode": "enforce",
"autopilot_enabled": false,
"hitl_timeout_seconds": 120,
"notification_channels": [],
"enduser_hitl_enabled": true,
"auto_disable_high_interference": true
}
```
**Response:** the updated settings object.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
# Human-in-the-Loop
Source: https://docs.visiqlabs.com/rules/action/hitl
Route sensitive AI agent actions to humans for real-time approval over Slack and email (Microsoft Teams coming soon).
## Overview
Not every action can be decided by a static rule. When evaluation returns `approval_required`, the tool call pauses while a human decides — from an interactive Slack message, a one-click email link, or the dashboard queue (a Microsoft Teams card is coming soon). Approve, and the tool runs; reject (or let it time out), and the agent receives the block message as the tool's output and moves on.
The `visiq()` harness manages the pause/resume cycle automatically — there is no approval-handling code to write in your agent.
***
## How the pause works
1. A rule (or the `ask` no-coverage default) resolves the tool call to `approval_required`. The backend records the decision, creates a queue item with an expiry, and pushes notifications to your configured channels.
2. The SDK polls `GET /v1/allow/decisions/:id` every 2 seconds for the resolution.
3. **Approved** → the tool runs with its full arguments. **Rejected, timed out, or unresolvable** → the tool never runs; the agent receives the standard `[VisIQ decision=approval_required code=] This tool call was NOT executed: …` block message — unless the rule opted into the [mask fallback](#timeouts-and-the-mask-fallback).
The approval window is capped at a hard ceiling of **120 seconds** — an agent never hangs longer than two minutes waiting for a human. Uncertainty fails closed: a persistently unreachable backend or an unknown resolution status blocks the call rather than running it.
***
## Notification channels
Approvals are delivered over three channels — **Email** and **Slack** are configurable today on the dashboard under **Integration → Connectors** (**Human-in-the-Loop** category); **Microsoft Teams** delivery is built server-side and its connector card is marked "Coming soon" while final vendor-delivered-card verification completes:
* **Slack** — a fully interactive message with **Approve** / **Deny** buttons, posted by your Slack app's bot token to a channel you choose. Button clicks resolve the item directly from Slack.
* **Microsoft Teams** — an Adaptive Card delivered to an incoming webhook, with **Approve** / **Deny** buttons carrying signed, single-use action links.
* **Email** — an approval email with the same signed one-click **Approve** / **Deny** links.
Step-by-step setup guides: **[Slack](/connectors/slack)** — including the Interactivity Request URL, without which a reviewer's button click never reaches VisIQ — and **[Email](/connectors/email)**.
The dashboard queue is always active regardless of channel configuration, and every queue item records which channels it was delivered to (`notified_via`), so you can see at a glance how a reviewer was reached. Each prompt includes the agent, the target app, the action and its context, and an AI-generated risk summary of what is being asked.
### Owner routing
Approvals can route to the person who owns the agent instead of a shared channel. Set `owner_email` on the agent from the Agents page; set both it and `hitl_pathway` (`slack` | `teams` | `email`) via `PUT /allow/agents/:id` or the SDK-install claim flow on the Connectors page:
* `slack` — the owner is direct-messaged in Slack (looked up by email).
* `email` — the owner receives the approval email directly.
* `teams` — Teams owner DMs currently deliver via email; channel-level Teams cards are unaffected.
For an owned agent, the shared channels are skipped — the approval goes straight to the owner, with the shared channels as the fail-safe if owner delivery fails, so an approval is never silently lost. Agents without an owner notify the organization-level channels, with a banner identifying the unclaimed agent so an admin can assign one.
***
## Two categories
Queue items come in two kinds with different audiences — and different response schemas.
### End-user approval (`enduser`)
A **live approval gate**: a rule decided this action needs human sign-off, and the agent is paused waiting. These are the only items that push Slack/email prompts (Microsoft Teams coming soon) — there is a real, pending decision to make.
**Use cases:** high-value transactions, bulk data export, permission changes, anything irreversible.
### Engineer coverage-gap (`engineer`)
Created when **no rule matched** and the action was denied by a no-coverage default (with an AI-recommended covering rule), or permitted by Autopilot (which drafts a covering rule). Actions permitted by the plain `approve` default are not queued — they appear only in the audit log. Nothing is waiting — so nothing is pushed. The item appears on the dashboard queue with an **AI-recommended covering rule**, asking your engineering team a different question: *should a rule exist for this?* As you add coverage, these approach zero.
Push notifications fire **only** for genuine approval gates. Coverage-gap items are a pull surface on the dashboard — reviewers are never paged about an action that was already decided.
***
## The queue
Pending items appear on the dashboard under **Harness → Escalations**, oldest first, with full context: agent, target app, action, arguments, category, expiry, and (for coverage gaps) the recommended rule. Approve or reject in one click; the waiting agent picks the result up on its next poll.
Programmatically:
* `GET /allow/hitl/queue` — list items; filter by `status` (`pending`, `approved`, `rejected`, `timeout`, `expired`, `dismissed`, `resolved`) and `category`.
* `POST /allow/hitl/queue` — create an item directly (`timeout_seconds` 10–120, default 120).
* `POST /allow/hitl/queue/:id` — respond. `enduser` items take `{ "decision": "approved" | "rejected", "responded_by": "..." }`; other categories take `{ "action": "dismiss" | "create_rule", "responded_by": "..." }` with a `linked_rule_id` for `create_rule`. `responded_by` is required on both. Sending the wrong shape for the item's category returns `400`.
Full request/response schemas are in the [API Reference](/rules/action/api-reference#human-in-the-loop-queue). Every human response emits a signed record envelope, so approvals are part of the verifiable audit trail.
***
## Timeouts and the mask fallback
The approval window is `hitl_timeout_seconds` in your organization settings — **30 to 120 seconds, default 120**. 120 is a hard ceiling: the evaluation path clamps to it regardless of stored settings, and a background sweep marks expired pending items `timeout` (mirrored to the audit log as `hitl_result: "timeout"`).
What a never-approved request becomes is the rule's `hitl_fallback`:
| `hitl_fallback` | Behavior on reject / timeout / poll failure |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deny` (default) | The tool call is blocked — fail closed |
| `mask` | The tool call proceeds with the rule's argument-masking applied — the graduated outcome for calls that should degrade rather than block. Requires the rule to carry masking directives; without them the fallback is `deny` |
An explicit human **approval always grants the full, unmasked call** — the fallback only governs the no-approval outcomes.
On the SDK side, the wait budget defaults to 120 seconds to match the server ceiling; shorten it with the `hitlTimeoutMs` option or the `VISIQ_HITL_TIMEOUT_MS` environment variable. Timed-out items stay visible in the queue for audit and rule-creation purposes, but they can no longer be responded to — the API returns `409` once an item is marked `timeout`, and the agent has already moved on.
***
## Uncovered actions and coverage gaps
When no rule matches, the per-operation-type **no-coverage default** (`read`, `write`, `delete`, `admin` in `no_coverage_defaults`) decides:
| Setting | Behavior |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `approve` (default) | Permit the action — no queue item, no push, no pause; the decision is visible in the audit log only. Enable Autopilot or tighten the default to `ask`/`deny` to surface uncovered actions for review |
| `deny` | Deny the action and record an `engineer` coverage-gap item — no push |
| `ask` | Hold the action as a live `enduser` approval gate — notifications are pushed |
Two settings modify this flow:
* **Autopilot** (`autopilot_enabled`) — for uncovered actions, AI drafts a covering rule automatically. The action is permitted, and the draft is created **disabled** with an `autopilot` source marker for you to review, edit, and enable from the Rules page. Useful during rollout when agents touch many APIs for the first time.
* **End-user approvals toggle** (`enduser_hitl_enabled`) — when `false`, the `ask` default resolves without holding the agent, and the audit log records the bypass (`hitl_result: "bypassed_disabled"`). Rule-triggered approval gates are unaffected by this toggle.
Leaving every no-coverage default on `approve` means new API patterns your agents discover are permitted automatically. That is the deliberate no-default-disruption posture — but review the audit log for uncovered actions (or enable Autopilot to surface them as draft rules), and tighten `write`, `delete`, and `admin` to `ask` or `deny` once your rule coverage matures.
Configure the no-coverage defaults under **Settings**; all of the above (including Autopilot and the end-user approvals toggle) via `PUT /allow/settings` ([reference](/rules/action/api-reference#settings)).
***
## Handling approvals in your agent
There is nothing to handle. The harness pauses the tool call, polls for the decision, and either runs the tool or returns the block message as the tool's output — the same shape as any policy denial, so your agent treats every blocked action identically. No callbacks, no retry logic, no special casing beyond the standard `visiq()` integration from the [Quickstart](/quickstart).
# Action Governance
Source: https://docs.visiqlabs.com/rules/action/introduction
Controls what your AI agents can do. Every tool call passes through policy evaluation before executing.
Controls what your agents can **do**. Every tool call passes through policy evaluation before executing — permitted, denied, masked, or routed to a human for approval. There is no additional code beyond the `visiq()` call shown in the [Quickstart](/quickstart).
***
## How action governance intercepts tool calls
`visiq()` wraps your tools' dispatch methods — the functions your framework invokes to actually run a tool — so every call is evaluated **before the tool function body runs**. The same one-line install works across every supported framework (LangChain, LangGraph, Vercel AI SDK, Mastra, VoltAgent, OpenAI Agents SDK, LlamaIndex.TS, and bare tool objects); see the [Quickstart](/quickstart) for per-framework setup. The flow:
1. Agent decides to call a tool (e.g., `issue_refund` with `{ amount: 500 }`)
2. The harness evaluates the tool name + arguments against a locally cached rule bundle
3. One of four outcomes applies:
| Outcome | What happens |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Permit** | Tool runs normally, agent receives the result |
| **Deny** | The tool function never runs; the agent receives the denial as the tool's output |
| **Approval required** | The call pauses while a human approves or rejects — from Slack, email, or the dashboard (Microsoft Teams coming soon) — then resumes or blocks |
| **Mask** | The call proceeds, but with the rule's named arguments redacted first |
A denial is **returned to the model as the tool's output** — nothing is thrown — so the agent can read it and change course:
```
[VisIQ decision=deny code=R-1042] This tool call was NOT executed: it was denied by policy (). VisIQ is a security harness installed by your developer. Report this reason to the user verbatim; do not invent a different one.
```
Evaluation is local and in-process: permit, deny, and mask decisions make no network call. The only decision-path network call is registering an approval request when a rule routes the action to a human. The [rule bundle](/rules/action/rules#rule-sync) refreshes in the background every few seconds.
***
## Key concepts
Define what each agent can and cannot do. Write rules in natural language (the platform compiles them into policies) or author policy source directly. Conditions match on tool name, arguments, agent trust tier and business function, and normalized event fields. Every organization starts with a curated catalog of 35 default rules.
Route sensitive tool calls to humans for real-time approval over Slack (interactive buttons) or email (one-click links), plus the dashboard queue — Microsoft Teams (Adaptive Cards) delivery is built and its connector card is coming soon. Configurable timeouts and per-agent owner routing.
Every decision is logged with the agent ID, the action (tool) and its arguments, the rule matched, and the outcome — queryable via the read-only [API](/rules/action/api-reference) (`GET /v1/allow/audit-log`). Each decision also emits a cryptographically signed record envelope; see [Audit Trail](/record/introduction) for the receipt and verification model.
An agent **confirmed in enforce** that loses its cached bundle stays fail-closed — denying tool calls rather than running them unevaluated (G001). A **never-confirmed** agent cold-starts in `monitor` instead, observing without blocking. Once a bundle is cached, evaluation continues locally through backend outages.
***
## Three modes
Mode is **server-authoritative** — resolved on the backend and shipped inside the rule bundle, so a change propagates to running SDKs on the next background refresh, within a few seconds. There is no SDK-side mode option.
| Mode | Behavior |
| --------- | ----------------------------------------------------------------------- |
| `enforce` | Denied tool calls are blocked, approval-gated actions pause for a human |
| `monitor` | Evaluate and log every decision but never block — the default |
| `off` | Bypass evaluation entirely — all tool calls proceed |
An agent's mode either **inherits an org-wide default** (`allow_settings.default_agent_mode`, itself defaulting to `monitor`) or is **overridden per agent** from the **Agents** page (under **Harness**). It can also be pinned **per operation** — for example `enforce` actions while keeping retrievals in `monitor` — with any operation left unset inheriting the agent's resolved mode.
Rollout is monitor-first by design. A never-before-seen `agent_id` is auto-provisioned in `monitor` mode on first contact, so the install observes real traffic without disrupting anything. Review what *would* have been blocked on the dashboard, tune coverage, then flip that agent to **Enforce** — the SDK picks up the change live.
***
## Next steps
Define what your agents can and cannot do.
Route sensitive actions to humans for approval.
REST API for rules, decisions, agents, and audit log.
Complete `visiq()` API — options, framework detection, error behavior.
# Rules
Source: https://docs.visiqlabs.com/rules/action/rules
Define what your AI agents can and cannot do with policy rules evaluated in-process.
## How Rules Work
Rules define what actions your agents are permitted to perform. Every tool call hooked by the in-process SDK is evaluated against your rule set. Rules are compiled into a bundle that SDKs download and cache locally, so evaluation happens in-process with no network round-trip on the hot path — and the SDK runs the **same evaluator** over the same rules as the backend, so a local decision always matches what the server would decide.
Each rule is a policy written in a Rego subset. When a rule matches a tool call, it produces one of four decisions:
| Decision | Effect |
| ------------------- | ----------------------------------------------------------------------------------- |
| `permit` | The tool call proceeds |
| `deny` | The tool call is blocked; the agent receives the block message as the tool's output |
| `approval_required` | The call pauses for a [human decision](/rules/action/hitl) |
| `mask` | The call proceeds with the rule's named arguments redacted first |
A `mask` rule whose masking directives cannot be resolved is downgraded to `deny` — a mask that would mask nothing never runs the tool with raw arguments.
***
## What you start with
You do not start from an empty rule set. Every organization is seeded a **curated catalog of 35 default rules** covering secrets and credentials, payment-card data, PII, funds transfers, destructive writes, privileged admin actions, outbound communications, and more. Each catalog rule grants access on a need-to-know matrix: the agent's **business function** (is this data or action part of its job?) crossed with its **trust tier** (`tier1` highest, `tier2` standard, `tier3` restricted) — full access for trusted agents that need it, human approval or masking in the middle, denial for agents with no need. Business functions are AI-assigned from observed traffic (and pinnable by you); trust tiers are set by you on the Agents page.
When **no rule matches at all**, the tenant-wide **no-coverage default** decides, per operation type (`read`, `write`, `delete`, `admin`), each set to `approve`, `ask`, or `deny`. The platform default is `approve` for all four — **no default disruption**, even in `enforce` mode: an uncovered action proceeds untouched until you tighten it. Configure this under **Settings**, or via `PUT /allow/settings` (`no_coverage_defaults`). Evaluation errors always fail closed to deny, and an agent **confirmed in `enforce`** that has lost its bundle stays fail-closed — but a **never-confirmed** agent cold-starts in `monitor` (monitor-until-confirmed) and blocks nothing.
***
## Authoring rules
The dashboard rule editor (**Harness → Rules**, then **+ New rule**) gives you three surfaces on the same rule:
* **Natural language** — describe the rule in plain English in the editor's chat. The compiler reads your existing rules for context, drafts the policy source, validates it against the live evaluation engine, and simulates it before anything is saved.
* **Visual condition builder** — compose condition branches and decisions as a graph; the editor serializes it to the same policy source.
* **Simulate panel** — replay a real captured event through the exact production evaluation chain, and run an aggregate simulation showing the outcome distribution the rule would produce over your recent traffic.
Programmatic management uses the [REST API](/rules/action/api-reference#rules); `POST /allow/rules/compile` is the natural-language compiler as an endpoint.
### Rule fields
| Field | Type | Description |
| ------------------ | --------- | ---------------------------------------------------------------------------------------- |
| `name` | `string` | Rule name, unique per organization |
| `description` | `string` | Optional longer description |
| `rego_source` | `string` | The policy source (required) — see the condition language below |
| `natural_language` | `string` | Optional plain-English intent the policy was compiled from |
| `priority` | `number` | Higher priority is evaluated first. Default: `0` |
| `enabled` | `boolean` | Whether the rule is active. Disabled rules are excluded from the bundle. Default: `true` |
### Example rule
```rego theme={null}
package allow
# Refunds of $1,000 or more require human approval; smaller ones proceed.
default decision = "deny"
decision = "approval_required" if {
input.action == "issue_refund"
input.context.issue_refund.amount >= 1000
}
decision = "permit" if {
input.action == "issue_refund"
input.context.issue_refund.amount < 1000
}
```
Condition lines inside a block are **AND**ed; write multiple blocks for **OR**. The `default` declaration is inert in the evaluation cascade — when none of a rule's condition blocks fire, the rule simply doesn't match and evaluation moves on to the next rule (see below).
***
## The condition language
Conditions match against fields of the evaluated event:
| Input | Meaning |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input.action` | The tool name (e.g. `issue_refund`) |
| `input.target_app` | The target application or resource the call is directed at |
| `input.context..` | The tool-call arguments, nested under the tool name |
| `input.agent.trust_tier` | The agent's operator-assigned trust tier (`tier1` \| `tier2` \| `tier3`) |
| `input.agent.business_function` | The agent's business function (e.g. `finance_accounting`, `hr`, `engineering`) |
| `input.normalized.*` | Canonical normalized event fields — e.g. `input.normalized.action_class` (a taxonomy of 46 action classes such as `funds_transfer`, `record_delete`, `credential_reset`, inferred by the AI schema mapper from the tool once it is learned) and `input.normalized.write.amount` |
Supported condition forms:
| Form | Example |
| ---------------------------------- | ------------------------------------------------------------------------------- |
| Equality | `input.action == "issue_refund"` |
| Inequality | `input.agent.trust_tier != "tier1"` |
| Negation | `not input.agent.trust_tier == "tier1"` |
| Set membership | `input.normalized.action_class in ["funds_transfer", "funds_disbursement"]` |
| Array includes | `"third_party" in input.agent.categories` |
| String prefix / suffix / substring | `startswith(input.action, "delete_")`, `endswith(...)`, `contains(...)` |
| Regular expression | `regex.match("^prod-", input.target_app)` |
| Numeric comparison | `input.context.issue_refund.amount >= 1000` |
| Count | `count(input.context.bulk_export.record_ids) > 100` |
| Cross-field equality | `input.normalized.write.subject_id == input.context.reset_password.subject_id` |
| Cross-field membership | `input.normalized.write.subject_id in input.session.identity.attested_subjects` |
The identity sets (`attested_subjects`, `claimed_subjects`, `acted_subjects`) are **arrays**, so an identity binding is the *membership* form, never the equality one. There is no scalar `input.session.identity.*` field to compare against — a rule written as if there were names a path the engine never populates, and by the relational rule below that body can never match in either polarity.
### What "absent" does to each form
An unrecognized condition line parses as always-false, so a rule containing one can never match. For every form the engine DOES recognize, the answer to "what happens when the field is not in the event?" is not one rule but three:
* A **negated condition over a literal** (`not x == "..."`, `not x in [...]`, `not startswith(...)`, `not x`) **is** satisfied by an absent field. That is the fail-closed authoring convention: write the protective branch this way and a missing attribute lands in it.
* **`x != "..."` is the one literal form that is NOT.** It requires the field to be present, so `not x == "v"` and `x != "v"` are *not* interchangeable — they differ on exactly the absent case.
* A **relational condition** (`x == y`, `x in y`) is never satisfied when either operand is absent, or when the right operand of `in` is not an array — in **either** polarity. So `not x in y` does **not** fire merely because `y` is missing, and does not fire if `x` is missing either. Bind the left operand to a path your schema actually populates, or the protective arm silently never runs.
The table below is generated from the executed contract in `packages/rego-evaluator/__tests__/absent-operand-contract.test.ts`, which drives every form through the real evaluator; the test fails if this table drifts from it.
| Condition form | Satisfied when the field is ABSENT? |
| ------------------------------ | ----------------------------------- |
| `input.x == "x"` | no |
| `input.x != "x"` | no |
| `input.x in ["x"]` | no |
| `"x" in input.x` | no |
| `startswith(input.x, "x")` | no |
| `input.x` | no |
| `count(input.x) > 0` | no |
| `input.x > 5` | no |
| `not input.x == "x"` | **yes** (fail-closed) |
| `not input.x != "x"` | **yes** (fail-closed) |
| `not input.x in ["x"]` | **yes** (fail-closed) |
| `not "x" in input.x` | **yes** (fail-closed) |
| `not startswith(input.x, "x")` | **yes** (fail-closed) |
| `not input.x` | **yes** (fail-closed) |
| `not count(input.x) > 0` | **yes** (fail-closed) |
| `not input.x > 5` | **yes** (fail-closed) |
| `input.x == input.y` | no |
| `not input.x == input.y` | no |
| `input.x != input.y` | no |
| `input.x in input.y` | no |
| `not input.x in input.y` | no |
***
## Rule Evaluation Flow
1. The engine sorts the cached bundle by `priority` **descending** — the highest number is evaluated first.
2. For each rule, the policy body is evaluated against the event. The **first rule with a firing condition block wins** and its decision applies.
3. A rule whose blocks don't fire is skipped — its `default` declaration never decides; the cascade continues.
4. Two blocks of the same rule that fire with **conflicting** decisions fail closed to `deny`.
5. If no rule matches anywhere, the per-operation-type **no-coverage default** resolves the action (`approve` by default — see [uncovered scenarios](/rules/action/hitl#uncovered-actions-and-coverage-gaps)).
Uncovered actions are **permitted** by default, not denied, even in `enforce` mode — governance comes from the seeded default catalog and the rules you add, and you can tighten any operation type to `ask` or `deny` in Settings. What fails closed is errors: an evaluation error, or an agent **confirmed in `enforce`** that has lost its bundle, denies. A never-confirmed agent cold-starts in `monitor` (monitor-until-confirmed) and blocks nothing.
### Priority guidelines
| Priority range | Suggested use |
| -------------- | --------------------------------------------------------- |
| `100+` | Override rules — specific denies that must always win |
| `61–99` | Your custom rules, evaluated ahead of the default catalog |
| `15–60` | Where the seeded default catalog ships |
| `0–14` | Broad catch-all rules |
***
## The interference gate
A rule that blocks half your real traffic is a bug, not a policy. Rule authoring in the dashboard — both the natural-language compiler and direct saves — simulates every new or edited rule against a sample of your recent traffic and **rejects any rule whose combined deny + approval share exceeds 5%** (a save over the limit fails with `422 INTERFERENCE_THRESHOLD_EXCEEDED`). Masking doesn't count toward the limit — a masked call still proceeds.
The same bar applies after saving: a daily background check disables any enabled rule inhibiting more than 5% of its recent evaluations (once it has a statistically meaningful sample), records why in the audit log and the rule's revision history, and leaves it for you to tighten and re-enable. Opt out per organization with the `auto_disable_high_interference` setting.
Every rule change — create, update, enable, disable, restore, delete — is recorded in the rule's **revision history** with the acting user and a full snapshot, so the editor can show contributors over time and restore any prior version.
***
## Rule Sync
The SDK fetches one compiled bundle from `GET /rules/bundle` — it carries your enabled rules (both action and retrieval facets), the agent's mode, the server-assigned agent attributes, and the no-coverage settings — and refreshes it in the background roughly every 5 seconds.
Sync uses `ETag` / `If-None-Match`: when nothing changed, the server answers `304 Not Modified` and no data is transferred. The bundle `version` is a SHA-256 hash over the serialized payload — rules, the agent's mode and attributes, and the no-coverage settings — so flipping an agent to enforce, or retiering it, propagates on the next refresh just like a rule edit.
There is no server-side fallback on the decision path. With a `VISIQ_API_KEY` the harness reaches the managed SaaS backend (`https://api.visiqlabs.com` by default) and confirms a bundle automatically, so monitor-until-confirmed is only the brief pre-first-bundle window (or the genuinely-no-key case). Until that first bundle loads, an agent **confirmed in `enforce`** fails closed and denies, while a **never-confirmed** agent cold-starts in `monitor` and blocks nothing. `awaitBundleReady()` is called automatically before the first evaluation so a warm start resolves locally.
The action-facet-only `GET /allow/rules/bundle` remains available and is documented in the [API Reference](/rules/action/api-reference#get-/allow/rules/bundle).
***
## Managing Rules
* **Dashboard**: **Harness → Rules**. Use **+ New rule** to open the editor — natural-language chat, visual builder, and Simulate panel in one place. The list shows each rule's priority, operations, and share of recent traffic it inhibits.
* **REST API**: the [Rules endpoints](/rules/action/api-reference#rules) — programmatic rule management, CI/CD pipelines, and bulk imports.
Rules can also govern both facets at once: a single rule authored in the dashboard can span action and retrieval, evaluated from the same bundle by the same in-process engine. See [Retrieval Governance](/rules/retrieval/introduction) for the retrieval side.
# API Reference
Source: https://docs.visiqlabs.com/rules/delegation/api-reference
Complete REST API reference for the delegation-governance endpoints — the agent-to-agent grant lifecycle and per-action enforcement.
Delegation-governance endpoints are mounted under `/orchestrate/*`. The base URL is `https://api.visiqlabs.com`.
These endpoints govern agent-to-agent handoffs: a parent agent creates a scoped grant, the named child accepts it and receives a signed token, and the child evaluates each delegated action against that grant. The decision vocabulary here is `permit` and `deny` — a delegated action is either within the grant's intersected authority or it is not.
## Authentication
All endpoints require a Bearer credential: `Authorization: Bearer `. Requests without a valid credential receive `401 Unauthorized`.
The whole `/orchestrate/*` surface is **management-audience**. A harness key (`vq_prod_...` / `vq_test_...` minted under **Settings → Harness Keys**, or an `allow_...` agent key) is confined to the SDK operational routes and receives `403 {"error": "harness_key_not_permitted"}` here — delegation is driven by a management key or a dashboard session. Management keys are **launching soon**: they are visible in the dashboard under **Settings → API Keys**, but creating one is not yet enabled. Until then, drive these endpoints from the dashboard, which authenticates with your session.
Scoped keys are additionally checked against their granted scopes. Every endpoint on this page requires `allow:write` (the read-only `GET /orchestrate/grants/:id` also accepts `allow:read`); `full_access` satisfies everything. A key without the required scope receives `403 {"error": "insufficient_scope"}` listing the required and granted scopes.
### Rate limiting
Every API-key request passes a per-key sliding-window rate limit (default 600 requests per 60 seconds). Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers; exceeding the window returns `429` with a `Retry-After` header and body `{"error": "rate_limited", "detail": "API key rate limit exceeded.", "retryAfter": }`.
***
## Grant lifecycle
***
### POST /orchestrate/grants
Create a delegation from a parent agent to a child agent. Both agents must be registered for your organization. The platform runs a policy check on the delegation itself, intersects the requested scopes against the parent's authority, writes a synchronous record proof, and returns a `pending` grant awaiting acceptance.
**Scope:** `allow:write`
**Request body:**
```json theme={null}
{
"parent_agent_id": "orchestrator",
"child_agent_id": "research-worker",
"tool_scope": ["search_web", "read_document"],
"context_scope": ["public", "internal"],
"purpose": "Gather background for the Q3 report",
"duration_seconds": 3600,
"max_depth": 3
}
```
| Field | Type | Required | Description |
| -------------------- | -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parent_agent_id` | `string` | Yes | The delegating agent — must be registered (1–255 chars) |
| `child_agent_id` | `string` | Yes | The agent receiving authority — must be registered (1–255 chars) |
| `tool_scope` | `string[]` \| `null` | No | Actions the child may perform. `null` (default) inherits the parent's tool authority; `[]` is deny-all; a list is the exact set. Intersected with the parent's own tool scope |
| `context_scope` | `string[]` \| `null` | No | Resource types the child may touch. Same `null` / `[]` / list semantics as `tool_scope`, intersected with the parent's context scope |
| `purpose` | `string` | No | Human-readable reason for the handoff (max 2000 chars) — recorded on the grant |
| `duration_seconds` | `number` | No | Lifetime of the token issued at acceptance, `1`–`604800` (7 days). Default `3600` |
| `max_depth` | `number` | No | Maximum chain depth for onward sub-delegation, `1`–`16`. Default `3`. Ignored when sub-delegating (the root grant's ceiling governs the whole chain) |
| `parent_grant_token` | `string` | No | A grant token the parent itself holds — present when **sub-delegating**. The parent must be that token's child agent, and the new grant's authority is intersected against it |
When `parent_grant_token` is supplied, the delegating `parent_agent_id` must equal the `child_agent_id` of that token's grant — you can only sub-delegate authority you were actually granted. The new grant's depth is the parent grant's depth plus one, and its `max_depth` is inherited from the parent grant.
**Response (200):**
```json theme={null}
{
"grant_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"effective_tool_scope": ["search_web", "read_document"],
"effective_context_scope": ["public", "internal"],
"accept_deadline": "2026-07-03T10:35:00Z"
}
```
| Field | Type | Description |
| ------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------- |
| `grant_id` | `string` (UUID) | The grant's id — used to accept, poll, and revoke |
| `status` | `string` | Always `pending` on creation |
| `effective_tool_scope` | `string[]` \| `null` | The **intersected** tool authority actually conferred (parent ∩ requested) |
| `effective_context_scope` | `string[]` \| `null` | The intersected context authority conferred |
| `accept_deadline` | `string` (ISO 8601) | The child must accept before this time (5 minutes from creation) or the grant can no longer be accepted |
**Decision & failure semantics:**
* If no rule permits `orchestrate.delegate` for the parent and the parent is in enforce mode, the delegation is denied fail-closed: `403 {"error": "Delegation denied by policy", "reason": "..."}`.
* If the synchronous record proof cannot be written, the just-created grant is revoked and the call returns `500 {"error": "Failed to write ORCHESTRATE proof record"}`.
**Status codes:** `200 OK`, `400 Bad Request` (invalid body), `401 Unauthorized` (missing credential, or invalid `parent_grant_token`), `403 Forbidden` (insufficient scope, delegation denied by policy, or the parent grant does not belong to the delegating agent), `404 Not Found` (parent or child agent not registered), `422 Unprocessable Entity` (delegation depth ceiling reached), `500 Internal Server Error`
**Example:**
```bash theme={null}
curl -X POST https://api.visiqlabs.com/orchestrate/grants \
-H "Authorization: Bearer $VISIQ_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"parent_agent_id": "orchestrator",
"child_agent_id": "research-worker",
"tool_scope": ["search_web", "read_document"],
"purpose": "Gather background for the Q3 report"
}'
```
***
### GET /orchestrate/grants/:id
Fetch a grant's current status — the parent polls this to learn whether the child has accepted, and to read the effective scopes and lifecycle timestamps.
**Scope:** `allow:write` or `allow:read`
**Path parameter:** `:id` — the grant UUID (from `POST /orchestrate/grants`)
**Response (200):**
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "active",
"parent_agent_id": "orchestrator",
"child_agent_id": "research-worker",
"depth": 0,
"max_depth": 3,
"effective_tool_scope": ["search_web", "read_document"],
"effective_context_scope": ["public", "internal"],
"purpose": "Gather background for the Q3 report",
"accept_deadline": "2026-07-03T10:35:00Z",
"created_at": "2026-07-03T10:30:00Z",
"accepted_at": "2026-07-03T10:31:00Z",
"expires_at": "2026-07-03T11:31:00Z",
"revoked_at": null,
"revocation_reason": null
}
```
`status` is one of `pending`, `active`, `revoked`, or `expired`. The grant token itself is never returned here — it is issued once, at acceptance.
**Status codes:** `200 OK`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `404 Not Found`, `500 Internal Server Error`
***
### POST /orchestrate/grants/:id/accept
The child agent accepts a pending grant and receives its signed grant token. Bilateral identity: the child declares itself in the `X-Agent-ID` header, which must match the grant's `child_agent_id`.
**Scope:** `allow:write`
**Request headers:**
| Header | Required | Description |
| ------------ | -------- | ------------------------------------------------------------------------------------------------- |
| `X-Agent-ID` | Yes | The accepting child agent's id. Must equal the grant's `child_agent_id` and be a registered agent |
**Path parameter:** `:id` — the grant UUID
This endpoint takes no request body. Acceptance is guarded against a concurrent double-accept: only the caller that flips the grant from `pending` to `active` wins; a lost race returns `409`.
**Response (200):**
```json theme={null}
{
"grant_token": "eyJ...signed-token...",
"tool_scope": ["search_web", "read_document"],
"context_scope": ["public", "internal"],
"expires_at": "2026-07-03T11:31:00Z"
}
```
| Field | Type | Description |
| --------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `grant_token` | `string` | The signed token the child presents on every `POST /orchestrate/evaluate`. Bound to this child and grant; expires at `expires_at`. Returned **only here** — store it; it cannot be re-fetched |
| `tool_scope` | `string[]` \| `null` | The effective tool authority the token carries |
| `context_scope` | `string[]` \| `null` | The effective context authority the token carries |
| `expires_at` | `string` (ISO 8601) | Token expiry, computed from the grant's `duration_seconds` |
The `grant_token` is present in this response only. Subsequent reads never return it. If a record proof write fails after the token is issued, the acceptance is rolled back to `pending` and the call returns `500` — the token you received in that case is not valid.
**Status codes:** `200 OK`, `400 Bad Request` (missing `X-Agent-ID`), `401 Unauthorized`, `403 Forbidden` (insufficient scope, `X-Agent-ID` does not match the grant's child, or the claimed agent is not registered), `404 Not Found` (unknown grant), `409 Conflict` (grant not pending, accept window expired, or already accepted), `500 Internal Server Error`
***
### POST /orchestrate/grants/:id/revoke
Revoke a grant. Revocation cascades eagerly to every descendant grant in one atomic step, and is checked at enforcement time, so there is no revocation lag.
**Scope:** `allow:write`
**Path parameter:** `:id` — the grant UUID
**Request body:**
```json theme={null}
{
"reason": "Task complete — tearing down the delegation chain"
}
```
| Field | Type | Required | Description |
| -------- | -------- | -------- | --------------------------------------------------------------------------- |
| `reason` | `string` | No | Why the grant is being revoked (max 2000 chars) — recorded in the audit log |
**Response (200):**
```json theme={null}
{
"revoked_count": 3
}
```
`revoked_count` is the number of grants invalidated — the target plus every descendant in its subtree.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `404 Not Found`, `422 Unprocessable Entity` (grant already revoked), `500 Internal Server Error`
***
## Per-action enforcement
***
### POST /orchestrate/evaluate
The child agent's per-action check. For each action it takes under a grant, the child presents its grant token; the platform verifies the token, walks the ancestor chain, checks the action against the grant's intersected tool and context scopes, records a handoff event, and returns the decision.
**Scope:** `allow:write`
**Request body:**
```json theme={null}
{
"grant_token": "eyJ...signed-token...",
"action": "search_web",
"resource_type": "public",
"resource_metadata": {
"query": "Q3 market outlook"
}
}
```
| Field | Type | Required | Description |
| ------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------- |
| `grant_token` | `string` | Yes | The token issued at acceptance |
| `action` | `string` | Yes | The action the child is attempting — checked against the grant's tool scope (1–255 chars) |
| `resource_type` | `string` \| `null` | No | The resource type being touched — checked against the grant's context scope (max 255 chars) |
| `resource_metadata` | `object` | No | Opaque metadata recorded with the handoff event. Default `{}` |
**Response (200):**
```json theme={null}
{
"decision": "permit",
"reason": "Action within grant scope",
"handoff_event_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
```
| Field | Type | Description |
| ------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `decision` | `permit` \| `deny` | Whether the action is within the grant's intersected authority and the whole ancestor chain is valid |
| `reason` | `string` | Human-readable explanation — e.g. an out-of-scope action names which scope it fell outside, and a revoked ancestor is called out |
| `handoff_event_id` | `string` (UUID) | The recorded handoff event — the audit trail **is** the permit (a handoff that cannot be recorded is never permitted) |
**Decision & failure semantics:**
* A token whose signature is invalid, whose vendor does not match the caller, that references an unknown grant, or that no longer matches the grant's stored token returns `401 {"error": "Invalid grant token"}`.
* An expired token or a grant that is not `active` returns `403` (`Grant expired` / `Grant is `).
* A revoked or invalid ancestor grant yields `decision: "deny"` with the reason naming the broken link — a `200` response with a deny, not an error.
* An action outside the grant's tool scope, or a resource type outside its context scope, yields `decision: "deny"`.
* If the handoff event cannot be recorded, the call returns `500 {"error": "Failed to record handoff event"}` — never a silent permit.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized` (invalid or mismatched grant token), `403 Forbidden` (insufficient scope, expired token, or inactive grant), `500 Internal Server Error`
***
## Errors & conventions
Every endpoint on this page follows the platform-wide REST conventions — the validation-error body shape, the `/v1/` vs unversioned split, and the API stability policy. See [REST API conventions](/reference/rest-conventions).
# Delegation Governance
Source: https://docs.visiqlabs.com/rules/delegation/introduction
Governs agent-to-agent handoffs — when one agent delegates authority to another, the child's power is the parent's authority intersected with an explicit, revocable grant.
Delegation governance controls what one agent can **hand off** to another. When a parent agent asks a child agent to act on its behalf, the platform issues a scoped, time-boxed, signed grant — and the child's authority is never more than the parent's own authority intersected with what the grant explicitly permits. `delegation` is a first-class operation alongside `action` and `retrieval`, evaluated by the same rule engine.
Delegation is the third governed operation. Where action governance decides what an agent can **do** and retrieval governance decides what it can **see**, delegation governance decides what authority an agent can **pass on** — and holds that transfer to a signed, revocable, audited grant.
***
## The invariant
One rule holds throughout the delegation surface:
> **child authority = parent authority ∩ explicit grant**
A child can never receive more than the parent already holds, and never more than the grant names. Both the tool scope (which actions) and the context scope (which resource types) are intersected on every handoff, so authority only ever narrows as it flows down a delegation chain — it can never widen.
Every grant is backed by a **synchronous, fail-closed record proof**: the platform writes the tamper-evident record row *before* it returns a grant id or a token. No proof, no grant. This closes the gap that fire-and-forget audit envelopes leave open — a delegation that isn't provably recorded never happens.
***
## The grant lifecycle
The parent calls `POST /orchestrate/grants` naming the child agent and the tool/context scopes it wants to hand off. The platform runs a policy check on the delegation itself, intersects the scopes against the parent's authority, and returns a `pending` grant with a short **accept window** (5 minutes).
The child calls `POST /orchestrate/grants/:id/accept`, declaring its own identity in the `X-Agent-ID` header. Only the named child can accept. On success the platform issues a **signed grant token** scoped to that child, bound to the grant, and expiring per the requested duration.
For each action the child performs under the grant, it calls `POST /orchestrate/evaluate` with the grant token. The platform verifies the token, walks the ancestor chain, checks the action against the grant's tool and context scopes, and returns `permit` or `deny` — recording a handoff event for every decision.
Either side revokes with `POST /orchestrate/grants/:id/revoke`. Revocation cascades eagerly to every descendant grant in one atomic step, so cutting a grant high in the chain instantly invalidates everything delegated beneath it.
***
## Bilateral identity
A handoff has two sides, and both are verified. The parent is the authenticated caller that creates the grant; the child proves its identity at accept time through the `X-Agent-ID` header, which must match the `child_agent_id` the grant was created for. Both agents must be registered for your organization — an unregistered agent on either side is rejected. The grant token issued at accept time is signed and carries the child's identity, so a token minted for one child can never be replayed by another.
***
## Sub-delegation and depth
A child that holds a grant can itself delegate onward by passing its grant token as the `parent_grant_token` on a new `POST /orchestrate/grants` call. Each hop increments the chain depth, and every grant carries a `max_depth` ceiling (default 3, hard cap 16). A delegation that would exceed the ceiling is rejected, so a chain can only run as deep as the root grant allows.
Because authority is intersected at every hop, a grant three levels down holds at most the intersection of all three grants above it — the transitive floor of the whole chain. Enforcement also re-walks the ancestor chain on every `POST /orchestrate/evaluate`: if any ancestor grant has been revoked or expired, the descendant's action is denied, even before the descendant's own token expires.
***
## Fail-closed throughout
Delegation is an authority transfer, so it defaults to the most restrictive outcome at every step:
* **Uncovered delegation in enforce mode is denied.** A delegation with no rule permitting `orchestrate.delegate` for the parent fails closed — authority transfer requires an explicit permitting rule (or an agent still in monitor mode during onboarding).
* **An unreadable ancestor chain denies.** If the platform cannot verify the chain of grants above an action, it denies rather than permits.
* **A missing record proof aborts the grant.** If the synchronous proof write fails at create time, the grant is immediately revoked and the call errors.
***
## Relationship to the unified engine
Delegation is not a separate system. The delegation decision itself rides the same event spine as every action and retrieval: creating a grant writes an audit event tagged `operations: ["delegation"]`, evaluated by the same engine. You can also run delegation-facet policy directly through the unified `POST /evaluate` call with `operations: ["delegation"]` — see [Unified Rules](/rules/unified/api-reference). The `/orchestrate/*` surface on this page is the **grant lifecycle and per-action enforcement**; the unified evaluate call is the **stateless policy check** for a single delegated event.
***
## Next steps
REST API for grants, acceptance, revocation, and per-action enforcement.
One rule surface over every operation, including `delegation`.
Every grant, acceptance, and revocation is a verifiable record.
What agents can do — the operation delegation hands off.
# API Reference
Source: https://docs.visiqlabs.com/rules/retrieval/api-reference
Complete REST API reference for the retrieval-governance endpoints.
Retrieval-governance endpoints are mounted under `/recall/*`, with the versioned audit-log read at `/v1/recall/audit-log`. The base URL is `https://api.visiqlabs.com`.
Action governance and retrieval governance share a single rule engine — each rule declares which facets it applies to. The endpoints on this page are the retrieval facet: they govern what your agents can **see** (retrieved documents, tool results, rendered prompts), with decisions `allow`, `deny`, `redact`, and `escalate`.
## Authentication
All endpoints require a Bearer credential: `Authorization: Bearer `. Requests without a valid credential receive `401 Unauthorized`.
Two credential audiences exist:
* **Harness keys** — the operational credential your SDK or harness runs with. Either a `vq_prod_...` / `vq_test_...` key minted in the dashboard under **Settings → Harness Keys**, or the `allow_...` key returned once by `POST /allow/agents`. On the retrieval surface the operational allowlist covers exactly `POST /recall/evaluate` and `GET /recall/rules/bundle` — calling any other endpoint on this page with a harness key returns `403 {"error": "harness_key_not_permitted"}`.
* **Management keys** — general automation credentials governed by explicit permission grants. They can call every endpoint on this page. Mint one in the dashboard under **Settings → API Keys**, scoped to the exact permission grants it needs; or drive the management endpoints from the dashboard, which authenticates with your session.
Scoped keys are additionally checked against their granted scopes. Evaluation requires `rules:evaluate` (or the legacy `recall:write`); reads require `rules:read` (or the legacy `recall:read`); rule mutations require `rules:write` (or the legacy `recall:write`); `full_access` satisfies everything. A key without the required scope receives `403 {"error": "insufficient_scope"}` listing the required and granted scopes.
### Rate limiting
Every API-key request passes a per-key sliding-window rate limit (default 600 requests per 60 seconds). Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers; exceeding the window returns `429` with a `Retry-After` header and body `{"error": "rate_limited", "detail": "API key rate limit exceeded.", "retryAfter": }`.
### List responses
Every list endpoint on this page returns the same envelope:
```json theme={null}
{
"data": [ ... ],
"total": 128,
"page": 1,
"pageSize": 50
}
```
Pagination is controlled by `page` (default `1`) and `limit` (default `50`, max `100`) query parameters.
***
## Evaluation
The endpoints your SDK or harness calls at runtime. Both accept a harness key. The SDK evaluates most retrievals locally from the cached rule bundle — the server and the SDK run the same policy interpreter over the same rule source, so local and remote decisions agree by construction.
***
### POST /recall/evaluate
Evaluate a retrieval against your suppression rules and return a decision.
**Scope:** `rules:evaluate` or `recall:write`
**Request body:**
```json theme={null}
{
"agent_id": "research-bot",
"operation": "retrieve",
"resource_type": "document",
"resource_metadata": {
"classification": "confidential",
"department": "finance"
},
"surface": "public_channel",
"query": "quarterly revenue figures"
}
```
| Field | Type | Required | Description |
| ------------------- | -------------------------------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `agent_id` | `string` | Yes | Identifier of the agent making the retrieval (1–255 chars) |
| `operation` | `retrieve` \| `tool_call` \| `prompt_render` | Yes | Which hook produced the content: a retriever, a tool result, or prompt assembly |
| `resource_type` | `string` | No | Resource type being accessed. Default `document` (1–255 chars) |
| `resource_metadata` | `object` | No | Metadata about the resource (classification, department, …). Default `{}` |
| `trust_tier` | `string` | No | Fallback trust tier — see the note below (max 50 chars) |
| `surface` | `string` | No | Delivery surface for the output, matched exactly against surface-scoped rules (max 100 chars) |
| `query` | `string` | No | The retrieval query string (max 2000 chars) |
| `telemetry` | `object` | No | Opaque client telemetry, attached to the decision's signed record envelope |
Agent attributes are **server-authoritative**. The platform looks up `agent_id` in the agent registry and hydrates its assigned trust tier, categories, and business function into rule input (`input.agent.*`). An assigned trust tier **overrides** the request's `trust_tier` — the body value is only a fallback for agents with no assigned tier, so varying it in a request cannot spoof a tier-gated rule. Unlike the action facet's evaluate endpoint, this endpoint does not auto-provision unknown agents.
For an agent in **monitor mode**, a non-allow decision is normalized to `allow` before it is recorded and returned, with `reason_code: "MONITOR_MODE"` and the engine's original reason preserved behind a `Monitor: ` prefix.
**Response:**
```json theme={null}
{
"decision_id": "550e8400-e29b-41d4-a716-446655440000",
"decision": "redact",
"reason_code": "POLICY_DENY",
"reason": "Matched rule: Mask account numbers for support agents",
"redaction_rules": [
{ "field": "ssn", "mode": "full", "replacement": "[REDACTED]" },
{ "field": "account_number", "mode": "partial", "keepLast": 4, "maskChar": "*" }
]
}
```
| Field | Type | Description |
| ----------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `decision_id` | `string` (UUID) | Unique ID for this decision — used for audit and the retained-original reveal |
| `decision` | `allow` \| `deny` \| `redact` \| `escalate` | The suppression outcome |
| `reason_code` | `string` | Machine-readable reason — see the table below |
| `reason` | `string` | Human-readable explanation |
| `redaction_rules` | `array` | Present on a `redact` decision (and on an `escalate` whose fallback is `mask`) — see below |
| `hitl_fallback` | `deny` \| `mask` | Present on an `escalate` decision — see below |
**Decision semantics:**
* `allow` — the content passes to the agent unchanged.
* `deny` — the content is suppressed; the agent never sees it.
* `redact` — the content proceeds with the accompanying `redaction_rules` applied.
* `escalate` — hold the content for human review; if no human approves in time, fall back per `hitl_fallback`.
**Redaction directives.** Each entry in `redaction_rules` may include `field`, `pattern`, `replacement`, `mode` (`full` | `partial` | `email` | `custom`), `keepFirst`, `keepLast`, `maskChar`, and `keepPattern`. `full` replaces the whole match (the default); `partial` keeps the first/last characters; `email` keeps the first character of the local part plus the domain; `custom` keeps every `keepPattern` match and masks the rest. A `redact` decision from a rule whose directives resolve to nothing passes content through unchanged — unless the rule is flagged fail-closed, in which case the decision is downgraded to `deny` server-side.
**The `escalate` decision** tells your harness to hold the content for human review — this endpoint does not itself notify approvers; register the approval through the human-in-the-loop flow (see [Human-in-the-Loop](/rules/action/hitl)). `hitl_fallback` tells the harness what to do when no human responds in time: `deny` (the default, fail-closed) or `mask` (proceed with the accompanying `redaction_rules` applied).
**Reason codes:**
| Code | Meaning |
| --------------------- | ------------------------------------------------------------------------------------------------------- |
| `POLICY_ALLOW` | A matched rule's conditions evaluated to allow |
| `POLICY_DENY` | A matched rule's conditions produced a non-allow outcome (deny, redact, or escalate) |
| `TIER_MISMATCH` | The agent's trust tier is below the minimum required by the rule or resource |
| `PRINCIPAL_EXCLUDED` | The agent is on the rule's (or resource's) principal exclusion list |
| `SURFACE_RESTRICTION` | A surface-scoped rule applied to this delivery surface and did not allow it |
| `AUDIENCE_EXPANSION` | Reserved — defined in the decision vocabulary but not currently emitted by the engine |
| `DEFAULT_DENY` | No rule matched — the retrieval facet fails closed |
| `EMERGENCY_BYPASS` | An active [emergency bypass](/rules/retrieval/emergency-bypass) on the matched rule allowed the request |
| `MONITOR_MODE` | The agent runs in monitor mode — a non-allow decision was recorded but returned as `allow` |
The response carries no receipt ID — tamper evidence is asynchronous. For organizations with artifact signing enabled, each decision emits a record envelope that receives an Ed25519 receipt off the request path, is Merkle-batched under a KMS-signed root with an RFC 3161 timestamp, and lands in a hash-chained checkpoint log. Verify a decision's record envelope server-side via `GET /record/envelopes/:id/verify` (`:id` is the record envelope's UUID, not the `decision_id` returned here).
**Minimum-necessary at rest:** the decision is evaluated against the real values, but the persisted copies of `resource_metadata` and `query` are masked — first by the matched rule's redaction directives, then by an always-on value-shape redaction floor. The raw original is discarded by default; organizations can opt in to retaining it encrypted, revealable only via the [retained-original endpoint](#get-/recall/decisions/id/unmask).
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `429 Too Many Requests`, `500 Internal Server Error`
**Example:**
```bash theme={null}
curl -X POST https://api.visiqlabs.com/recall/evaluate \
-H "Authorization: Bearer $VISIQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "research-bot",
"operation": "retrieve",
"resource_type": "document",
"resource_metadata": { "classification": "confidential" }
}'
```
***
### GET /recall/rules/bundle
Fetch your enabled retrieval rules as one bundle for local evaluation. Clients can cache this bundle and revalidate it with `If-None-Match`, so retrieval decisions on the hot path never wait on the network. (The VisIQ SDK consumes the unified `GET /rules/bundle`, which carries both facets.)
**Request headers:**
| Header | Description |
| --------------- | ----------------------------------------------------------------------------------------------- |
| `If-None-Match` | ETag from a previous response. The server returns `304 Not Modified` if the bundle is unchanged |
**Response:**
```json theme={null}
{
"version": "a3b4c5d6e7f8...",
"rules": [
{
"id": "rule-uuid",
"name": "Deny tier3 from confidential",
"rego_source": "package recall.rules.tier3_guard\n\ndefault decision = \"deny\"\n...",
"trust_tier": "tier3",
"surface": null,
"principal_exclusions": [],
"priority": 50
}
]
}
```
| Field | Description |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | SHA-256 hash of the serialized rules array — changes whenever any included rule changes |
| `rules` | Enabled retrieval rules, sorted by priority descending. Each carries its full `rego_source` for local evaluation. Rules without policy source are excluded from the bundle (they are not yet enforceable) |
**Response headers:**
| Header | Value |
| --------------- | ------------------------------------------- |
| `ETag` | `""` (quoted, per RFC 7232) |
| `Cache-Control` | `private, max-age=60` |
**Status codes:** `200 OK`, `304 Not Modified`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `500 Internal Server Error`
***
## Rules
Rule management. These are management endpoints — a harness key receives `403 harness_key_not_permitted` here. The recommended authoring path is the dashboard's rule editor (natural-language compile, visual condition builder, and a Simulate panel that checks a new rule against your recent traffic — rules projected to inhibit more than 5% of it are blocked). These endpoints are the programmatic equivalent.
***
### GET /recall/rules
List retrieval rules, sorted by priority descending — the same order the engine evaluates them in (first match wins).
**Permission:** `recall_rules:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `100`)
**Response:**
```json theme={null}
{
"data": [
{
"id": "rule-uuid",
"name": "Deny tier3 from confidential",
"description": "Prevent low-trust agents from accessing sensitive data",
"natural_language": "Deny tier3 agents from internal, confidential, or restricted data",
"priority": 50,
"enabled": true,
"trust_tier": "tier3",
"surface": null,
"principal_exclusions": null,
"bypass_active": false,
"bypass_reason": null,
"bypass_activated_by": null,
"bypass_activated_at": null,
"bypass_expires_at": null,
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z"
}
],
"total": 8,
"page": 1,
"pageSize": 50
}
```
The list omits `rego_source`; fetch a single rule to read the policy source.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### POST /recall/rules
Create a retrieval rule.
**Permission:** `recall_rules:create`
**Request body:**
```json theme={null}
{
"name": "Deny tier3 from confidential",
"description": "Prevent low-trust agents from accessing sensitive data",
"rego_source": "package recall.rules.tier3_guard\n\ndefault decision = \"deny\"\n\ndecision = \"deny\" if {\n input.resource_metadata.classification in [\"internal\", \"confidential\", \"restricted\"]\n}",
"natural_language": "Deny tier3 agents from accessing internal, confidential, or restricted data",
"priority": 50,
"enabled": true,
"trust_tier": "tier3"
}
```
| Field | Type | Required | Description |
| ---------------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | Yes | Rule name (max 255 chars) |
| `description` | `string` | No | Description (max 1000 chars) |
| `rego_source` | `string` | Yes | Policy source (see note below) |
| `natural_language` | `string` | No | The plain-English intent the policy was compiled from (max 2000 chars) |
| `priority` | `number` | No | Evaluation priority — higher runs first. Default `0` |
| `enabled` | `boolean` | No | Whether the rule is active. Default `true` |
| `trust_tier` | `string` | No | Minimum trust tier this rule requires (`tier1` is highest trust, `tier3` lowest). Agents below the minimum are denied with `TIER_MISMATCH` (max 50 chars) |
| `surface` | `string` | No | Exact-match surface restriction — the rule applies only to retrievals delivered to this surface (max 100 chars) |
| `principal_exclusions` | `string[]` | No | Agent IDs this rule excludes — an excluded agent is denied with `PRINCIPAL_EXCLUDED` |
`rego_source` holds the rule's policy source in the platform's Rego-subset condition language (equality, inequality, set membership, `startswith`/`endswith`/`contains`, regex, counts, and numeric comparisons). The decision (`allow`, `deny`, `redact`, `escalate`) is derived from the policy body. Prefer `POST /recall/rules/compile` — the compiler drafts and validates the policy against the live evaluation engine.
**Response (201):** the created rule object, including `rego_source`.
**Status codes:** `201 Created`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### POST /recall/rules/compile
Compile a natural-language description into a retrieval rule. The compiler reads your existing rules for context, drafts the policy source, and validates it against the live evaluation engine.
**Permission:** `recall_rules:create`
**Rate limit:** 10 compile requests per minute per organization — exceeding it returns `429`.
**Query parameters:** `stream=true` — stream the compile over Server-Sent Events instead of a single JSON response (also triggered by `Accept: text/event-stream`).
**Request body:**
```json theme={null}
{
"prompt": "Deny tier3 agents from accessing any document classified as internal, confidential, or restricted"
}
```
| Field | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------------------------------------ |
| `prompt` | `string` | Yes | Plain-English rule description (max 4000 chars) |
| `nodeRef` | `string` | No | Rule-graph node reference for editor context (max 255 chars) |
**Response:**
```json theme={null}
{
"rego_source": "package recall.rules.tier3_guard\n\ndefault decision = \"deny\"\n...",
"natural_language": "This rule denies tier3 agents access to internal, confidential, or restricted documents...",
"name": "Compiled retrieval rule",
"description": "AI-compiled suppression rule",
"suggested_priority": 50
}
```
**Streaming response** (`?stream=true`): a `text/event-stream` of JSON events —
```
data: {"type":"text","content":"Drafting the policy..."}
data: {"type":"done","result":{"rego_source":"...","natural_language":"...","name":"...","description":"...","suggested_priority":50}}
```
On failure the stream emits `{"type":"error","message":"..."}` and closes.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `422 Unprocessable Entity` (the compiler could not produce a valid policy — refine the prompt), `429 Too Many Requests`, `500 Internal Server Error`, `503 Service Unavailable` (rule compilation is not configured in this environment)
***
### GET /recall/rules/:id
Get a single rule by UUID, including `rego_source`.
**Permission:** `recall_rules:view`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### PUT /recall/rules/:id
Update a rule. Same fields as `POST /recall/rules`, all optional; at least one is required. Only provided fields change. `trust_tier`, `surface`, and `principal_exclusions` accept `null` to clear.
**Permission:** `recall_rules:update`
**Response:** the updated rule object.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### DELETE /recall/rules/:id
Delete a rule permanently.
**Permission:** `recall_rules:delete`
**Response:** `{ "deleted": true }`
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
## Emergency Bypass
Temporarily suspend one rule's enforcement during an incident — see [Emergency Bypass](/rules/retrieval/emergency-bypass) for when and why. Bypasses auto-expire; expiry is checked at evaluation time, so there is no revocation lag. Both activation and deactivation are written to the audit log with reason code `EMERGENCY_BYPASS`.
***
### POST /recall/rules/:id/bypass
Activate an emergency bypass on a rule. While active, any request that reaches the bypassed rule in the priority cascade returns `allow` with reason code `EMERGENCY_BYPASS` — even requests the rule's conditions would not have matched — and lower-priority rules never run for it. Higher-priority rules keep enforcing.
**Permission:** `recall_rules:bypass`
**Request body:**
```json theme={null}
{
"reason": "Incident INC-2041: unblocking on-call diagnosis",
"duration_minutes": 30
}
```
| Field | Type | Required | Description |
| ------------------ | -------- | -------- | ------------------------------------------------------------------- |
| `reason` | `string` | Yes | Why the bypass is needed (min 10 chars) — recorded in the audit log |
| `duration_minutes` | `number` | Yes | One of `15`, `30`, `60`, `120`. The bypass expires automatically |
**Response:** the full rule object with the bypass fields populated:
```json theme={null}
{
"id": "rule-uuid",
"name": "Deny tier3 from confidential",
"bypass_active": true,
"bypass_reason": "Incident INC-2041: unblocking on-call diagnosis",
"bypass_activated_by": "user-uuid",
"bypass_activated_at": "2026-07-03T10:30:00Z",
"bypass_expires_at": "2026-07-03T11:00:00Z",
"...": "..."
}
```
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `409 Conflict` (a bypass is already active on this rule), `500 Internal Server Error`
***
### DELETE /recall/rules/:id/bypass
Deactivate an active bypass before it expires.
**Permission:** `recall_rules:bypass`
**Response:** the full rule object with the bypass fields cleared.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `409 Conflict` (no active bypass on this rule), `500 Internal Server Error`
***
## Audit Log
***
### GET /v1/recall/audit-log
Query the retrieval decision audit log. Every decision made through this endpoint — allow and deny, including monitor-mode observations and bypass activations — is recorded. (Locally evaluated SDK decisions surface through the runtime telemetry pipeline instead.) The log is append-only; no writes are accepted via this API.
**Permission:** `recall_audit_log:view`
Tamper evidence comes from the signed record pipeline, not from the query API: for organizations with artifact signing enabled, each decision emits a record envelope that receives an asynchronous Ed25519 receipt, is Merkle-batched under a KMS-signed root with an RFC 3161 timestamp, and lands in a hash-chained checkpoint log. Verify a decision's record envelope server-side via `GET /record/envelopes/:id/verify`.
**Scope:** `rules:read` or `recall:read` (management keys / dashboard session — harness keys cannot query the log)
**Query parameters:**
| Parameter | Type | Description |
| ------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_id` | `string` | Filter by agent logical ID |
| `operation` | `string` | `retrieve` \| `tool_call` \| `prompt_render` \| `emergency_bypass_activate` \| `emergency_bypass_deactivate` |
| `decision` | `string` | `allow` \| `deny` \| `redact` \| `escalate` |
| `reason_code` | `string` | `TIER_MISMATCH` \| `PRINCIPAL_EXCLUDED` \| `POLICY_DENY` \| `AUDIENCE_EXPANSION` \| `SURFACE_RESTRICTION` \| `POLICY_ALLOW` \| `DEFAULT_DENY` \| `EMERGENCY_BYPASS` |
| `start_date` | ISO 8601 datetime | Lower bound on `created_at` |
| `end_date` | ISO 8601 datetime | Upper bound on `created_at` |
| `page` | `number` | Page index (default `1`) |
| `limit` | `number` | Records per page (default `50`, max `100`) |
Rows recorded under monitor mode carry `reason_code: "MONITOR_MODE"`; it is not yet accepted as a filter value.
**Response:**
```json theme={null}
{
"data": [
{
"id": "audit-entry-uuid",
"agent_id": "research-bot",
"operation": "retrieve",
"resource_type": "document",
"decision": "deny",
"reason_code": "TIER_MISMATCH",
"reason": "agent trust tier 'tier3' (level 1) is below the required minimum 'tier2' (level 2)",
"rule_id": "rule-uuid",
"receipt_id": null,
"metadata": {
"surface": "public_channel",
"trust_tier": "tier3",
"query": "quarterly revenue",
"resource_metadata": { "classification": "confidential" },
"floor_detectors": [],
"decision_id": "decision-uuid"
},
"created_at": "2026-07-03T10:30:00Z"
}
],
"total": 1247,
"page": 1,
"pageSize": 50
}
```
`metadata.query` and `metadata.resource_metadata` are the **masked** persisted copies (rule directives plus the always-on redaction floor); `metadata.floor_detectors` names any floor detectors that fired, and `metadata.decision_id` is the underlying decision's ID for the retained-original reveal below. `receipt_id` is a legacy column and is `null` on new rows — receipts flow through the record pipeline instead.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `403 Forbidden` (insufficient scope), `429 Too Many Requests`, `500 Internal Server Error`
***
## Retained Originals
***
### GET /recall/decisions/:id/unmask
Reveal the retained, encrypted original payload for one retrieval decision. Persisted copies are masked at rest; when your organization has enabled retained-original storage (off by default), the raw `resource_metadata` and `query` are additionally stored AES-256-GCM-encrypted, and this endpoint decrypts them.
**Permission:** `payloads:unmask`
This is a management endpoint gated on the `payloads:unmask` permission — only explicitly-authorized roles can ever see the original values, and every reveal is logged with who requested it. Harness keys receive `403`.
**Path parameter:** `:id` — the decision UUID (`decision_id` from `POST /recall/evaluate`, or `metadata.decision_id` on an audit-log row)
**Response:**
```json theme={null}
{
"decision_id": "decision-uuid",
"original": {
"resource_metadata": { "classification": "confidential", "account_number": "4111111111111111" },
"query": "quarterly revenue figures"
}
}
```
**Status codes:** `200 OK`, `400 Bad Request` (invalid UUID), `401 Unauthorized`, `403 Forbidden`, `404 Not Found` (unknown decision, or retention is disabled for your organization), `500 Internal Server Error`
# Emergency Bypass
Source: https://docs.visiqlabs.com/rules/retrieval/emergency-bypass
Suspend enforcement of a single retrieval rule for a bounded window — fully audited, auto-expiring.
## Overview
Emergency bypass lets an authorized operator temporarily suspend enforcement of **one retrieval rule** when a critical situation requires immediate access to context that rule is suppressing. Every bypass is bounded (15 minutes to 2 hours), requires a written reason, and is fully audited — activation, every decision made through the bypassed rule, and deactivation all land in the audit log with the reason code `EMERGENCY_BYPASS`.
Emergency bypass is a safety valve, not a workflow. It exists for scenarios where a rule would prevent an agent from accessing information needed to resolve an urgent incident.
***
## When to Use Emergency Bypass
* A production incident requires an agent to access documents a rule is suppressing, right now
* A security investigation needs an agent to review suppressed context for threat analysis
* A misconfigured rule is denying context that should be allowed, and you need relief while you fix it
Emergency bypass should never be used as a routine workflow. If you find yourself bypassing frequently, the rule itself needs adjustment — frequent bypass usage is a signal that your policy configuration does not match your operational reality.
***
## How It Works
An operator with the `recall_rules:bypass` permission calls the bypass endpoint for a specific rule, with a mandatory reason (at least 10 characters) and a duration of exactly 15, 30, 60, or 120 minutes. Activating a rule that already has an active bypass returns `409 Conflict`.
While the bypass is active, any request that reaches the bypassed rule in the priority cascade returns `allow` with the reason code `EMERGENCY_BYPASS` and your reason string. Only that one rule is suspended — higher-priority rules evaluate first and keep enforcing normally throughout the window.
Activation and deactivation are first-class audit operations, and the bypass **fails if its audit entry cannot be written** — you cannot bypass without leaving a record. Each decision allowed through the bypassed rule is logged with `reason_code: "EMERGENCY_BYPASS"` and the rule's ID.
Expiry is checked at decision time — no background job is involved, so there is no window where a stuck process leaves a bypass open past its deadline. The operator can also deactivate early with a `DELETE`; deactivating a rule with no active bypass returns `409 Conflict`. Either way, the transition back to normal enforcement is immediate.
***
## Activating and Deactivating
Bypass is controlled per rule via the REST API. Both calls require an operator whose role grants the dedicated `recall_rules:bypass` permission — separate from rule editing, so you can restrict who may pull the emergency lever. Harness (agent) keys cannot call these endpoints.
```bash theme={null}
# Activate: reason ≥ 10 characters, duration_minutes ∈ {15, 30, 60, 120}
curl -X POST 'https://api.visiqlabs.com/recall/rules//bypass' \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"reason": "Incident #1234 — rule suppressing runbook docs needed for diagnosis",
"duration_minutes": 30
}'
# Deactivate early
curl -X DELETE 'https://api.visiqlabs.com/recall/rules//bypass' \
-H "Authorization: Bearer "
```
Both calls return the updated rule, including its bypass state: `bypass_active`, `bypass_reason`, `bypass_activated_by`, `bypass_activated_at`, and `bypass_expires_at`.
While a bypass is active, the rule shows a **Bypass active** badge in the dashboard's retrieval rules view.
***
## Audit Trail
Three kinds of entries record a bypass, all in the retrieval audit log:
**Activation** — `operation: "emergency_bypass_activate"`, with metadata:
| Metadata Field | Description |
| ------------------ | ----------------------------------------- |
| `action` | `"activate"` |
| `duration_minutes` | The requested window (15, 30, 60, or 120) |
| `activated_by` | The operator who activated the bypass |
| `activated_at` | ISO 8601 activation timestamp |
| `expires_at` | ISO 8601 auto-expiry timestamp |
| `rule_name` | The bypassed rule's name |
**Decisions during the window** — normal decision entries whose `decision` is `allow`, whose `reason_code` is `EMERGENCY_BYPASS`, whose `reason` is the operator's reason string, and whose `rule_id` is the bypassed rule. Requests decided by other (non-bypassed) rules during the window carry their normal reason codes.
**Deactivation** — `operation: "emergency_bypass_deactivate"`, with metadata recording `deactivated_by` plus the original bypass details (`original_reason`, `original_activated_by`, `original_activated_at`, `original_expires_at`, `rule_name`).
Example activation entry:
```json theme={null}
{
"id": "audit-entry-uuid",
"agent_id": "system",
"operation": "emergency_bypass_activate",
"resource_type": "recall_rule",
"decision": "allow",
"reason_code": "EMERGENCY_BYPASS",
"reason": "Incident #1234 — rule suppressing runbook docs needed for diagnosis",
"rule_id": "rule-uuid",
"metadata": {
"action": "activate",
"duration_minutes": 30,
"activated_by": "user-uuid",
"activated_at": "2026-04-13T10:00:00Z",
"expires_at": "2026-04-13T10:30:00Z",
"rule_name": "Deny tier3 from confidential data"
},
"created_at": "2026-04-13T10:00:00Z"
}
```
Bypass decisions flow through the same audit pipeline as every other decision — record envelopes with asynchronous Ed25519-signed receipts when artifact signing is enabled for your organization. See [Receipts](/record/receipts).
***
## Querying Bypass Events
Use the audit log API for post-incident review:
```bash theme={null}
# Every decision allowed through a bypassed rule
curl 'https://api.visiqlabs.com/v1/recall/audit-log?reason_code=EMERGENCY_BYPASS&start_date=2026-04-13T00:00:00Z' \
-H "Authorization: Bearer "
# Who activated bypasses, when, and for how long
curl 'https://api.visiqlabs.com/v1/recall/audit-log?operation=emergency_bypass_activate' \
-H "Authorization: Bearer "
# Deactivations (manual early ends)
curl 'https://api.visiqlabs.com/v1/recall/audit-log?operation=emergency_bypass_deactivate' \
-H "Authorization: Bearer "
```
This supports:
* Post-incident reviews to understand exactly what data was accessed during the window
* Compliance reporting to demonstrate that every bypass is documented, justified, and time-bounded
* Trend analysis — repeated bypasses of the same rule mean the rule needs fixing
***
## Best Practices
The API enforces a minimum length, but make the reason genuinely useful: reference an incident number, ticket, or specific operational justification. It becomes the `reason` on every decision allowed through the bypassed rule.
Bypass is deliberately scoped to a single rule — identify which rule is actually denying the context and bypass that one. If the rule is simply wrong, fix the rule instead of repeatedly bypassing it.
Choose the smallest duration that covers the need — 15 minutes is often enough. Deactivate manually the moment the incident no longer needs the access; don't wait for auto-expiry.
After every bypass, query both the activation events and the `EMERGENCY_BYPASS` decisions to see what was accessed. Use the review to decide whether the rule needs adjustment to prevent the next bypass.
# Retrieval Governance
Source: https://docs.visiqlabs.com/rules/retrieval/introduction
Retrieval-facet governance inside the visiq() harness. Governs what your AI agents can see at runtime.
Retrieval governance controls what your agents can **see**. Every retrieved document is evaluated against your rules before it reaches the agent's context window — allow, redact, deny, or escalate per document. There is no additional code beyond the `visiq()` call shown in the [Quickstart](/quickstart).
***
## How retrieval governance works
When `visiq()` wraps your agent, it instruments every retriever it finds. The flow:
1. Agent calls a retriever tool (e.g., `search_knowledge` with query `"Q3 revenue"`)
2. The retriever fetches raw results from the vector store
3. Each returned document is evaluated in-process against a locally cached rule bundle
4. **Allow** — document enters the context window unchanged
5. **Redact** — sensitive fields and patterns are masked before the agent sees the document
6. **Deny** — document is silently suppressed; the agent never sees it
7. **Escalate** — the access is recorded for human review; retrieval is synchronous (it can't pause on a human), so the rule chooses whether the document passes through or comes back masked in the meantime
No error is thrown for denied documents — suppression is a normal policy outcome. If every document is denied, the retriever returns an empty array.
Evaluation is local: the SDK evaluates against a cached rule bundle refreshed in the background (roughly every 5 seconds, with `ETag`/`304` caching), so there is no network round-trip in the retrieval hot path. On a cold start with no bundle loaded yet, the mode envelope decides: an agent **confirmed in `enforce`** that has lost its bundle stays fail-closed — nothing leaks while rules are unknown — while a **never-confirmed** agent runs `monitor` (monitor-until-confirmed), observing without suppressing. Retrieval's no-match default stays `deny` (the data-protection floor) wherever enforcement is active.
***
## How retrievers are detected
The harness checks three structural patterns on each object it walks:
| Pattern | Match condition | What gets instrumented |
| ------------------------- | ----------------------------------- | ------------------------------------------------------------ |
| LangChain `BaseRetriever` | `tool._getRelevantDocuments` exists | `_getRelevantDocuments()` wrapped |
| Generic retriever | `tool.retrieve` exists | `retrieve()` wrapped |
| Nested retriever | `tool.retriever` is an object | Recurses into `.retriever` and re-applies the above patterns |
### Retriever-backed tools
Many frameworks wrap the retriever inside a tool function. The harness recognizes a retriever-backed tool structurally (a reachable `.retriever` property) or by name — names containing `retriev`, or search/knowledge-style names that carry no mutation verb (`knowledge_base_update` is treated as an action tool, not a retriever). Two cases follow:
* **Retriever reachable** — the backing retriever is instrumented directly, so every document is evaluated with its own metadata (`classification`, `data_categories`, …) before the tool joins the results into a string. Full per-document fidelity.
* **Retriever hidden in a closure** — LangChain's `createRetrieverTool` captures the retriever in a closure and returns one joined string. The harness wraps the tool's function and filters that string as a single blob: always-on secret detection, pattern masking, and whole-result rules still apply, but rules keyed on per-document metadata cannot fire on a metadata-less string. The SDK logs a one-time console warning for each such tool. For full per-document governance, pass the retriever to `visiq()` directly or use a tool that returns a `Document[]`.
A tool that both retrieves and mutates (e.g. `retrieve_and_archive`) is a hybrid: one decision tagged `['retrieval', 'action']` governs both legs — the action side gates the call itself, the retrieval side filters what comes back.
Retrieval governance is not LangChain-only. On execute-based frameworks — Vercel AI SDK, Mastra, OpenAI Agents SDK, LlamaIndex, VoltAgent — the same `visiq()` call filters tool results through the retrieval facet.
***
## Always-on secret detection
Even with zero authored rules, retrieved content passes a deterministic value-shape floor that masks sensitive values by shape, wherever they appear: private keys, JWTs, cloud and vendor API keys (AWS, GitHub, Slack, Stripe, Google, LLM providers), bearer tokens, connection strings with inline passwords, checksum-validated card numbers and IBANs, SSNs, tax IDs, and email addresses. Detectors with a larger false-positive surface (phone numbers, IP addresses, MAC addresses, bank routing codes) are available opt-in. Manage detectors under **Settings → Organization → Security** in the [dashboard](https://app.visiqlabs.com).
***
## Key concepts
Policies over document metadata, trust tiers, and surfaces. Author in natural language, in the visual condition builder, or as policy source directly.
Every tenant is seeded a curated catalog of 35 default rules combining each agent's trust tier (`tier1` highest trust → `tier3` restricted) with its business function: agents that need a data category get it, medium-trust access escalates or is masked, and agents with no need-to-know never see raw values.
Mask SSNs, credentials, and account numbers inside permitted documents. The agent sees the structure but not the sensitive values.
Decisions — action and retrieval alike — emit record envelopes with asynchronous Ed25519-signed receipts (retrieval envelopes require artifact signing to be enabled for your organization). Tamper-evident proof for compliance audits.
***
## Agent modes
Governance mode is set **per agent**, is server-authoritative, and is controlled from the **Harness → Agents** page in the dashboard — not from SDK config:
| Mode | Behavior |
| --------- | ----------------------------------------------------------------------------------------------------------- |
| `monitor` | Evaluate and record every decision but never block or mask — observe-only. The default for every new agent. |
| `enforce` | Apply per-document decisions — deny, redact, and escalate per policy. |
| `off` | Bypass retrieval governance entirely — all documents pass through. |
Agents the harness sees for the first time are auto-provisioned in `monitor` mode, so instrumenting an agent never disrupts it. Watch its decision telemetry, tune your rules, then flip that one agent to `enforce` — no config change or redeploy required.
Monitor-first rollout is the intended adoption path: you see exactly what would have been denied, redacted, or escalated before any of it is enforced.
***
## Operations of one rule engine
Action governance and retrieval governance are not two systems — they are the **same rule engine** evaluating one event against the **operations** it carries. An event is tagged with an `operations[]` set (`action`, `retrieval`, `delegation` — a hybrid read-then-write tool carries `['retrieval', 'action']`), and a rule applies to whatever operations it targets, emitting an outcome from one shared vocabulary. The operations differ only in where the harness intercepts.
| Dimension | Action operation | Retrieval operation |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Governs** | What agents can **do** (tool calls) | What agents can **see** (retrieved context) |
| **Interception** | Wrapped tool dispatch methods (`invoke`, `call`, `_call`) — framework callbacks are audit-only because they cannot block | Wrapped retriever methods (`_getRelevantDocuments`, `retrieve`) plus tool-result filtering |
| **Outcomes** | permit / deny / approval\_required / mask | allow / deny / redact / escalate |
| **Audit** | Record envelopes with signed receipts, plus the human-approval queue | Record envelopes with signed receipts, plus a per-document audit log |
The unified outcome set is `permit · deny · approval_required · redact · escalate · mask`; each operation uses only the subset that applies to it. The retrieval facet's pass-through outcome appears on the wire as `allow` — the retrieval-plane spelling of the unified `permit`. (The unified `POST /evaluate` normalizes it to `permit`; the per-facet `POST /recall/evaluate` returns the literal `allow`.)
Every operation is evaluated by the same engine — they activate together from a single `visiq()` call.
***
## Next steps
The rule model: policy format, trust tiers, surfaces, and masking.
Tamper-evident audit trail with Ed25519 signatures.
Suspend one rule during an incident — bounded, audited, auto-expiring.
REST API for rules, receipts, and the audit log.
# Rules
Source: https://docs.visiqlabs.com/rules/retrieval/rules
Define retrieval policies for your AI agents with trust tiers, surfaces, and per-branch masking.
## How Rules Work
Rules define what context your agents are permitted to see. Every document or tool result captured by the in-process SDK is evaluated against your rule set. Rules compile into a bundle that SDKs download and cache locally — evaluation happens in-process, with no network round-trip on the retrieval hot path.
There is **one rule engine** for action and retrieval governance: a rule is a single object tagged with the operations it targets (`operations[]` — `action`, `retrieval`, or both), and the same rule source evaluates identically on the server and inside the SDK. Rules created through the retrieval endpoints are tagged `retrieval` automatically; the dashboard rule editor can tag one rule with both operations so a single policy governs a hybrid read-write tool.
Evaluation is a **priority-descending, first-match-wins cascade**: rules are sorted by `priority` (highest number first) and the first rule whose checks and conditions match decides the outcome.
***
## Rule Structure
A rule has the following fields:
| Field | Type | Description |
| ---------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | Human-readable name for the rule |
| `description` | `string` | Optional longer description |
| `rego_source` | `string` | Policy source code (required — see [Policy Format](#policy-format)) |
| `natural_language` | `string` | Plain-English statement of the rule, used for AI compilation |
| `priority` | `number` | Rules with a higher number are evaluated first. Default: `0` |
| `enabled` | `boolean` | Whether the rule is active. Disabled rules are excluded from the bundle |
| `trust_tier` | `string` | Minimum agent trust tier for this rule (e.g. `tier2`) — lower-trust agents get an immediate deny from this rule |
| `surface` | `string` | Restrict the rule to one communication surface (e.g. `PUBLIC_CHANNEL`) |
| `principal_exclusions` | `string[]` | Agent IDs this rule **denies outright** — an agent on the list gets a `PRINCIPAL_EXCLUDED` deny when the cascade reaches this rule |
| `redaction_spec` | `object` | Masking directives applied by `redact` decisions (authored in the dashboard rule editor — see [Masking](#masking-redaction_spec)) |
| `hitl_fallback` | `deny` \| `mask` | Fallback for `escalate` branches: with `mask`, escalated content is returned masked immediately (using the rule's masking directives) while the escalation is reviewed |
### Example Rule (JSON)
```json theme={null}
{
"name": "Deny tier3 from confidential data",
"description": "Prevent tier3 agents from accessing internal, confidential, or restricted documents",
"rego_source": "package recall.rules.tier3_guard\n\ndefault decision = \"deny\"\n\ndecision = \"deny\" if {\n input.trust_tier == \"tier3\"\n input.resource_metadata.classification in [\"internal\", \"confidential\", \"restricted\"]\n}",
"natural_language": "Deny tier3 agents from accessing internal, confidential, or restricted data",
"priority": 50,
"enabled": true,
"trust_tier": null,
"surface": null,
"principal_exclusions": null
}
```
***
## Trust Tiers and Need-to-Know
Each agent is assigned a **trust tier** by an operator, and (separately) a **business function** classified automatically from what the agent does — you can pin it by hand. Both are server-authoritative: rules read them as `input.agent.trust_tier` and `input.agent.business_function`, and a caller cannot spoof them.
| Trust Tier | Meaning |
| ---------- | -------------------------------------------------------------- |
| `tier1` | Highest trust — full access where the agent has a need to know |
| `tier2` | Standard trust |
| `tier3` | Restricted — least-trusted agents |
The default protection every tenant ships with is a **curated catalog of 35 default rules** built on a need-to-know matrix: for each protected data category, the outcome combines whether the agent's business function *needs* that category with its trust tier —
* **Need + `tier1`** → full value
* **Need + `tier2`** → escalate for human review (redact for the highest-volume categories)
* **Need + `tier3`** → masked
* **No need-to-know** → masked, or denied for the most sensitive classes
* **Missing/unknown attributes** → falls into a protective branch (fail-closed)
### Document Classifications
Rules gate on the metadata your documents carry — most commonly `resource_metadata.classification` (a sensitivity level: `public`, `internal`, `confidential`, `restricted`) and `resource_metadata.data_categories` (content tags such as `pii`, `pci`, `financial`, `credentials`).
Retrieval is fail-closed: when **no rule matches** a document, the decision is `deny` with reason code `DEFAULT_DENY`. In `monitor` mode this is observed but not enforced; in `enforce` mode an uncovered document is suppressed. Roll out with monitor mode and confirm your coverage before enforcing.
***
## Surfaces
Surfaces represent the communication channel where the agent's output will be delivered. Rules can restrict context based on the destination surface — a document might be allowed in a private group but denied in a public channel.
| Surface | Description |
| ------------------ | ------------------------------------ |
| `DIRECT_MESSAGE` | One-to-one private message |
| `PRIVATE_GROUP` | Private group or channel |
| `INTERNAL_CHANNEL` | Company-internal channel |
| `PUBLIC_CHANNEL` | Publicly visible channel |
| `EXTERNAL_CHANNEL` | Channel shared with external parties |
A rule with `surface: "PUBLIC_CHANNEL"` applies only when the agent's output surface is a public channel; on other surfaces the cascade skips it. Rules without a surface restriction match all surfaces.
***
## Policy Format
Every rule's `rego_source` is a policy in a supported subset of Rego, interpreted by the same evaluator on the server and in the SDK — a rule decided one way on the server is decided identically in-process.
```
package recall.rules.
# Default: deny for gating rules (fail-closed); use "allow" only for
# transform-only rules such as redaction.
default decision = "deny"
# One block per outcome. Conditions inside a block are AND-ed;
# add more blocks for OR.
decision = "allow" if {
# conditions...
}
```
### Input Document
The evaluation input has these fields:
| Field | Type | Description |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input.agent_id` | `string` | Agent identifier |
| `input.trust_tier` | `string` | `tier1`, `tier2`, or `tier3` |
| `input.operation` | `string` | `retrieve`, `tool_call`, or `prompt_render` |
| `input.resource_type` | `string` | Resource kind, e.g. `document` |
| `input.resource_metadata` | `object` | Tags on the candidate resource: `data_categories`, `classification`, `owner`, etc. |
| `input.surface` | `string` | Communication surface (e.g. `PUBLIC_CHANNEL`) |
| `input.query` | `string` | Retrieval query string (optional) |
| `input.normalized` | `object` | The canonical normalized event (unified schema). A canonical path like `read/data_categories` is referenced as `input.normalized.read.data_categories` |
| `input.agent` | `object` | Server-authoritative agent attributes: `input.agent.trust_tier`, `input.agent.business_function`, `input.agent.categories` — injected from the agent record, never from the caller |
### Supported Conditions
| Form | Meaning |
| ------------------------------------ | ------------------------------------------------------- |
| `input.path == "value"` | Equality (also numbers, `true`/`false`) |
| `input.path != "value"` | Inequality |
| `input.path >= 100` | Numeric comparison (`>` `>=` `<` `<=`) |
| `input.path in ["a", "b"]` | Scalar field is one of the listed values |
| `"a" in input.path` | Array field contains the value (e.g. `data_categories`) |
| `not ` | Negation |
| `startswith(input.path, "v")` | String prefix (also `endswith`, `contains`) |
| `regex.match("pattern", input.path)` | Regular-expression test over a string field |
| `count(input.path) > 0` | Array/string length comparison |
| `input.path` | Truthy check |
Constructs outside the subset (`some` iterators, comprehensions, custom functions, cross-field comparisons) are not supported. A condition the engine cannot parse becomes always-false — the rule can never match (fail-closed) — and both the dashboard editor and the AI compiler reject non-evaluable conditions before saving.
Prefer fail-closed negation: `not input.agent.trust_tier == "tier1"` matches when the attribute is *absent*, while `input.agent.trust_tier != "tier1"` does not. The curated default catalog uses `not … ==` forms exclusively so a missing attribute protects rather than leaks.
#### What "absent" does to each form
The fail-closed convention above holds for every **negated literal** form, and for exactly two kinds of condition it does not:
* **`x != "..."`** requires the field to be present, so it is not interchangeable with `not x == "..."`.
* A **relational** condition (`x == y`, `x in y`) is never satisfied when either operand is absent, or when the right operand of `in` is not an array — in **either** polarity. `not x in y` does not fire because `y` is missing, and does not fire if `x` is missing either.
Generated from the executed contract in `packages/rego-evaluator/__tests__/absent-operand-contract.test.ts` and drift-checked by it.
| Condition form | Satisfied when the field is ABSENT? |
| ------------------------------ | ----------------------------------- |
| `input.x == "x"` | no |
| `input.x != "x"` | no |
| `input.x in ["x"]` | no |
| `"x" in input.x` | no |
| `startswith(input.x, "x")` | no |
| `input.x` | no |
| `count(input.x) > 0` | no |
| `input.x > 5` | no |
| `not input.x == "x"` | **yes** (fail-closed) |
| `not input.x != "x"` | **yes** (fail-closed) |
| `not input.x in ["x"]` | **yes** (fail-closed) |
| `not "x" in input.x` | **yes** (fail-closed) |
| `not startswith(input.x, "x")` | **yes** (fail-closed) |
| `not input.x` | **yes** (fail-closed) |
| `not count(input.x) > 0` | **yes** (fail-closed) |
| `not input.x > 5` | **yes** (fail-closed) |
| `input.x == input.y` | no |
| `not input.x == input.y` | no |
| `input.x != input.y` | no |
| `input.x in input.y` | no |
| `not input.x in input.y` | no |
### Decisions
| Value | Effect |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow` | Content passes through unchanged |
| `deny` | Content is withheld entirely |
| `redact` | Content passes through with the rule's masking directives applied |
| `escalate` | The access is recorded for human review. With `hitl_fallback: mask` the content returns masked in the meantime; otherwise it passes through while flagged |
The decision response also carries a machine `reason_code` (`TIER_MISMATCH`, `PRINCIPAL_EXCLUDED`, `POLICY_DENY`, `AUDIENCE_EXPANSION`, `SURFACE_RESTRICTION`, `POLICY_ALLOW`, `DEFAULT_DENY`, `EMERGENCY_BYPASS`, and `MONITOR_MODE` — recorded when a monitor-mode agent's would-be decision is overridden to allow) derived by the engine — you do not author reason codes in the policy.
### Example: Surface-Based Restriction
```
package recall.rules.audience_expansion_guard
default decision = "deny"
# Deny confidential content in public or external channels
decision = "deny" if {
input.resource_metadata.classification == "confidential"
input.surface in ["PUBLIC_CHANNEL", "EXTERNAL_CHANNEL"]
}
# Allow confidential content in private contexts
decision = "allow" if {
input.resource_metadata.classification == "confidential"
input.surface in ["DIRECT_MESSAGE", "PRIVATE_GROUP", "INTERNAL_CHANNEL"]
}
```
***
## Masking (`redaction_spec`)
A `redact` decision applies the rule's masking directives. Each directive names a structured `field` to mask wherever it appears and/or a regex `pattern` applied to string content, plus a masking mode — surfaced in the rule editor as:
| Editor mode | Behavior |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Replace** | The whole match is replaced with a fixed string (default `[REDACTED]`) |
| **Transform** | Format-preserving: keep the first/last N characters, mask the rest (an email variant keeps the first character and the domain) |
| **Custom** | A keep-pattern regex decides what survives; everything else is masked |
Masking is authored **per decision branch**: each `redact` branch of a rule carries its own set of directives, so one rule can mask account numbers on its medium-trust branch and everything on its low-trust branch. A rule supports up to 50 directives and 50 masking branches.
Masking is fail-closed end to end: a redact decision whose directives cannot be resolved is denied when the rule opts into fail-closed behavior, and if applying a mask throws at runtime, the SDK excludes the document rather than leaking it unredacted.
***
## Natural Language Rules
You can describe rules in plain English. Retrieval governance compiles them to policy using AI (the `POST /recall/rules/compile` endpoint, rate-limited to 10 requests per minute per organization). No policy-language expertise required.
**Example prompt:** "Deny tier3 agents from accessing any document classified as internal, confidential, or restricted."
The compiler reads your existing rules for context, validates that every condition is evaluable by the engine, and can save the rule directly. The dashboard rule editor offers the same NL-first authoring plus a visual condition builder and a **Simulate** panel that replays your rule against recent real traffic before you save.
Rules authored in the dashboard also pass a **simulated-interference gate**: a rule that would deny or escalate more than 5% of your recent real traffic is rejected (`INTERFERENCE_THRESHOLD_EXCEEDED`) until you make it more specific. Masking never counts as interference — a masked request still proceeds.
The compile endpoint supports streaming via `?stream=true` or `Accept: text/event-stream` for real-time feedback during rule compilation.
***
## Rule Evaluation Flow
When a retrieved document is evaluated, the engine walks rules from highest priority down. For each rule:
1. **Emergency bypass** — a rule under an active [emergency bypass](/rules/retrieval/emergency-bypass) returns `allow` (`EMERGENCY_BYPASS`) immediately.
2. **Principal exclusions** — an agent listed in the rule's `principal_exclusions` is denied (`PRINCIPAL_EXCLUDED`).
3. **Surface restriction** — a surface-scoped rule applies only when the input surface matches; otherwise the cascade skips it.
4. **Trust tier** — a rule with `trust_tier` set denies agents below that tier (`TIER_MISMATCH`).
5. **Policy conditions** — the rule's `rego_source` is evaluated; if a decision block matches, its decision is final. For rules without a surface restriction, a non-matching body falls through to the next rule — but a surface-scoped rule whose surface matches is always decisive: a non-allow body resolves to `SURFACE_RESTRICTION` rather than falling through.
A rule whose body doesn't match contributes nothing — its `default decision` declaration never decides the event, so per-rule defaults are inert and the cascade continues. When no rule matches at all, the decision is `deny` (`DEFAULT_DENY`, fail-closed).
The SDK evaluates the same cascade in-process against its cached bundle (`GET /rules/bundle`, a SHA-256-versioned bundle with `ETag`/`304` caching, refreshed in the background roughly every 5 seconds). With a `VISIQ_API_KEY` the harness reaches SaaS (`https://api.visiqlabs.com` by default) and confirms this bundle automatically — monitor-until-confirmed is only the brief pre-first-bundle window. On that cold start with no bundle loaded, an agent **confirmed in `enforce`** stays fail-closed, while a **never-confirmed** agent runs `monitor`; the no-match `DEFAULT_DENY` floor still applies wherever enforcement is active.
***
## Managing Rules
Rules can be created and managed via:
* **Dashboard**: Navigate to **Harness → Rules** at [app.visiqlabs.com](https://app.visiqlabs.com). The unified editor covers both action and retrieval rules — create manually, describe in natural language, or edit the visual condition graph.
* **REST API**: Use the rules endpoints documented in the [API Reference](/rules/retrieval/api-reference) for programmatic rule management, CI/CD pipelines, and bulk imports.
### Rule Priority Guidelines
| Priority range | Suggested use |
| -------------- | --------------------------------------------------------- |
| `100+` | Override rules — emergency denials that should always win |
| `50–99` | Standard rules for known patterns |
| `10–49` | Broad trust-tier and category rules |
| `1–9` | Catch-all rules |
| `0` | Default / lowest precedence |
# API Reference
Source: https://docs.visiqlabs.com/rules/unified/api-reference
Complete REST API reference for the unified rule surface — one CRUD collection over every operation, the SDK bundle, and the single evaluation call.
The unified rule surface is mounted under `/rules/*`, with the single evaluation call at `/evaluate`. The base URL is `https://api.visiqlabs.com`.
One rules collection spans every operation. Each rule declares an `operations[]` array (its stored `applies_to`) drawn from `action`, `retrieval`, and `delegation`, and the engine evaluates one event against the operations it carries.
The per-facet management endpoints — `/allow/rules` (action) and `/recall/rules` (retrieval) — are **additive, permanent compatibility aliases** over the same `rules` table, not deprecated surfaces. A rule created here appears there and vice versa; issued keys keep their existing grants. Use whichever surface fits your integration.
## Authentication
All endpoints require a Bearer credential: `Authorization: Bearer `. Requests without a valid credential receive `401 Unauthorized`.
Two credential audiences exist:
* **Harness keys** — the operational credential your SDK or harness runs with. On this surface a harness key covers exactly `GET /rules/bundle` and `POST /evaluate`; calling the rule-management endpoints (`GET/POST /rules`, `GET/PUT/DELETE /rules/:id`) with one returns `403 {"error": "harness_key_not_permitted"}`.
* **Management keys** — general automation credentials governed by explicit permission grants. They can call every endpoint on this page. Management keys are **launching soon**: they are visible in the dashboard under **Settings → API Keys**, but creating one is not yet enabled. Until then, drive the management endpoints from the dashboard, which authenticates with your session.
The management CRUD reuses the action-facet permissions (`allow_rules:view` / `:create` / `:update` / `:delete`), so a key or role that already manages action rules manages unified rules unchanged. Evaluation is scope-gated: `POST /evaluate` accepts the operation-native `rules:evaluate` **or** the legacy `allow:write` / `recall:write`; `full_access` satisfies everything.
### Rate limiting
Every API-key request passes a per-key sliding-window rate limit (default 600 requests per 60 seconds). Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers; exceeding the window returns `429` with a `Retry-After` header and body `{"error": "rate_limited", "detail": "API key rate limit exceeded.", "retryAfter": }`.
### List responses
The list endpoint returns the standard envelope:
```json theme={null}
{
"data": [ ... ],
"total": 128,
"page": 1,
"pageSize": 50
}
```
Pagination is controlled by `page` (default `1`) and `limit` (default `50`, max `100`) query parameters.
***
## Evaluation
***
### POST /evaluate
The single SDK-facing evaluation call for every governed event. The request declares which operations the event performs, and the response carries the union decision vocabulary — so one contract spans every facet. This is a pure projection onto the same plane handlers the per-facet evaluate endpoints use: there is exactly one decision implementation.
**Scope:** `rules:evaluate`, `allow:write`, or `recall:write`
**Request body (operation-native form):**
```json theme={null}
{
"operations": ["action"],
"agent_id": "billing-agent",
"target_app": "stripe.com",
"action": "POST /v1/charges",
"context": { "amount": 5000 }
}
```
| Field | Type | Required | Description |
| ------------------- | -------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `operations` | `string[]` | Yes | 1–3 of `action`, `retrieval`, `delegation` |
| `agent_id` | `string` | Yes | The agent making the request (1–255 chars) |
| `target_app` | `string` | When `operations` include `action`/`delegation` | Hostname or app identifier (1–255 chars) |
| `action` | `string` | When `operations` include `action`/`delegation` | The action string — HTTP method + path or tool name (1–255 chars) |
| `context` | `object` | No | Key-value context for action/delegation rule matching. Default `{}` |
| `operation` | `retrieve` \| `tool_call` \| `prompt_render` | No | Retrieval hook that produced the content. Default `retrieve` |
| `resource_type` | `string` | When `operations` include `retrieval` | Resource type being accessed (1–255 chars) |
| `resource_metadata` | `object` | No | Metadata for retrieval rule matching. Default `{}` |
| `trust_tier` | `string` \| `null` | No | Fallback trust tier for retrieval (an assigned tier overrides it) (max 64 chars) |
| `surface` | `string` \| `null` | No | Delivery surface for retrieval, matched exactly against surface-scoped rules (max 128 chars) |
| `query` | `string` \| `null` | No | The retrieval query string (max 4000 chars) |
| `telemetry` | `object` | No | Opaque client telemetry attached to the decision's record envelope |
A `delegation` operation projects onto the action facet (it therefore requires `target_app` and `action`), which runs the delegation-facet policy check — the `orchestrate.delegate` authority transfer. This is the stateless policy leg; the grant/token lifecycle lives on the [delegation endpoints](/rules/delegation/api-reference).
A legacy `kind`-discriminated form is still accepted and behaves identically: `{ "kind": "action", ... }` or `{ "kind": "retrieval", ... }`. The `operations[]` form is canonical.
**Response (single-operation):**
```json theme={null}
{
"operations": ["action"],
"decision": "permit",
"plane_decision": "permit",
"decision_id": "550e8400-e29b-41d4-a716-446655440000",
"reason": "Matched rule: Allow Stripe reads",
"reason_code": null,
"rule_code": "R-1042",
"enforced": true,
"agent_mode": "enforce"
}
```
| Field | Type | Description |
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------- |
| `decision` | `string` | The union outcome — see the table below |
| `plane_decision` | `string` | The facet handler's original word (e.g. a retrieval `allow`, before it maps to `permit`) |
| `decision_id` | `string` (UUID) \| `null` | The underlying decision's id, for audit and polling |
| `reason` / `reason_code` / `rule_code` | — | Explanation, machine-readable reason, and the matched rule code |
| `enforced` / `agent_mode` | — | Whether the decision was enforced, and the agent's server-authoritative mode |
| `redaction_rules` | `array` | Present when the outcome carries masking directives |
| `hitl_fallback` | `deny` \| `mask` | Present on an outcome that can fall back after a human timeout |
The union decision vocabulary:
| Outcome | Facet | Meaning |
| ------------------- | --------- | ------------------------------------------------------------------------------------ |
| `permit` | any | The event proceeds unchanged |
| `deny` | any | Blocked or suppressed |
| `approval_required` | action | Pauses for a human decision |
| `mask` | action | Proceeds with named arguments redacted |
| `redact` | retrieval | Proceeds with fields/patterns masked |
| `escalate` | retrieval | Recorded for review; passes through (masked if the rule chooses) rather than pausing |
**Hybrid events.** When `operations` includes both `action` and `retrieval`, both facets are evaluated and the response carries a per-operation `results[]` plus an overall `decision` equal to the **most restrictive** facet outcome (fail-closed combine):
```json theme={null}
{
"operations": ["retrieval", "action"],
"decision": "deny",
"reason": "...",
"reason_code": null,
"rule_code": "R-1099",
"results": [
{ "operation": "retrieval", "decision": "redact", "plane_decision": "redact", "...": "..." },
{ "operation": "action", "decision": "deny", "plane_decision": "deny", "...": "..." }
]
}
```
**Status codes:** `200 OK`, `400 Bad Request` (invalid body), `401 Unauthorized`, `403 Forbidden` (insufficient scope), `429 Too Many Requests`, `500 Internal Server Error`
***
### GET /rules/bundle
Fetch the compiled unified rule bundle for local evaluation — every enabled rule whose `applies_to` overlaps the action ∪ retrieval facets, plus the agent's mode and attributes. This is the bundle the tagged SDK runtime pulls; it caches it and revalidates with `If-None-Match` so decisions on the hot path never wait on the network.
**Permission:** `allow_rules:view` (a harness key passes the audience gate here)
**Query parameters:**
| Parameter | Required | Description |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_id` | Yes | The agent the bundle is compiled for — carries that agent's mode and attributes. An unknown id auto-provisions the agent in monitor mode |
**Request headers:**
| Header | Description |
| --------------- | ----------------------------------------------------------------------------------------------- |
| `If-None-Match` | ETag from a previous response. The server returns `304 Not Modified` if the bundle is unchanged |
**Response:**
```json theme={null}
{
"version": "a3b4c5d6e7f8...",
"dialect_version": 1,
"min_dialect": 1,
"agent_mode": "enforce",
"cognition_capture": false,
"agent_attributes": {
"trust_tier": "tier2",
"categories": ["transactional"],
"business_functions": ["finance_accounting"],
"business_function": "finance_accounting",
"blast_radius_tier": null,
"no_coverage": null
},
"rules": [ { "id": "rule-uuid", "applies_to": ["action"], "...": "..." } ],
"no_coverage": {
"no_coverage_defaults": { "read": "approve", "write": "approve", "delete": "approve", "admin": "approve" },
"autopilot_enabled": false,
"enduser_hitl_enabled": true,
"hitl_timeout_seconds": 120
}
}
```
| Field | Description |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | SHA-256 over the serialized payload — changes when any rule, the agent's mode/attributes, or the no-coverage settings change |
| `dialect_version` / `min_dialect` | The wire-contract dialect this bundle is compiled for, and the minimum an SDK must speak. A client below `min_dialect` refuses the bundle and fails closed |
| `agent_mode` | The agent's resolved mode (`enforce` \| `monitor` \| `off`) |
| `agent_attributes` | Server-authoritative attributes hydrated into `input.agent.*` for local evaluation |
| `rules` | Enabled effective rules overlapping the action ∪ retrieval facets, priority-descending, parsed by the SDK with the same parser the server uses |
| `no_coverage` | Your organization's no-coverage policy, so the SDK resolves uncovered events locally |
A shut-down agent (operator kill-switch) receives a **terminal bundle** — enforce mode, zero rules, all-deny no-coverage — so an SDK that honours the `shutdown` flag stops immediately, and one that ignores it still denies everything.
**Response headers:** `ETag` (quoted SHA-256), `Cache-Control: private, max-age=60`
**Status codes:** `200 OK`, `304 Not Modified`, `400 Bad Request` (missing/invalid `agent_id`), `401 Unauthorized`, `404 Not Found` (unknown agent after provisioning), `500 Internal Server Error`
***
## Rules
Management endpoints — a harness key receives `403 harness_key_not_permitted` here. One collection over every operation; the `applies_to` array on each rule decides which facets it governs.
***
### GET /rules
List rules, priority-descending, optionally filtered to a facet.
**Permission:** `allow_rules:view`
**Query parameters:** `page` (default `1`), `limit` (default `50`, max `100`), `operations` (optional CSV of `action`,`retrieval`,`delegation` — returns only rules whose `applies_to` overlaps the set)
**Response:**
```json theme={null}
{
"data": [
{
"id": "rule-uuid",
"rule_code": "R-1042",
"name": "Allow Stripe reads",
"description": "Permit read-only Stripe API calls",
"natural_language": "Allow my billing agent to read from Stripe",
"priority": 10,
"enabled": true,
"applies_to": ["action"],
"target_app": "stripe.com",
"action_pattern": "GET *",
"trust_tier": null,
"surface": null,
"principal_exclusions": null,
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z"
}
],
"total": 12,
"page": 1,
"pageSize": 50
}
```
The list omits `rego_source`; fetch a single rule to read the policy source.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `500 Internal Server Error`
***
### POST /rules
Create a rule. The `operations[]` array is stored as the rule's `applies_to`.
**Permission:** `allow_rules:create`
**Request body:**
```json theme={null}
{
"operations": ["action"],
"name": "Allow Stripe reads",
"description": "Permit read-only Stripe API calls",
"rego_source": "package rules\n\ndefault decision = \"deny\"\n...",
"natural_language": "Allow my billing agent to read from Stripe",
"priority": 10,
"enabled": true,
"target_app": "stripe.com",
"action_pattern": "GET *"
}
```
| Field | Type | Required | Description |
| ---------------------- | -------------------- | -------- | ---------------------------------------------------------------------------------------------- |
| `operations` | `string[]` | Yes | 1–3 of `action`, `retrieval`, `delegation` — stored (deduped, canonical order) as `applies_to` |
| `name` | `string` | Yes | Rule name, unique per organization (max 255 chars) |
| `description` | `string` | No | Description (max 1000 chars) |
| `rego_source` | `string` | Yes | Policy source (1–100000 chars) in the platform's Rego-subset condition language |
| `natural_language` | `string` | No | The plain-English intent the policy was compiled from (max 2000 chars) |
| `priority` | `number` | No | Evaluation priority — higher runs first. Default `0` |
| `enabled` | `boolean` | No | Whether the rule is active. Default `true` |
| `target_app` | `string` \| `null` | No | Action-facet: hostname/app the rule scopes to (max 255 chars) |
| `action_pattern` | `string` \| `null` | No | Action-facet: action glob the rule pre-filters on (max 255 chars) |
| `trust_tier` | `string` \| `null` | No | Retrieval-facet: minimum trust tier the rule requires (max 64 chars) |
| `surface` | `string` \| `null` | No | Retrieval-facet: exact-match delivery surface restriction (max 128 chars) |
| `principal_exclusions` | `string[]` \| `null` | No | Retrieval-facet: agent ids the rule excludes |
**Response (201):** the created rule object, including `rego_source`.
**Status codes:** `201 Created`, `400 Bad Request` (invalid JSON or body), `401 Unauthorized`, `409 Conflict` (a rule with this name already exists), `500 Internal Server Error`
***
### GET /rules/:id
Get a single rule by UUID, including `rego_source`.
**Permission:** `allow_rules:view`
**Status codes:** `200 OK`, `400 Bad Request` (invalid rule ID), `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
### PUT /rules/:id
Update a rule. Every field from `POST /rules` is accepted and optional; at least one is required (an empty body returns `400 No fields to update`). Only provided fields change; supplying `operations` rewrites `applies_to`.
**Permission:** `allow_rules:update`
**Response:** the updated rule object.
**Status codes:** `200 OK`, `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `409 Conflict` (duplicate name), `500 Internal Server Error`
***
### DELETE /rules/:id
Delete a rule permanently.
**Permission:** `allow_rules:delete`
**Response:** `{ "deleted": true }`
**Status codes:** `200 OK`, `400 Bad Request` (invalid rule ID), `401 Unauthorized`, `404 Not Found`, `500 Internal Server Error`
***
## Errors & conventions
Every endpoint on this page follows the platform-wide REST conventions — the validation-error body shape, the `/v1/` vs unversioned split, and the API stability policy. See [REST API conventions](/reference/rest-conventions).
# Unified Rules
Source: https://docs.visiqlabs.com/rules/unified/introduction
One rule surface and one evaluation call over every governed operation — action, retrieval, and delegation — folded into a single rules table and a single decision vocabulary.
Action, retrieval, and delegation are not three engines — they are one engine evaluating one event against the **operations** it carries. The unified surface is the operation-native way to manage that engine: a single `rules` collection whose every row declares which operations it applies to, and a single `POST /evaluate` call that spans every facet with one decision vocabulary.
The per-facet surfaces still exist and are **not** deprecated. The action (`/allow/*`) and retrieval (`/recall/*`) rule-management and evaluation endpoints are permanent, additive compatibility aliases over the same `rules` table. Use whichever surface fits — a rule created here is visible there, and vice versa.
***
## One rule, many operations
Every rule carries an `operations[]` array — its `applies_to` set, drawn from `action`, `retrieval`, and `delegation`. A rule can target one operation or several:
* A rule with `operations: ["action"]` governs tool calls.
* A rule with `operations: ["retrieval"]` governs retrieved context.
* A rule with `operations: ["delegation"]` governs agent-to-agent handoffs.
* A rule with `operations: ["retrieval", "action"]` governs a hybrid read-then-write tool with one decision.
Because all facets share one table, listing, creating, updating, and deleting rules is one set of endpoints — `GET/POST /rules`, `GET/PUT/DELETE /rules/:id` — with an optional `?operations=` filter to scope a listing to a facet.
***
## One decision vocabulary
The unified `POST /evaluate` call returns outcomes from the full union set:
| Outcome | Meaning |
| ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `permit` | The event proceeds unchanged |
| `deny` | The event is blocked or suppressed |
| `approval_required` | The event pauses for a human decision |
| `redact` | Retrieved content proceeds with fields masked |
| `escalate` | The retrieval is recorded for review; it passes through (masked, if the rule so chooses) rather than pausing |
| `mask` | An action proceeds with named arguments redacted |
A single-facet event resolves to one outcome. A **hybrid** event (for example `operations: ["retrieval", "action"]`) is evaluated on both facets and combined **most-restrictively** — a deny on either facet denies the whole event (fail-closed).
***
## One bundle for the SDK
The tagged SDK runtime pulls one bundle from `GET /rules/bundle` covering both the action and retrieval facets, and evaluates every event locally against it — the same policy interpreter the server runs, so local and remote decisions agree by construction. The bundle carries the agent's server-authoritative mode and attributes, a content-addressed `version` ETag, and a `min_dialect` floor: an SDK too old to honour the bundle's constructs refuses it and fails closed rather than mis-applying a restriction it cannot understand.
***
## Next steps
Unified rule CRUD, the SDK bundle, and the `POST /evaluate` call.
The action facet and its compatibility endpoints.
The retrieval facet and its compatibility endpoints.
The delegation facet and the grant lifecycle.
# Support
Source: https://docs.visiqlabs.com/support
How to reach the VisIQ team — email, the in-product dashboard, and where to file the details that get you a fast answer.
Need a hand? Here is how to reach the VisIQ team and what to include so we can
help quickly.
***
## Contact
Reach the VisIQ team at **[hello@visiqlabs.com](mailto:hello@visiqlabs.com)** for setup help, questions
about governance behavior, connector access, or anything else.
Manage keys, rules, agents, and your audit trail. Most day-to-day answers
live here — start with **Harness → Agents** and **Harness → Rules**.
If you're working with a VisIQ contact already (a solutions engineer or your
account team), reach out to them directly — they have your tenant's context.
***
## Before you reach out
A few details up front turn a back-and-forth into a single reply:
The rule or behavior you expected, and what the agent actually did (a tool
that should have been blocked ran, a document that should have passed was
suppressed, and so on).
Open **Harness → Runtime Enforcement** in the dashboard and find the decision
in the stream. The rule code (e.g. `[VisIQ decision=deny code=D-WRITE-DENY]`) and the agent id
tell us exactly which evaluation to look at.
The SDK and version (`@visiq/harness` / `visiq`), the framework you're
wrapping, and the agent's mode (`monitor` / `enforce` / `off`). Never paste a
full API key — the first 16 characters (`vq_prod_f3a91c0e…`) are enough to
identify it.
**Never share a full API key, or any secret, in a support request.** VisIQ stores
only a SHA-256 hash of each key and can identify one from its first 16 characters.
If you believe a key is exposed,
[rotate or self-revoke it](/automation/api-keys#revoke-your-own-key-self-revocation)
first, then tell us.
***
## Common answers first
Many questions have a direct reference:
How action governance evaluates tool calls, and what each outcome means.
How retrieval governance evaluates documents, redaction, and trust tiers.
Audiences, permissions, rotation, self-revoke, and the `4xx` error reference.
The outcomes, operation facets, modes, and audit primitives in one place.
# Troubleshooting
Source: https://docs.visiqlabs.com/troubleshooting
Symptom → cause → fix for the errors you are most likely to hit wiring up the VisIQ harness — bad keys, 401/403 responses, missing peer deps, and a silently ungoverned agent.
Most first-run problems come down to one of a few things: a missing or
wrong-audience key, an unset endpoint, an uninstalled peer dependency, or a
missing `OPENAI_API_KEY` for the sample agent. Find your symptom below.
**Symptom.** The harness (or a `curl`) gets `401` with body
`{"error":"Invalid API key or token"}`.
**Cause.** `VISIQ_API_KEY` is unset, empty, mistyped, or revoked — or you pasted
something that is not a VisIQ key (a harness key starts with `vq_prod_` or
`vq_test_`).
**Fix.** Mint a fresh **harness key** in the dashboard under **Settings →
Harness Keys**, then set it exactly:
```bash theme={null}
VISIQ_API_KEY=vq_prod_...
```
Keys are shown once at creation — if you lost it, roll a new one. Confirm there
is no trailing space or newline in your `.env`.
**Symptom.** A management call (rules, agents, audit log, settings) returns
`401` with body `{"error":"Unauthorized"}`.
**Cause.** No credential reached the route, or the session/token could not be
resolved to a tenant. This is the session/RBAC gate, distinct from the
key-validation `Invalid API key or token` above.
**Fix.** For the SDK path you never call management routes directly — the
harness only uses its operational endpoints, so this points at a hand-rolled
request. Send a valid credential, and remember that harness keys cannot reach
management routes at all (see the next item).
**Symptom.** A request returns `403` with body:
```json theme={null}
{
"error": "harness_key_not_permitted",
"detail": "This is a harness/agent API key. It is limited to the SDK operational routes and cannot access the management API. Use a management API key for this operation.",
"method": "GET",
"path": "/rules"
}
```
**Cause.** You used a **harness key** (`vq_prod_` / `vq_test_`) on a
**management** route (rules, agents, audit log, settings). Harness keys are
deliberately confined to the SDK's runtime endpoints — they cannot read or
write configuration.
**Fix.** Use a management API key for management calls, and reserve the harness
key for `VISIQ_API_KEY` in your agent. Management keys are minted separately;
until self-serve creation ships, drive those workflows from the dashboard (see
[Platform Automation](/automation/introduction)).
**Symptom.** A `Cannot find module 'langchain/agents'` (or similar) at import,
or the harness throws
`[VisIQ] Cannot detect agentic framework. Pass a LangChain AgentExecutor, …`.
**Cause.** The framework peer dependency is not installed, or it is the wrong
major version. `@visiq/harness` does not bundle any framework — you install the
one you use. LangChain in particular must be pinned: the sample uses
`langchain@^0.3` and `zod@^3` (LangChain 1.x moved `AgentExecutor`, and zod v4
schemas serialize in a way OpenAI rejects).
**Fix.** Install exactly the packages in your framework tab's install line, e.g.:
```bash theme={null}
npm install @visiq/harness "langchain@^0.3" "@langchain/openai@^0.3" "@langchain/core@^0.3" "zod@^3"
```
Then pass a supported target to `visiq()` — see the
[SDK Reference](/reference#framework-detection) detection table.
**Symptom.** The agent fails before any VisIQ decision with an OpenAI error
about a missing or invalid API key.
**Cause.** The quickstart's sample agents instantiate an OpenAI model
(`gpt-4o`), which needs `OPENAI_API_KEY`. This is unrelated to your VisIQ key.
**Fix.** Export it alongside `VISIQ_API_KEY`:
```bash theme={null}
OPENAI_API_KEY=sk-...
```
Any model provider works — swap the model import (e.g. `@ai-sdk/anthropic`) and
set that provider's key instead. VisIQ governs the tool calls regardless of
which model drives them.
**Symptom.** Your agent runs, but no decisions show up and nothing is ever
blocked — or, in Python, every tool call raises `ToolBlocked`.
**Cause.** The harness never reached a backend, so it never loaded a rule
bundle. The endpoint **defaults** to the managed SaaS host
`https://api.visiqlabs.com`, so the usual cause is a **missing `VISIQ_API_KEY`**
(with no key there's no backend to reach). The two SDKs then behave differently:
* **TypeScript** cold-starts in `monitor` (monitor-until-confirmed): every call
is observed but **nothing is blocked** — you get a silently ungoverned agent.
* **Python** (`Governor`) **fails closed**: with no bundle, `gate_tool` raises
`ToolBlocked("Governance unavailable — tool blocked (fail-closed, G001)")`
and `gate_documents` returns `[]`.
**Fix.** Set a `VISIQ_API_KEY` — with it, the harness reaches SaaS and loads a
bundle automatically:
```bash theme={null}
VISIQ_API_KEY=vq_prod_...
```
For **onprem / self-hosted** deployments also set `VISIQ_ENDPOINT`
(`https://api.visiqlabs.com` is not used there) — Python also accepts the
`VISIQ_BASE_URL` alias. Governance only takes effect once the harness reaches a
backend and loads a bundle.
**Symptom.** You ran the agent but it is not listed on the **Harness → Agents**
page.
**Cause.** One of: the harness never reached the backend (missing
`VISIQ_API_KEY` — or, onprem, an unreachable `VISIQ_ENDPOINT`); the run made no governed tool call yet, so
there was nothing to report; or it registered under an **auto-derived** id (your
`package.json` name, then hostname) that you didn't recognize.
**Fix.** Set all three of `VISIQ_API_KEY`, `VISIQ_ENDPOINT`, and an explicit
`VISIQ_AGENT_ID`, then run the agent once with a prompt that triggers a tool
call. The id you set is exactly what appears in the list — auto-provisioned in
**Monitor — Log only** mode on first contact.
## Still stuck?
Confirm the four things every wired-up agent needs, in order:
`VISIQ_API_KEY` is a `vq_prod_` / `vq_test_` key from **Settings → Harness Keys**.
`VISIQ_ENDPOINT=https://api.visiqlabs.com` (Python also accepts `VISIQ_BASE_URL`).
`VISIQ_AGENT_ID=support-bot` so the same agent shows up run to run.
`OPENAI_API_KEY` (or your chosen provider's key) so the sample agent can call its model.
With all four set, run the agent once and open the
[dashboard](https://app.visiqlabs.com) — the agent appears under **Harness →
Agents** and its decisions stream into **Harness → Runtime Enforcement**.
# SDK versioning & compatibility
Source: https://docs.visiqlabs.com/versioning
The VisIQ SDK versioning posture — pre-1.0 today (no wire-compatibility guarantee yet), the two engineering invariants we hold even now, and the compatibility window that takes effect at v1.0.
This page describes how the `@visiq/harness` SDK and its sibling packages
evolve: what a version number promises today, the invariants that hold
regardless of version, and the compatibility commitment that takes effect at
v1.0.
This is about the **SDK** — the client library you install (`@visiq/harness`,
`visiq`, and the other language bindings). The **OEM Partner API** has its own,
separate versioning contract with a dated version header and a 12-month
deprecation window — see [Versioning & deprecation](/partners/oem-versioning).
***
## Pre-1.0: no wire-compatibility guarantee yet
**The VisIQ SDK is pre-1.0 (0.x): there is no wire-compatibility guarantee
yet. Breaking changes may ship in any minor release until v1.0 GA.**
This is exactly what a `0.x` version already means under
[Semantic Versioning 2.0](https://semver.org/#spec-item-4): "anything MAY
change at any time; the public API SHOULD NOT be considered stable." A minor
bump (`0.2.0` → `0.3.0`) is allowed to remove or rename a field, change a
default, or tighten a shape — and our [published changelog](/changelog) does exactly that
(for example, `0.2.0` made `fleet.heartbeat()` require an argument that earlier
releases did not).
We are treating the pre-launch window as deliberate design time: **the point of
staying on 0.x is to get the wire contract right before we commit to it.** Once
a construct is in a v1.0 API it is expensive to change; pinning that contract
prematurely would trade a few months of convenience for years of carrying a
shape we later regret. So the posture is intentional, not an oversight.
**What this means for you today**
* **Pin an exact version** and upgrade deliberately. Don't float a range across
a minor boundary in an unattended pipeline.
* **Read the [changelog](/changelog) before upgrading.** Every breaking change is
called out under a `Breaking` / `Removed` heading in that package's changelog.
* Expect the surface to keep moving until the v1.0 line below is in effect.
***
## Two invariants we hold even at 0.x
"Pre-1.0" governs the *shape* of the SDK surface — the types, fields, and
function signatures you compile against. Two properties are **not** on the
table even while we are pre-1.0, because a governance product cannot be casual
about them. Both are enforced by the release process and proven continuously,
not left to reviewer diligence.
### 1. The wire contract fails closed, and the SDK ships first
The control plane and the SDK talk over a rule **bundle** — a document of
outcome verbs (`permit`, `deny`, `approval_required`, `mask`, `redact`,
`escalate`), fields, and multi-valued `operations[]` facets (`retrieval`,
`action`, `delegation`). Two rules govern that document forever:
* **Additive-only, SDK ships first.** The control plane never emits a bundle
construct — a new outcome verb, a new field, or a new `operations[]` facet —
that the reading SDK cannot already enforce. The SDK that understands a
construct is published *before* the plane starts emitting it. This is the
**tolerant-reader** principle applied in the safe direction: readers tolerate
what they don't need, but the writer never gets ahead of the readers on
anything load-bearing.
* **Fail closed on the unrecognized.** Every SDK treats a construct it does not
recognize as **MUST-UNDERSTAND**: it does not silently ignore it and proceed.
A feature-flag SDK can afford to skip an unknown flag; a governance SDK cannot
skip an unknown *restriction*. So an unrecognized outcome or an unparseable
bundle resolves to the fail-closed outcome for a confirmed-enforcing agent —
deny the action, suppress the document — never fail open. This is the same
fail-closed ladder the runtime already follows when a bundle is missing or a
mask fails (see [Local evaluation](/reference#local-evaluation)).
Together these mean a version skew between plane and SDK can degrade *coverage*
(an older SDK may not know about a brand-new rule facet) but can never degrade
*safety* (it will never wave through something it didn't understand). This
invariant is guarded by a forward-compatibility corpus — bundles carrying
not-yet-known constructs, which every SDK must resolve to the fail-closed
outcome — that runs on every release.
### 2. Every shipped plugin tracks the current stable host
The CLI plugins — [Claude Code](/quickstart/claude-code) and
[OpenClaw](/quickstart/openclaw) — are the one place our governance rides
inside another program's process. For each, the invariant is: the shipped
plugin both **registers with** and **actually enforces against** the current
stable release of its host. It is not enough to load — a plugin that installs
but no longer intercepts the host's tool calls is worse than none, because it
looks governed.
We verify this **daily against the real host binary**, not a mock: a canary
installs the current stable Claude Code and OpenClaw, drives a tool call that a
rule denies, and asserts the call was actually blocked and recorded. A host
release that moves an interception point turns the canary red before it can
reach a user.
***
## At v1.0: the compatibility window
**Not yet in effect.** The commitment below takes effect at the v1.0 GA
release. Until then the pre-1.0 posture above governs.
v1.0 is where the wire contract **freezes** and the SDK enters a supported
compatibility window. From GA:
* **We support any SDK released in the last 12 months.** A control plane will
interoperate with every SDK version published within the trailing twelve
months, across **all** the SDKs we distribute — TypeScript (`@visiq/harness`
on npm), Python (`visiq` on PyPI), Ruby (`visiq` on RubyGems), Java
(`com.visiqlabs:visiq-sdk` on Maven Central), and the Rust and Go bindings
distributed via git/module. You get a full year to adopt a new release, not a
flag day.
* **Breaking changes move to major versions.** After 1.0, a change that removes
or renames a surface, changes a default, or tightens a shape ships in the next
**major** (`2.0.0`), with the prior major supported through the window above.
Minors and patches stay additive and backward-compatible.
The model mirrors established practice rather than inventing one:
* **[SemVer 2.0](https://semver.org/)** — the pre-1.0 / post-1.0 split and the
major-for-breaking rule are SemVer verbatim.
* **[Kubernetes version-skew policy](https://kubernetes.io/releases/version-skew-policy/)**
— a bounded, published window in which older and newer components are
guaranteed to interoperate, so operators upgrade on their own schedule.
* **Tolerant-reader / schema BACKWARD & FORWARD compatibility** — the
additive-only, fail-closed-on-unknown discipline above is the standard
contract-evolution posture from schema-registry ecosystems (Avro/Protobuf),
applied in the direction a governance system requires.
Until v1.0, treat every minor as potentially breaking, pin exact versions, and
read the [changelog](/changelog). The two invariants above hold the whole way
there.