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 whenwait()raisesHITLTimeoutErrorrequest.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:
| Class | Parent | E_CODE |
|---|---|---|
HITLTimeoutError | PolicyDeniedError | E1301 |
HITLBackendUnreachableError | HostedBootstrapError | E1302 |
HITLPolicyVersionConflictError | HybridModeError | E1303 |
HITLNotConfiguredError | RuntimeError | E1304 |
HITLNoApproverAvailable | PolicyDeniedError | E1305 |
HITLIdentityNotInOrg | RuntimeError | E1306 |
HITLIdentityRequired | RuntimeError | E1307 |
HITLIdentityClaimRejected | PolicyDeniedError | E1308 |
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
- Approval Workflow
- Multi-user keys. Identity setup
- Secrets approvals. Approvals on
get_secret()