AGT + Provedit · Integration reference

AGT → Provedit over OTLP

Microsoft Agent Governance Toolkit v3.5.0 shipped OTelLogsBackend, a built-in audit backend that emits every AuditEntry as an OpenTelemetry LogRecord. Provedit accepts those records on an OTLP/HTTP endpoint, re-decides policy on a different trust boundary, and appends both verdicts to a hash-linked chain. This is observability and compliance evidence, not a network gate. AGT decides and runs in-process; Provedit records and re-judges after the fact.

Prerequisites

Endpoint

POST https://api.provedit.ai/otlp/v1/logs
Authorization: Bearer pvk_…
Content-Type:  application/json

Standard OTLP/HTTP path. An OTel Collector configured with endpoint: https://api.provedit.ai/otlp appends /v1/logs itself. Direct exporters must use the full URL above.

v1 is JSON-only. Set encoding: json on your exporter. Protobuf returns 415 Unsupported Media Type with a pointer at this knob; we'll add protobuf when a customer needs it.

Recipe A: AGT → OTel Collector → Provedit (recommended)

Run a Collector (sidecar or host agent). It handles batching, retries, queueing, and protocol negotiation, so a Provedit outage never costs you audit records.

1. Attach the OTel backend to AGT

from agent_os.audit_logger import GovernanceAuditLogger
from agent_os.audit_logger.otel import OTelLogsBackend

audit = GovernanceAuditLogger()
audit.add_backend(OTelLogsBackend())   # reads OTEL_EXPORTER_OTLP_* env vars

2. Configure the Collector

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:
    timeout: 5s
    send_batch_size: 100

exporters:
  otlphttp/provedit:
    endpoint: https://api.provedit.ai/otlp
    encoding: json
    headers:
      Authorization: "Bearer ${PROVEDIT_API_KEY}"
    sending_queue:
      enabled: true
      queue_size: 5000
    retry_on_failure:
      enabled: true
      max_elapsed_time: 5m

service:
  pipelines:
    logs:
      receivers:  [otlp]
      processors: [batch]
      exporters:  [otlphttp/provedit]

3. Point AGT at the Collector

export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4318/v1/logs
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

Recipe B: AGT direct OTLP exporter

No Collector. AGT's OTelLogsBackend posts to Provedit directly. Simpler to stand up; you lose Collector-side queueing and any failure mid-batch is on you to retry.

export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://api.provedit.ai/otlp/v1/logs
export OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_LOGS_HEADERS="Authorization=Bearer ${PROVEDIT_API_KEY}"

Verify with curl

Hand-craft an ExportLogsServiceRequest and post it. Useful for first-time setup, CI smoke tests, and proving credentials work before debugging an exporter.

curl -sS -X POST https://api.provedit.ai/otlp/v1/logs \
  -H "Authorization: Bearer $PROVEDIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceLogs": [{
      "scopeLogs": [{
        "logRecords": [{
          "timeUnixNano": "'"$(date +%s)000000000"'",
          "body": { "stringValue": "{\"hash\":\"'"$(openssl rand -hex 32)"'\",\"agent_id\":\"smoke-bot\",\"action\":\"smoke.curl\",\"decision\":\"allow\",\"context\":{\"note\":\"hello\"}}" },
          "attributes": [
            { "key": "event.type",          "value": { "stringValue": "policy_decision" } },
            { "key": "agent.id",            "value": { "stringValue": "smoke-bot" } },
            { "key": "governance.action",   "value": { "stringValue": "smoke.curl" } },
            { "key": "governance.decision", "value": { "stringValue": "allow" } }
          ]
        }]
      }]
    }]
  }'

A clean accept returns {}. Partial failures return { "partialSuccess": { "rejectedLogRecords": N, "errorMessage": "…" } }. The chain entry should appear in the operator console timeline within a second.

Field mapping

Provedit reads the agt.* attribute namespace and the JSON-encoded AuditEntry body. Attributes outside this namespace are ignored by the receiver (no crash, but not stored). Anything under agt.audit.meta.* is preserved verbatim under params._agt.meta.

Source (OTLP)Destination (Provedit action)
attribute governance.actiontool (required; missing → record rejected as partialSuccess)
attribute agent.idactor.agentName, actor.vendor = "agt"
attribute event.typeactionKind: policy_decision → invocation, tool_call|tool_result → result, other → event
attribute governance.decisionparams._agt.decision (hint; Provedit re-decides)
attribute governance.latency_msparams._agt.latencyMs
attribute agt.audit.meta.session_idsessionId
attribute agt.audit.meta.*params._agt.meta.* (prefix stripped, verbatim)
body .context (or .metadata)params (top level; hashed into the chain)
body .hashparams._agt.entryHash (also intra-batch dedupe key)
body .previous_hashparams._agt.previousEntryHash
observedTimeUnixNano (else timeUnixNano)requestedAt

Choose a policy for the agent key

Every pvk_ key is bound to one named policy. Because the OTLP path is post-hoc, that binding only decides what kind of signal you want from Provedit's re-decision, not whether traffic flows.

You can pick when minting the key in app.provedit.ai/account/agent-keys, or switch later by minting a new key bound to a different policy and rotating it in your Collector / env.

Why the policy lives off the host

AGT's policy is a file that ships next to the agent. That works for honest operators, but it is the same threat model as an EDR whose exclusion list lives on the endpoint: whoever has write access to the box can quietly loosen a rule, then run the action, then put the rule back. The on-host audit log records what the on-host policy said at the time, which is exactly what the attacker just edited.

Provedit holds the policy in the control plane. The agent key (pvk_) is bound to a policy name; the active version of that policy is fetched, version-pinned, and re-evaluated server-side for every record that arrives over OTLP. The host never sees the policy text and cannot edit it. The chain entry commits to the policy version id, so an auditor can prove which rules were in force when each action ran, not which rules the host claimed were in force.

Net effect: the host can still lie about what AGT decided. It cannot lie about what the central policy would have decided, and it cannot quietly forget that the two disagreed. Both verdicts ride on the same hash-linked entry.

Two verdicts, one of them post-hoc

AGT decides and executes in-process, then emits the LogRecord. By the time Provedit sees it, the tool has already run. We are not in the call path; we cannot block, queue, or require approval for something that already happened.

What we do is re-decide on a different trust boundary and stamp the verdict on the chain. AGT's verdict lives at params._agt.decision. Provedit's lives at policyDecision. Both are hashed into the same entry, both signed by the chain. The pair is the artefact: compliance evidence that two independent policy engines saw the same record and agreed (or didn't).

Mismatches are the high-value rows. Both verdicts ride on the same chain entry, so a query against GET /v1/actions (or the chain export) that compares params._agt.decision with policyDecision surfaces every case where AGT's local policy let a call through that your central Provedit policy would have rejected, or vice versa. These are the records to alert on, retro-review, and tighten policy from. There is no approval workflow on a mismatch; the action already ran.

If you want Provedit in the call path, blocking before execution, use the SDK or the MCP proxy instead. See /docs. The OTLP receiver is for the case where you've already standardised on AGT and want a tamper-evident system of record with a second policy opinion attached.

Failure modes

ResponseMeaningCollector behaviour
200 {}All records accepted and on-chain.Discard batch.
200 partialSuccessSome records rejected (e.g. missing governance.action). errorMessage names the reasons.Discard rejected records; does not retry. Fix your emitter.
401Bearer token missing or wrong.Retries until backoff cap, then dead-letters.
409 no_active_policyThe agent key is bound to a policy that has no active version on this tenant.Retries. Fix in app.provedit.ai/policies.
415Content-Type was protobuf. Set encoding: json on the exporter.Will keep failing until config changes.
429 + Retry-AfterGlobal rate limit hit (600 req/min/IP).Honors Retry-After; standard OTel backoff.
503Backend temporarily unavailable.Retries with backoff.

Provedit does not gate AGT's tool calls; we are a record, not an interceptor. A network outage between AGT and Provedit means delayed entries, never blocked operations. The OTel Collector's sending_queue + retry_on_failure are what keep records from being lost during the outage.

Rate limits & quotas

OTLP traffic shares the global rate limit of 600 requests per minute per source IP. One Collector pushing batches almost never bumps it; a misconfigured loop will. 429 responses include both the draft-7 RateLimit-* headers and a classic Retry-After in seconds, which the OTel Collector honors by default.

One OTLP batch counts as one HTTP request regardless of how many LogRecords it contains. One LogRecord counts as one action against your plan's monthly action include. Plan limits and overage policy live on the pricing page.

Verify the chain

OTLP entries are indistinguishable from direct POST /v1/actions entries on the chain. The same verifier replays them:

npx @provedit/verifier verify --from 1 --to 1000

See Verify the chain in the SDK docs for the full check list, anchor format, and CI workflow.