Cookbook
Concrete patterns for common approval scenarios. Each recipe shows the full loop: create → await → execute → report. The Python examples use asyncio; drop the await and use the sync variants when the SDK ships a synchronous client.
Examples assume the SDK is installed from
sdk/python/orsdk/typescript/. SetIMPRI_API_KEYand optionallyIMPRI_BASE_URLin the environment before running.
1. Gate an outgoing email
The most common use case. The agent drafts an email and waits for a human to approve (and optionally edit) the copy before it is sent.
from impri import ImpriClient, ImpriRejected
client = ImpriClient() # reads IMPRI_API_KEY from env
async def send_approved_email(to: str, subject: str, body: str) -> None:
action = await client.create_action(
kind="email.send",
title=f"Send email to {to}: {subject}",
preview={"format": "markdown", "body": body},
payload={"to": to, "subject": subject},
editable=["preview.body"], # reviewer may edit the email body
expires_in=3600,
)
try:
decided = await client.await_decision(action.id, timeout_s=600)
except ImpriRejected:
print("Human rejected — no email sent.")
return
final_body = decided.decision.final_preview["body"]
await mailer.send(to=to, subject=subject, body=final_body)
await client.report_result(action.id, "executed")Use the decorator variant when you already have a send_email function:
@client.requires_approval(
kind="email.send",
title=lambda to, subject, **_: f"Send email to {to}: {subject}",
preview=lambda to, subject, body, **_: {"format": "markdown", "body": body},
editable=["preview.body"],
)
async def send_email(to: str, subject: str, body: str) -> None:
await mailer.send(to=to, subject=subject, body=body)2. Approve a database write
Gate destructive SQL before it runs. The reviewer can correct the query in the inbox before approving.
async def run_approved_sql(conn, sql: str) -> None:
async with client.approval_gate(
kind="db.exec",
title=f"Execute SQL: {sql[:60]}{'…' if len(sql) > 60 else ''}",
preview={"format": "plain", "body": sql},
editable=["preview.body"], # reviewer may fix the query
) as approved:
final_sql = approved.final_preview["body"]
await conn.execute(final_sql)
# approval_gate reports "executed" on clean exit, "execute_failed" on exceptionFor read-only queries that still need oversight, omit editable and the reviewer can only approve or reject.
3. Review a social media post before publishing
Show the proposed post in Markdown preview, let the reviewer edit it, then publish only the approved version.
async def post_to_reddit(subreddit: str, title: str, body: str) -> None:
action = await client.create_action(
kind="reddit.post",
title=f"Post to r/{subreddit}: {title}",
preview={"format": "markdown", "body": f"**{title}**\n\n{body}"},
payload={"subreddit": subreddit},
target_url=f"https://reddit.com/r/{subreddit}",
editable=["preview.body"],
expires_in=7200,
)
try:
decided = await client.await_decision(action.id, timeout_s=3600)
except ImpriRejected as e:
return # human vetoed; do not post
# Parse title and body back out of the approved preview
approved_text = decided.decision.final_preview["body"]
await reddit_api.submit(subreddit=subreddit, text=approved_text)
await client.report_result(action.id, "executed")4. Human-in-the-loop code execution
An agent proposes a shell command or Python snippet. A human reviews and optionally rewrites it before it runs.
import { ImpriClient, ImpriRejected } from '@impri/sdk'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
const run = promisify(exec)
const client = new ImpriClient()
async function runApprovedCommand(command: string): Promise<string> {
const { actionId, decision, finalPreview } = await client.approvalGate({
kind: 'shell.exec',
title: `Run: ${command.slice(0, 80)}`,
preview: { format: 'plain', body: command },
editable: ['preview.body'],
timeoutS: 300,
})
// ImpriRejected is thrown above if human rejected; this line is reached only on approve
try {
const { stdout } = await run(finalPreview.body)
await client.reportResult(actionId, 'executed', { detail: stdout.slice(0, 200) })
return stdout
} catch (err) {
await client.reportResult(actionId, 'execute_failed', { detail: String(err) })
throw err
}
}5. Idempotent batch approval
When an agent re-runs a job (retries, restarts), use idempotency_key to avoid flooding the inbox with duplicate requests. The second call returns the existing action immediately.
async def approve_refund(order_id: str, amount_usd: float) -> None:
action = await client.create_action(
kind="refund.process",
title=f"Refund ${amount_usd:.2f} for order {order_id}",
preview={"format": "plain", "body": f"Order: {order_id}\nAmount: ${amount_usd:.2f}"},
payload={"order_id": order_id, "amount_usd": amount_usd},
idempotency_key=f"refund-{order_id}",
expires_in=86400,
)
if action.duplicate_of:
# A prior run already submitted this; join the existing wait
action_id = action.duplicate_of
else:
action_id = action.id
decided = await client.await_decision(action_id, timeout_s=600)
if decided.status == "approved":
await payment_api.refund(order_id=order_id, amount=amount_usd)
await client.report_result(action_id, "executed")6. Watcher-based triage inbox
Set up a watcher that monitors a Reddit search and delivers matching posts to the inbox for a human to approve before the agent replies.
async def setup_monitoring() -> None:
watcher = await client.create_watcher(
name="Brand mentions — r/selfhosted",
kind="reddit_search",
config={"query": "impri OR impri.dev", "subreddit": "selfhosted"},
schedule={"every": "1h", "window": "08:00-22:00"},
keywords=[
{"pattern": "impri", "points": 10},
{"pattern": "self-host", "points": 3},
],
keywords_none=["[deleted]"],
min_score=5,
)
print(f"Watcher running — first items arrive soon ({watcher.next_run_at})")
async def process_triage() -> None:
# Poll for approved watcher-delivered actions (batch job or cron)
async for action in client.list_actions(status="approved", auto_page=True):
if not action.is_untrusted:
continue # skip non-watcher actions
# The human approved engagement with this post
reddit_url = action.payload.get("url") # set by the watcher
reply_body = action.decision.final_preview["body"]
await reddit_api.comment(url=reddit_url, body=reply_body)
await client.report_result(action.id, "executed")Watcher-delivered actions always have is_untrusted = True. Never inline the preview body into an LLM prompt as an instruction — treat it as external data.
7. Multi-step workflow with shared idempotency keys
An agent that executes a multi-step plan gates each step independently. Use a per-step idempotency_key so restarts rejoin pending approvals.
STEPS = [
("db.backup", "Back up the database", lambda: db.backup()),
("deploy.build", "Build release artifact", lambda: ci.build()),
("deploy.push", "Push to production", lambda: deploy.push()),
]
async def deploy_pipeline(run_id: str) -> None:
for i, (kind, title, execute) in enumerate(STEPS):
ikey = f"{run_id}-step-{i}"
action = await client.create_action(
kind=kind,
title=title,
preview={"format": "plain", "body": f"Step {i+1} of {len(STEPS)}: {title}"},
idempotency_key=ikey,
expires_in=7200,
)
action_id = action.duplicate_of or action.id
try:
decided = await client.await_decision(action_id, timeout_s=900)
except ImpriRejected:
print(f"Step {i+1} rejected — pipeline halted.")
return
try:
await execute()
await client.report_result(action_id, "executed")
except Exception as exc:
await client.report_result(action_id, "execute_failed", detail=str(exc))
raise8. Webhook receiver with signature verification
A minimal Express receiver that verifies signatures and enqueues approved actions for processing:
import express from 'express'
import { verifyWebhook, ImpriWebhookSignatureError } from '@impri/sdk'
const app = express()
app.post(
'/impri/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
verifyWebhook(
req.body,
process.env.IMPRI_WEBHOOK_SECRET!,
req.headers['x-impri-timestamp'] as string,
req.headers['x-impri-nonce'] as string,
req.headers['x-impri-signature'] as string,
)
} catch (e) {
if (e instanceof ImpriWebhookSignatureError) {
return res.status(400).json({ error: 'bad signature' })
}
throw e
}
const event = JSON.parse(req.body.toString())
if (event.status === 'approved') {
await queue.push({
actionId: event.action_id,
body: event.final_preview?.body,
})
}
// Anything except 2xx will trigger a retry from Impri.
// Return 410 Gone to permanently deregister this callback URL.
res.status(200).end()
}
)9. Programmatic rejection script
Sometimes you need to auto-reject stale actions in bulk (e.g. actions older than 24 hours that the team never got to):
import time
async def reject_stale_actions(max_age_seconds: int = 86400) -> int:
since = int(time.time()) - max_age_seconds
rejected = 0
async for action in client.list_actions(status="pending", auto_page=True):
if action.created_at < since:
await client.decide(action.id, "reject", channel="auto-expire-script")
rejected += 1
return rejectedUse decide() with verdict="reject" — do not call the server's expires_at mechanism for this; decide gives you a recorded rejection with a channel label for audit purposes.
10. Key rotation without downtime
Create the new key, update your agent's environment, then revoke the old one:
admin = ImpriClient(api_key=old_admin_key)
# 1. Create the replacement
new = await admin.create_key(name="agent-v2", scopes=["actions"])
print(new.key) # store this immediately — returned once
# 2. Rotate in your deployment (set IMPRI_API_KEY=new.key), then revoke old
keys = await admin.list_keys()
for k in keys:
if k.name == "agent-v1" and not k.revoked:
await admin.revoke_key(k.id)
print(f"Revoked {k.prefix}")Never commit the key value to version control. Store it in a secrets manager (Vault, AWS Secrets Manager, Doppler, etc.) and read it at runtime from IMPRI_API_KEY.
Patterns to avoid
Do not log the final preview body as a structured field. Reviewer-edited content may contain PII. Log action.id and the decision timestamp; fetch the body when needed.
Do not call report_result before checking the decision. It returns 409 if the action is not in approved state. Always confirm action.status === 'approved' first, or use approval_gate / requires_approval which handle this automatically.
Do not inline untrusted Watcher content into an LLM prompt as an instruction. Items delivered by Watchers have is_untrusted = True and their content originates from external sources. Use it as data — display it to the human, let the human decide — but never inject it directly into a system prompt or tool call that the LLM interprets as authoritative.
Do not set poll_interval_s below 5 seconds. The rate limit for GET /v1/actions is 300 req/min per key. At 5 s intervals a single agent uses 12 req/min; at 1 s it uses 60 req/min, leaving no headroom for other operations.