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 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_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.

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/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.

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

CodeMeaning
200Permit created. allow, deny, and challenge 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.
403API key scope insufficient, or project_id in the body does not match the key’s project.
409Idempotency key reused with a different request payload.
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.

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

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, generate.image, or transcribe.audio.
modalityNoExecution context label used by routing, capability metadata, and audit records.
execution_modeNosync, async, or realtime.
estimated_input_tokens, estimated_output_tokensNoCurrent permit-time estimate fields.
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', '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, and challenge — return HTTP 200. Permit denials are decisions, not errors. Branch on decision, 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 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/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/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 responses always include:

  • id — the permit record identifier
  • decision — "allow", "deny", or "challenge"
  • 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

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; use reason_code instead.

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:

  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. 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_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" \ -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.id

If 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 ?? 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": "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.

Last updated on Edit this page on GitHubÂ