CL
Clausgap
/Docs

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.

text
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

FieldTypeDescription
agent_role*stringMust match the agent_role in your policy
agent_idstringStable identifier for this agent instance. Used for per-agent rate limit tracking.
session_idstringGroups decisions into a session for the forensic trail
confidencefloatModel confidence in the action (0.0–1.0). Compared in escrow when confidence < N rules.
step_indexintStep number within the current session
model_idstringModel 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

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

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.

bash
pip install clausgap-agents

The 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.

python
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")
OptionTypeDescription
proxy_url*strBase URL of the clausgap-proxy instance (HTTP listener)
signing_secret*strShared HMAC secret — matches proxy.yaml hmac_secret
agent_role*strMust match the agent_role field in your policy
agent_idstrStable identifier for this agent instance. Used for per-agent rate limit tracking.
agent_versionstrVersion string logged in forensic events
on_escrowcallableCalled 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.

bash
npm install @clausgap/sdk

Same architecture as the Python SDK. Escrow polling is automatic — the SDK re-sends the original request once released. Requires Node ≥ 18 (uses native fetch).

typescript
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.

text
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.

CodeMeaningAgent action
200Allowed — forwarded to upstreamUse the response.
403Denied — policy blocked the actionDo 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.
423Locked (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.
429Rate limit exceededRespect Retry-After header.
503Proxy unavailable or no policies loadedRetry with exponential back-off. Check /readyz on the proxy.