Developer documentation
Python SDK
Last reviewed 31 August 2026
All docs
# Hosted wheel (works today; PyPI publication is pending): pip install https://cainstudio.online/cainstudio-0.2.0-py3-none-any.whl # LangChain extra: pip install "cainstudio[langchain] @ https://cainstudio.online/cainstudio-0.2.0-py3-none-any.whl"
Python 3.9+. No runtime dependencies. It is a thin client for the HTTP decision API: every verdict comes from CAIN, nothing is decided locally. Verify the wheel's SHA-256 (.../cainstudio-0.2.0-py3-none-any.whl.sha256) before installing; the HTTP API is exactly what the SDK calls and can be used directly.
See a decision without an account#
cainstudio try # a prompt injection, blocked, stage by stage cainstudio try safe-read # a normal call, held: a new agent has no trust history cainstudio try --list
Guard a function#
import cainstudio # reads CAIN_API_KEY
@cainstudio.guard()
def transfer(amount_usd: float, to: str) -> str:
... # unchanged
The function's arguments are sent as the action payload, so tool rules such as "hold transfers where amount_usd > 1000" see them. The decision happens before the body runs. If the call is not allowed, the body does not run and one of these is raised:
| Exception | Meaning |
ActionBlocked | CAIN refused it. e.decision.reasons says why. |
ApprovalRequired | Held for a human. e.approval_id is the review-queue entry. |
CainUnavailable | No verdict (timeout, outage, malformed reply). Fail-closed: not run. |
AuthenticationError | Missing, unknown or unpaid key. |
@cainstudio.guard("wire_transfer") names the tool explicitly; the default is the function name. Async functions work the same way.
Wait for a human#
@cainstudio.guard(wait_for_approval=300) # seconds def transfer(amount_usd: float, to: str): ...
A held call waits for an approve or deny in the console (or cainstudio approve), then is decided again and runs once. The approval is single-use and bound to that call. An agent key cannot approve its own actions.
Ask without guarding#
from cainstudio import Cain
cain = Cain() # or Cain(api_key=..., agent_id="support-bot")
d = cain.decide("send_email", {"to": "a@example.com"}, agent_id="support-bot")
if d.allowed:
send_email(...)
Decision fields: verdict, allowed, held, blocked, reasons, stages, id, run_id, raw (the full response).
The server's verdicts are ALLOWED, ALLOWED_DEGRADED (allowed, a stage could not run), ALLOWED_WITH_DENIALS (shadow mode: a stage objected but was not enforcing), REQUIRE_APPROVAL and BLOCKED. d.allowed is true only for the three ALLOWED* verdicts with blocked false. Anything else, including a verdict this version does not know, is not allowed.
dry_run=True evaluates without writing an evidence record: use it for "how would this be decided?" in tests and policy work.
LangChain and LangGraph#
from cainstudio.langchain import protect tools = protect([search, send_email, transfer_funds], agent_id="support-agent") agent = create_agent(model, tools)
Names, descriptions and argument schemas are unchanged, so the model sees the same tools. A refused call is returned to the model as the tool error (on_block="raise" raises instead).
Other frameworks: put @cainstudio.guard() directly on the tool function, under the framework's own decorator.
Traces#
with cainstudio.run() as run_id:
agent.invoke({"messages": [...]})
Every decision inside the block carries run_id, and the run appears under Traces in the console.
Evidence and approvals#
cain.decisions(20) # recent decisions
cain.get_decision("fd_...") # one record
cain.explain("fd_...") # stage by stage, and which run it belongs to
cain.approvals() # pending review queue
cain.approve("ap_...", note="checked with finance")
cain.deny("ap_...")
Command line#
cainstudio decide send_email --args '{"to": "a@example.com"}' --agent support-bot
cainstudio decisions
cainstudio explain fd_...
cainstudio approvals
cainstudio approve ap_... --note "checked with finance"
decide exits 0 allowed, 2 blocked, 3 held, 1 error.
Configuration#
CAIN_API_KEY (required except for try). CAIN_BASE_URL (default https://cainstudio.online; must be https except for localhost, so a key is never sent in clear text).