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
protocol and API name the review-required outcome challenge.
For legacy compatibility, a bare require_human_review rule can return
decision: "allow" with a blocking review action. That Permit does not
authorize immediate execution until the review requirement is satisfied.
Permit-first callers must inspect both decision and actions.
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_idin 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.
Route summary
| Route | Status | Purpose |
|---|---|---|
POST /v1/permits | Canonical public | Evaluate a permit request and create the permit record. |
GET /v1/permits | Official public | List permit audit records. |
GET /v1/permits/{permit_id} | Official public | Fetch one permit audit record. |
GET /v1/permits/{permit_id}/timeline | Official public | Reconstruct this Permit’s dated lifecycle, expiry boundary, use evidence and revocation trail. |
GET /v1/permits/{permit_id}/lineage | Official public | Fetch parent, ancestor and child relationship records. |
GET /v1/permits/{permit_id}/bundle | Official public | Download the default audit bundle or request the separate work-chain.v1 profile. |
GET /v1/permits/export | Official public | Export a project audit bundle. |
POST /v1/permits/{permit_id}/usage | Official public | Report final verified usage for permit-first callers. |
POST /v1/permits/{permit_id}/close | Official public | End Work authority using an admin-scoped key. |
POST /v1/permits/{permit_id}/revoke | Official public | Revoke a Permit using an admin-scoped key. |
POST /v1/projects/{project_id}/permits | Compatibility | Older path-bound permit create flow. |
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
HTTP status codes
| Code | Meaning |
|---|---|
200 | Permit created. allow, deny, and challenge decisions all return 200 — branch on decision, not status code. |
400 | Malformed payload or validation failure (invalid subject, action, or resource structure). |
401 | Missing or invalid API key. |
403 | API key scope insufficient, or project_id in the body does not match the key’s project. |
409 | Idempotency key reused with a different request payload. |
500 | Unhandled 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 headerIdempotency-Keyinstead. 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/permitsstill requiresproject_idin the request body so the decision is attached to the correct project record.
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",
"expires_at": "2026-08-01T18:00:00Z", // optional; may only shorten the policy maximum
"subject": {
"type": "user",
"id": "usr_123"
},
"action": {
"name": "ai.generate.summary"
},
"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, // optional
"estimated_output_tokens": 250, // optional
"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
| Field | Required? | Current meaning |
|---|---|---|
project_id | Yes | Project that owns the permit request and resulting permit record. You can find your project_id in the Keel dashboard under Project Settings. |
idempotency_key | No | Client-supplied replay key for safe retries. |
subject | Yes | Actor the decision applies to. |
action | Yes | Requested governed action. |
resource | Yes | Request-shaped resource and execution metadata under evaluation. See resource fields below. |
context | No | Optional request context such as timestamp, IP, or user agent. This public top-level field is permit-only. |
work_request | Work root only | Strict payment-focused bounded-job request. It cannot be combined with caller-supplied root subject, action, or resource fields. |
parent_permit_id, work_authority_id | Linked Work child only | Select the exact signed Work parent and named authority that the child must fit. |
resource fields
| Field | Required? | Current meaning |
|---|---|---|
resource.type | Yes | Caller-supplied label for the resource kind. Not validated against an enum — any non-empty string is accepted. Common values: "request", "ai.chat.completion". |
resource.id | Yes | Caller-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.attributes | Yes | Execution metadata. See fields below. |
resource.attributes fields
| Field | Required? | Current meaning |
|---|---|---|
provider, model | Yes | Requested provider and model identity. |
operation | Yes | Canonical governed operation such as generate.text, generate.image, or transcribe.audio. |
modality | No | Execution context label used by routing, capability metadata, and audit records. |
execution_mode | No | sync, async, or realtime. |
estimated_input_tokens, estimated_output_tokens | No | Current permit-time estimate fields. |
max_output_tokens_requested | No | Requested upper bound for generated output. |
inputs | No | Declared inputs or assets. |
asset_summary | No | Optional asset metadata summary. |
routing | No | Optional routing hints. On POST /v1/permits these are recorded, not executed. |
callback_url | No | Optional callback target relevant to async execution. |
routing and context on permits
- On permits,
resource.attributes.routingis governance intent and recorded decision context. It can influence policy evaluation and audit records, butPOST /v1/permitsdoes not dispatch provider work or execute fallback. contextexists on permits as a public top-level request field. Execution routes do not expose a public top-levelcontextfield.
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',
'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.summary'
},
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 requests
response = requests.post(
"https://api.keelapi.com/v1/permits",
headers={
"Authorization": "Bearer keel_sk_your_key_here",
"Content-Type": "application/json",
},
json={
"project_id": "<project_uuid>",
"idempotency_key": "permit-demo-001",
"subject": {
"type": "user",
"id": "usr_123",
},
"action": {
"name": "ai.generate.summary",
},
"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, andchallenge— return HTTP 200. Permit denials are decisions, not errors. Branch ondecision, not on HTTP status code. This differs from execution routes, which return HTTP 403 for denials.
Allow response (HTTP 200)
{
"id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z",
"decision": "allow",
"actions": [
{
"type": "allow",
"message": "Allowed by base policy."
}
],
"metadata": {
"evaluated_at": "2026-03-26T18:42:11Z"
}
}Allow decisions do not carry reason_code.
Deny response (HTTP 200)
{
"id": "permit_01jyf4p6c3m7n9q2t5v8w1x4y",
"decision": "deny",
"reason_code": "policy.model_not_allowed",
"reason_detail": {
"category": "policy",
"kind": "model_not_allowed",
"outcome": "deny"
},
"message": "The requested model is not allowed for this project.",
"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 carries richer reason_detail. For example, a budget.daily_cap_exceeded deny includes cap_usd_micros, current_spend_usd_micros, and projected_spend_usd_micros inside reason_detail.
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
providerormodel, 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/permit_01jyf4m3n8q2r6t9v1w5x7y0z/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": "permit_01jyf4m3n8q2r6t9v1w5x7y0z",
"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/verifyThe 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 responses always include:
id— the permit record identifierdecision—"allow","deny", or"challenge"issued_at— when Keel issued the Permitexpires_at— the Permit boundary selected from the applicable lifetime limits, ornullwhen no limit appliesactions— array of action objects your application should processmetadata.evaluated_at— evaluation timestamp
Non-allow decisions (deny and challenge) additionally carry:
reason_code— machine-readable reason code (see Errors › Permit reason codes). Filter on this in error handlers and audit queries.reason_detail— structured evidence:category,kind,outcome, and dimension-specific fields such as spend amounts, cap values, or rate counters.message— human-readable explanation. Do not parse this field programmatically; usereason_codeinstead.
Allow decisions do not carry reason_code.
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:
- What may happen? The Permit names the action.
- 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.”
- How much or how often? Spend, token, Work-use and other supported limits remain part of their typed control surfaces.
- Until when?
issued_atandexpires_atshow 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; andnot_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.
usage_limits.max_calls on a general Permit is currently descriptive metadata,
not a dispatch gate. Do not treat it as enforced.
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_usesacross 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}/timelineThe 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" \
-d '{
"project_id": "<project_uuid>",
"idempotency_key": "flow-001",
"subject": {"type": "user", "id": "usr_123"},
"action": {"name": "ai.generate.summary"},
"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):
{
"id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z",
"decision": "allow",
"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.idIf decision is "deny", do not call the provider. Read reason_code for the machine-readable denial code and message 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 the call in permit-only mode.
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 ?? 0Step 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": "permit_01jyf4m3n8q2r6t9v1w5x7y0z",
"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.