Skip to Content
AI Permits

AI Permits

Keel’s standard product experience uses Keel-controlled execution paths configured through the dashboard. Use this advanced permit-first API when your architecture requires your application to retain enforcement and provider execution.

Use this API when you want Keel to evaluate an intended action, return an AI Permit, and leave downstream enforcement and execution to your application.

An AI Permit is a pre-execution decision record. It may allow, deny, throttle or require review.

An AI Permit is not the same as approval. Only an allow decision authorizes execution. Deny, throttle and review-required outcomes are also AI Permits. The internal policy protocol names the review-required state challenge; current public response models serialize it as review. Permit-first callers must dispatch only on decision: "allow" and must honor every returned constraint.

Where supported, Keel cryptographically signs and binds the AI Permit and its decision evidence.

Current implementation detail: an allow Permit must carry a valid signed binding before managed dispatch. If runtime signing is unavailable, non-allow outcomes can still be persisted without a binding signature so the decision record is not lost. Inspect the receipt or export artifact before making a signature claim about a specific Permit.

Permit creation requires project_id in the body.

When to use permits vs execution routes

  • You control the provider call — use POST /v1/permits. Keel evaluates policy and returns an AI Permit. Your app must enforce the decision and any constraints before it calls the provider, and it may report usage or supply receipt evidence later.
  • You want Keel to call the provider with a portable, provider-neutral request — use POST /v1/executions.
  • You have provider-shaped payloads but want normalized output — use POST /v1/execute.
  • You want provider-native input and output — use POST /v1/proxy/*.

All execution routes apply the same governance boundary before provider dispatch. The difference is whether you or Keel makes the provider call.

Model identity in a permit-first decision

A permit-first allow is a policy decision about the provider and model you name. It does not say that Keel can execute that model, that the provider offers it to your account, or that Keel prices it. Your application keeps responsibility for its own provider call and capability checks.

Keel’s own execution routes check again before dispatch: a model must be listed as executable, not past its sunset date, priced, and allowed by policy. Some models are listed for discovery only, because their published pricing changes with prompt length or on a set date; they are refused on execution routes. Some models cost more once a prompt passes a long-context threshold; Keel prices that tier in its estimates and settles the provider’s reported tokens at the tier the provider charges.

Route summary

RouteStatusPurpose
POST /v1/permitsCanonical publicEvaluate a permit request and create the permit record.
GET /v1/permitsOfficial publicList permit audit records.
GET /v1/permits/{permit_id}Official publicFetch one permit audit record.
GET /v1/permits/{permit_id}/timelineOfficial publicReconstruct this Permit’s dated lifecycle, expiry boundary, use evidence and revocation trail.
GET /v1/permits/{permit_id}/lineageOfficial publicFetch parent, ancestor and child relationship records.
GET /v1/permits/{permit_id}/bundleOfficial publicDownload the default audit bundle or request the separate work-chain.v1 profile.
GET /v1/permits/{permit_id}/proof-packOfficial publicDownload one signed decision plus the manifests required by the public offline verifier; available on Starter.
GET /v1/permits/exportOfficial publicExport a project audit bundle.
POST /v1/permits/{permit_id}/usageOfficial publicReport final verified usage for permit-first callers.
POST /v1/permits/{permit_id}/closeOfficial publicEnd Work authority using an admin-scoped key.
POST /v1/permits/{permit_id}/revokeOfficial publicRevoke a Permit using an admin-scoped key.
POST /v1/projects/{project_id}/permitsCompatibilityOlder path-bound permit create flow. Requires the same X-Keel-Timestamp and new X-Keel-Nonce as POST /v1/permits; see Request Freshness.

Authentication

Authorization: Bearer <project_api_key>

X-API-Key remains accepted for compatibility, but Bearer auth is the documented contract.

POST /v1/permits/{permit_id}/usage is narrower than the rest of the permit surface:

  • it requires an admin-scope project API key
  • public closeout requires supplied receipt or callback material. Keel records and binds that material; the generic closeout path does not independently prove provider origin or the ultimate external result

Verify a Starter Sandbox decision offline

Every persisted Permit decision, including a Starter Sandbox decision that has not executed at a provider, can be downloaded as a permit-scoped proof pack:

curl -fsS -H "Authorization: Bearer $KEEL_API_KEY" \ "https://api.keelapi.com/v1/permits/$KEEL_PERMIT_ID/proof-pack" \ -o permit-proof.zip unzip permit-proof.zip -d permit-proof python3 -m pip install keel-verifier keel-verify export --json \ permit-proof/export.json permit-proof/manifest.json \ --key-manifest permit-proof/key_manifest.json

Use the key manifest included in the downloaded pack. No local development trust root or manually supplied signing key is required. A valid result proves the signed policy decision. It does not prove provider dispatch, provider acceptance, or an external outcome. Bulk compliance exports remain a Production-plan feature.

HTTP status codes

CodeMeaning
200Permit created. allow, deny, review, and throttle decisions all return 200 — branch on decision, not status code.
400Malformed payload or validation failure (invalid subject, action, or resource structure).
401Missing or invalid API key, or request_not_fresh. For freshness errors, generate a current timestamp and a new nonce; see Request Freshness.
403API key scope insufficient, project_id in the body does not match the key’s project, or a structural refusal: a platform precondition (plan request quota budget.plan_quota_exceeded, billing state billing_*, or the platform rate limit rate_limited) stopped evaluation. No Permit is issued; the body carries error, reason_code, and outcome_kind: "precondition".
409Idempotency key reused with a different request payload, or legacy_review_replay_blocked: the key belongs to a historical Permit whose review is unresolved in the legacy representation. That Permit cannot authorize execution; request new authority with a new idempotency key.
500Unhandled server error.

Idempotency

POST /v1/permits uses the body field idempotency_key (not an HTTP header).

Execution routes (/v1/executions, /v1/execute, /v1/proxy/*) use the HTTP header Idempotency-Key instead. Do not mix the two — body keys are ignored on execution routes, and headers are ignored on permits.

  • same key plus the same semantic permit payload replays the prior permit
  • same key plus a different semantic payload returns 409 conflict
  • if the client omits the field, Keel generates a server-side idempotency key

That default makes onboarding ergonomic, but retries without a client-supplied key do not get a stable client-controlled replay contract.

Permit replay is also stateful: active or terminal permit states replay, while stale allow permits with inactive reservations can revalidate or return expired state instead of replaying the original allow body.

Request shape

The standard action request shape follows below. A bounded Work-root request is the strict exception: it uses the same POST /v1/permits route but accepts only project_id, idempotency_key, and work_request. Keel derives the root subject, work.authorize action, resource attributes, role, semantic identity, and presentation title from the authenticated agent and server-owned contract. See AI Permit-to-X for the full Work and linked-action examples.

The bearer token is project-scoped, but POST /v1/permits still requires project_id in the request body so the decision is attached to the correct project record.

Hosted Permit creation requires X-Keel-Timestamp and a new X-Keel-Nonce; see the runnable example below and Request Freshness.

Start here. Fields marked optional can be omitted for a first call — add them when your policy needs estimates, routing hints, or request context.

{ "project_id": "<project_uuid>", "idempotency_key": "permit-demo-001", "subject": { "type": "user", "id": "usr_123" }, "action": { "name": "ai.generate" }, "resource": { "type": "request", "id": "req_123", "attributes": { "provider": "openai", "model": "gpt-4o-mini", "operation": "generate.text", "modality": "text", // optional "execution_mode": "sync", // optional "estimated_input_tokens": 200, // required "estimated_output_tokens": 250, // required "max_output_tokens_requested": 300, // optional "routing": {} // optional — routing hints } }, "context": { // optional, permit-only "timestamp": "2026-03-09T00:00:00Z", "ip": "127.0.0.1", "user_agent": "curl" } }

Top-level fields

FieldRequired?Current meaning
project_idYesProject that owns the permit request and resulting permit record. You can find your project_id in the Keel dashboard under Project Settings.
idempotency_keyNoClient-supplied replay key for safe retries.
subjectYesActor the decision applies to.
actionYesRequested governed action.
resourceYesRequest-shaped resource and execution metadata under evaluation. See resource fields below.
contextNoOptional request context such as timestamp, IP, or user agent. This public top-level field is permit-only.
work_requestWork root onlyStrict payment-focused bounded-job request. It cannot be combined with caller-supplied root subject, action, or resource fields.
parent_permit_id, work_authority_idLinked Work child onlySelect the exact signed Work parent and named authority that the child must fit.

resource fields

FieldRequired?Current meaning
resource.typeYesCaller-supplied label for the resource kind. Not validated against an enum — any non-empty string is accepted. Common values: "request", "ai.chat.completion".
resource.idYesCaller-generated identifier for this resource instance. Not validated beyond being a non-empty string — no UUID or format requirement. Use this to correlate the permit with your internal request tracking.
resource.attributesYesExecution metadata. See fields below.

resource.attributes fields

FieldRequired?Current meaning
provider, modelYesRequested provider and model identity.
operationYesCanonical governed operation such as generate.text or embed.text. Keel denies operations it cannot price authoritatively (for example unsupported_operation).
modalityNoExecution context label used by routing, capability metadata, and audit records.
execution_modeNosync, async, or realtime.
estimated_input_tokens, estimated_output_tokensYes, for the standard action requestNon-negative token estimates. Supply 0 where the operation has no token use. Bounded Work-root requests use work_request instead.
max_output_tokens_requestedNoRequested upper bound for generated output.
inputsNoDeclared inputs or assets.
asset_summaryNoOptional asset metadata summary.
routingNoOptional routing hints. On POST /v1/permits these are recorded, not executed.
callback_urlNoOptional callback target relevant to async execution.

routing and context on permits

  • On permits, resource.attributes.routing is governance intent and recorded decision context. It can influence policy evaluation and audit records, but POST /v1/permits does not dispatch provider work or execute fallback.
  • context exists on permits as a public top-level request field. Execution routes do not expose a public top-level context field.

Compatibility note:

  • canonical docs prefer resource.attributes.*
  • new integrations should use the documented resource.attributes.* fields rather than relying on older compatibility shapes

What POST /v1/permits does not do

  • it does not execute provider work
  • it does not proxy the provider request on your behalf
  • it does not by itself prove later provider execution
  • it does not replace POST /v1/executions, POST /v1/execute, or the provider-specific proxy routes
  • a Work root does not dispatch and is not a workflow, progress model, or job-completion assertion

API example

JavaScript

const response = await fetch('https://api.keelapi.com/v1/permits', { method: 'POST', headers: { Authorization: 'Bearer keel_sk_your_key_here', 'X-Keel-Timestamp': String(Math.floor(Date.now() / 1000)), 'X-Keel-Nonce': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: '<project_uuid>', idempotency_key: 'permit-demo-001', subject: { type: 'user', id: 'usr_123' }, action: { name: 'ai.generate' }, resource: { type: 'request', id: 'req_123', attributes: { provider: 'openai', model: 'gpt-4o-mini', operation: 'generate.text', modality: 'text', execution_mode: 'sync', estimated_input_tokens: 200, estimated_output_tokens: 250, max_output_tokens_requested: 300 } } }) }) const data = await response.json() console.log(data)

Python

import secrets import time import requests response = requests.post( "https://api.keelapi.com/v1/permits", headers={ "Authorization": "Bearer keel_sk_your_key_here", "Content-Type": "application/json", "X-Keel-Timestamp": str(int(time.time())), "X-Keel-Nonce": secrets.token_hex(16), }, json={ "project_id": "<project_uuid>", "idempotency_key": "permit-demo-001", "subject": { "type": "user", "id": "usr_123", }, "action": { "name": "ai.generate", }, "resource": { "type": "request", "id": "req_123", "attributes": { "provider": "openai", "model": "gpt-4o-mini", "operation": "generate.text", "modality": "text", "execution_mode": "sync", "estimated_input_tokens": 200, "estimated_output_tokens": 250, "max_output_tokens_requested": 300, }, }, }, ) print(response.json())

Response examples

All permit decisions — allow, deny, review, and throttle — return HTTP 200. Permit denials are decisions, not errors. The exception is a structural refusal (plan quota, billing state, platform rate limit), which returns HTTP 403 with an error object and no Permit. Branch on decision, not on HTTP status code. Execution routes instead project denials and throttles to their documented HTTP error statuses.

The excerpts below show the fields a caller normally branches on. The generated schema remains authoritative for the complete response object.

Allow response (HTTP 200)

{ "permit_id": "31d66d92-4e90-4dc6-9278-0b86551e31bc", "project_id": "<project_uuid>", "issued_at": "2026-03-26T18:42:11Z", "decision": "allow", "reason": "policy.active_policy_candidate", "reason_code": "policy.active_policy_candidate", "display_reason": "Active policy authorized this action.", "decision_source": "Decision made by Keel", "actions": [ { "type": "allow", "message": "Allowed by base policy." } ], "metadata": { "evaluated_at": "2026-03-26T18:42:11Z" } }

Deny response (HTTP 200)

{ "permit_id": "b5178c7f-8bec-4f02-9a86-07e8384fc7fb", "project_id": "<project_uuid>", "issued_at": "2026-03-26T18:43:02Z", "decision": "deny", "reason": "policy.model_not_allowed", "reason_code": "policy.model_not_allowed", "display_reason": "The requested model is not allowed for this project.", "decision_source": "Decision made by Keel", "decision_details": { "decision": "deny", "code": "policy.model_not_allowed", "reason": "The requested model is not allowed for this project.", "reason_detail": { "category": "policy", "kind": "model_not_allowed", "outcome": "deny" } }, "actions": [ { "type": "deny", "message": "The requested model is not allowed for this project." } ], "metadata": { "evaluated_at": "2026-03-26T18:43:02Z" } }

A deny with a budget code looks the same but may carry richer structured fields inside decision_details.reason_detail and decision_details.outcome_detail. Treat additive detail fields as diagnostics; branch on decision and reason_code.

actions array

The actions array tells your application what to do next. Iterate it — do not ignore it.

Each action object has type (string) and message (string). Both fields are always present. A single response can contain multiple actions.

The primary action types are allow and deny. Additional action types may appear — always check the type field and handle unknown types gracefully.

Report usage

After an allowed permit completes, close it out on the permit record:

POST /v1/permits/{permit_id}/usage

This route is intentionally narrower than permit creation:

  • it requires an admin-scope project API key
  • completed public closeout requires supplied receipt or callback material. Keel records and binds that material; the generic closeout path does not independently prove provider origin or the ultimate external result
  • positive billed cost is still required for completed public closeout
  • if you send provider or model, they must match the issued permit

For the broader permit-first observation boundary, see Scope and Limits § Permit-first observation boundary.

curl -sS -X POST https://api.keelapi.com/v1/permits/31d66d92-4e90-4dc6-9278-0b86551e31bc/usage \ -H "Authorization: Bearer keel_sk_admin_project_key_here" \ -H "Content-Type: application/json" \ -d '{ "provider": "openai", "model": "gpt-4o-mini", "actual_input_tokens": 182, "actual_output_tokens": 247, "actual_total_tokens": 429, "cost_usd_micros": 820, "usage_idempotency_key": "usage-demo-001", "verification": { "method": "provider_receipt", "provider_request_id": "req_123", "receipt_json": { "request_id": "req_123" } } }'
{ "permit_id": "31d66d92-4e90-4dc6-9278-0b86551e31bc", "project_id": "<project_uuid>", "usage_reported_at": "2026-04-10T18:42:11Z", "actual_input_tokens": 182, "actual_output_tokens": 247, "actual_total_tokens": 429, "actual_cost_usd_micros": 820, "usage_source": "caller_report", "usage_verification": { "method": "provider_receipt", "status": "pending", "updated_at": "2026-04-10T18:42:11Z" }, "status": "completed" }

Permit-first closeout truth

  • permit-first closeout is still not execution-bound
  • Keel does not directly observe the provider call on this path
  • public verification material narrows the trust gap, but it does not turn permit-first closeout into direct execution proof
  • when an allowed permit is never closed out, Keel can mark it as missing usage report
  • a later valid closeout can still transition that permit to completed, but billing-period request counting remains anchored to permit issuance

Verification track

After a permit-first request closes out via POST /v1/permits/{permit_id}/usage, callers can attach stronger receipt evidence — provider receipt material or signed-callback material — through:

POST /v1/permits/{permit_id}/usage/verify

The verification track records receipt evidence separately from permit completion state and progresses through explicit verification states (unverified, pending, verified, rejected, reconciled). Multiple verification calls can refine the evidence over time.

The full verification surface — methods, state semantics, the accounting_disposition interaction, and a worked example — is documented as part of the Reconciliation pillar.

Response shape

Permit-creation responses include these core fields:

  • permit_id — the Permit record identifier (a UUID)
  • decision — "allow", "deny", "review", or "throttle"
  • issued_at — when Keel issued the Permit
  • expires_at — the Permit boundary selected from the applicable lifetime limits, or null when no limit applies
  • actions — array of action objects your application should process
  • metadata.evaluated_at — evaluation timestamp
  • reason_code — machine-readable reason for the decision, including allow
  • display_reason — human-readable explanation; do not parse it programmatically
  • decision_source — identifies Keel as the decision source

Non-allow decisions may add structured evidence in decision_details and outcome-specific fields. The separate GET /v1/permits/{permit_id} audit readback uses id and created_at, not the create response’s permit_id and issued_at. See the generated schema for the complete shape.

Additional fields may appear depending on the decision outcome. Treat any fields not listed above as optional.

The four questions a Permit should answer

In human terms, an operational Permit should make four things easy to see:

  1. What may happen? The Permit names the action.
  2. Where may it happen? The Permit is bound to the requested resource or endpoint. This is generic endpoint scope, not a special concept of “vendor.”
  3. How much or how often? Spend, token, Work-use and other supported limits remain part of their typed control surfaces.
  4. Until when? issued_at and expires_at show the Permit’s time window.

Who sets the expiry?

Policy sets the maximum lifetime. A caller may ask for a shorter window, but cannot ask past that maximum. Keel uses the earliest applicable boundary across the caller’s request, matching Policy rules, parent Permit, delegated authority, approval or platform limit.

Matching Policies set:

  • max_ttl_seconds — the required maximum number of seconds after issuance; and
  • not_after — an optional absolute date and time the Permit must not outlive.

The response constraint metadata records the effective boundary and which source limited it. Changing a Policy later does not rewrite the historical Permit, but final managed dispatch still checks current Permit and authority liveness.

Which endpoint is covered?

Policy can narrow Permits by endpoint type, exact endpoint ID, provider and operation. The issued lifecycle record repeats the request-bound target so an operator can see the scope without reconstructing the original request.

This is an authorization binding, not proof that an unrelated remote endpoint independently verified the Permit. On permit-first flows, the application still owns downstream enforcement. On supported Keel-managed flows, Keel checks the Permit again before dispatch.

How many times may it be used?

There is not one universal “number of uses” counter for every Permit. Keel enforces usage_limits.max_calls only when Keel itself performs the final dispatch for that Permit; that is what the API reference means by “enforced at final dispatch”. Keel does not dispatch a Permit you create with POST /v1/permits for your own application to act on (permit-first), so it does not count or enforce that Permit’s max_calls. It records the value, and even max_calls: 0 still returns the policy decision. Your application must enforce it.

Where a surface has a real use contract, Keel says so specifically:

  • exact reviewed dispatch authority is consumed once where that execution path defines single-use approval; and
  • bounded Work authority enforces its issued max_uses across linked action Permits.

Use idempotency to prevent duplicate Permit creation. Use the documented surface-specific execution or Work limit when you need an enforced use count.

Permit timeline and revocation trail

Fetch the Permit-centric lifecycle with:

GET /v1/permits/{permit_id}/timeline

The response includes a compact Permit summary, the request-bound target, related Permit IDs and dated events. It can show when the Permit was granted, when recorded execution or usage events occurred, when it expires, and whether it was revoked, including the recorded actor and reason.

The expiry marker is derived from the stored expires_at boundary. It is labelled as derived rather than presented as a separately observed event. Revocation is an observed stored state. For a chronology organized around the whole request instead of one Permit, use Timeline Replay.

Constraints

Some permits can carry constraint details, such as an output-token limit.

Public boundary:

  • on Keel-managed execution routes, supported constraints are enforced before dispatch
  • on permit-first flows, your application is responsible for honoring any constraints carried by the permit decision
  • treat constraint fields as decision output, not as a promise about internal enforcement mechanics

Budget snapshot

When budget caps or guardrails are configured, permits can carry an optional budget snapshot. Each section is optional and appears only when the relevant cap or guardrail is active. All monetary values are in usd_micros. Example:

{ "request": { "estimated_cost": 120000, "cap": 150000, "remaining": 30000 }, "daily": { "current_spend": 2200000, "projected_spend": 2320000, "cap": 3000000, "remaining": 800000 }, "monthly": { "current_spend": 7800000, "projected_spend": 7920000, "cap": 10000000, "remaining": 2080000 } }

End-to-end permit flow

Use this when your app controls the provider call and wants decision-first governance.

Step 1 — Create the permit

curl -sS -X POST https://api.keelapi.com/v1/permits \ -H "Authorization: Bearer keel_sk_your_key_here" \ -H "Content-Type: application/json" \ -H "X-Keel-Timestamp: $(date +%s)" \ -H "X-Keel-Nonce: $(openssl rand -hex 16)" \ -d '{ "project_id": "<project_uuid>", "idempotency_key": "flow-001", "subject": {"type": "user", "id": "usr_123"}, "action": {"name": "ai.generate"}, "resource": { "type": "request", "id": "req_flow_001", "attributes": { "provider": "openai", "model": "gpt-4o-mini", "operation": "generate.text", "estimated_input_tokens": 120, "estimated_output_tokens": 80, "max_output_tokens_requested": 120 } } }'

Allow response (HTTP 200):

{ "permit_id": "31d66d92-4e90-4dc6-9278-0b86551e31bc", "decision": "allow", "reason_code": "policy.active_policy_candidate", "actions": [ {"type": "allow", "message": "Allowed by base policy."} ] }

Step 2 — Check the decision

const permit = await response.json() if (permit.decision !== 'allow') { throw new Error(`blocked: ${permit.reason_code}`) } const permitId = permit.permit_id

If decision is anything other than "allow", do not call the provider. Read reason_code for the machine-readable category and display_reason for a human-readable explanation. Always iterate the actions array and handle each action type your integration supports.

Step 3 — Call the provider directly

Your app uses its own provider credential for this step. Keel does not proxy a call your application executes after using the decision-only API.

const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY!}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Summarize this document in one sentence.' }], max_tokens: 120 }) }) const completion = await openaiResponse.json() const inputTokens = completion.usage?.prompt_tokens ?? 0 const outputTokens = completion.usage?.completion_tokens ?? 0

Step 4 — Report usage

Close the permit with caller-reported actual usage and verification material.

await fetch(`https://api.keelapi.com/v1/permits/${permitId}/usage`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.KEEL_ADMIN_API_KEY!}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'openai', model: 'gpt-4o-mini', actual_input_tokens: inputTokens, actual_output_tokens: outputTokens, actual_total_tokens: inputTokens + outputTokens, cost_usd_micros: 820, usage_idempotency_key: 'usage-demo-001', verification: { method: 'provider_receipt', provider_request_id: completion.id, receipt_json: { request_id: completion.id } } }) })

Response:

{ "permit_id": "31d66d92-4e90-4dc6-9278-0b86551e31bc", "status": "completed", "usage_verification": { "method": "provider_receipt", "status": "pending" } }

If you skip usage reporting, the permit remains a decision record only. Keel can later mark its accounting disposition as missing_usage_report, and exact downstream tokens and cost can remain unknown.

Last updated on Edit this page on GitHubÂ