Skip to main content

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's slug is what telemetry queries key on; its id is 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:

ClientOperationsLive boundary
mcpServersCRUD, test, capabilities, user access, connectionsCRUD and read/user-access workflows verified; test and connection revocation returned 403
usageLimitsPolicy CRUD, entities, entity resetIsolated-policy CRUD verified; reset needs a traffic-derived owned entity
rateLimitsPolicy CRUDIsolated-policy lifecycle verified
logExportsList/create/get/update/start/cancel/downloadDraft 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.

No published OpenAPI spec

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 workflowsScopePlane
An admin roleTenant root (empty final scope segment)/ai_gw/admin/v2/*
view_only_admin or highermain_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, it does not add

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:

ResponseLayerWhat to check
403 with header x-opa-decision: false and body {"msg":"Access denied"}SCM OPA policyPolicy 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 authorizationCheck 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/routingVerify 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 VarFallbackRequiredDescription
PANW_AI_GW_CLIENT_IDPANW_MGMT_CLIENT_IDYesOAuth2 client ID from SCM
PANW_AI_GW_CLIENT_SECRETPANW_MGMT_CLIENT_SECRETYesOAuth2 client secret
PANW_AI_GW_TSG_IDPANW_MGMT_TSG_IDYesTenant Service Group ID
PANW_AI_GW_DATA_ENDPOINT--NoData plane URL (default: https://api.apps.paloaltonetworks.com/ai_gw/v2)
PANW_AI_GW_ADMIN_ENDPOINT--NoAdmin plane URL (default: https://api.apps.paloaltonetworks.com/ai_gw/admin/v2)
PANW_AI_GW_TOKEN_ENDPOINTPANW_MGMT_TOKEN_ENDPOINTNoToken URL (default: https://auth.apps.paloaltonetworks.com/oauth2/access_token)
PANW_IAM_ENDPOINT--NoSCM 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 host

api.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-ClientPlaneAccess
telemetryDatagw.telemetry
workspacesDatagw.workspaces
configsDatagw.configs
guardrailsDatagw.guardrails
providersDatagw.providers
apiKeysDatagw.apiKeys
mcpServersDatagw.mcpServers
usageLimitsDatagw.usageLimits
rateLimitsDatagw.rateLimits
logExportsDatagw.logExports
integrationsAdmingw.integrations
mcpIntegrationsAdmingw.mcpIntegrations
deploymentsAdmingw.deployments
pluginsAdmingw.plugins
organisationsAdmingw.organisations
auditLogsAdmingw.auditLogs
secretReferencesAdmingw.secretReferences
iamScopesIAM (/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 optionSCM queryContract
statusCodes: number[]statusCodeNonempty CSV list; members match with OR
apiKeyIds: string[]apiKeyIdsNonempty CSV list of API-key UUIDs; members match with OR
aiOrgModels: string[]aiOrgModelNonempty CSV list of provider__model pairs; members match with OR
totalUnitsMin: numbertotalUnitsMinInclusive lower total-token bound; nonnegative safe integer
totalUnitsMax: numbertotalUnitsMaxInclusive upper total-token bound; zero is preserved
costMin: numbercostMinInclusive lower bound in cents, including fractional cents
costMax: numbercostMaxInclusive 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 methodSCM route relative to its planePlane
telemetry.errorCategoryTrends(window)/logs/charts/error-category-trendsData
telemetry.groupedErrors(window)/logs/charts/grouped-errorsData
telemetry.filterBoundaries(window)/analytics/filter-boundariesData
organisations.getInfo(tsgId)/organisations/{tsgId}/infoAdmin
guardrails.getCatalog()/utils/static-resources/schema?resource=guardrailsAdmin

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 });
configs.list() and configs.get() are different shapes

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',
});
providers.get() can return credentials

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' });
create() returns a receipt, not the record

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);
configs/guardrails/providers delete() is a HARD delete

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:

  1. POST /iam/v1/scopes { name, description, resources: [] } — create the scope.
  2. POST /ai_gw/admin/v2/workspaces { name, description, scope_name } — create the workspace; the response carries the server-generated slug.
  3. PUT /iam/v1/scopes/{name} with resources: [{ 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);
Private data planes and 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​

request_body is returned unredacted

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 by workspaceSlug; config-plane lists (gw.configs.list, gw.guardrails.list, gw.providers.list, gw.apiKeys.list*) are keyed by workspaceId. Both come off the same gw.workspaces.list() row — ws.slug and ws.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 you
    const all = await gw.workspaces.list({ plane: 'admin' }); // every active workspace
    const 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 returns 403 AB03, not 404, so a permission problem can read like a missing record.

  • workspaces.delete() archives; it does not destroy. The row survives under list({ status: 'archived' }). This matches deployments.delete() and contradicts configs/guardrails/providers, which hard delete. There is no hard delete for workspaces. Note that list is the only way to see an archived workspace — get() returns 404 AB08 for 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/deployments creates hand back 3-5 field receipts, and apiKeys.create*(), integrations.create(), mcpIntegrations.create(), and plugins.create() return an unmodelled passthrough object (GatewayWriteResponse). Even so it omits status, is_default, icon, usage_limits, rate_limits, and the settings blocks, so follow it with get() if you need those. workspaces.update() returns an empty {} — the write lands, but you must re-read to see it.

  • workspaces.create() needs a scope_name that already exists. It names an SCM IAM scope (gw.iamScopes), not a free-form label, and it is not derived from name. A scope_name with no matching scope fails with 400 AB01; a scope that exists but is never bound to the workspace (step 3) leaves the workspace invisible to data-plane list() — the most common way a freshly created workspace "goes missing". Use workspaces.provision() to do all three steps in order (see the Admin Plane section).

  • usage_limits / rate_limits are arrays of policy objects. On workspaces.get() and on the integrations.getWorkspaces() rows, these hold zero or more limit policies — not a single settings object. They are typed as a union (array, legacy object, or null), 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 API
    console.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() and configs.get() are different shapes — the list read omits config/format/type/version_id, and config is 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 returns 200 with an empty body and the record persists in deployments.list() with status: 'archived'. configs.delete(), guardrails.delete(), and providers.delete() are hard deletes — the resource vanishes from its list() 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, and credentials.password / client_auth are masked on every subsequent deployments.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 UUID
    auth_settings: { allow_all_workspaces: true },
    });
    // receipt.credentials.password — capture now; gw.deployments.get(receipt.id) masks it.
  • telemetry.logs() uses zero-based currentPage (SDK 0.28.0+). offset/page/skip are not the pagination contract. Full pagination was verified September 8 with 174 unique rows across four pages. Filter by statusCode: 446 for AIRS blocks, but still validate completeness; do not assume any filtered response contains every match.

  • organisation_id is 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 response organisation_id back 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.