The SDK
Your agent's tools ask before they act, in Python and JavaScript: check, ask again, report, read the rules.
A small library your agent's tools ask Himmel through, in Python and in JavaScript. The check sits in the code of the tool that takes the action — the refund, the discount, the credit — so the agent cannot take it without asking: for anything that moves money. Five calls, each one or two requests of the API and nothing more: check asks before an action, ask_again asks about the same action once a person has decided or with a fact an answer asked for, report says what became of it, read_rules reads what the agent is held to today, and read_request where a request Himmel made of a person stands.
Himmel answers; your tool enforces. The SDK blocks nothing and takes no action: what the tool does with the answer is yours.
Install
pip install himmel
npm install @gethimmel/sdk
The Python SDK runs on Python 3.10 and later, the JavaScript SDK on Node 20.19, 22.12 and later; neither installs anything else. Both are free to use, change and ship under the MIT licence, and both are for your servers alone: a key never sits in a browser.
Writing in another language? Call the API itself: see Any other language.
Before you start
The agent is registered and has a key made for it: see A key of its own for every agent. Every check the key sends records the agent, whatever the request says, and the SDK is built for that key: give it to the client, or set HIMMEL_KEY in the agent's environment. A test key acts in test, as everywhere. HIMMEL_BASE_URL points the client somewhere other than https://api.gethimmel.com.
from himmel import Himmel
himmel = Himmel() # the agent's own key, from HIMMEL_KEY
import { Himmel } from "@gethimmel/sdk";
const himmel = new Himmel(); // the agent's own key, from HIMMEL_KEY
An agent written in CommonJS requires it, as any package:
const { Himmel } = require("@gethimmel/sdk");
Where a TypeScript build to CommonJS refuses to import it - TypeScript before 5.8, or module set to node16 - load it with const { Himmel } = await import("@gethimmel/sdk").
Ask before the action
In the tool that gives a refund, ask first, act on the answer, then say what became of it:
from datetime import date
from himmel import Determined, Himmel
himmel = Himmel()
PLAYBOOK = "policy.example_shop.customer_support"
def give_refund(ticket, order, amount_minor):
answer = himmel.check(
PLAYBOOK,
{
"action_date": date.today().isoformat(),
"action": "refund",
"customer": {"customer_number": order.customer_number},
"amount": {"amount": amount_minor, "currency": "EUR"},
"order_total": {"amount": order.total_minor, "currency": "EUR"},
"delivered_on": order.delivered_on.isoformat(),
"reported_damaged": ticket.reported_damaged,
},
ref=ticket.number,
run=ticket.id,
on_behalf_of=ticket.assignee_id,
system="payments",
)
if isinstance(answer, Determined) and answer.outcome == "allowed":
refund = payments.refund(order, amount_minor)
himmel.report(answer, executed=True, ref=refund.id)
return "refunded"
himmel.report(answer, executed=False, reason="refused_by_caller")
return f"not given: {answer.state}"
const answer = await himmel.check(
"policy.example_shop.customer_support",
{
action_date: today,
action: "refund",
customer: { customer_number: order.customerNumber },
amount: { amount: amountMinor, currency: "EUR" },
order_total: { amount: order.totalMinor, currency: "EUR" },
delivered_on: order.deliveredOn,
reported_damaged: ticket.reportedDamaged,
},
{ ref: ticket.number, run: ticket.id, onBehalfOf: ticket.assigneeId, system: "payments" },
);
if (answer.state === "determined" && answer.outcome === "allowed") {
const refund = await payments.refund(order, amountMinor);
await himmel.report(answer, { executed: true, ref: refund.id });
}
check sends POST /v1/check as the agent (Check): the rule set you name; transaction.status: "proposed", always — asking is asking, and nothing is recorded as done because a field was left out; channel: "agent"; and what you give it of the action — ref, your own reference for it; run, the run of the agent it is part of, a ticket or a conversation, which the agent's runs are listed by (Your agents); on_behalf_of (onBehalfOf), the person the agent acts for, by your systems' id for them; provenance, where the instruction came from, opaque references; and system, the system the tool's action is taken in, by your company's code for it — payments, support_desk: lowercase letters, digits, _ and -, starting with a letter, at most 40 characters (The request). None of them is ever a name or an email address. The tool that acts in a system knows which it is, so each tool says its own, and the check log finds the actions taken there (system=). The facts are the rule set's, by name, as the catalogue lists them: money in minor units with its currency, a date YYYY-MM-DD, and never a person or the figure an approval was given for, which Himmel holds for the agent (An agent never names a person). A fact you have not read is left out: the answer names what it needs.
An action asked about counts nowhere until it is reported done, and one never reported closes as not taken after 24 hours.
One agent, many customers
If your agent acts for many customers, each with an account of its own (Accounts for your customers), it holds a key for each - made for it in that customer's account - and asks with the key of the customer it is acting for: a client for each. A customer's playbook is named by its short name, the template's policy.<short name>.customer_support.
from himmel import Himmel
clients: dict[str, Himmel] = {}
def himmel_for(shop) -> Himmel:
"""The agent's own key in this shop's account: one client for each shop."""
if shop.id not in clients:
clients[shop.id] = Himmel(key=vault.himmel_key(shop.id))
return clients[shop.id]
answer = himmel_for(shop).check(f"policy.{shop.slug}.customer_support", facts, run=ticket.id)
const himmel = new Himmel({ key: await vault.himmelKey(shop.id) });
const answer = await himmel.check(`policy.${shop.slug}.customer_support`, facts, { run: ticket.id });
Every check is recorded in that customer's account as your agent's, and its owner reads it in their check log; a key never acts in another customer's account.
The four answers
Every check is one of four answers, never a fifth (The four answers). In Python each is a class of its own — Determined, UndeterminedMissingFacts, NotCovered, Contested — to test with isinstance or match; in TypeScript, answer.state narrows the type:
| State | What it carries | What your tool does next |
|---|---|---|
determined | outcome: what the rule set you named decided — allowed, needs_approval, hand_to_a_person, whatever its rules say | Act on the outcome, and report what became of the action. |
undetermined_missing_facts | missing: the facts a rule needs | Read them in your systems and ask again with them. |
not_covered | reason: the playbook does not say (policy_gap), or a rule waits for your rule owner (awaiting_confirmation) | Do not act on it. Hand it to a person. |
contested | question, and who decides it: signer | Do not act on it. A person decides. |
outcome is the SDK's: an answer gives an outcome per rule set — the law's, where your account has law switched on, and each policy's (layers) — and the one your tool asked about is the rule set it named. Everything else is the answer as the API gave it, never reworded: in Python whole in raw, in JavaScript the answer itself, with rule_set, the rule set the check named, beside it. Keep its check_id with whatever your tool then did: GET /v1/checks/{id} gives the answer back for as long as your records are kept.
When an answer names a person
An answer that needs a person's approval, or leaves a question to one, names them, and Himmel asks them itself: the answer's requests says whom it asked, what (asks: an approval, or the answer to a question), where that stands (status) and until when it waits (waits_until), so your agent can tell whoever it acts for (When an answer names a person). Never ask them yourself: only their answer in Himmel counts.
answer = himmel.check(PLAYBOOK, discount_facts, run=ticket.id)
if isinstance(answer, Determined) and answer.outcome == "needs_approval":
request = answer.requests[0]
tell_customer(f"Our team lead decides by {request.waits_until:%d %B}.")
notes.keep(ticket, answer.check_id) # the receipt, for the run that asks again
# Later - another run, perhaps another day:
if himmel.read_request(request_id).status == "approved":
answer = himmel.ask_again(PLAYBOOK, notes.check_id(ticket))
read_request reads a request by the id requests gave (GET /v1/approvals/{id}): waiting, approved, refused, decided by a sign-off or an admin's override, or lapsed, with reason. Once the person has decided, asking again is answered with it: allowed once they approved, the approval in decisions.
Asking again
ask_again (askAgain) asks about an action you asked about before — once a person its answer named has decided, or with the facts it asked for — named by any answer about it: the answer object you were given, or a check_id you kept. Send only the facts an answer asked for, or none: the action keeps the facts it recorded, its run, and the day you first asked.
from himmel import UndeterminedMissingFacts
answer = himmel.check(PLAYBOOK, facts_without_the_order_total, run=ticket.id)
if isinstance(answer, UndeterminedMissingFacts):
answer = himmel.ask_again(PLAYBOOK, answer, facts={"order_total": {"amount": 6000, "currency": "EUR"}})
It sends the check as Asking again says: transaction.status: "proposed", and the action's booking in transaction.completes — read from the answer you give it, or with a check_id from GET /v1/checks/{id} first. Where that booking was asked about again since, the API names the one that stands (409 answer-replaced, its transaction_id), and the SDK asks again on it, once: so any receipt of the action asks about it. It sends no run, no system and no person: the action's own stand, and another run would be refused.
Report what became of it
himmel.report(answer, executed=True, ref=refund.id)
himmel.report(answer, executed=False, reason="abandoned") # or refused_by_caller, failed
report sends POST /v1/checks/{check_id}/execution on any answer about the action (Proposals): executed true once it is done, with ref, your reference for what was done — kept beside the check's own, never over it — and executed_at (executedAt), when, with its offset — left out, the moment you report it; or false with its reason. system is where it was done: the check's, or named here where the check named none. Once done, the action counts in every total. A key made for an agent reports on its own agent's actions only.
Read the rules at the start of each run
rules = himmel.read_rules()
system_prompt = f"{instructions}\n\n{rules.text}"
read_rules (readRules) reads what the agent is held to today, GET /v1/agents/{agent_id}/rules for the key's own agent (What an agent reads before it starts): text, every rule in the words your rule owner confirmed, for the agent's instructions; playbooks, the same as data — each rule set's version, its rules and the facts a check sends; and checksum, which changes whenever what the agent is told does. Read with the agent's key, it is recorded as the agent's latest read. The words are for planning: each action is still checked when its tool asks.
Retries are answered once
Every call that changes something — a check, asking again, a report — goes with an Idempotency-Key made for the call and kept across its retries (Retrying a request): a check whose answer was lost on the way is answered again as it was stored, never a second action. What is retried is what never reached an answer — no connection, a timeout — and 429, 500, 502, 503 and 504, twice at most (max_retries, maxRetries), a rate limit waiting until it resets. Pass a key of your own — idempotency_key (idempotencyKey), made from your ticket and the action, say — to make a call safe across your own process restarting.
Errors
A refusal is raised as Problem, in the API's own words (Errors): status, code — the last part of its type, such as invalid-fact — detail, errors, each field or fact that was wrong and how, and body, the problem whole. It is never retried: the request earned it.
from himmel import Problem, Unreachable
try:
answer = himmel.check(PLAYBOOK, facts, run=ticket.id)
except Problem as problem:
if problem.code == "agent-switched-off":
... # the agent's owner switched it off: hand the ticket to a person
raise
except Unreachable as unreachable:
... # Himmel did not answer: ask again later with idempotency_key=unreachable.idempotency_key
Unreachable is Himmel not answering after every retry; it carries the call's idempotency_key (idempotencyKey), so a later attempt with it is answered once. Both are a HimmelError, as is an answer from somewhere that is not Himmel — a redirect is never followed. Missing facts, a rule waiting for your rule owner and a question for a person are never errors: they are answers.
Any other language
The SDKs are a thin layer over the API, and the API is the product: from Ruby, Java, PHP, Go or any other language, send the same requests yourself — as these docs' curl does — or generate a client from the published API document, https://api.gethimmel.com/v1/openapi.json (API reference). What the SDK does for you, your code then does itself:
- Ask as a proposal.
transaction.status: "proposed"before the action, thenPOST /v1/checks/{check_id}/executiononce you know what became of it. A key made for an agent says which it sends:statusleft out is refused (422 invalid-request,required). - One
Idempotency-Keyfor each action you ask about, made before the first attempt and sent again, the same, with every retry of it: an answer lost on the way then comes back as it was stored, never as a second action. Another action, or asking again about the same one, takes a key of its own. - Retry only what never reached an answer — no connection, a timeout — and
429,500,502,503and504, a429afterRateLimit-Resetseconds. Every other refusal is a problem in the API's words, which the request earned: retried unchanged, it is refused again. - Ask again by the action's booking:
transaction.completes, thetransaction_idof its latest answer, with only the facts an answer asked for. Where that booking was asked about again since, the API answers409 answer-replacedand names the one that stands (transaction_id): ask on that. - Never follow a redirect: Himmel answers where you sent the request, or it is not Himmel answering.
Ask once for each action
If your own software also asks Himmel about the actions your agent takes — a helpdesk's refund button asking before every refund, say — let one of them ask. Two checks of one action are two actions: asked about twice and reported done twice, it counts twice in every total.
The SDK or the MCP server
Through the SDK the check is in your tool's code, and the agent cannot take the action without it. Through the MCP server an agent adds Himmel by configuration, with no code, and the model chooses whether to ask. Both are the same API underneath, and an agent may use both — the MCP server to read its rules and ask about what its tools do not cover, the SDK in the tools that move money.