Connect an agent by MCP
An agent adds Himmel by configuration: a URL, its own key, and tools to read its rules, ask and report.
An agent whose runtime speaks the Model Context Protocol (MCP) — the Claude Agent SDK, Claude Code, or any client that takes a server's URL and headers — adds Himmel by configuration: a URL and the agent's own key, and nothing in its code. Himmel's tools then ask as the agent: read_rules reads the rules it is held to, a check tool asks before an action, and report says what became of it.
Each tool is one call of the API, answered as the API answers anyone, so everything the API holds holds here: the agent's scopes, its switch, a retry answered once, the answer stored before it is given, and every check recorded as sent by the agent's key. The MCP server stores nothing, decides nothing and takes no action. Himmel answers; your agent's runtime decides what to do with the answer.
Before you start
The agent is registered and has a key made for it: see A key of its own for every agent. Only a key made for an agent opens the server. Your account's own keys and a person signed in are refused with 403 insufficient-scope: the server is each agent's own, and a key that acts for many would leave the model to say who acts.
The key's scopes decide which tools the agent is given, and a key made for an agent holds all four unless it was made with fewer:
read_rulesneedsrules:read;- a check tool needs
checkandrules:read: its facts are the rule set's, which only a key that reads the rules is given; reportneedschecks:write.
A check tool is given for each rule set that answers today: a playbook not switched on yet, or switched on from a later date, has none until then.
The server
https://api.gethimmel.com/v1/mcp: MCP over Streamable HTTP. Each request carries the agent's key, and may carry the run it is part of, the person it acts for and the system it acts in:
Authorization: Bearer hk_live_...
Himmel-Run: ticket-4471
Himmel-On-Behalf-Of: emp_4f1c
Himmel-System: support_desk
Authorization: the agent's own key. A test key acts in test, as everywhere.Himmel-Run, optional: the run of the agent — the task a person dispatched it to do: a ticket, a conversation — as your own systems identify it. Every check through the server names it astransaction.run, and the agent's runs are listed by it (Your agents).Himmel-On-Behalf-Of, optional: the person the agent acts for, by your own systems' id for them, never a name or an email address. Every check names them astransaction.actor.on_behalf_of.Himmel-System, optional: the system the agent's actions are taken in, by your company's code for it —support_desk,finance(The request,transaction.system). Every check and every report through the server names it. It is set here rather than by the model, so that one system keeps one name; an agent that acts in several systems names each from the tool that acts there, with the SDK. A code in any other shape is refused when the session connects (422 invalid-request,format).
The run and the person come from whatever starts the agent, never from the model. Set both once for each run, when you start the agent's session for a task; the model sends the action's facts and nothing else. They are opaque references, held as a check holds them — at most 200 characters (422 invalid-request, string_too_long), never an email address or an IBAN (422 pii-in-facts) — and refused when the session connects, naming the header and never its value: a configuration that would refuse every check fails the session's start, rather than each check halfway through a run. Left out, a check names no run and nobody it acts for, and the agent's runs count it among its actions without_run.
The Claude Agent SDK
Start a session for each run, with Himmel among its MCP servers and its tools allowed. In Python:
import os
from claude_agent_sdk import ClaudeAgentOptions
def options_for(ticket: str, assigned_by: str) -> ClaudeAgentOptions:
return ClaudeAgentOptions(
mcp_servers={
"himmel": {
"type": "http",
"url": "https://api.gethimmel.com/v1/mcp",
"headers": {
"Authorization": f"Bearer {os.environ['HIMMEL_KEY']}",
"Himmel-Run": f"ticket-{ticket}",
"Himmel-On-Behalf-Of": assigned_by,
},
},
},
allowed_tools=[
"mcp__himmel__read_rules",
"mcp__himmel__check_customer_support",
"mcp__himmel__report",
],
)
In TypeScript, the same options are mcpServers and allowedTools:
import { query } from "@anthropic-ai/claude-agent-sdk";
const session = query({
prompt: task,
options: {
mcpServers: {
himmel: {
type: "http",
url: "https://api.gethimmel.com/v1/mcp",
headers: {
Authorization: `Bearer ${process.env.HIMMEL_KEY}`,
"Himmel-Run": `ticket-${ticket}`,
"Himmel-On-Behalf-Of": assignedBy,
},
},
},
allowedTools: ["mcp__himmel__read_rules", "mcp__himmel__check_customer_support", "mcp__himmel__report"],
},
});
The SDK names each tool of a server mcp__<server>__<tool>: with the server named himmel, read_rules is mcp__himmel__read_rules, and the sandbox's sample playbook's check tool is mcp__himmel__check_customer_support. mcp__himmel__* allows every tool of the server, a check tool that comes with a playbook switched on later included. A tool you allow runs without asking for permission each time. One you do not is put to whatever approves tool calls, and where nothing does — a session run unattended, in the permission mode dontAsk — it is refused.
Any other client
Any MCP client that takes a server's URL and headers connects the same way. Claude Code takes the server from its MCP configuration — a project's .mcp.json, or a file given with --mcp-config — and fills ${VAR} in a url or a header from the environment, so whatever starts the agent can set the run and the person in the environment of the process it starts:
{
"mcpServers": {
"himmel": {
"type": "http",
"url": "https://api.gethimmel.com/v1/mcp",
"headers": {
"Authorization": "Bearer ${HIMMEL_KEY}",
"Himmel-Run": "${HIMMEL_RUN:-}",
"Himmel-On-Behalf-Of": "${HIMMEL_ON_BEHALF_OF:-}"
}
}
}
}
What a client needs to know of the server:
- Both revisions of the protocol. 2026-07-28's, which Claude Code speaks —
server/discoverfirst, no handshake, each request carrying its own context — and the handshake's,initialize, from 2025-11-25 back to 2025-03-26, told apart by the request'sMCP-Protocol-Version. - No session.
Mcp-Session-Idis never given, nothing is kept between requests, and each answer is one JSON body, never a stream. POSTonly. AGET, the stream a client may open, and aDELETE, a session's end, are405 method-not-allowedwithAllow: POST.- Tools only. No prompts, resources or subscriptions, and the server's capabilities say so.
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: give each session the key of the customer it acts for, in the server's Authorization header, as Himmel-Run is given for each run. The tools follow the customer's rules, and a playbook filled from Himmel's template has the same check tool in every customer's account - check_customer_support - so the agent's allowed tools are the same for each.
What the session is told
When it connects, the session is given the server's instructions: the same for every agent, and short enough to be kept whole, where Claude Code cuts a server's instructions at 2,048 characters. They say that these tools ask Himmel, whose answer applies your company's rules as they stand when asked; to read its rules with read_rules before it plans; to ask with its rule set's check tool before each action they cover, sending the action's facts as it read them in your systems and never guessing one; to say with report what became of the action; the four answers, a line each; how an amount and a date are written; that an answer names a person by their Himmel id, while a check never sends one; and where it names one to approve the action or to answer its question, that requests says whom Himmel has asked and until when it waits, so the agent can tell whoever it acts for — that it never asks them itself, only their answer in Himmel counting, and hears what they decided by asking again with the same check tool and that answer's check_id, leaving out the facts the action has (When an answer names a person) - keeping that check_id with the work it is about, a note on the ticket, so that a later session, which starts afresh, can ask again with it. Never the rules themselves: those are what read_rules gives, read at the start of each run.
The tools
read_rules
No arguments. The rules the agent is held to today, in the words your rule owner confirmed, given as text: the text of GET /v1/agents/{agent_id}/rules (What an agent reads before it starts) — what it may do, up to what limit, who approves above it, the facts each check sends, and each rule set's check tool by name. Read through the server, it is the agent's own read, kept on its record (rules_read_at).
A check tool for each rule set
One for each rule set that answers today, named for it: a policy, policy.<slug>.<name>, as check_<name> — the sandbox's policy.sandbox.customer_support is check_customer_support — and a law set by its key, check_de_gifts_entertainment. Any character but a-z, 0-9 and _ becomes _, and where two rule sets of your account would share a name, each is named by its whole key. It takes:
facts, typed from the version that answers today: each fact with its type and the words your rule set gives it, the version's required facts required, and no other fact taken. Money is{"amount", "currency"}in minor units — €34.90 is{"amount": 3490, "currency": "EUR"}— withequivalentsbeside an amount in another currency (An amount in another currency); a date isYYYY-MM-DD; a customer is the keys a customer has, or therecipient_idan answer gave. It never offers a person, nor the figure an approval was given for: Himmel holds them for the agent (An agent never names a person). Every fact also takesnull. A fact the agent has not read is left out, or sent asnullwhere the tool requires it, and the answer names what it needs.ref, optional: the action's own reference in your systems — a ticket's or an order's number — opaque, liketransaction.ref.check_id, optional: thecheck_idof an answer the agent was given about the same action, to ask about it again — once a person its answer named has decided, or with a fact it asked for. Any answer to the action names it, one replaced since included: the server finds the booking that stands for the action. With it,factsholds only what an answer asked for, or nothing —{}: the action keeps the facts it recorded and the day it was first asked, which the tool says, though its schema still lists the facts a new action requires. Left out, the check is about a new action. An id no answer has is the tool's error,404 not-found.
It sends POST /v1/check as the agent: the tool's rule set; transaction.status: "proposed", always — asking is asking, and nothing is recorded as done because a field was left out; channel: "agent"; the run, the person and the system from the headers; then the facts and ref the model gave, and with a check_id that booking as transaction.completes. Any other argument — rule_set, status, run, system — is refused as the tool's error, 422 invalid-request naming it (unknown_argument), and never passed on.
report
What became of an action asked about: check_id, from the check's answer; executed, true once the action is done; where it was not done, reason — abandoned, refused_by_caller or failed; and optionally ref, your own reference for the action done, kept beside the action's own, and executed_at. It sends POST /v1/checks/{check_id}/execution (Proposals), with the system from Himmel-System, and the agent reports on its own actions only. An action asked about and never reported closes as not taken after 24 hours, and counts nowhere.
The tools follow your rule sets
The tools are listed each time a session connects, from what the agent is held to that day. When your rule owner switches on another playbook, the next session has another check tool; when a version adds a fact, the check tool takes it. A runtime that allows tools by name refuses a new check tool until it is named, and one that stops a session whose tools are not the ones it expects stops it.
Answers
A check's or a report's answer is the tool's result as the API gives it: its structured content, and the same JSON as its text, for a client that reads only text. It is one of the four answers (The four answers), never reworded, and undetermined_missing_facts is an answer here as everywhere: the agent finds the facts it names and asks again about the same action, with the answer's check_id and those facts alone.
An answer that names a person — an approver, a question's signer — carries requests, whom Himmel asked and until when it waits (When an answer names a person). The server has no tool that reads an answer, so the agent hears what they decided by asking again: the check tool with the check_id of any answer to the action and facts: {}, answered with their decision — allowed once they approved, with the approval in decisions — in the same session or a later one under the same Himmel-Run: an action is one run's, and asked again in another it is refused with changed. The check_id it keeps is the one it reports on, whichever answer it was. A refusal — a problem the API returns, 4xx or 5xx — is the tool's result marked an error (isError), with the problem as the API words it (Errors). A tool the server does not have is the protocol's own error, -32602.
A call retried is answered once
Claude Code gives each tool call an id of its own, and the server makes the call's Idempotency-Key from that id and the agent: a call retried — after a connection dropped, say — gets the first answer, never a second proposal. The same question asked again in a new call is a new question, as it is in the API: another proposal, and one never reported closes as not taken after 24 hours — unless the call names the action by check_id, when it is the same action asked again. A client that gives its calls no id of its own sends no key, so a call it retries is asked again.
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.
What MCP cannot promise
Through MCP the model chooses whether to ask: nothing makes it call a check tool before it acts. And a server the client cannot reach is left out — Claude Code starts the session with it failed, and goes on without its tools — so the agent would work unchecked. Two things close that.
Give the agent its task only once Himmel is connected. Claude Code connects a session's servers when the session starts, and says of each its status — connected, or failed, needs-auth, pending, disabled — and its tools. A server not connected has no tools in the session: one that cannot be reached, a key refused, an agent you have switched off (403 agent-switched-off) all start the session with Himmel failed. Claude Code gives the reason in its own words, by the status alone: a key refused reads "Server rejected the configured Authorization header (HTTP 401)", and an agent switched off, or a key not made for an agent, the same with HTTP 403. In Python, ClaudeSDKClient starts the session without a task, so its servers can be read before the model is asked anything. Right after it starts, a server is still pending: wait until it is not.
import asyncio
from claude_agent_sdk import ClaudeSDKClient
class HimmelNotConnected(Exception):
pass
async def himmel_server(client: ClaudeSDKClient) -> dict:
"""Himmel's server as the session has it - its status, tools and error - once it is no longer
connecting, within 30 seconds."""
for _ in range(120):
status = await client.get_mcp_status()
himmel = next((server for server in status["mcpServers"] if server["name"] == "himmel"), {})
if himmel.get("status") != "pending":
break
await asyncio.sleep(0.25)
return {key: himmel[key] for key in ("status", "tools", "error") if key in himmel}
async def work(task: str, ticket: str, assigned_by: str) -> None:
async with ClaudeSDKClient(options=options_for(ticket, assigned_by)) as client:
himmel = await himmel_server(client)
if himmel.get("status") != "connected" or not himmel.get("tools"):
raise HimmelNotConnected(f"the task was not given: Himmel is {himmel.get('status', 'missing')}")
await client.query(task)
async for message in client.receive_response():
...
Keep what himmel_server keeps — the server's status, its tools and its error — and never log the entry whole: get_mcp_status() gives each server's configuration back with it, its headers filled in, the agent's key among them even where your configuration names ${HIMMEL_KEY}.
The session's first message, init, lists the servers and tools too, but query() sends the task with the session's start: by the time init is read, the model may already have been asked, and a session stopped there may have gone on without Himmel. In TypeScript, a query says the same with mcpServerStatus(), and a query given its task as a string has sent it already.
Where an action must never go unchecked, have the tool that takes it ask. The code of the tool that gives the refund asks Himmel itself, before it acts, and acts on the answer: the model cannot skip a check it does not make. See The SDK, or Check for the request it sends.
When the server refuses
Before any MCP method runs, the server refuses a request as the API refuses one, with a problem (Errors):
| Status | Problem type | Why |
|---|---|---|
| 401 | unauthorized | No key, a header that is not Bearer <key>, or a key not in use |
| 403 | insufficient-scope | Not a key made for an agent: your account's own, or a person signed in |
| 403 | agent-switched-off | The agent is switched off |
| 405 | method-not-allowed | Not a POST |
| 422 | invalid-request | A query parameter (unknown_parameter), or a header over 200 characters (string_too_long) |
| 422 | pii-in-facts | A header holds an email address or an IBAN |
| 429 | rate-limited | Too many requests in the last minute |
Each request to the server counts once against its key's limit, and the API calls its tools make count nothing more. A refusal of Himmel-Run, Himmel-On-Behalf-Of or Himmel-System names the header in errors[].field, never its value. What the protocol itself refuses — a body that is not JSON-RPC, a Content-Type that is not JSON, an Accept without it, a method or a revision the server does not serve — is answered as the protocol answers it, with a JSON-RPC error.