Skip to main content

SDK: Approval callback

Supported modes: Hosted Hybrid SDK: 1.6.0+ (validator-additive in 1.5.8)

When client.guard() returns a denied decision and decision.hitl_eligible is true, you can request approval interactively via client.request_approval(). This page is the API reference.

Python

from controlzero import Client, PolicyDeniedError

client = Client() # reads ~/.controlzero/config.yaml (api_key + identity.email)

decision = client.guard("Bash:sudo apt-get install python3-foo")
if decision.denied and decision.hitl_eligible:
request = client.request_approval(
decision,
message="installing test dep for FOO-1234",
timeout_s=300, # default 300s; server cap 1800s
)
final = request.wait() # blocks; polls /api/approval-requests/{id}
if final.denied:
raise PolicyDeniedError(final)
# proceed; final.approved_via_hitl is True

For async code:

final = await request.wait_async()  # event-loop friendly

Observable attributes

request is a PendingApproval with readable attributes:

  • request.poll_interval_s. Current poll interval (1s for first 10 polls, then exp backoff to 10s ceiling)
  • request.deadline_at. Absolute timestamp when wait() raises HITLTimeoutError
  • request.status. "pending" | "resolved" | "expired"

Mock mode (local dev)

client = Client(api_key="...", hitl_mock="approve_after_2s")
# Modes: "approve_after_2s" | "approve_timed_after_2s" |
# "approve_forever_after_2s" | "deny_after_2s" |
# "timeout" | None (live)

In-process; no backend stand-up. Same request_approval / wait flow; the mock short-circuits the network calls.

CLI helper

controlzero test Bash:sudo --hitl approve
controlzero test Bash:sudo --hitl deny
controlzero test Bash:sudo --hitl timeout

Walks the SDK through the approval branch end-to-end with the mock.

Node

import { Client, PolicyDeniedError } from '@controlzero/sdk';

const client = new Client();

const decision = await client.guard('Bash:sudo apt-get install python3-foo');
if (decision.denied && decision.hitlEligible) {
const request = await client.requestApproval(decision, {
message: 'installing test dep for FOO-1234',
timeoutS: 300,
});
const final = await request.wait();
if (final.denied) {
throw new PolicyDeniedError(final);
}
// proceed; final.approvedViaHitl is true
}

Node has no sync HTTP, so there's no wait_async distinction; wait() returns a Promise. Same observable attributes (pollIntervalS, deadlineAt, status).

Go

package main

import (
"context"
"time"
"controlzero.ai/sdk/go"
)

func main() {
ctx := context.Background()
client, _ := controlzero.NewClient()

decision, _ := client.Guard(ctx, "Bash:sudo apt-get install python3-foo")
if decision.Denied() && decision.HITLEligible {
req, _ := client.RequestApproval(ctx, decision, controlzero.ApprovalOpts{
Message: "installing test dep for FOO-1234",
Timeout: 5 * time.Minute,
})
final, _ := req.Wait(ctx)
if final.Denied() {
// return &controlzero.PolicyDeniedError{Decision: final}
}
// proceed; final.ApprovedViaHITL is true
}
}

Go uses idiomatic time.Duration for the timeout and honors ctx.Done() on Wait().

Exception class hierarchy

All SDKs share the same E_CODE catalog. Catching PolicyDeniedError catches the timeout + identity-rejected + no-approver classes too:

ClassParentE_CODE
HITLTimeoutErrorPolicyDeniedErrorE1301
HITLBackendUnreachableErrorHostedBootstrapErrorE1302
HITLPolicyVersionConflictErrorHybridModeErrorE1303
HITLNotConfiguredErrorRuntimeErrorE1304
HITLNoApproverAvailablePolicyDeniedErrorE1305
HITLIdentityNotInOrgRuntimeErrorE1306
HITLIdentityRequiredRuntimeErrorE1307
HITLIdentityClaimRejectedPolicyDeniedErrorE1308

The class names retain the HITL prefix because they are part of the SDK's stable public API surface. Renaming them would be a breaking change for any caller that catches them by name.

Idempotency

request_approval() auto-generates an Idempotency-Key UUID per invocation. Agent retries with the same key return the same request_id, so no duplicate row on the backend. The key is opaque to the user; you don't construct or read it.

Polling cadence (public contract)

wait() polls at 1s intervals for the first 10 polls, then exponential backoff up to a 10s ceiling. This is stable across the 1.x line. SDK 1.5.7 polls the same way; SDK 1.6.0 polls the same way; future minors won't change it without a major version bump.

If you need different cadence, do not call wait(). Use the lower-level client.poll_approval(request_id) instead.

See also