AI Gateway API
Manage and observe the Prisma AIRS AI Gateway — the SCM-managed proxy that routes an app's LLM traffic through a workspace, applying provider configs, guardrails, and API keys, and recording telemetry for every request.
How it works
The AI Gateway is configured and provisioned entirely through Strata Cloud Manager (SCM) — there is no separate onboarding flow. The SDK's AIGatewayClient reads and writes the same resources the SCM console does:
- Workspaces — the top-level container (
client.workspaces). A workspace'sslugis what telemetry queries key on; itsidis what config-plane resources key on. They are not interchangeable. - Telemetry — usage, cost, latency, tokens, cache, feedback, and raw request logs, all read-only (
client.telemetry). - Config plane resources — gateway configs, guardrails, provider bindings, and API keys, all scoped to a workspace (
client.configs,client.guardrails,client.providers,client.apiKeys). - Admin plane resources — organisation-level provider integrations, MCP server integrations, deployments, plugins, org/auth settings, and audit logs — not scoped to a single workspace (
client.integrations,client.mcpIntegrations,client.deployments,client.plugins,client.organisations,client.auditLogs).
AIGatewayClient exposes 17 SCM sub-clients, spanning two API planes on one credential set, plus the separately authenticated inference runtime client:
- the data plane (
/ai_gw/v2) — telemetry and workspace-scoped config - the admin plane (
/ai_gw/admin/v2) — organisation-level config
Both planes share one OAuth2 token; what differs is the SCM role scope each plane authorizes against, which is the first thing to get right (see Authorization below).
MCP servers, policies, and exports
The local September 2026 expansion adds four workspace-scoped data-plane clients:
| Client | Operations | Live boundary |
|---|---|---|
mcpServers | CRUD, test, capabilities, user access, connections | CRUD and read/user-access workflows verified; test and connection revocation returned 403 |
usageLimits | Policy CRUD, entities, entity reset | Isolated-policy CRUD verified; reset needs a traffic-derived owned entity |
rateLimits | Policy CRUD | Isolated-policy lifecycle verified |
logExports | List/create/get/update/start/cancel/download | Draft lifecycle verified; start returned 500, so completion/download remain unverified |
const servers = await gw.mcpServers.list({ workspace_id: workspace.id, page_size: 10, current_page: 0 });
const policies = await gw.usageLimits.list({ workspace_id: workspace.id });
const exports = await gw.logExports.list({ workspace_id: workspace.id });
New query options use documented snake_case, unlike some older convenience options. Unknown
query keys fail locally; zero and false are preserved. Stable request objects are strict, while
explicit metadata/configuration extension maps accept finite JSON. Responses preserve unknown
fields. Portkey catalog strings are not assumed to be exhaustive SCM enums.
Guardrails support target: 'mcp_tools' and getMcpServers(), syncMcpServers(), and
upsertMcpServer(). MCP integrations expose legacy and v2 workspace-binding reads. Integration
model deletion requires explicitly named custom models; SCM rejects deletion of base models.
New integrations may have last_updated_at: null; disabled global workspace access can omit its
limit fields. Model catalog flags accept the observed boolean and numeric 0/1 forms.
Provider catalog and self-hosted endpoints (SDK 0.33.0)
gw.integrations.catalog() reads the static provider catalog
(/utils/static-resources/ai-providers, 77 families such as open-ai, x-ai, azure-openai,
bedrock); gw.integrations.resolveProviderId('x-ai') turns a slug into the ai_provider_id
UUID a create needs and passes UUIDs through untouched. The catalog carries no
configuration-field metadata, so per-family fields remain the typed
GatewayCatalog*ConfigurationSchema models.
A create needs a credential: key inline or secret_mappings. The SCM UI sends key with
configurations: {} and secret_mappings: [] (captured 2026-09-12); the gateway answers a
generic 400 AB01 when the credential is missing. To point an integration at a self-hosted or
OpenAI-compatible endpoint, use customHostConfiguration({ host, headers }), which emits the
live-verified shape { provider_auth_type: 'apiKey', custom_host, custom_headers }.
custom_host without provider_auth_type is rejected with the same AB01, and so is a host
that does not look resolvable. Verified on open-ai and x-ai for both create() and
update() with owned, immediately deleted integrations.
await gw.integrations.create({
organisation_id: '1001464285',
ai_provider_id: await gw.integrations.resolveProviderId('open-ai'),
name: 'talos7',
slug: 'talos7',
key: process.env.QWEN_API_KEY,
configurations: customHostConfiguration({
host: 'http://qwen38-talos7.ai-inference.svc.cluster.local:8000/v1',
}),
});
Log-export creation requires a description and bounded generation-time filters on SCM. An export
record is persistent audit metadata, with no delete endpoint. Never assume an accepted draft means
its job completed. Methods without successful live execution carry @experimental in the API
reference. See the full upstream compatibility ledger and
validation report. The separate runtime inference client requires its own explicit endpoint and gateway key.
Secret references
gw.secretReferences exposes list, create, get, update and delete on the admin base /secret-references route. The owned, unbound lifecycle passed all seven live checks on September 6, including filtered listing, retrieval by slug and deletion confirmed by 404. This verifies reference CRUD only: no AWS/Azure/HashiCorp secret was resolved or bound to an integration.
const gw = new AIGatewayClient();
const references = await gw.secretReferences.list({ current_page: 0, page_size: 20 });
const reference = await gw.secretReferences.get('your-owned-reference-slug');
Listing accepts manager_type, JSON-encoded tags, search, current_page and page_size. Do not supply workspace_id: the admin route returned 403 with that unsupported query and succeeds without it. Create/update workspace access uses allowed_workspaces in the body; it cannot be combined with allow_all_workspaces: true.
The create request validates all nine pinned authentication variants, including their discriminator and required credential fields. The manager must match its authentication configuration. Partial updates must contain at least one field; auth_config updates merge server-side. Request objects reject unknown stable fields, and response objects preserve additions. get may return masked authentication fields and nullable secret_key/tags. Treat the entire auth_config subtree as sensitive, including masked values; debug bodies are always omitted, and redactAIGatewaySecrets provides matching CLI-safe redaction metadata.
The runnable lifecycle and captured output uses only disposable synthetic credentials. Deletion targets the exact owned reference and can be rejected when a reference is in use; the SDK never detaches existing integrations to force deletion.
Unlike most of this SDK's other subsystems, the AI Gateway API has no Palo Alto Networks-published OpenAPI spec. Portkey's OpenAPI document is used to identify candidate capabilities, but routes and schemas are only released after verification through SCM. Endpoint names can diverge (user-trends is plural, groups/model is singular, cache-hits-trend 404s); see the expansion plan and Gotchas.
Authorization
The two planes authorize against different SCM role scopes. The verified tenant workflows use both scopes below, but neither grant guarantees access to every route or operation. Use the least privilege required for the intended operation; do not add administrator permissions solely because an unverified route returns 403.
| Grant observed in verified workflows | Scope | Plane |
|---|---|---|
| An admin role | Tenant root (empty final scope segment) | /ai_gw/admin/v2/* |
view_only_admin or higher | main_airs_workspace_<TSG> | /ai_gw/v2/* (telemetry and config) |
Both grants can coexist on one service account. Inspect the account's JWT access claim locally
to review the scopes represented in the token. Do not paste a bearer token into an online decoder
or retain it in diagnostics. Decoding a claim is not verification of a token or proof that a
particular operation is authorized:
{
"prn:<TSG>::::": ["superuser", "base"],
"prn:<TSG>::::main_airs_workspace_<TSG>": ["superuser"]
}
The first key (empty final segment) is the tenant-root grant; the second, scoped to main_airs_workspace_<TSG>, is the workspace grant. The example shows both scopes used by the verified workflows; its superuser roles are not a recommendation for every caller. The workspace scope's exact name is also returned as scope_name on each gw.workspaces.list() row (on gw.workspaces.get() it only arrives via .passthrough(), untyped) — use that to check the requested scope in SCM.
SCM's Access Management UI edits an existing role row by default. If a service account already has a tenant-root role and you need to add the workspace-scope role too, you must explicitly Add Role to create a second row — otherwise you will move the existing grant to the new scope instead of adding to it, and the plane you thought you kept will go dark.
Telling the failure modes apart
These response markers help locate a rejection, but do not establish its complete cause:
| Response | Layer | What to check |
|---|---|---|
403 with header x-opa-decision: false and body {"msg":"Access denied"} | SCM OPA policy | Policy denied this request. The header alone does not identify a missing grant or prove the endpoint is available. Verify the plane, supported route and exact query before reviewing permissions. |
403 with body {"success":false,"data":{"errorCode":"AB03"}} | Gateway app authorization | Check the requested workspace, resource access and selected plane. Missing workspace scope is one observed cause, not a universal diagnosis or instruction to assign a particular role. |
404 with errorCode: "AB02" | Gateway lookup/routing | Verify the supported route, resource identifier and required query. A missing workspace_id caused some observed failures, but 404 alone does not prove a collection exists. |
AISecSDKException appends a returned errorCode to its message (see Error Handling).
That message is not a substitute for an OPA response header and cannot reliably identify a
missing role. Debug logging can capture response metadata without enabling body logging; do
not retain tokens, keys or member payloads while diagnosing access.
The September 7 read-only administration probes passed both workspace authentication controls while all 24 candidate route requests were OPA-denied. A successful control proves that request worked on its plane, not that every other route is supported or authorized. Conversely, a denied candidate does not prove feature absence or that broader permissions would make it work. Correct pagination did not remove these denials. The coverage ledger retains those operations as unverified.
Configuration
The SCM AI Gateway client uses OAuth2 client_credentials, like ManagementClient. The
PANW_AI_GW_CLIENT_ID / _CLIENT_SECRET / _TSG_ID / _TOKEN_ENDPOINT variables fall back
to the corresponding PANW_MGMT_* ones if unset (the two endpoint overrides have no fallback).
Reusing management credentials does not establish Gateway authorization: verify the scopes and
intended operation. The separate runtime inference client uses its own gateway key, not OAuth.
| Env Var | Fallback | Required | Description |
|---|---|---|---|
PANW_AI_GW_CLIENT_ID | PANW_MGMT_CLIENT_ID | Yes | OAuth2 client ID from SCM |
PANW_AI_GW_CLIENT_SECRET | PANW_MGMT_CLIENT_SECRET | Yes | OAuth2 client secret |
PANW_AI_GW_TSG_ID | PANW_MGMT_TSG_ID | Yes | Tenant Service Group ID |
PANW_AI_GW_DATA_ENDPOINT | -- | No | Data plane URL (default: https://api.apps.paloaltonetworks.com/ai_gw/v2) |
PANW_AI_GW_ADMIN_ENDPOINT | -- | No | Admin plane URL (default: https://api.apps.paloaltonetworks.com/ai_gw/admin/v2) |
PANW_AI_GW_TOKEN_ENDPOINT | PANW_MGMT_TOKEN_ENDPOINT | No | Token URL (default: https://auth.apps.paloaltonetworks.com/oauth2/access_token) |
PANW_IAM_ENDPOINT | -- | No | SCM IAM URL for workspace scopes (default: https://api.apps.paloaltonetworks.com/iam/v1) |
Setup
export PANW_AI_GW_CLIENT_ID=your-client-id
export PANW_AI_GW_CLIENT_SECRET=your-client-secret
export PANW_AI_GW_TSG_ID=1234567890
Or reuse existing management credentials (the fallback kicks in automatically):
export PANW_MGMT_CLIENT_ID=your-client-id
export PANW_MGMT_CLIENT_SECRET=your-client-secret
export PANW_MGMT_TSG_ID=1234567890
Client Initialization
import { AIGatewayClient } from '@cdot65/prisma-airs-sdk';
// From env vars (recommended)
const gw = new AIGatewayClient();
// Explicit
const gw = new AIGatewayClient({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
tsgId: '1234567890',
});
// Custom endpoints
const gw = new AIGatewayClient({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
tsgId: '1234567890',
dataEndpoint: 'https://api.sase.paloaltonetworks.com/ai_gw/v2',
adminEndpoint: 'https://api.sase.paloaltonetworks.com/ai_gw/admin/v2',
});
Token fetch, caching, and refresh are handled automatically, same as every other client.
api.apps and api.sase are the same hostapi.apps.paloaltonetworks.com (the default for every product since 0.32, and what PANW documents) and api.sase.paloaltonetworks.com resolve to the same backend and behave identically — same status codes, same error bodies, same x-opa-decision header. If your egress allowlist is still scoped to api.sase, override the endpoint options to reuse it rather than opening a new firewall rule. DLP is the one product on its own host, api.dlp.paloaltonetworks.com.
Sub-Clients
AIGatewayClient exposes 17 SCM sub-clients, the SCM IAM iamScopes client that workspace provisioning depends on, and the separate inference runtime client:
| Sub-Client | Plane | Access |
|---|---|---|
telemetry | Data | gw.telemetry |
workspaces | Data | gw.workspaces |
configs | Data | gw.configs |
guardrails | Data | gw.guardrails |
providers | Data | gw.providers |
apiKeys | Data | gw.apiKeys |
mcpServers | Data | gw.mcpServers |
usageLimits | Data | gw.usageLimits |
rateLimits | Data | gw.rateLimits |
logExports | Data | gw.logExports |
integrations | Admin | gw.integrations |
mcpIntegrations | Admin | gw.mcpIntegrations |
deployments | Admin | gw.deployments |
plugins | Admin | gw.plugins |
organisations | Admin | gw.organisations |
auditLogs | Admin | gw.auditLogs |
secretReferences | Admin | gw.secretReferences |
iamScopes | IAM (/iam/v1) | gw.iamScopes |
Walkthrough: workspace spend and blocked requests
The core loop for observability — resolve a workspace, then pull its telemetry.
import { AIGatewayClient } from '@cdot65/prisma-airs-sdk';
const gw = new AIGatewayClient();
// 1. Workspaces carry both keys telemetry and config need: `slug` and `id`.
const ws = (await gw.workspaces.list()).data[0];
// 2. Telemetry is keyed by slug. Costs come back in CENTS.
const cost = await gw.telemetry.cost({ workspaceSlug: ws.slug, days: 7 });
console.log(`$${(cost.data.total / 100).toFixed(2)} over 7 days`);
// 3. Group requests by HTTP status to see what AIRS blocked.
const codes = await gw.telemetry.byStatusCode({
workspaceSlug: ws.slug,
days: 7,
columns: ['cost', 'avg_latency'],
});
const blocks = codes.data.find((r) => r.status_code === 446);
console.log(`AIRS blocks: ${blocks?.requests ?? 0}`); // 446 = blocked before it hit the LLM
Telemetry
gw.telemetry has 22 read methods: 17 chart endpoints (cost, requests, latency, tokens, errors, users, cacheSummary, cacheHitTrend, userTrends, errorTrends, errorCategoryTrends, groupedErrors, rescuedRetries, feedbackTrend, feedbackWeighted, feedbackScoreDistribution, feedbackModels), 3 group-by aggregates (groupBy, byUser, byStatusCode), filterBoundaries, and raw logs. Every method takes a window: { workspaceSlug, days? } or { workspaceSlug, start, end }. The September 8 additions below require SDK 0.28.0 or later.
Charts
const [cost, requests, latency, tokens] = await Promise.all([
gw.telemetry.cost({ workspaceSlug: 'ws-main-a-349e0e', days: 7 }),
gw.telemetry.requests({ workspaceSlug: 'ws-main-a-349e0e', days: 7 }),
gw.telemetry.latency({ workspaceSlug: 'ws-main-a-349e0e', days: 7 }),
gw.telemetry.tokens({ workspaceSlug: 'ws-main-a-349e0e', days: 7 }),
]);
console.log(cost.data.total); // cents, e.g. 411083
console.log(latency.data.p99); // ms
console.log(tokens.data.total_request_units, tokens.data.total_response_units);
Use an explicit window instead of a rolling one by passing start/end Dates instead of days.
The request-count, cost, token and latency charts accept semantically verified filters (SDK 0.23.0+):
const selected = await gw.telemetry.requests({
workspaceSlug: 'ws-develo-71f8d8',
days: 1,
traceId: ownedTraceId,
});
const tagged = await gw.telemetry.requests({
workspaceSlug: 'ws-develo-71f8d8',
days: 1,
metadata: { sdk_e2e: ownedTestTag },
});
AIGatewayChartOptions exports the shared contract; the existing AIGatewayRequestChartOptions interface remains available. SCM uses camel-case traceId; the request-chart probe found that upstream trace_id is ignored. Metadata is encoded as JSON with exact string-valued matches. Known, nonexistent and combined filters were tested against existing owned traffic, not inferred from HTTP 200. See actual captured filter output. Other chart methods retain their own contracts and do not accept these filters.
An empty latency cohort returns null for data.total, p50, p90 and p99, with zero-valued time buckets. SDK 0.23.0 preserves those nulls instead of rejecting the response. Callers must handle number | null; null is an undefined aggregate, not measured zero latency. These four methods remain partial SCM adapters: the remaining upstream filters, envelopes and aggregations are not fully equivalent.
SDK 0.24.0 adds the following verified filters to those same four methods:
| SDK option | SCM query | Contract |
|---|---|---|
statusCodes: number[] | statusCode | Nonempty CSV list; members match with OR |
apiKeyIds: string[] | apiKeyIds | Nonempty CSV list of API-key UUIDs; members match with OR |
aiOrgModels: string[] | aiOrgModel | Nonempty CSV list of provider__model pairs; members match with OR |
totalUnitsMin: number | totalUnitsMin | Inclusive lower total-token bound; nonnegative safe integer |
totalUnitsMax: number | totalUnitsMax | Inclusive upper total-token bound; zero is preserved |
costMin: number | costMin | Inclusive lower bound in cents, including fractional cents |
costMax: number | costMax | Inclusive upper bound in cents; zero is preserved |
Distinct filters combine with AND. Equal minimum/maximum values select an exact range; reversed ranges are rejected before authentication. Provider/model analytics identifiers use double underscores, not the @provider/model inference-routing syntax. Elements cannot contain commas or whitespace. Empty arrays and pre-serialized list strings are rejected.
AIGatewayChartFiltersSchema and its inferred AIGatewayChartFilters type expose the same filter validation independently of workspace/time-window fields. CLI or application adapters can parse a filter object before resolving a workspace or creating an authenticated client; validation returns a copied, typed value. The transport reuses those field and range definitions.
const selectedCost = await gw.telemetry.cost({
workspaceSlug: 'ws-develo-71f8d8',
days: 1,
statusCodes: [200, 446],
aiOrgModels: ['openai__gpt-5.6-terra'],
totalUnitsMin: 1,
costMax: 1, // one cent, not one dollar
});
console.log(selectedCost.data.total); // cents
Read-only tests checked singleton/CSV alternatives, inclusive bounds and combined filters against one existing owned inference trace on each chart. The corresponding upstream snake-case filter names were silently ignored by SCM. Prompt/completion-token bound probes did not establish the complete promised semantics, so those options remain unexposed. See the actual captured query-contract output. No additional operation is counted as directly covered by these partial adapters. CLI 4.3.0 and later expose these chart filters with shared SDK validation.
Telemetry options are validated before authentication/network I/O. Filters, columns and log options are serialized from the parsed copy, so caller-owned getters are not reevaluated after validation. Unknown fields, invalid dates, non-finite days, reversed windows, malformed log options and unsupported grouping dimensions/columns raise USER_REQUEST_PAYLOAD_ERROR without including rejected values. Fractional days remain valid; explicit start overrides finite days. Dates retain numeric UTC offsets required by SCM. A zero-length explicit window is allowed; no unverified server maximum is imposed.
groupBy, byUser, byStatusCode
SDK 0.25.0 extends the same nine verified filters to all six group endpoints: groupBy dimensions ai_service, model, api_key, and provider, plus byStatusCode and byUser. AIGatewayGroupOptions combines the shared filters/window with existing optional columns. byUser accepts AIGatewayChartOptions for compatibility with the shared filter type name, but not extra columns.
Lists use OR, distinct filters use AND, and token/cost bounds are inclusive. The shared standalone AIGatewayChartFiltersSchema also validates group filters before workspace resolution. Validation and serialization reuse the chart contract; unsupported columns, coercible strings, unsafe numbers and reversed ranges still fail before authentication.
SCM's user response remains { success, data: { records: [{ _user, count, cost }], total, isQuotaExceeded } }; the other groups retain { object, data: [...], total, is_quota_exceeded }. No response is renamed to imitate Portkey. The source-pinned provider grouping omits trace_id even though SCM supports traceId; its fixture records this as an SCM-only extension. Remaining upstream pagination, sorting, filters and aggregation differences keep these adapters partial. See the actual grouped-filter checks.
// Spend by model — dimension names are underscore-only: 'ai_service' | 'model' | 'api_key' | 'provider'.
const byModel = await gw.telemetry.groupBy('model', {
workspaceSlug: 'ws-main-a-349e0e',
days: 7,
columns: ['cost', 'total_tokens'],
statusCodes: [200, 446],
costMax: 1, // cents
});
// Requests and cost per end user.
const byUser = await gw.telemetry.byUser({
workspaceSlug: 'ws-main-a-349e0e', days: 7,
metadata: { environment: 'dev' },
});
// Requests by HTTP status — 446 is an AIRS security block, not a server error.
const byStatus = await gw.telemetry.byStatusCode({
workspaceSlug: 'ws-main-a-349e0e',
days: 7,
columns: ['cost', 'avg_latency'],
});
logs
// SDK 0.28.0+: use a fixed window and zero-based currentPage.
const end = new Date();
const window = { workspaceSlug: 'ws-main-a-349e0e', start: new Date(end.getTime() - 86400000), end };
const recent = await gw.telemetry.logs({ ...window, pageSize: 50, currentPage: 0 });
const next = await gw.telemetry.logs({ ...window, pageSize: 50, currentPage: 1 });
// A filtered page is not necessarily the entire matching dataset.
const blocked = await gw.telemetry.logs({ ...window, statusCode: 446, pageSize: 50, currentPage: 0 });
console.log(blocked.data.records.length);
currentPage, not offset, skip, or page, advances the SCM log collection. This corrects
the earlier conclusion that pagination was unavailable. Validate stable totals, unique row
identities and nonempty intermediate pages; bound the number of requests. A fixed window
reduces drift but does not guarantee snapshot consistency. Do not use capturedTotal as
the termination count: the verified response returned zero while total was 174.
September 8 dashboard feed additions (0.28.0)
These reads use the existing OAuth client-credentials workflow plus x-tsg-id. Browser
cookies, copied bearer tokens, browser-identification headers and runtime gateway API keys
are unnecessary. The five new response schemas preserve unknown fields. The guardrail
catalog describes available evaluators; it is not a list of configured security policies.
| SDK method | SCM route relative to its plane | Plane |
|---|---|---|
telemetry.errorCategoryTrends(window) | /logs/charts/error-category-trends | Data |
telemetry.groupedErrors(window) | /logs/charts/grouped-errors | Data |
telemetry.filterBoundaries(window) | /analytics/filter-boundaries | Data |
organisations.getInfo(tsgId) | /organisations/{tsgId}/info | Admin |
guardrails.getCatalog() | /utils/static-resources/schema?resource=guardrails | Admin |
Data-plane base: https://api.apps.paloaltonetworks.com/ai_gw/v2.
Admin-plane base: https://api.apps.paloaltonetworks.com/ai_gw/admin/v2.
The root client routes the catalog explicitly to admin; direct sub-client users must supply
adminBaseUrl for this operation. Existing guardrail configuration methods stay on data.
const window = { workspaceSlug: 'ws-develo-71f8d8', days: 1 };
const categories = await gw.telemetry.errorCategoryTrends(window);
const grouped = await gw.telemetry.groupedErrors(window);
const bounds = await gw.telemetry.filterBoundaries(window);
const catalog = await gw.guardrails.getCatalog();
console.log({
errorCount: categories.data.summary.totalErrors,
errorBuckets: grouped.data.trend.length,
availableModels: bounds.data.unique_ai_models.length,
evaluatorCount: catalog.evals.length,
});
groupedErrors.data.trend[].y is an array of { response_status_code, count }, including
empty arrays for quiet buckets. Filter boundaries retain nullable numeric bounds for empty
windows. Request/error/token charts now type optional LLM/MCP/A2A breakdown fields while
retaining compatibility with older responses that omit them. Zero counts and null averages
remain distinct. Costs stay in cents. Do not sum overlapping aggregate and per-protocol counts.
Verified live SDK output
At 2026-09-08T00:44:12.217Z, all 25 supplied response fixtures passed validation, and fresh OAuth reads through the built SDK passed 27/27 checks: all 25 supplied calls, full log pagination, and an empty filter-boundary window. No remote mutations or inference requests were made. Both the credential file and input capture remained unchanged. The following are aggregate-only projections from the live verification runner, not CLI command output:
{
"pagination": {
"pages": 4,
"returned": 174,
"total": 174,
"complete": true,
"pastEndEmpty": true
},
"guardrailCatalog": { "evalsCount": 83 },
"filterBoundaries": {
"isQuotaExceeded": false,
"unique_ai_modelsCount": 8,
"unique_status_codesCount": 3
},
"groupedErrors": { "total": 38, "isQuotaExceeded": false, "trendCount": 68 }
}
The supplied captures use different windows (some approximately one day, others September 5–8). These numbers are not one unified daily report and should not be combined without aligned query windows. Log rows can contain metadata, identities and request URLs; API-key inventories can contain credentials. The verification output retains only approved counts/aggregates. Organisation settings and boundary response data have operation-specific debug redaction. Treat catalog descriptions and parameter schemas as untrusted display data, never executable code.
Runnable example: docs-site/examples/gateway-dashboard-feeds.ts. Reproduce the read-only
fixture/live checks from this checkout with npx tsx scripts/e2e-gateway-dashboard-inputs.ts
(GATEWAY_DASHBOARD_CAPTURE selects the private input file; default /var/tmp/t2.txt).
Use --fixtures-only to validate captured responses without credentials or network access.
Use --built after npm run build to verify the distributable SDK rather than source imports.
SDK 0.28.0 includes these reads. The CLI consumes these methods for its AI Gateway dashboard; SDK verification projections above are not CLI stdout.
Config Plane
Workspace-scoped resources: gateway configs, guardrails, provider bindings, and API keys. All list methods take { workspaceId } (the workspace UUID, not the slug telemetry uses).
Structured writes and local validation
Every body-taking AI Gateway method uses an exported Zod schema, and every public request type is inferred from that schema. You can parse explicitly when accepting external input; the client also parses automatically before OAuth or network access:
import {
GatewayConfigCreateRequestSchema,
type GatewayConfigCreateRequest,
} from '@cdot65/prisma-airs-sdk';
const request: GatewayConfigCreateRequest = GatewayConfigCreateRequestSchema.parse({
name: 'vertex-fallback',
workspace_id: workspaceId,
config: {
strategy: { mode: 'fallback' },
retry: { attempts: 3, on_status_codes: [429, 503] },
targets: [{ provider: '@vertex-primary' }, { provider: '@vertex-backup' }],
},
});
await gw.configs.create(request);
Stable request envelopes reject unknown fields. Partial *UpdateRequest schemas reject {}.
Provider- and routing-specific extension points accept recursive finite JSON so supported upstream
options remain usable without permitting undefined, functions, NaN, or infinities.
For CLI-style repeated settings, build nested JSON without hand-authoring a config file:
import { buildDottedObject, setDottedValue } from '@cdot65/prisma-airs-sdk';
const config = buildDottedObject([
{ path: 'retry.attempts', value: 3 },
{ path: 'targets[0].provider', value: '@vertex-primary' },
{ path: 'targets[1].provider', value: '@vertex-backup' },
]);
const updated = setDottedValue(config, 'strategy.mode', 'fallback');
Both helpers return new objects. They reject duplicate/conflicting paths, sparse arrays, malformed
escapes, non-JSON values, and __proto__ / constructor / prototype path segments.
Known-value catalogs such as AI_GATEWAY_DEPLOYMENT_TYPES and
AI_GATEWAY_KNOWN_CONFIG_STRATEGIES are sorted for deterministic CLI help. Deployment types,
deployment statuses, and mutable MCP capability kinds are closed schemas. Research-derived values
such as routing strategy, MCP transport/auth, scopes, and rate-limit units remain open non-empty
strings so a new SCM value does not require an SDK release first.
const workspaceId = 'a1b2c3d4-0000-4000-8000-000000000000';
const configs = await gw.configs.list({ workspaceId });
const guardrails = await gw.guardrails.list({ workspaceId });
const providers = await gw.providers.list({ workspaceId });
const serviceKeys = await gw.apiKeys.listService({ workspaceId });
const userKeys = await gw.apiKeys.listUser({ workspaceId });
List rows are a 12-field subset that omits config, format, type, and version_id. Fetch a config's detail to get the routing config itself — and note it comes back as a JSON-encoded string, not an object:
const detail = await gw.configs.get(configs.data[0].id);
const routing = JSON.parse(detail.config) as Record<string, unknown>;
// Each history row includes the full JSON-string config and version metadata.
const versions = await gw.configs.listVersions(detail.id);
console.log(versions.data[0].version_created_at);
Provider detail is also available, but it is secret-bearing:
const provider = await gw.providers.get(providers.data[0].id);
console.log(provider.name, provider.ai_provider_name);
await gw.providers.update(provider.id, {
name: 'Vertex production',
note: 'Managed by platform automation',
});
The provider detail response can contain key, model_config service-account material, and secret_mappings. Do not log or persist the complete response. SDK debug output redacts the known credential fields for this operation, but unknown future secret fields are a residual risk, so debug logging still belongs outside production.
Guardrails also have a detail read, with the same list/detail split:
const guardrail = await gw.guardrails.get(guardrails.data[0].id);
console.log(guardrail.checks[0].id); // e.g. 'panw-prisma-airs.intercept'
await gw.guardrails.update(guardrail.id, { name: 'Production AIRS guardrail' });
configs.create(), guardrails.create(), and providers.create() all return a minimal creation receipt rather than the resource they just created — the same pattern as deployments.create(). configs/guardrails receipts carry { id, version_id, slug, object }; providers receipts have no version_id: { id, slug, object }. Call the matching get() (or list(), for providers) for the full record.
const receipt = await gw.configs.create({
name: 'vertex-airs',
workspace_id: workspaceId,
config: { retry: { attempts: 3 } },
});
const full = await gw.configs.get(receipt.id);
Unlike deployments.delete() (which archives — see the Admin Plane gotcha below), configs.delete(), guardrails.delete(), and providers.delete() all permanently remove the resource: it disappears from the corresponding list() entirely. None of the three take an organisation_id query param.
await gw.configs.delete(receipt.id);
// receipt.id no longer appears in gw.configs.list()
Admin Plane
Organisation-level resources, not scoped to a single workspace: deployments, provider integrations, MCP integrations, plugins, and organisation/auth settings.
const deployments = await gw.deployments.list();
const integrations = await gw.integrations.list();
const mcp = await gw.mcpIntegrations.list();
const plugins = await gw.plugins.list();
const org = await gw.organisations.getSelf();
Provisioning a workspace: the scope comes first
A workspace's scope_name is not a label — it names an SCM IAM scope that must already exist. Strata Cloud Manager's own "create workspace" flow (captured 2026-09-11) is three requests, and workspaces.provision() runs exactly that sequence:
POST /iam/v1/scopes{ name, description, resources: [] }— create the scope.POST /ai_gw/admin/v2/workspaces{ name, description, scope_name }— create the workspace; the response carries the server-generatedslug.PUT /iam/v1/scopes/{name}withresources: [{ resource_type: 'workspace', resource_id: <slug>, metadata: [] }]— bind the scope to the workspace. This is the step that actually grants data-plane access.
const { scope, workspace } = await gw.workspaces.provision({
name: 'truffles',
description: 'Online recipe generation application',
});
workspace.slug; // 'ws-truffl-03e7d9' (server-generated)
scope.name; // 'ws_truffles_ggolfu' (generated: ws_<name>_<6 chars>, SCM's own convention)
scope.resources; // [{ resource_type: 'workspace', resource_id: 'ws-truffl-03e7d9', metadata: [] }]
Pass scope_name to pick the name yourself, or { existingScope: true } to bind a scope you created earlier (its existing bindings are preserved). The three primitives are public too — gw.iamScopes.create(), gw.workspaces.create(), gw.iamScopes.bindWorkspace() — and gw.iamScopes.list() shows every scope in the tenant, including unbound leftovers (resources: []).
Skipping step 1 is why a bare workspaces.create() with a made-up scope_name fails with 400 AB01: that was the outcome of the 2026-09-06 revalidation, and it was the missing prerequisite rather than a contract change.
provision() does not hide partial failures. If the workspace step fails after the scope was created, the scope is deleted again and the error says whether that rollback worked. If the bind step fails, the workspace exists but is unbound, and the error names the slug and scope so you can finish with gw.iamScopes.bindWorkspace(scope, slug). iamScopes.delete() itself has not been observed live.
For a self-hosted data plane, register it against only the workspace it may serve. Capture the creation receipt immediately because its client_auth and gateway password are returned only once:
const registration = await gw.deployments.create({
name: 'gcp-ai-dev-calvin',
type: 'non_production',
organisation_id: process.env.PANW_AI_GW_TSG_ID!,
auth_settings: {
allow_all_workspaces: false,
workspaces_allowed: ['ws-develo-71f8d8'],
},
});
// Store these in a secret manager; never print them.
const { client_auth, credentials } = registration;
After the separately managed gateway runtime has an HTTPS endpoint reachable by SCM, attach it and optionally run SCM's bidirectional connectivity test:
await gw.deployments.update(registration.id, {
auth_settings: {
gateway_base_url: 'https://gateway.example.com',
workspaces_allowed: ['ws-develo-71f8d8'],
},
});
const health = await gw.deployments.ping(registration.id);
console.log(health.status, health.outbound.status, health.inbound.status);
ping()deployments.ping() is not the same signal as the deployment heartbeat. The ping asks SCM to
initiate a request to the registered gateway and then verify its callback. A private data plane
that intentionally rejects control-plane-initiated ingress can therefore return an unhealthy ping
while its outbound heartbeat remains current and connection_status is healthy. Do not expose a
private gateway merely to make this optional diagnostic green.
MCP integration detail, discovered capabilities, and server metadata use separate reads:
const mcpId = mcp.data[0].id;
const detail = await gw.mcpIntegrations.get(mcpId);
const capabilities = await gw.mcpIntegrations.getCapabilities(mcpId);
const metadata = await gw.mcpIntegrations.getMetadata(mcpId);
console.log(detail.url);
console.log(capabilities.data.map((capability) => capability.name));
console.log(metadata.sync_status);
Unlike MCP list rows, where configurations is a JSON-encoded string, mcpIntegrations.get() returns configurations as an object.
MCP workspace bindings use explicit enablement entries. This call disables an integration in one workspace without replacing unrelated bindings or enabling global access:
await gw.mcpIntegrations.setWorkspaces(mcpId, {
workspaces: [{ id: 'ws-development', enabled: false }],
global_workspace_access: { enabled: false },
override_existing_workspace_access: false,
});
// Verified SCM response: {}
The workspace may be supplied by slug or UUID. Use override_existing_workspace_access: false
for a targeted update; use true only when you intentionally want SCM to override the existing
binding set.
Per-integration model and workspace bindings:
const id = integrations.data[0].id;
const models = await gw.integrations.getModels(id);
const bound = await gw.integrations.getWorkspaces(id);
console.log(bound.global_workspace_access.enabled); // an OBJECT on reads — see Gotchas
Regular integration writes use the same object shape. A plain boolean is rejected by SCM:
await gw.integrations.setWorkspaces(id, {
global_workspace_access: { enabled: false },
workspaces: [{ id: 'ws-develo-71f8d8', enabled: true }],
override_existing_workspace_access: false,
});
The setModels(), setWorkspaces(), mcpIntegrations.setCapabilities(), and
mcpIntegrations.setWorkspaces() inputs are named *BulkUpdateRequest: they describe the listed
children and do not imply deletion by omission. Only an endpoint's explicit override flag requests
replacement behavior. Ordinary update() methods are non-empty partial updates. For configs, the
outer update is partial, but supplying config replaces that routing document rather than merging
individual nested fields.
Secret metadata and debug output
AI_GATEWAY_SECRET_FIELDS publishes operation-scoped request/response paths for the CLI and other
diagnostic tooling. redactAIGatewaySecrets() returns a redacted clone without mutating its input:
import { redactAIGatewaySecrets } from '@cdot65/prisma-airs-sdk';
const safe = redactAIGatewaySecrets('integrations.create', {
key: process.env.PROVIDER_API_KEY,
configurations: { vertex_region: 'us-central1' },
});
// safe.key === '[REDACTED]'
When PANW_AI_SEC_DEBUG is enabled, the request pipeline applies the same metadata to known AI
Gateway secret-bearing operations. Auth headers remain hashed. Unknown fields, audit-log
request_body values, and bodies from other service domains are not automatically classified, so
captured output still requires review before sharing.
Audit Logs
gw.auditLogs.list() returns each entry's request_body unredacted. Records for credential-bearing calls (creating integrations, plugins, API keys) can contain live secrets — provider API keys, private keys — in plaintext. The sibling request_headers field is masked, but request_body is not. The SDK returns the response faithfully rather than altering it; never log these records wholesale, and never forward them to a third-party sink. Project to safe fields instead:
const audit = await gw.auditLogs.list({
start: new Date(Date.now() - 7 * 86_400_000),
end: new Date(),
});
// Safe projection — timestamp, method, and path only, never request_body.
const summary = audit.records.map((r) => `${r.timestamp} ${r.method} ${r.uri.split('?')[0]}`);
Gotchas
-
Costs are in cents, everywhere.
telemetry.cost().data.total,groupBy(...).data[].cost,byUser(...).data.records[].cost— none of them are dollars. Divide by 100 yourself:(cents / 100).toFixed(2). -
Workspace slug vs. id. Telemetry (
gw.telemetry.*) is keyed byworkspaceSlug; config-plane lists (gw.configs.list,gw.guardrails.list,gw.providers.list,gw.apiKeys.list*) are keyed byworkspaceId. Both come off the samegw.workspaces.list()row —ws.slugandws.id— but passing one where the other is expected fails. -
workspaces.list()hides rows by default — twice over. It returns active workspaces only, and only those your service account holds a workspace-scope grant on. Neither omission is visible in the response. To see everything in the tenant:const mine = await gw.workspaces.list(); // active + scoped to youconst all = await gw.workspaces.list({ plane: 'admin' }); // every active workspaceconst archived = await gw.workspaces.list({ plane: 'admin', status: 'archived' });There is no single call for "active and archived" — the filter is one or the other, so merge two reads if you need both.
-
Workspace refs may be a UUID or a slug.
gw.workspaces.get('ws-produc-985697')is as valid as passing the UUID. Reading a workspace you are not scoped to needs{ plane: 'admin' }— on the data plane it returns403 AB03, not404, so a permission problem can read like a missing record. -
workspaces.delete()archives; it does not destroy. The row survives underlist({ status: 'archived' }). This matchesdeployments.delete()and contradictsconfigs/guardrails/providers, which hard delete. There is no hard delete for workspaces. Note thatlistis the only way to see an archived workspace —get()returns404 AB08for one, on either plane, so a 404 straight after a delete is expected rather than a bug. -
workspaces.create()returns most of the record, not a receipt. It is the odd one out:configs/guardrails/providers/deploymentscreates hand back 3-5 field receipts, andapiKeys.create*(),integrations.create(),mcpIntegrations.create(), andplugins.create()return an unmodelled passthrough object (GatewayWriteResponse). Even so it omitsstatus,is_default,icon,usage_limits,rate_limits, and the settings blocks, so follow it withget()if you need those.workspaces.update()returns an empty{}— the write lands, but you must re-read to see it. -
workspaces.create()needs ascope_namethat already exists. It names an SCM IAM scope (gw.iamScopes), not a free-form label, and it is not derived fromname. Ascope_namewith no matching scope fails with400 AB01; a scope that exists but is never bound to the workspace (step 3) leaves the workspace invisible to data-planelist()— the most common way a freshly created workspace "goes missing". Useworkspaces.provision()to do all three steps in order (see the Admin Plane section). -
usage_limits/rate_limitsare arrays of policy objects. Onworkspaces.get()and on theintegrations.getWorkspaces()rows, these hold zero or more limit policies — not a single settings object. They are typed as a union (array, legacy object, ornull), so narrow before indexing:const ws = await gw.workspaces.get(workspaceId);const limits = Array.isArray(ws.usage_limits) ? ws.usage_limits : [];for (const l of limits) {// credit_limit is in cents, like every other cost in this APIconsole.log(`${l.type}: ${l.credit_limit} cap, resets ${l.periodic_reset}`);}A live tenant also returns bookkeeping fields the upstream contract omits (
id,status,current_usage,is_exhausted_alerts_sent,is_threshold_alerts_sent); these pass through rather than being stripped. -
configs.list()andconfigs.get()are different shapes — the list read omitsconfig/format/type/version_id, andconfigis a JSON string on the detail read, not an object. See the callout above. -
Delete semantics differ by resource — there is no gateway-wide convention.
deployments.delete()is the one soft delete: it returns200with an empty body and the record persists indeployments.list()withstatus: 'archived'.configs.delete(),guardrails.delete(), andproviders.delete()are hard deletes — the resource vanishes from itslist()entirely. Don't assume one behavior generalizes to the other. -
deployments.create()is the only place credentials are ever readable. It returns a 5-field receipt —{ id, client_auth, credentials: { username, password }, organisation_id, object }— not a deployment record, andcredentials.password/client_authare masked on every subsequentdeployments.get(). Capture them from the create response or not at all:const receipt = await gw.deployments.create({name: 'prod-us',type: 'production',organisation_id: '1852583913', // the TSG, NOT the org UUIDauth_settings: { allow_all_workspaces: true },});// receipt.credentials.password — capture now; gw.deployments.get(receipt.id) masks it. -
telemetry.logs()uses zero-basedcurrentPage(SDK 0.28.0+).offset/page/skipare not the pagination contract. Full pagination was verified September 8 with 174 unique rows across four pages. Filter bystatusCode: 446for AIRS blocks, but still validate completeness; do not assume any filtered response contains every match. -
organisation_idis polymorphic across request and response. Write requests take the TSG as a numeric string (e.g.'1852583913'); read responses return the internal organisation UUID in the same-named field. Never round-trip a responseorganisation_idback into a request — use the TSG value you already have.
Error Handling
import { AISecSDKException, ErrorType } from '@cdot65/prisma-airs-sdk';
try {
await gw.configs.get('00000000-0000-4000-8000-000000000000');
} catch (err) {
if (err instanceof AISecSDKException) {
switch (err.errorType) {
case ErrorType.OAUTH_ERROR:
console.error('Auth failed:', err.message);
break;
case ErrorType.CLIENT_SIDE_ERROR:
// AB03: check workspace/resource access and the selected plane.
// "Access denied" alone cannot identify OPA or a missing role.
// AB02: verify the route, resource identifier and required query.
// Compare a known-good read on the same plane before considering permission changes.
console.error('Client error:', err.statusCode, err.message);
break;
case ErrorType.USER_REQUEST_PAYLOAD_ERROR:
console.error('Invalid input:', err.message);
break;
}
}
}
AISecSDKException.message has the API's errorCode appended automatically (e.g. "... (errorCode: AB03)").
Use it as a diagnostic hint, not an automatic instruction to change IAM grants or retry an
unsupported route. Explicit OPA policy denials are returned without a token-refresh retry.
ID parameters are also validated before any network call. UUID-shaped fields (config, guardrail, provider, integration, deployment IDs, and the workspaceId list filter) use assertUuid; workspace refs on workspaces.get/update/delete use assertWorkspaceRef, which accepts a UUID or a slug; the TSG/organisation ID field uses assertNumericId, since it's a numeric string rather than a UUID. Both throw AISecSDKException with ErrorType.USER_REQUEST_PAYLOAD_ERROR immediately, without hitting the network:
try {
await gw.configs.get('not-a-uuid');
} catch (err) {
if (err instanceof AISecSDKException) console.log(err.errorType); // AISEC_USER_REQUEST_PAYLOAD_ERROR
}
For the complete, per-method list with input/output shapes (AIGatewayClient, its 17 SCM sub-clients and its separately authenticated runtime client), see the Full API reference.
Testing against a live tenant
Unit tests run against recorded fixtures written by the same person who wrote the schemas, so they can't catch a schema that disagrees with the live API. scripts/smoke-ai-gateway.ts exists specifically to catch that class of bug: it calls all 48 read checks across both planes against a real tenant and reports which parse cleanly.
It is opt-in, read-only, and not part of CI or npm test — it needs live credentials and hits a real tenant. It has already caught 3 real schema mismatches before they shipped (a config list/detail shape confusion, an MCP integration field typed as an object when the API returns a JSON string, and an integration's workspace-binding flag typed as a boolean when the API returns an object).
set -a && source .env && set +a && npm run smoke:ai-gateway
The SDK 0.20 write-conformance suite is separately armed and never runs in CI. It requires both write flags plus an explicit workspace. It creates and cleans up one uniquely named config; regular integration binding probes resend an existing binding's current state with override disabled:
npm run e2e:ai-gateway:writes -- \
--execute --allow-mutation \
--workspace ws-development
Credentials are read in one request from the configured 1Password item unless PANW_MGMT_* is
already present. Pass --integration-id <uuid> to restrict the idempotent binding probe to a
specific regular integration that is already bound to the workspace.
A runnable, read-only example covering telemetry, config plane, admin plane, audit logs, and error handling end-to-end lives at examples/ai-gateway.ts in the repo root — run it directly with npx tsx examples/ai-gateway.ts.