Direct HTTP integration
For an agent that isn't built on one of the supported frameworks — or any tool reachable over HTTP. Route requests through the proxy and identify the agent with a signed context header, either by hand or with the core SDK.
Context signing
Every request your agent sends through the proxy must include an X-Clausgap-Context header. The proxy uses this to identify the agent, match it against policies, and log the decision. The header is HMAC-SHA256 signed with the shared secret so the proxy can verify it wasn't forged.
Header format
Each header is signed for one request: the signature covers the method, the path and query exactly as sent, a SHA-256 of the body and a random nonce. A header can't be reused, or moved to a different request — the proxy refuses it.
X-Clausgap-Context: v2.<base64-payload>.<hex-signature>.<unix-timestamp>.<hex-nonce>
Components:
base64-payload — standard base64 of a JSON object (see fields below)
unix-timestamp — current Unix epoch seconds (must be within ±30 s of proxy clock)
hex-nonce — 16+ random bytes as hex, new for every request
hex-signature — HMAC-SHA256(secret, message) as hex, where message is these
lines joined with "\n":
clausgap-context-v2
<unix-timestamp>
<hex-nonce>
<METHOD, upper case>
<path + "?query" exactly as sent, e.g. /web_search?q=Q3+revenue>
<hex SHA-256 of the request body — of empty bytes if none>
<base64-payload>Payload fields
| Field | Type | Description |
|---|---|---|
| agent_role* | string | Must match the agent_role in your policy |
| agent_id | string | Stable identifier for this agent instance. Used for per-agent rate limit tracking. |
| session_id | string | Groups decisions into a session for the forensic trail |
| confidence | float | Model confidence in the action (0.0–1.0). Compared in escrow when confidence < N rules. |
| step_index | int | Step number within the current session |
| model_id | string | Model identifier, e.g. "gpt-4", "claude-3-5-sonnet" |
The timestamp must be within ±30 seconds of the proxy's clock, and each nonce is accepted once. The older three-part header (no v2. prefix) is refused unless the proxy sets legacy_context_until during a migration.
Manual signing — Python
import base64, hashlib, hmac, json, secrets, time
def make_context_header(
*,
method: str,
target: str, # path + "?query", exactly as you will send it
body: bytes = b"",
agent_role: str,
agent_id: str = "",
session_id: str = "",
confidence: float = 1.0,
step_index: int = 0,
model_id: str = "",
secret: str,
) -> str:
payload = json.dumps({
"agent_role": agent_role,
"agent_id": agent_id,
"session_id": session_id,
"confidence": confidence,
"step_index": step_index,
"model_id": model_id,
}, separators=(",", ":")).encode()
b64 = base64.b64encode(payload).decode()
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
message = "\n".join([
"clausgap-context-v2", ts, nonce, method.upper(), target,
hashlib.sha256(body).hexdigest(), b64,
])
sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
return f"v2.{b64}.{sig}.{ts}.{nonce}"
# Usage — sign the exact target you send
import requests
target = "/web_search?q=Q3+revenue"
header = make_context_header(
method="GET",
target=target,
agent_role="analyst",
agent_id="a-001",
session_id="sess-abc",
confidence=0.92,
secret="my-hmac-secret", # matches proxy.yaml hmac_secret
)
resp = requests.get("http://localhost:8443" + target, headers={"X-Clausgap-Context": header})Manual signing — TypeScript
import { createHash, createHmac, randomBytes } from "crypto"
function makeContextHeader({
method,
target, // path + "?query", exactly as sent
body = "",
agentRole,
agentId = "",
sessionId = "",
confidence = 1.0,
stepIndex = 0,
modelId = "",
secret,
}: {
method: string
target: string
body?: string
agentRole: string
agentId?: string
sessionId?: string
confidence?: number
stepIndex?: number
modelId?: string
secret: string
}): string {
const payload = JSON.stringify({
agent_role: agentRole,
agent_id: agentId,
session_id: sessionId,
confidence,
step_index: stepIndex,
model_id: modelId,
})
const b64 = Buffer.from(payload).toString("base64")
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = randomBytes(16).toString("hex")
const message = [
"clausgap-context-v2", ts, nonce, method.toUpperCase(), target,
createHash("sha256").update(body).digest("hex"), b64,
].join("\n")
const sig = createHmac("sha256", secret).update(message).digest("hex")
return `v2.${b64}.${sig}.${ts}.${nonce}`
}
// Usage — sign the target fetch will send (fetch normalizes URLs)
const url = new URL("http://localhost:8443/web_search?q=Q3+revenue")
const header = makeContextHeader({
method: "GET",
target: url.pathname + url.search,
agentRole: "analyst",
agentId: "a-001",
sessionId: "sess-abc",
confidence: 0.92,
secret: process.env.HMAC_SECRET!,
})
const resp = await fetch(url, { headers: { "X-Clausgap-Context": header } })Python SDK
Private beta. The SDK packages aren't on the public registries yet. Access comes with onboarding — email hello@clausgap.dev.
pip install clausgap-agentsThe SDK handles HMAC signing, escrow polling, and rate-limit errors automatically. Your agent code never sees a 423 — it either gets the upstream response or a typed exception.
from clausgap_agents import Clausgap, PolicyDenied, EscrowTimeout, RateLimited
gate = Clausgap(
proxy_url="http://localhost:8443",
signing_secret=os.environ["CLAUSGAP_HMAC_SECRET"],
agent_role="analyst", # must match policy agent_role
agent_id="a-001", # stable agent instance ID
# Optional: called when a 423 escrow hold fires (before polling)
on_escrow=lambda data: print("Escrow hold:", data["escrow_id"]),
)
# Open a session — all requests share the same session_id in the forensic trail
with gate.session("sess-001") as sess:
sess.set_confidence(0.92)
sess.record_tokens(prompt=320, completion=88)
try:
# Signs and injects the context header automatically.
# If the proxy returns 423, polls until released then re-sends.
resp = sess.http.get("/web_search", params={"q": "Q3 revenue"})
print(resp.json())
except PolicyDenied as e:
print(f"Denied: {e.reason} (decision_id={e.decision_id})")
except EscrowTimeout as e:
print(f"Escrow {e.reason}: {e.escrow_id}")
except RateLimited as e:
print(f"Rate limited — retry after {e.retry_after}s")| Option | Type | Description |
|---|---|---|
| proxy_url* | str | Base URL of the clausgap-proxy instance (HTTP listener) |
| signing_secret* | str | Shared HMAC secret — matches proxy.yaml hmac_secret |
| agent_role* | str | Must match the agent_role field in your policy |
| agent_id | str | Stable identifier for this agent instance. Used for per-agent rate limit tracking. |
| agent_version | str | Version string logged in forensic events |
| on_escrow | callable | Called when a 423 escrow hold fires, before polling begins. Receives the full 423 response body. |
TypeScript SDK
Private beta. The SDK packages aren't on the public registries yet. Access comes with onboarding — email hello@clausgap.dev.
npm install @clausgap/sdkSame architecture as the Python SDK. Escrow polling is automatic — the SDK re-sends the original request once released. Requires Node ≥ 18 (uses native fetch).
import { Clausgap, PolicyDenied, EscrowTimeout, RateLimited } from "@clausgap/sdk"
const gate = new Clausgap({
proxyUrl: "http://localhost:8443",
signingSecret: process.env.CLAUSGAP_HMAC_SECRET!,
agentRole: "analyst",
agentId: "a-001",
// Optional: called when a 423 escrow hold fires (before polling)
onEscrow: (data) => console.log("Escrow hold:", data.escrow_id),
})
const session = gate.createSession("sess-001")
session.setConfidence(0.92)
session.recordTokens({ prompt: 320, completion: 88 })
try {
// Signs and injects the context header automatically.
// 423 → polls until released, then re-sends.
const resp = await session.http.get("/web_search", { params: { q: "Q3 revenue" } })
console.log(resp.json())
} catch (e) {
if (e instanceof PolicyDenied) console.error("Denied:", e.reason, e.decisionId)
else if (e instanceof EscrowTimeout) console.error("Escrow", e.reason, e.escrowId)
else if (e instanceof RateLimited) console.error("Rate limited, retry after", e.retryAfter, "s")
else throw e
}
session.close()Escrow flow
When a policy's escrow rule fires, the proxy returns 423 Locked with an escrow_id and a status_url. The agent polls that URL — on the proxy, identified exactly as for any request — until approvers release the hold, then sends the same request again with X-Clausgap-Escrow-Id. Every SDK above does this for you; this is what happens underneath.
Agent Proxy Control Plane
│ │ │
│── POST /submit_payment►│ │
│ │── policy: escrow ──────────►│
│◄─ 423 {escrow_id, │◄─ {escrow_id, hold_type} ──│
│ status_url} ────│ │
│ │ [approvers vote in the dashboard, Slack or Teams]
│── GET /_clausgap/escrow/{id} ►│── GET /v1/escrow/{id} ►│
│◄─ {status: "released"} ───────│◄──────────────────────│
│ │ │
│── POST /submit_payment►│ │
│ X-Clausgap-Escrow-Id: esw_... │
│ │── verify + consume ────────►│
│◄─ 200 (upstream resp) ─│ │The status endpoint only answers an agent about its own holds — anyone else's, or an unknown ID, is 404. A released hold is consumed exactly once, by the agent that requested it, for exactly the request that was approved (method, path, query, agent headers and body): a changed request is refused with HASH_MISMATCH, another agent with AGENT_MISMATCH, a replay with ALREADY_CONSUMED.
The SDKs stop waiting as soon as waiting can't help: a rejection raises EscrowRejected (a subclass of EscrowTimeout, so existing handlers still catch it), and an expired, cancelled or unknown hold raises EscrowTimeout with the reason.
Proxy response codes
When using the HTTP proxy directly, these are the status codes your agent may receive. The SDK handles 202, 423, and 429 automatically — they will only surface as exceptions, never as raw responses.
| Code | Meaning | Agent action |
|---|---|---|
| 200 | Allowed — forwarded to upstream | Use the response. |
| 403 | Denied — policy blocked the action | Do not retry without a different approach. The body names the policy_id and the reason (e.g. explicit_deny, no_allow_rule); the SDKs raise PolicyDenied with both. |
| 423 | Locked (escrow hold) | Poll status_url (GET /_clausgap/escrow/{id} on the proxy, as the same agent); when status=released, resend the same request with X-Clausgap-Escrow-Id. Stop on rejected, expired or cancelled. |
| 429 | Rate limit exceeded | Respect Retry-After header. |
| 503 | Proxy unavailable or no policies loaded | Retry with exponential back-off. Check /readyz on the proxy. |