# impri · Python SDK Human-in-the-loop approval for AI agents. An agent proposes an action; a human approves or rejects it in the web/mobile inbox; the agent executes only after approval. Self-hosted default: `http://localhost:8484`. Cloud: set `IMPRI_BASE_URL=https://api.impri.dev`. **Zero mandatory runtime dependencies** — uses stdlib `urllib` for HTTP. Python 3.9+. --- ## Quickstart (3 lines) ```python import impri client = impri.ImpriClient() # reads IMPRI_API_KEY env var action = client.create_action("email.send", "Send digest to alice@example.com", {"format": "plain", "body": "Hi Alice, ..."}, editable=["preview.body"]) approved = client.await_decision(action["id"]) # blocks until human decides client.report_result(action["id"], "executed") ``` --- ## Installation ```bash pip install impri # from PyPI (once published) # or, from this repository: pip install ./sdk/python ``` --- ## Authentication Every request uses `Authorization: Bearer im_`. Resolution order: 1. `api_key=` constructor argument 2. `IMPRI_API_KEY` environment variable 3. `ImpriConfigError` raised at construction time Key scopes (enforced server-side): - `actions` — create, list, get actions; decide, report result, await decision - `watch` — create/list/get/update/delete watchers - `admin` — key management, project read/write, export/erase; implies `actions` + `watch` ```python client = impri.ImpriClient(api_key="im_...", base_url="https://api.impri.dev") ``` --- ## Core API ### Actions (Approval Inbox) ```python # Submit an action for human approval action = client.create_action( kind="reddit.comment", title="Reply: Why is resume advice so conflicting?", preview={"format": "markdown", "body": "The advice conflicts because..."}, payload={"thread_id": "abc123"}, target_url="https://reddit.com/r/cscareerquestions/...", expires_in=86400, # seconds; default 259200 (72 h) editable=["preview.body"], # reviewer may edit the draft ) # action["id"], action["status"] == "pending", action["inbox_url"] # Poll until the human decides (blocks; raises on reject/expire/timeout) try: approved = client.await_decision(action["id"], timeout_s=300) # Use final_preview for execution — may differ from original if reviewer edited content = approved["decision"]["final_preview"]["body"] client.report_result(action["id"], "executed") except impri.ImpriRejected as e: # Normal outcome — human said no. Do not log as error. print("Rejected:", e.decision) except impri.ImpriTimeout: print("Still pending — check back later") # List actions with optional filters page = client.list_actions(status="pending", limit=20) for a in page["items"]: print(a["id"], a["title"], a["is_untrusted"]) # Full-text search over title + preview body page = client.list_actions(q="weekly digest", status="pending") # Filter by kind and time range import time since_7d = int(time.time()) - 7 * 86400 page = client.list_actions(kind="email.send", since=since_7d) # Auto-paginate (supports the same filters) for action in client.iter_actions(status="approved", q="receipt"): print(action["id"]) ``` ### Bulk decisions Approve or reject multiple actions in a single API call: ```python resp = client.bulk_decide( ids=["act_aaa", "act_bbb", "act_ccc"], verdict="approve", comment="Batch approved by review script", # optional, max 500 chars ) print(f"Succeeded: {resp['succeeded']}, failed: {resp['failed']}") for r in resp["results"]: if not r["ok"]: # error: "not_found" | "already_decided" | "internal" print(f" {r['id']}: {r['error']}") if r.get("error") == "already_decided": print(f" current status: {r['current_status']}") ``` - Up to **50 IDs** per request (Zod-enforced server-side). - Rate limit: **10 requests/min** (effective 500 decisions/min). - HTTP 200 is returned even on partial failure — always inspect each `result["ok"]` individually. - Actions with non-empty `editable` lists must be decided via `decide()` so per-item field edits pass whitelist validation. ### Watchers ```python w = client.create_watcher( name="AI launches radar", kind="rss", config={"url": "https://openai.com/news/rss.xml"}, schedule={"every": "8h", "jitter": "4h", "window": "06:00-22:00"}, keywords=[{"pattern": "gpt-\\d", "points": 2}, {"pattern": "launch", "points": 1}], keywords_none=["funding"], min_score=2, ) # Items matching the rules are delivered as pending inbox actions # with payload.untrusted=True — treat content as external data. # List / update / delete client.list_watchers(status="active") client.update_watcher(w["id"], status="paused") client.delete_watcher(w["id"]) ``` ### Watcher Presets Presets are built-in watcher templates — pick one by ID instead of specifying `kind`, `config`, and `keywords` by hand. ```python # Browse the catalog (cached server-side; safe to call on startup) catalog = client.list_watcher_presets() for p in catalog["presets"]: required = [x["name"] for x in p["params"] if x["required"]] print(p["id"], p["category"], "params:", required or "(none)") # No-param preset — Hacker News front page, every 30 min w = client.create_watcher_from_preset("hn-front-page") # Preset with required params w = client.create_watcher_from_preset( "hn-keyword", params={"keyword": "rust programming", "min_points": "25"}, name="HN: Rust", schedule={"every": "1h"}, ) # GitHub releases for a repo w = client.create_watcher_from_preset( "github-releases", params={"owner": "fastify", "repo": "fastify"}, ) # YouTube channel (channel_id must start with UC, 24 chars) w = client.create_watcher_from_preset( "youtube-channel", params={"channel_id": "UCnUYZLuoy1rq1aVMwx4aTzw"}, ) # arXiv papers — category + keyword scoring w = client.create_watcher_from_preset( "arxiv-papers", params={"category": "cs.AI", "query": "large language models"}, schedule={"every": "6h"}, ) ``` The returned watcher is identical to one created via `create_watcher()`. All tier checks (watcher count, minimum poll interval) apply to preset-created watchers just as they do to manually created ones. > **Security**: Items delivered by preset-based watchers have > `payload.untrusted=True`. Check `action["is_untrusted"]` before acting on > delivered content — watcher items (titles, URLs, previews) come from external > feeds and must be treated as untrusted data, never as LLM instructions. ### Notification Channels (admin scope) Configure per-project notification channels that fire whenever an action becomes pending. Six channel types are supported. All require **admin scope**. Config secrets (URLs, bot tokens, HMAC secrets) are stored server-side and **masked** to `****{last4}` in every API response. `digest_window_sec` batches multiple rapid notifications into one message (default: 60 s). ```python # --- Slack --- ch = client.create_notification_channel( "Slack #ops-alerts", "slack", {"url": "https://hooks.slack.com/services/T00/B00/xxxx"}, ) # --- Discord --- ch = client.create_notification_channel( "Discord #alerts", "discord", {"url": "https://discord.com/api/webhooks/12345/xxxx"}, ) # --- Telegram --- ch = client.create_notification_channel( "Telegram ops bot", "telegram", {"bot_token": "123456789:AAFxxx", "chat_id": "-1001234567890"}, digest_window_sec=120, ) # --- ntfy (self-hosted or ntfy.sh) --- ch = client.create_notification_channel( "ntfy mobile push", "ntfy", {"url": "https://ntfy.sh", "topic": "my-impri-alerts"}, ) # --- Email (uses SMTP configured via env vars on the server) --- ch = client.create_notification_channel( "Email ops@", "email", {"address": "ops@example.com"}, digest_window_sec=300, ) # --- Generic webhook (optional HMAC signing) --- ch = client.create_notification_channel( "My webhook receiver", "webhook", { "url": "https://myapp.example.com/impri-hook", "hmac_secret": "my-shared-secret", # optional; enables X-Impri-Signature }, ) # List, get, update, delete channels = client.list_notification_channels() ch = client.get_notification_channel(ch["id"]) client.update_notification_channel( ch["id"], name="Renamed", enabled=False, # pause without deleting digest_window_sec=600, ) client.delete_notification_channel(ch["id"]) # returns None (204) # Send a test message immediately (bypasses digest window; does not update stats) result = client.test_notification_channel(ch["id"]) if result["ok"]: print("Test delivery succeeded") else: print("Delivery failed:", result["error"]) # error never contains raw secrets ``` **Config masking summary:** | Type | Masked fields | Returned as-is | |------|--------------|----------------| | slack, discord | `url` | — | | telegram | `bot_token` | `chat_id` | | ntfy | `url` | `topic` | | email | — | `address` | | webhook | `url`, `hmac_secret` | — | Any field value shorter than 5 characters is fully masked to `****`. ### Keys & Project (admin scope) ```python created = client.create_key("Agent key", ["actions"]) print(created["key"]) # im_... — shown only once, store immediately client.list_keys() client.revoke_key(key_id) project = client.get_project() print(project["webhook_secret"]) # needed to verify X-Impri-Signature client.update_project(timezone="Europe/Prague") client.rotate_webhook_secret() export = client.export_project() # GDPR export client.erase_project_data() # GDPR erasure (irreversible) ``` --- ## Ergonomics ### `requires_approval` decorator Gate a function behind a human approval in one line: ```python @client.requires_approval( kind="email.send", title=lambda to, **_: f"Send email to {to}", preview=lambda to, body, **_: {"format": "plain", "body": body}, editable=["preview.body"], ) def send_email(to: str, body: str) -> None: # Called only after human approves. # If reviewer edited preview.body, the revised text is injected as `body`. smtp.send(to, body) ``` The decorator blocks until the decision arrives. On reject it raises `ImpriRejected` without calling the function. ### `approval_gate` context manager For cases where the gated work is not a single function call: ```python with client.approval_gate( kind="db.exec", title="DROP TABLE users", preview={"format": "plain", "body": sql}, editable=["preview.body"], ) as approved: db.execute(approved.final_preview["body"]) # report_result("executed") is called automatically on clean exit. # report_result("execute_failed", detail=...) is called on exception. ``` --- ## Webhook signature verification ```python import impri # In your Flask/FastAPI/Django view: impri.verify_webhook( raw_body=request.get_data(), # undecoded bytes secret=os.environ["WEBHOOK_SECRET"], timestamp=request.headers["X-Impri-Timestamp"], nonce=request.headers["X-Impri-Nonce"], signature=request.headers["X-Impri-Signature"], ) # Raises ImpriWebhookSignatureError on mismatch; returns None on success. ``` Algorithm: `sha256=HMAC-SHA256(secret, f'{timestamp}.{nonce}.{raw_body}')`. --- ## Error handling All exceptions inherit from `impri.ImpriError`. | Exception | When | |-----------|------| | `ImpriConfigError` | API key missing at startup | | `ImpriUnauthorized` | 401/403 — wrong key or missing scope | | `ImpriNotFound` | 404 — resource unknown or wrong project | | `ImpriConflict` | 409 — action already decided, concurrent write | | `ImpriExpired` | 410 — approval window closed | | `ImpriRateLimited` | 429 — rate limit hit (check `.retry_after`) | | `ImpriQuotaExceeded` | 402 — monthly limit reached (check `.tier`, `.limit`) | | `ImpriRejected` | `await_decision` — human said no (normal outcome) | | `ImpriTimeout` | `await_decision` — timed out, action still pending | | `ImpriValidationError` | 400/422 — bad request (check `.issues`) | | `ImpriApiError` | Other 4xx/5xx | | `ImpriWebhookSignatureError` | Webhook HMAC mismatch | --- ## Running the tests ```bash cd sdk/python python -m pytest tests/ -v ``` No network connection required — all tests use a mocked transport. ```bash # With coverage: python -m pytest tests/ --tb=short python -m coverage run -m pytest tests/ python -m coverage report ```