Gateway Proxy
Supported modes: Hosted Hybrid Available in: Free Solo Teams
Control Zero Gateway is a transparent proxy that sits between your AI agents and LLM providers. It intercepts every request, evaluates tool calls against your policies, and blocks unauthorized actions, all without changing your agent's code.
The gateway governs traffic at the LLM API boundary. Use the SDK for finer in-process control in a new application, or coding hooks to govern developer-tool actions inside Claude Code, Cursor, and Codex CLI.
How It Works
The gateway operates in two phases:
- Pre-flight (request guard): Before forwarding to the LLM, the gateway checks model blocking rules, estimates cost against budget caps, and scans for PII in prompts.
- Response interception: After the LLM responds, every
tool_use(Anthropic) orfunction_call(OpenAI) block is evaluated against your policies. Denied tool calls are replaced inline with a policy denial message. Both streaming and non-streaming responses are supported.
Quick Start
Change your LLM provider base URL to point to the Control Zero gateway:
Anthropic (Claude)
# Before
ANTHROPIC_BASE_URL=https://api.anthropic.com
# After
ANTHROPIC_BASE_URL=https://gateway.controlzero.ai
Add the headers:
X-ControlZero-API-Key: cz_live_xxx
X-ControlZero-Agent-ID: my-first-agent
X-ControlZero-API-Key(required) is your project key from the dashboard.X-ControlZero-Agent-ID(optional) labels the caller for audit attribution. Defaults to<provider>-direct(e.g.anthropic-direct) if omitted.
OpenAI
# Before
OPENAI_BASE_URL=https://api.openai.com
# After
OPENAI_BASE_URL=https://gateway.controlzero.ai/v1
Add the same Control Zero headers as above.
That is it. No SDK installation, no code changes. Your existing agent code keeps working. The gateway enforces your policies transparently.
What the agent receives when a tool call is blocked
When the gateway blocks a request, the agent receives an HTTP 403 response with a JSON body:
{
"error": "policy_denied",
"reason": "<human-readable reason from the matching policy>",
"policy_id": "<id of the policy that matched>"
}
Handle this in your agent code the same way you'd handle an API error from the LLM provider.
Features
Pre-flight Request Guard
Before forwarding to the LLM provider, the gateway runs these checks:
- Model blocking: Deny requests to unauthorized models (e.g., block agents from using expensive models).
- Cost estimation: Reject if estimated token cost exceeds your budget cap.
- PII detection, masking, and blocking: Detect, mask, or block PII in prompts. Under
maskthe gateway redacts each match and forwards the request with the redacted body -- it does not deny. Masking here is fail-closed: if redaction raises, or if it runs and leaves the body unchanged while PII was detected, the request is denied withDLP_BLOCKEDrather than forwarded. Two things can selectmaskon this path:CZ_GATEWAY_PII_ACTION=mask, or a policy bundle whosepii_profile.actionismask-- the bundle route means request masking can be turned on from the control plane without a gateway environment change.
If the policy engine is unavailable and fail_closed is enabled (the default), all requests are blocked. No silent failures.
Response-Side DLP
Response-side DLP scans what the model sends back — model output containing credit card numbers, national ID numbers, health records, or API keys — before that output reaches the caller. It runs on both response shapes: JSON bodies on the non-streaming path, and streamed SSE responses through a hold-back guard that withholds bytes across a chunk boundary until it has scanned across it, so a secret split over two chunks is not delivered ahead of detection.
CZ_GATEWAY_RESPONSE_DLP_ENABLED defaults to false, and none of the compose files shipped in this repository set it. Until you set it, model responses are not scanned — and CZ_GATEWAY_PII_ACTION alone does not turn it on. PII_ACTION selects what happens to a finding; it does not decide whether the response is looked at.
Request-side PII scanning is separate and is not gated by this flag.
CZ_GATEWAY_RESPONSE_DLP_ENABLED=true # the master switch
CZ_GATEWAY_PII_ACTION=block # what to do with a finding
While the switch is off, no finding can be produced for a response, so treat an empty finding list on a response as "not looked at" rather than "clean". The proxy marks each response response_dlp_scanned=false internally, but that marker is not currently persisted to the audit trail your dashboard queries -- the gateway's audit path forwards only a fixed set of fields to the backend, and this is not among them. Until it is, the switch itself is the only reliable answer to "was this response scanned".
Once enabled, CZ_GATEWAY_PII_ACTION selects what happens to a finding:
| Mode | Behavior |
|---|---|
detect | Log the finding in the audit trail but return the response unmodified. |
mask | Replace each match in the response body with a placeholder and deliver the redacted response. |
block | Withhold the matched content and everything after it, and return a policy denial. On a non-streaming response nothing is delivered at all. On a streaming response the clean prefix already sent cannot be recalled, but the hold-back buffer means the matched value itself is never delivered. |
If the response scanner itself raises, the gateway fails closed: it withholds the unscanned response and returns the standard governance error rather than delivering it. CZ_GATEWAY_RESPONSE_DLP_FAIL_OPEN=true is an explicit opt-out of that posture.
One gap to know about: on the non-streaming path, a 2xx body that is not valid JSON cannot be parsed, so it is delivered unchanged and unscanned — the fail-closed posture covers scanner errors, not unparseable bodies. The proxy marks that response unscanned internally, but as above that marker does not reach the audit trail today, so this gap is not visible to a dashboard query.
The pattern library ships 12 locale and category sets. CZ_GATEWAY_DLP_LOCALES defaults to default alone (28 patterns); adding eu, ja, ko, medical and secrets brings it to 59, and all 12 sets together resolve to 64 unique patterns. See DLP coverage and locales for the full list and how to enable a set.
Rate Limiting
The gateway enforces configurable rate limits at three scopes:
| Scope | Environment Variable | Default | Description |
|---|---|---|---|
| Per-user | CZ_GATEWAY_RATE_LIMIT_PER_USER | 100 | Maximum requests per minute per user identity. |
| Per-org | CZ_GATEWAY_RATE_LIMIT_PER_ORG | 1000 | Maximum requests per minute per organization. |
| Per-provider | CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER | 500 | Maximum requests per minute per upstream provider (fallback when no per-provider override is set). |
Per-provider overrides are supported via CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_<NAME>, for example CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_OPENAI=2000 or CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_ANTHROPIC=1500. When a request's provider has a specific override set, that value is used; otherwise the fallback CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER applies.
Rate limit state is stored in an in-memory cache (CZ_GATEWAY_REDIS_URL, default redis://localhost:6379 — Redis-compatible URL scheme) and shared across gateway instances using a 60-second sliding window. When the cache is unreachable, rate limiting fails open and requests are allowed through with a warning logged.
When a limit is exceeded, the gateway returns HTTP 429 with a Retry-After header along with X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
Correlation IDs
Every request processed by the gateway is assigned a correlation ID. If the caller includes an X-Request-ID header, the gateway preserves it. Otherwise, a new unique ID is generated.
The correlation ID is:
- Returned in the
X-Request-IDresponse header. - Included in every audit log entry for the request.
- Propagated to upstream LLM providers where supported.
Use correlation IDs to trace a single request across your agent, the gateway, and the LLM provider.
Prometheus Metrics
The gateway exposes a /metrics endpoint in Prometheus exposition format. Scrape it with any Prometheus-compatible collector.
Available metric families:
| Metric | Type | Description |
|---|---|---|
cz_gateway_requests_total | Counter | Total requests by provider, model, and status code. |
cz_gateway_request_duration_seconds | Histogram | Request latency distribution. |
cz_gateway_policy_evaluations_total | Counter | Policy evaluations by decision (allow/deny). |
cz_gateway_pii_detections_total | Counter | PII detections by type and direction (req/resp). |
cz_gateway_rate_limit_hits_total | Counter | Rate limit rejections by scope. |
cz_gateway_upstream_errors_total | Counter | Upstream provider errors by provider and status. |
cz_gateway_active_connections | Gauge | Current active connections. |
cz_gateway_identity_conflicts_total | Counter | Identity conflicts by field and resolution mode. See Which identity wins. |
/metrics is served by the gateway application itself, on the same port that
serves proxy traffic — port 8000 inside the container, for example
http://localhost:8000/metrics. There is no separate metrics port and no
switch to turn the endpoint off; it is registered unconditionally at startup.
Set CZ_GATEWAY_METRICS_ORG_LABEL=true to add an org_id label to the
auto-instrumented request metrics.
Scrape the /metrics endpoint with Prometheus and build Grafana dashboards from the counters and histograms above.
Tool Call Interception
After the LLM responds:
- Every
tool_useblock (Anthropic) orfunction_call(OpenAI) is evaluated against your policies. - Denied tool calls are replaced inline with a policy denial message.
- Each decision is logged separately for auditing.
- Both streaming and non-streaming responses are supported.
Supported Providers
| Provider | Gateway Path | Protocol |
|---|---|---|
| Anthropic (Claude) | /v1/messages | Anthropic Messages API |
| OpenAI (GPT) | /v1/chat/completions | OpenAI Chat Completions |
| Google AI (Gemini) | /google/v1beta/models/{model}:generateContent | Native Gemini (also :streamGenerateContent) |
| Ollama | /ollama/v1/chat/completions | OpenAI-compatible |
| DeepSeek | /deepseek/chat/completions | OpenAI-compatible |
| MoonshotAI | /moonshot/v1/chat/completions | OpenAI-compatible |
| HuggingFace TGI | /huggingface/v1/chat/completions | OpenAI-compatible (no tool interception) |
| Mistral | /mistral/v1/chat/completions | OpenAI-compatible |
| Cohere | /cohere/v1/chat/completions | OpenAI-compatible |
| AWS Bedrock | /bedrock/v1/chat/completions | OpenAI-compatible |
| Google Vertex AI | /vertex/v1/chat/completions | OpenAI-compatible |
| Azure OpenAI | /azure-openai/v1/chat/completions | OpenAI-compatible |
| Snowflake Cortex | /snowflake/v1/external-function | Snowflake external function |
Google AI, Ollama, DeepSeek, MoonshotAI, HuggingFace, Mistral, and Cohere are disabled by default. Enable them with environment variables:
CZ_GATEWAY_GOOGLE_ENABLED=true
CZ_GATEWAY_OLLAMA_ENABLED=true
CZ_GATEWAY_DEEPSEEK_ENABLED=true
CZ_GATEWAY_MOONSHOT_ENABLED=true
CZ_GATEWAY_HUGGINGFACE_ENABLED=true
CZ_GATEWAY_MISTRAL_ENABLED=true
CZ_GATEWAY_COHERE_ENABLED=true
Identity and Context Headers
| Header | Required | Description |
|---|---|---|
X-ControlZero-Agent-ID | Yes | Identifies the agent making the call |
X-ControlZero-API-Key | Yes | Control Zero API key (cz_live_ or cz_test_) |
X-ControlZero-Identity-Token | Optional | JWT from your identity provider. The gateway verifies the signature against your IdP's JWKS before using any claim. See Which identity wins. |
X-ControlZero-User-ID | Optional | User identifier for policy scoping. Caller-asserted and unsigned. |
X-ControlZero-User-Group | Optional | User group for RBAC policy evaluation. Caller-asserted and unsigned. |
Which identity wins
Policy rules match on the resolved user_id and user_group, so when two
sources supply the same field it matters which one the gateway believes.
The two sources are not equivalent:
X-ControlZero-Identity-Tokenis verified. The gateway fetches your IdP's JWKS, verifies the token signature against the matching signing key, and pins the accepted algorithms toRS256andES256. The issuer and audience are checked against your configured IdP. A token that fails any of those checks is rejected and contributes no claims at all. This verification runs on every provider proxy route that resolves identity: Anthropic, OpenAI, Google, Azure OpenAI, Bedrock, Vertex AI, and the shared OpenAI-compatible route that serves Ollama, DeepSeek, MoonshotAI, HuggingFace, Mistral, and Cohere. It is a signature check rooted in your IdP, not a convenience header.X-ControlZero-User-IDandX-ControlZero-User-Groupare not verified. Whatever the caller types is what the gateway receives. There is no signature to check.
Resolution is controlled by the CZ_GATEWAY_IDENTITY_PRECEDENCE environment
variable on the gateway. It is a deployment setting, not a dashboard setting:
there is no control-plane surface that changes it. On Hosted, Control Zero
operates the gateway, so this is not a value you set — the checks below are
for a gateway you run yourself, in Hybrid or self-managed mode.
| Value | What the gateway does |
|---|---|
enforce | The verified claim wins. The unsigned header is discarded for any field the token asserts. |
observe | The unsigned header wins, which is how the gateway behaved before this change. |
Conflicts are detected identically in both modes. Detection is a comparison of the distinct values each source supplied, run after resolution and independent of it, so switching modes changes which identity policy receives, never whether the disagreement is noticed.
The gateway refuses to start on an unrecognised value rather than run in an identity posture that cannot be named. A typo does not silently fall back to either mode.
The built-in default and the shipped compose value are not the same today
This is the part to read carefully before assuming which mode you are running.
- The built-in default in the gateway code is
enforce. That is the value used whenCZ_GATEWAY_IDENTITY_PRECEDENCEis unset or empty. - Both compose files we ship set
CZ_GATEWAY_IDENTITY_PRECEDENCE=observeexplicitly — the production compose and the on-prem compose. An explicit value always beats the built-in default.
So a deployment brought up from our compose files runs observe, and reaches
the built-in enforce only if you remove or override that line. Do not infer
your mode from the default; read it off the running gateway.
Check the boot log first. The gateway announces the posture before it accepts traffic, and prints the effective mode, the raw env value, and the built-in default on one line:
docker logs <gateway-container> 2>&1 | grep 'identity precedence mode='
A gateway started from our compose prints:
identity precedence mode=observe (env CZ_GATEWAY_IDENTITY_PRECEDENCE='observe', default=enforce)
An empty env ...='' in that line means nothing was configured and the
built-in default applied. If the grep prints nothing at all, the log may have
rotated past startup — restart the container or fall back to reading the
value the live process holds:
docker exec <gateway-container> printenv CZ_GATEWAY_IDENTITY_PRECEDENCE \
|| echo "unset -- the built-in default (enforce) applies"
printenv exits non-zero and prints nothing when the variable is unset, so
the || branch is what keeps "unset" distinguishable from "I could not read
it". A .env or compose file on disk is not an answer to this question: only
the running process is.
How to see whether any of your callers are affected
Two channels report identity conflicts today. Both work; use either.
1. The Prometheus counter. cz_gateway_identity_conflicts_total is
incremented once per conflicting field per request, labelled by field and by
mode. The gateway serves /metrics on the same port that serves proxy
traffic — port 8000 inside the container, not a separate metrics port:
curl -sf http://localhost:8000/metrics > /tmp/gw-metrics.txt \
|| echo "SCRAPE FAILED -- no conclusion available"
# Positive control: 1 means the counter is registered and the scrape worked.
grep -c '^# TYPE cz_gateway_identity_conflicts_total' /tmp/gw-metrics.txt
# The samples. No lines here means the counter has never been incremented.
grep '^cz_gateway_identity_conflicts_total{' /tmp/gw-metrics.txt
Run the two greps together. A labelled Prometheus counter emits no sample line
until it first fires, so "no output" from the second grep is meaningful only
when the first prints 1. Without that control, a failed scrape and a clean
fleet look the same.
There is no Prometheus scrape job for this counter in the compose files we
ship. If you want history rather than a point reading, point your own
Prometheus at the gateway's /metrics.
2. The gateway log. Every conflict is also logged as a warning naming the fields that disagreed, the source the identity was resolved from, and the mode:
docker logs <gateway-container> 2>&1 | grep 'Identity conflict on'
# Positive control: prove you are reading a non-empty log.
docker logs <gateway-container> 2>&1 | wc -l
The gateway container uses Docker's json-file driver with rotation in both
compose files we ship (default 50m per file, 5 files kept), so this log is
a rolling window, not an archive.
identity_conflictEarlier guidance on this page told you to query your audit rows for the
identity_conflict tag and read the result as your answer. That instruction
cannot work on the audit sink we ship, and we are retracting it.
The gateway forwards a fixed set of fields to the backend audit ingest. A
small number of tags are lifted into first-class wire fields — provider,
model, input_tokens, output_tokens, and a set of DLP and failover keys —
and every remaining tag is dropped because it has no wire field to land in.
identity_conflict is one of the dropped ones. It is computed, it is attached
to the in-process record, and it does not reach the audit row your dashboard
queries.
An audit query for identity_conflict therefore returns zero on every
deployment, whether or not a single conflict occurred. That is "not
recorded", not "none happened", and the two are not interchangeable.
Until the tag has a wire field, use the counter or the log above.
A zero conflict count tells you that no conflicting call was observed on the channel you looked at, over the window that channel retained. It does not tell you that no caller depends on header override.
Both channels are bounded. The Prometheus counter lives in the gateway process and resets to zero on restart, redeploy, or a scale event, and unless you are scraping it into a time-series database you are reading one instant. The container log rotates. Neither is a complete record of your traffic since the feature shipped.
The traffic itself is also uneven. A caller that sends the override only from
a monthly batch job, a failover path, a rarely used tenant, or a code branch
that was idle while you looked will produce a zero and still change behaviour
the moment you switch to enforce.
Do not read an empty result as proof that switching modes is a no-op. That inference is wrong today and it stays wrong after the audit tag lands — a better recording channel widens the window, it never turns an absence into a proof. Treat a zero as a reason to proceed carefully with a rollback ready, not as a clearance.
Configurations that are not affected either way
Most documented setups never produce a conflict, and behave identically under both modes. You are in the clear if any of these describes your callers:
- Header only, no identity token. With no verified claim there is nothing
to outrank, so the header is the identity and resolves as
caller_header. This path is by design and byte-identical in both modes. It is also the largest documented population. Of the fifteen blueprints in the Blueprints library, nine scope policy by user identity, and every one of them does it through the unsigned header or the group field — not one of the fifteen sends anX-ControlZero-Identity-Token. Every blueprint we publish is therefore in this unaffected set as written. - Identity token only, with no
X-ControlZero-User-IDorX-ControlZero-User-Group. One source, nothing to resolve against. - Both sent, and they agree. Two sources asserting the same string is not a conflict — there is nothing for a reviewer to adjudicate. No tag, no counter increment, same resolved identity in both modes.
- Neither header nor token sent. No identity fields are supplied at all.
- Only
X-ControlZero-Agent-IDandX-ControlZero-API-Key.agent_idis read straight into the audit record's agent column and is never subject to identity precedence. - A token that authenticates but asserts nothing on a field. An empty
claim asserts nothing, so the unsigned header remains the only source for
that field — in
enforceexactly as inobserve. These calls carry anidentity_token_silent_fieldstag, because "the token said nothing" and "there was no token" are different facts. That tag is subject to the same sink limitation asidentity_conflictbelow: it is computed, and it does not reach the audit row your dashboard queries.
A conflict requires all three conditions at once: a token that verifies, a claim present on the field, and an unsigned header supplying a different value for that same field.
Switching modes
Set the value per deployment and restart the gateway:
CZ_GATEWAY_IDENTITY_PRECEDENCE=enforce # verified claim wins
CZ_GATEWAY_IDENTITY_PRECEDENCE=observe # unsigned header wins (pre-change behaviour)
Moving to enforce is a behaviour change for any caller that was relying on
the header to override a verified claim: those calls begin matching policy
rules under the signed identity instead. Confirm with the counter and the log
above before you switch, keep the ability to set observe back, and watch the
first minutes of traffic rather than the diff.
Fail-Closed Mode
If the gateway cannot reach the Control Zero backend, or the policy bundle is expired or tampered with, it blocks ALL requests by default. This is controlled by the CZ_GATEWAY_FAIL_CLOSED setting (default: true).
The gateway also runs periodic integrity self-checks on the loaded policy bundle (configurable via CZ_GATEWAY_INTEGRITY_CHECK_INTERVAL_SECONDS, default: 60s). If the bundle checksum fails, traffic is blocked and an alert is sent.
Tamper Detection
Policy bundles are encrypted at rest and cryptographically signed. The gateway verifies the signature and checksum on every load and at regular intervals. Tampering triggers fail-closed mode and an alert.
Audit Logging
Every proxied request is logged to the append-only audit trail with:
- Provider, model, and token usage
- Tool calls detected in the response
- Policy decisions (allow/deny) for each tool call
- Latency, status codes, and error information
- Agent ID and user identity context
Run the gateway yourself (Hybrid)
In Hybrid mode you run the gateway container in your own infrastructure while it still talks to the Control Zero SaaS backend (api.controlzero.ai). Deploying the entire Control Zero stack on your own infrastructure (self-managed / air-gap) is not covered here -- contact us; install and operator steps ship with your package.
The gateway runs as a Docker container:
docker run -d \
-p 8000:8000 \
-e CZ_GATEWAY_CZ_API_KEY=cz_live_xxx \
-e CZ_GATEWAY_CZ_BACKEND_URL=https://api.controlzero.ai \
-e CZ_GATEWAY_ANTHROPIC_API_KEY=sk-ant-xxx \
-e CZ_GATEWAY_OPENAI_API_KEY=sk-xxx \
controlzero/gateway:latest
Environment Variables
All gateway settings use the CZ_GATEWAY_ prefix:
| Variable | Default | Description |
|---|---|---|
CZ_GATEWAY_CZ_API_KEY | (required) | Your Control Zero API key |
CZ_GATEWAY_CZ_BACKEND_URL | http://control-zero-backend:8080 | Control Zero backend URL |
CZ_GATEWAY_ANTHROPIC_API_URL | https://api.anthropic.com | Anthropic upstream URL |
CZ_GATEWAY_ANTHROPIC_API_KEY | (empty) | Anthropic API key (injected if set) |
CZ_GATEWAY_OPENAI_API_URL | https://api.openai.com | OpenAI upstream URL |
CZ_GATEWAY_OPENAI_API_KEY | (empty) | OpenAI API key (injected if set) |
CZ_GATEWAY_FAIL_CLOSED | true | Block all traffic when policies unavailable |
CZ_GATEWAY_ENFORCE_TOOL_POLICIES | true | Enforce policies on tool calls (false = shadow mode) |
CZ_GATEWAY_ENFORCE_LLM_POLICIES | true | Enforce pre-flight checks (model/cost/PII) |
CZ_GATEWAY_PII_ACTION | detect | What to do with a PII finding: detect, mask, or block. On the response path mask redacts in place; see Response-Side DLP |
CZ_GATEWAY_RESPONSE_DLP_ENABLED | false | Master switch for response-side DLP. Off means responses are not scanned |
CZ_GATEWAY_RESPONSE_DLP_FAIL_OPEN | false | Deliver an unscanned response if the response scanner raises |
CZ_GATEWAY_POLICY_REFRESH_INTERVAL_SECONDS | 300 | How often to re-pull policies |
CZ_GATEWAY_POLICY_MAX_AGE_SECONDS | 86400 | Max bundle age before fail-closed |
CZ_GATEWAY_ALERT_WEBHOOK_URL | (empty) | Slack webhook for tamper/failure alerts |
CZ_GATEWAY_DLP_LOCALES | default | Comma-separated DLP locales (see Locale-Aware DLP) |
CZ_GATEWAY_RATE_LIMIT_PER_USER | 100 | Per-user rate limit (requests per minute) |
CZ_GATEWAY_RATE_LIMIT_PER_ORG | 1000 | Per-org rate limit (requests per minute) |
CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER | 500 | Per-provider rate limit fallback (requests per minute) |
CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_<NAME> | (unset) | Per-provider override (e.g. _OPENAI, _ANTHROPIC) |
CZ_GATEWAY_REDIS_URL | redis://localhost:6379 | URL of the in-memory cache backing the sliding-window rate limiter |
CZ_GATEWAY_METRICS_ORG_LABEL | false | Add an org_id label to auto-instrumented request metrics |
CZ_GATEWAY_IDENTITY_PRECEDENCE | enforce (compose ships observe) | Which identity source wins. See Which identity wins |
CZ_GATEWAY_GOOGLE_API_KEY | (empty) | Google AI API key (injected if set) |
CZ_GATEWAY_MISTRAL_API_KEY | (empty) | Mistral API key (injected if set) |
CZ_GATEWAY_COHERE_API_KEY | (empty) | Cohere API key (injected if set) |
The gateway listens on port 8000 inside the container. That is fixed in the
image's start command, not an environment variable — publish it on whatever
host port you like with Docker's -p flag or a compose ports: entry.
Shadow Mode
Set CZ_GATEWAY_ENFORCE_TOOL_POLICIES=false to run in shadow mode. The gateway evaluates every tool call against policies and logs the decision, but does not modify responses. Use this to audit what would be blocked before enabling enforcement.
Docker Compose
services:
cz-gateway:
image: controlzero/gateway:latest
ports:
- '8000:8000'
environment:
CZ_GATEWAY_CZ_API_KEY: cz_live_xxx
CZ_GATEWAY_CZ_BACKEND_URL: https://api.controlzero.ai
CZ_GATEWAY_ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
CZ_GATEWAY_OPENAI_API_KEY: ${OPENAI_API_KEY}
restart: unless-stopped
Gateway vs SDK
| Gateway | SDK | |
|---|---|---|
| Code changes | None. Change base URL only | Install package, wrap tool calls |
| Works with | Any agent that calls LLM APIs | Python, Node.js |
| Enforcement point | Network layer (proxy) | Application layer (in-process) |
| Latency | Network hop to gateway | Local, in-process evaluation |
| Best for | Existing agents, quick rollout | New agents, tightest integration |
Both approaches enforce the same policies defined in your dashboard. You can use them together. The gateway handles LLM-level enforcement while the SDK handles application-level tool governance.
Multi-tenant mode: per-request API keys
For platforms proxying requests on behalf of multiple Control Zero customers, each request can carry its own project API key via the X-ControlZero-API-Key header. The gateway resolves project context per request and applies the corresponding policy bundle, cached for 5 minutes.
curl https://gateway.controlzero.ai/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "X-ControlZero-API-Key: cz_live_tenant_specific_key" \
-H "anthropic-version: 2023-06-01" \
-d '{"model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [...]}'
When the header is absent, the gateway falls back to its configured CZ_GATEWAY_CZ_API_KEY.
Next Steps
- Quick Start: Get up and running in 5 minutes.
- Policies: Learn how to write policies.
- Locale-Aware DLP: Configure region-specific PII detection patterns.
- Governing MCP tool calls: Govern MCP server and tool access.
- CLI Scanner: Scan projects for governance gaps in CI/CD.