Integration
The gateway, the MCP proxy, the check API, the Python SDK, the Aginies integration, webhooks and the approval API, your own detector.
Updated: October 2026The OpenAI-compatible gateway
The shortest path: the application’s base URL points at the gateway and the API key becomes a tenant-bound thp_live_… key. The model name is a tenant alias defined in the console; the upstream URL and credential are sealed there and never visible to the client.
- base_url = "https://api.openai.com/v1"
+ base_url = "https://thulpar.corp.internal:8053/v1"
+ api_key = "thp_live_…" # scope: gateway, bound to the tenant
model = "gpt-4o-prod" # a tenant alias from Gateway → Models
# optional headers
x-thulpar-app-id: support-bot # selects the app pack
x-thulpar-conversation-id: c-91 # conversation state for the conversation rail- Routes: /v1/chat/completions, /v1/completions, /v1/responses, /v1/embeddings; GET /v1/models lists the aliases. An Anthropic Messages upstream can be defined as a row too.
- The input stage runs before a single byte reaches the upstream: deny is 403 thulpar_blocked, hold 409 thulpar_hold. Tool outputs in the request get their own tool_result pass; the model’s proposed tool calls are checked before the client sees them when the pack asks.
- On buffered answers the output stage runs before the answer is written; on SSE sentence by sentence. A pack that masks the answer holds each sentence until its verdict; the first token arrives one sentence later.
- The stream frame contract: stopped content sends a {"retract"} frame (fromOffset, reason) and masked text arrives as redactedText. A {"prepend"} frame is written only to a reader that sets options.prependFrames; an older reader may fail on an unknown frame.
- Egress: the gateway reaches only the upstreams defined in the console; a stored credential never silently follows a row’s URL to a new host.
The MCP proxy
The client talks to /mcp/:server (Streamable HTTP) with an mcp-scoped key instead of to the tool server. On every tools/list answer the schemas are hashed and compared with the pinned version; a changed tool is quarantined and the diff waits for approval in the console. Instructions such as “always call” in descriptions are stripped.
- tools/call arguments pass the tool_call stage’s rails: Cedar, schema, size, SSRF, injection, sink labels; results pass the indirect-injection, PII and secrets rails at tool_result (in windows; refused above the cap).
- Images in a tool result go through the file pipeline when the pack runs file.analyze.
- The OAuth 2.1 resource-server rule: audience separation, no token passthrough. For a protected upstream Thulpar is a confidential client of the authorization server the tenant names; optional token exchange (RFC 8693) acts on behalf of the caller.
- Client authentication can use private_key_jwt instead of a shared secret: the key pair is generated in Thulpar, the private key sealed, the public key published at the gateway’s JWKS address; a rotated key stays published for 24 hours.
- When the IdP supports dynamic registration (RFC 7591) Thulpar registers itself; deleting the server deletes the registration too (RFC 7592). A client secret about to expire is flagged on the page beforehand.
POST /mcp/crm HTTP/1.1
Authorization: Bearer thp_live_… # scope: mcp
Content-Type: application/json
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "export_customers", "arguments": { "segment": "vip", "format": "csv" } } }
→ { "jsonrpc": "2.0", "id": 7,
"error": { "code": -32001, "message": "held for approval",
"data": { "decision": "hold", "approvalId": "apr_01J…", "ledgerId": "evt_01J…" } } } The check API
POST /v1/check is the single hook every adapter and customer application calls: stage, context (tenantId, appId, agentId, conversationId, labels) and content (text, messages, toolCall, toolResult, file, trace; optional attachments and sources). The answer carries decision, reason, actions, findings, labels, ledgerId, packVersion and the rails run and skipped. The key has the check scope.
POST /v1/check
{ "stage": "tool_call",
"context": { "tenantId": "tnt_acme", "appId": "ops-agent", "agentId": "payments-v3",
"labels": { "integrity": "untrusted", "confidentiality": "pii", "pii": ["iban"] } },
"content": { "toolCall": { "name": "bank:transfer",
"arguments": { "iban": "TR12…", "amount": 4800, "currency": "TRY" } } },
"options": { "mode": "enforce", "deadlineMs": 300 } }
→ 200 { "decision": "hold",
"approval": { "id": "apr_01J…", "state": "pending", "profileName": "four-eyes",
"expiresAt": "2026-10-19T11:40:00Z" },
"findings": [ { "railId": "tool.authz", "category": "tool.authz.hold", "severity": "medium" } ],
"ledgerId": "evt_01J…", "packVersion": "acme-banking@1.2.0", "latencyMs": 9 }- In shadow the answer is allow_with_findings and enforcedDecision / enforcedActions say what enforce would have answered; nothing is held and no licence transaction is counted.
- With options.speculative=true a 202 {ticket} returns at once; the caller may start generation but must read GET /v1/check/{ticket} before releasing the first output token. options.mode=audit answers 202 {ledgerId} and evaluates later.
- content.sources ({id, text, title?, uri?}; at most 50, 16 KiB each, 256 KiB in all) feed the groundedness rail at the output stage; unsupported sentences become findings with sourceIds. Sources are referenced, never judged.
- Files go through POST /v1/check/file: the x-tenant-id, x-mode (enforce|shadow) and x-surface (the upload surface) headers; the answer can also collapse to {clean, reason}.
- A retry with the same options.idempotencyKey answers the same decision and ledgerId with no second event or transaction. 413 body ceiling, 429 rate limit (Retry-After), 402 licence, 503 overload (a shed event).
- A held check resumes with POST /v1/check/resume or by re-sending the request; GET /v1/approvals/{id}/wait is the long poll.
The Python SDK
pip install thulpar: the check API 0.3.x client; sync and async with the same surface. It does the time budgets, the circuit breaker, applying a decision to text, releasing a stream sentence by sentence, holding for an approval and resuming, trajectory normalisation and canary tokens on the client side. It never raises on the check path and never logs.
from thulpar import Thulpar, apply_decision
t = Thulpar(url="https://thulpar.corp.internal:8051", api_key="thp_live_…",
tenant_id="tnt_acme", app_id="support-bot", mode="enforce")
d = t.check("input", user_text, context={"conversationId": conv_id})
if d.blocks: # deny, hold, reask — or a closed posture while Thulpar is down
reply(d.reason)
else:
text = apply_decision(d, user_text) # redactions and rewrites applied
guarded = t.guard_stream(model_stream, messages=conversation)
for piece in guarded: # one cleared sentence (or its redaction) at a time
send_to_client(piece)
store(guarded.result.record_text) # the text of record, never the raw answer| Field | Meaning |
|---|---|
| passes | allow, allow_with_findings, escalate: the content goes on as it is |
| rewrites | redact, rewrite: the content goes on changed; apply_decision(d, text) applies it |
| blocks | deny, hold, reask: nothing goes on |
| held / approval | a hold opened an approval; resume by re-sending the request once approved |
| pending | a 202: no decision yet (a speculative ticket, an audit job); t.ticket() reads the result |
| ok / source | ok when Thulpar answered; otherwise source names the failure and decision the posture applied |
| enforced_decision | what enforce would have decided, in shadow |
- Every failure is a decision too: deadline, network, 5xx and malformed answers apply the stage’s posture (input, tool_call, file closed; the rest open) and count on the breaker; 429 affects that request only; 400/401/402/403 are configuration errors, closed by default.
- One breaker per Thulpar URL: after five consecutive failures it opens for 30 seconds (or Retry-After), then admits a single probe. In shadow a failure is always allow.
- Streams: in enforce a sentence is released once its verdict arrives; deny with its retract, hold and rewrite cut the answer there; in shadow everything is released as it arrives and the verdict is awaited in the background.
The Aginies integration
In the Aginies-embedded profile the Platform, Hub, Dialog, Voice and Code adapters call the check API from the executor hooks: the workflow core, the provider request, tool execution, knowledge search and before the stream tee. Policy applies from those hooks whatever the graph; a thulpar block is sugar, never the only control.
- Identity is Platform SSO; a local admin only as break-glass. The Platform supplies identities and the tenant list, never the licence: the licence file is in Thulpar’s console and names the Platform tenants too.
- A hold lands on the Platform’s approval card; the run resumes where it paused. The same console, ledger and evidence packs.
- The adapters use an installation-scoped API key: one key serves several tenants, and every check is still judged with that tenant’s pack, licence and chain.
- The Platform can end a user’s or a tenant’s Thulpar sessions at once (logout, disable, role change).
Webhooks and the approval API
Webhook endpoints per tenant subscribe to events: a hold opened, an approval decided or expired, an escalate finding returned by the async judge, a benchmark published. Every delivery is signed with HMAC-SHA256 (X-Thulpar-Signature) and carries the event and delivery ids; private and metadata addresses are subject to the egress rule.
- GET /v1/approvals/{id}: the approval’s state (pending, approved, denied, expired), profile and outcome; GET /v1/approvals/{id}/wait?timeoutMs= is the long poll.
- Approval is given in the console’s inbox: four eyes needs two different approvers, the profile’s fallback applies when the SLA expires (deny or allow with a note), the escalation target is notified.
- A user in the approver role sees only the console’s inbox; every approval decision is its own ledger event.
Your own detector
Any HTTP service implementing the Detector API is a detector: the organisation’s own fine-tuned classifier, a vendor model or an open guard. The request and response shape follows the FMS Guardrails Orchestrator detector API; Thulpar adds an info endpoint and a normalised result. Registration is done in the console; tenant detectors pass the egress rule.
GET /api/v1/info
{ "id": "acme-fraud-intent", "version": "2026.10.1", "class": "policy_judge", "grade": "B",
"languages": ["tr", "en"], "rails": ["self_check.input"],
"capabilities": ["contents", "chat"], "maxTokens": 2048, "latencyP50Ms": 60 }
POST /api/v1/text/contents
{ "contents": ["…"], "detector_params": { "language": "tr", "threshold": 0.7 }, "deadline_ms": 300 }
→ [ [ { "start": 12, "end": 41, "detection": "fraud_intent", "detection_type": "policy_judge",
"score": 0.91, "text": "…" } ] ]- Endpoints: GET /api/v1/info (id, version, class, grade, languages, rails, capabilities), POST /api/v1/text/contents (text segments → spans), /text/chat (a message list), /text/stream (a stateful sentence session); /file/analyze and /image/contents for file and image detectors.
- A rail binds to the detector by class and grade; for rails the class does not imply, the rails field lists the implemented rail ids. Every call carries a deadline_ms; a busy or unavailable detector says so with a 503 and is recorded with the rail’s posture.
- Detectors keep no tenant data at rest and are reached only from the api/gateway; optionally over mTLS and with the Detector API key.