GOPURA

Guard — policy for chatbots

Ring-fence an assistant to your own subject matter, forbid whole capabilities, and hand off to a human — enforced on the key, not in the request.

Shipping a chatbot on a public page means answering an awkward question: what stops a visitor from using it as a free coding assistant, talking it into something embarrassing, or coaxing out the system prompt?

Gopura Guard is a policy that lives on the API key. Every request that key makes is enforced against it — the ring-fence holds even if the key leaks or the frontend is compromised, because the policy was never in the request in the first place.

What it does

{
  "version": 1,
  "scope": "questions about Acme's products, orders, shipping and returns",
  "deny": ["code", "medical"],
  "systemPrompt": "You are Acme's support assistant. Acme sells outdoor furniture.",
  "refusal": "I can only help with Acme orders — let me pass you to a colleague.",
  "escalate": { "enabled": true },
  "maxOutputTokens": 500
}

Set that on a key in the admin console and every /v1/chat/completions call made with it is governed. There is no client-side change and no new endpoint — your existing OpenAI SDK code keeps working.

A blocked turn comes back as a normal 200 so a chat widget keeps rendering:

{
  "choices": [{
    "message": { "role": "assistant", "content": "I can only help with Acme orders — let me pass you to a colleague." },
    "finish_reason": "content_filter"
  }],
  "gopura_guard": { "action": "blocked", "rule": "deny.code", "escalate": true },
  "usage": { "cost": 0.00014 }
}

escalate: true is your cue to route the conversation to a human. Set guard.onBlock: "error" instead if you would rather have a 403.

Where to read the verdict

gopura_guard is a top-level field, a sibling of choices — not inside the message, and not inside choices[0]. It is present on every guarded response, blocked or not, so the same code path reads it either way:

CaseWhere gopura_guard appears
Non-streamingTop level of the response body, beside choices and usage
StreamingTop level of the final SSE chunk — the one carrying usage and an empty choices: []
onBlock: "error"Top level of the 403 body, beside error
resp = client.chat.completions.create(model="openai/gpt-5.4", messages=[...])
guard = getattr(resp, "gopura_guard", None)          # SDKs keep unknown fields
if guard and guard["action"] == "blocked" and guard.get("escalate"):
    hand_off_to_human()

The object itself is small and stable:

FieldTypeMeaning
action"allowed" | "blocked" | "flagged"flagged = monitor mode saw a violation and let it through
rulestring, optionalwhich rule fired, e.g. deny.code, scope, banned_pattern
escalateboolean, optionalhand this conversation to a human
messagestring, optionalthe escalation message from your policy

The policy is on the key, not in the request

This trips people up, so plainly: you cannot send a policy in the request body. A key with no policy is unguarded, and no request can change that — which is the entire point, since a request-supplied ring-fence is one that an attacker who has your key can simply omit.

Requests may only ever tighten a policy the key already carries — lowering maxOutputTokens, or adding to deny. Anything that would loosen the key's policy is ignored rather than rejected, so a caller cannot widen its own ring-fence by accident or on purpose.

Policies are set per key in the admin console. The full schema is published as JSON Schema at /schemas/key-policy.json.

How it is enforced

Three layers, cheapest first.

Static rules, free. maxOutputTokens clamps the caller's max_tokens, and systemPrompt is prepended server-side where the caller cannot strip or replace it.

A guard classifier, ~$0.14 per 1,000 turns. When scope is set, one cheap call judges the incoming turn against your ring-fence before your model is ever dispatched — so a blocked turn costs a fraction of a cent instead of a full completion. It runs only when scope is set, because that is the only part of a policy that needs semantic judgement; a deny: ["code"] policy is handled by the next layer for free.

The classifier is not deterministic, even at temperature zero. In our own measurements roughly one in twelve clearly in-scope questions drew a block on some runs — so a block is always confirmed by a second, independent judgement, and only stands if both agree. Turning away a real customer is the expensive mistake, and forbidden output is caught deterministically no matter what the classifier says. Ordinary traffic never pays for the second opinion; only a turn that was going to be blocked does, which is why a blocked turn costs about $0.29 per 1,000 rather than $0.14.

Measured on a live ring-fenced assistant after that change: 15 of 15 in-scope product, shipping and returns questions answered, and 8 of 8 attacks — code requests, off-topic freeloading, instruction override and a persona jailbreak — blocked.

A deterministic output scan, free. Fenced code blocks, unmistakable source syntax, verbatim recital of your system prompt, and any regex you supply in bannedPatterns. This layer is the one we actually stand behind: a model can be argued with, a regex over what the model produced cannot. On a streamed response the scan runs as tokens arrive and aborts mid-flight.

RuleEnforced byTalk-out-able?
deny: ["code"]output scanno
bannedPatternsoutput scanno
system-prompt leakageoutput scanno
scopeguard classifier (confirmed)it is a model — treat as strong, not absolute
deny (medical, legal, roleplay, …)guard classifier (confirmed)same

What we do not claim

We do not claim immunity to prompt injection, and you should be sceptical of anyone who does. Published research demonstrates evasion of detectors in this class. What Gopura Guard gives you is layered enforcement where the strongest layer is deterministic, a policy an attacker cannot reach, an audit trail of every blocked turn, and a red-team corpus we run in CI so regressions surface before you meet them.

Two limits worth stating plainly. On a streamed response in the default scan mode, tokens already sent cannot be recalled — the scanner fires on the opening code fence, before any code follows it, but if you need zero leakage set guard.stream: "buffer" and trade time-to-first-token for it. And when the output scan catches an answer, the upstream call already happened, so it is billed; what changes is that the caller receives your refusal instead.

Tuning a new policy

Start in monitor mode. Violations are recorded and reported in gopura_guard with action: "flagged", but the answer still ships — so you can watch real traffic for a day before anything gets blocked.

{ "version": 1, "scope": "…", "guard": { "mode": "monitor" } }

Switch to "block" when the flagged turns look right.

Failure behaviour

If the guard classifier is unreachable, the default is to block: a key that asked for a ring-fence should not start answering without one. Set guard.onError: "allow" if availability matters more to you than containment for that particular assistant.

Tightening per request

A request may carry its own policy object, and it is merged tighten-only — it can add denied categories, narrow the scope, lower the token ceiling and turn the guard up. It can never remove a denial, widen the scope, or downgrade block to monitor. That is what makes the key-level policy a floor rather than a default.

Picking a model

For a ring-fenced assistant, instruction adherence and a graceful refusal matter more than raw capability, and cost matters because volume is high. google/gemini-3.6-flash and anthropic/claude-haiku-4.5 are the usual picks; reach for a frontier model only if the subject matter genuinely needs it. Guard works with any chat model, including gopura/auto.