Zum Inhalt springen

Dokumentation / API

Webhooks

Lass calmo.cloud deinen eigenen Endpunkt aufrufen, wenn in einer Coding-Agent-Sitzung etwas passiert: was dort ankommt, wie du es prüfst und wie du über die API reagierst.

Auf der Seite Webhook können Team-Admins calmo.cloud auf einen eigenen Endpunkt zeigen lassen. Jeder Webhook hört auf eine Quelle. Mit der Quelle Agent sessions bekommt dein Endpunkt eine signierte Anfrage, sobald sich in einer Coding-Agent-Sitzung etwas tut: Ihr Status ändert sich, der Agent braucht eine Entscheidung, ihr Pull Request ändert sich, der Agent übergibt Dateien, oder die Sitzung kann nicht starten. Ein eigenes Skript, ein Ticketsystem oder ein Chat-Bot kann dann darauf reagieren — und über die REST-API in die Sitzung eingreifen.

Hier geht es um die Quelle Agent sessions. Sie steht zur Auswahl, sobald Coding-Agenten für dein Team freigeschaltet sind. Die übrigen Quellen der Seite schicken eine Anfrage, wenn ein Datensatz dieser Art (ein Server, ein Odoo-Dienst, ein Uptime-Monitor oder ein SSH-Schlüssel) angelegt, geändert, gelöscht oder wiederhergestellt wird; sie sind hier nicht beschrieben.

An Benachrichtigungsziele gehen Ereignisse von Coding-Agenten nicht — in Slack oder Microsoft Teams landen sie also nicht von allein. Wer eine Sitzung im Panel gestartet hat, bekommt außerdem über die Glocke im Panel Bescheid, wenn der Agent eine Entscheidung oder eine Antwort braucht und wenn die Sitzung fehlschlägt oder nicht starten kann — dafür ist kein Webhook nötig.

Die Namen von Buttons und Feldern stehen hier so, wie das Panel sie anzeigt.

Einen Webhook einrichten #

Webhooks sehen und ändern können nur Owner und Admins des Teams.

  1. Öffne in der Seitenleiste Developer > Webhook. Auch der Button Webhooks in der Liste der Agent-Sitzungen führt dorthin.
  2. Klicke auf Add New Webhook und fülle aus:
    • Name und Description — für dich, damit du den Webhook später wiedererkennst.
    • Url to Notify — die Adresse deines Endpunkts. Sie muss mit https:// beginnen und aus dem Internet erreichbar sein (siehe Wohin calmo.cloud sendet).
    • Model — wähle Agent sessions.
    • Header — optional eigene Header, die jede Anfrage mitbringt, zum Beispiel ein API-Schlüssel, den dein Endpunkt erwartet. Welche Namen nicht erlaubt sind, steht unter Die Anfrage.
    • Events — mindestens eines der Ereignisse unten.
    • Secret — das Secret, mit dem die Anfragen signiert werden; siehe Ein Secret wählen.
  3. Klicke auf Create.

Bei Agent sessions fehlen die Felder Method, Data option und Verify SSL?: Jede Anfrage ist ein POST mit dem JSON-Body, der unten beschrieben ist, und das TLS-Zertifikat deines Endpunkts wird immer geprüft.

Ein Team kann bis zu 10 Webhooks mit der Quelle Agent sessions haben. Willst du einen weiteren anlegen, lösch vorher einen. Webhooks mit anderen Quellen zählen dabei nicht mit.

Einen Webhook kannst du nicht bearbeiten. Willst du seine Adresse, seine Ereignisse oder sein Secret ändern, lösch ihn und leg einen neuen an. View zeigt seine Einstellungen, Logs listet seine Zustellversuche auf (siehe Logs), und Send test event schickt ihm eine Testanfrage (siehe Send test event).

Ereignisse #

Ereignis Im Body Wird gesendet, wenn
Status changed status_changed eine Sitzung startet, arbeitet, auf eine Antwort wartet, endet, fehlschlägt oder abgebrochen wird
Decision requested decision_requested der Agent um Erlaubnis für ein Werkzeug bittet — etwa, um seine Arbeit zu GitHub zu pushen — oder eine Frage stellt
Decision resolved decision_resolved eine Berechtigung erlaubt oder abgelehnt, eine Frage beantwortet wird oder eine Anfrage verfällt bzw. zurückgezogen wird
Start failed start_failed die Sandbox oder der Agent einer Sitzung nicht gestartet werden konnte; die Sitzung bleibt auf pending
Pull request changed pull_request_changed der Pull Request der Sitzung geöffnet, wieder geöffnet, geschlossen oder gemergt wird
Files shared files_shared der Agent Dateien übergibt

Jede Tatsache kommt als genau ein Ereignis. Auch wenn du mehrere abonnierst, bekommst du also nichts doppelt angekündigt. Status changed kommt bei jedem Status; interessieren dich nur manche, schau auf data.to und ignoriere den Rest.

Welche Sitzungen ein Webhook mitbekommt #

Ein Webhook bekommt die Sitzungen auf den Odoos mit, die dein Team hostet: die eigenen Sitzungen deines Teams und die Sitzungen, die ein Partnerteam auf einem Odoo startet, den du mit ihm teilst. Sitzungen, die dein Team auf einem Odoo startet, den ein anderes Team mit dir teilt, gehen an die Webhooks dieses Teams, nicht an deine — wer sie gestartet hat, bekommt die Glocke im Panel aber trotzdem.

Die Anfrage #

Jedes Ereignis ist ein POST mit JSON-Body und diesen Headern:

Header Inhalt
Content-Type application/json
User-Agent calmo.cloud-Webhooks/1
Signature Der HMAC-SHA256 des rohen Bodys, hexadezimal, mit dem Secret des Webhooks als Schlüssel — siehe Signaturen prüfen
webhook-id Die ID des Ereignisses, dieselbe wie id im Body. Sie bleibt bei jeder Wiederholung gleich.
webhook-timestamp Wann dieser Versuch verschickt wurde, in Unix-Sekunden. Jeder Versuch bekommt einen neuen.
webhook-signature v1, gefolgt von einer Base64-Signatur nach der Spezifikation Standard Webhooks

Deine eigenen Header aus Header werden mitgeschickt. Die von calmo.cloud können sie nicht ersetzen: Die Namen Host, Content-Type, Content-Length, Transfer-Encoding, Connection, User-Agent, Signature und alle Namen, die mit webhook- beginnen, werden abgelehnt (egal in welcher Schreibweise), ebenso Namen, die keine gültigen Header-Namen sind, und Werte mit einem Steuerzeichen wie einem Zeilenumbruch.

Antworte innerhalb von 10 Sekunden mit einem beliebigen 2xx-Status (für den Verbindungsaufbau wartet calmo.cloud 3 Sekunden). Was dein Endpunkt zurückschickt, wird weder gelesen noch gespeichert — es zählt nur der Status. Dauert deine Verarbeitung länger, nimm die Anfrage erst an und erledige die Arbeit danach, zum Beispiel in einer Queue.

Der Body #

Der Body ist ein JSON-Objekt mit diesen Schlüsseln:

Schlüssel Typ Inhalt
id String Die ID des Ereignisses. Sie ist bei jeder Wiederholung und an jedem Webhook deines Teams gleich — daran erkennst du Wiederholungen.
module String Immer AgentSession
event String Das Ereignis, z. B. decision_requested. Ein Testereignis verwendet webhook.test.
version Integer Version des Body-Formats, derzeit 1
occurred_at String Wann es passiert ist, ISO 8601 in UTC mit Mikrosekunden
team Objekt {uuid, slug} deines Teams
agent_session Objekt oder null Die Sitzung, wie sie stand, als calmo.cloud die Anfrage vorbereitet hat (siehe unten); null nur bei einem Testereignis
timeline_event Objekt oder null {seq, type, created_at} des Ereignisses in der Timeline der Sitzung, das dahintersteht — oder null, wenn es keins gibt, etwa bei einer Änderung am Pull Request, einem gescheiterten Start oder einer verfallenen Entscheidung
data Objekt Was passiert ist. Je nach Ereignis verschieden, siehe Ereignisse im Detail.
links Objekt panel: die Sitzung im Panel. api: die API-Aufrufe für diese Sitzung, siehe Über die API reagieren.
content_omitted Boolean true, wenn Teile von data weggelassen wurden, damit der Body unter 64 KiB bleibt, siehe Größenlimit

Ein vollständiges Beispiel — der Agent hat seinen Durchgang beendet und wartet auf eine Antwort:

{
    "id": "01997f2a-6c3e-7d41-b2a9-4e5f6a7b8c9d",
    "module": "AgentSession",
    "event": "status_changed",
    "version": 1,
    "occurred_at": "2026-09-25T14:03:12.482913Z",
    "team": {
        "uuid": "9d3f6c2a-4b1e-4f7a-8c2d-5e6f7a8b9c0d",
        "slug": "acme"
    },
    "agent_session": {
        "uuid": "5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
        "title": "Fix invoice rounding",
        "status": "waiting_for_input",
        "is_terminal": false,
        "is_resumable": false,
        "model": "claude-sonnet-5",
        "permission_mode": "default",
        "branch": "18.0",
        "work_branch": "agent/5b0e9c7d",
        "change_status": "unpushed",
        "pull_request": null,
        "repository": {
            "uuid": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
            "full_name": "acme/odoo-addons"
        },
        "source_service": {
            "uuid": "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
            "name": "ACME Live"
        },
        "started_via": "panel",
        "usage": {
            "tokens_in": 184230,
            "tokens_out": 9412,
            "cost_usd": "0.6939"
        },
        "last_event_seq": 214,
        "expires_at": "2026-09-26T13:41:05Z"
    },
    "timeline_event": {
        "seq": 214,
        "type": "session.status",
        "created_at": "2026-09-25T14:03:12.000000Z"
    },
    "data": {
        "from": "running",
        "to": "waiting_for_input",
        "cause": "runner",
        "stop_reason": "end_turn",
        "last_error": null,
        "error": null
    },
    "links": {
        "panel": "https://calmo.cloud/admin/acme/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
        "api": {
            "session": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
            "events": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/events?after=213",
            "messages": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/messages",
            "cancel": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/cancel"
        }
    },
    "content_omitted": false
}

Die Beispiele hier sind zum Lesen eingerückt. Der Body, der ankommt, ist kompaktes JSON in einer Zeile — prüf Signaturen immer über genau diese rohen Bytes, nie über JSON, das du neu kodiert hast.

Der Schnappschuss in agent_session:

Schlüssel Inhalt
uuid, title Die Sitzung. title kann null sein.
status pending, running, waiting_for_input, completed, failed oder cancelled — siehe Was die Status bedeuten
is_terminal true bei completed, failed und cancelled
is_resumable true, wenn eine Nachricht die Sitzung fortsetzen würde: Sie ist abgeschlossen, oder sie ist fehlgeschlagen und ihre Sandbox existiert noch
model, permission_mode Das Modell des Agenten und sein Berechtigungsmodus (default, acceptEdits, auto oder plan)
branch, work_branch Der Branch, von dem der Agent ausgegangen ist, und der Branch, auf den er committet
change_status Wo die Änderungen des Agenten stehen: unknown, no_changes, unpushed, pushed, pr_open, pr_closed oder merged
pull_request {number, state, url, merged_at}, sobald es einen gibt, sonst null. state ist open, closed oder merged.
repository {uuid, full_name} des Repositories, an dem der Agent arbeitet
source_service {uuid, name} des Odoo, von dem aus die Sitzung gestartet wurde
started_via panel oder api
usage {tokens_in, tokens_out, cost_usd} bisher; cost_usd ist eine Dezimalzahl als String
last_event_seq Die seq des neuesten Ereignisses in der Timeline der Sitzung zu dem Zeitpunkt, als die Anfrage vorbereitet wurde, oder null vor ihrem ersten Ereignis. Nicht jede Änderung erzeugt ein Ereignis, mehrere Anfragen können also denselben Wert tragen — siehe Wiederholungen, Duplikate und Reihenfolge.
expires_at Bis wann der aktuelle Lauf des Agenten sich zurückmelden darf, oder null

Alles im Body wird über UUIDs benannt. Interne numerische IDs kommen nicht vor — mit Ausnahme der Panel-Adresse eines Odoo-Dienstes in links.panel bei der Sitzung eines Partners.

Neue Schlüssel können jederzeit dazukommen. Ignoriere die, die du nicht kennst, und beantworte auch Ereignisse, die du nicht verarbeitest, mit 2xx.

Ereignisse im Detail #

Die Beispiele unten zeigen event, timeline_event, data und — wo sie sich unterscheiden — links. Die übrigen Schlüssel sind immer dabei, wie im vollständigen Beispiel oben.

Status changed #

status_changed — der Status der Sitzung hat sich geändert.

Schlüssel in data Inhalt
from Der vorherige Status, null, wenn die Sitzung gerade angelegt wurde
to Der neue Status
cause Warum: created, runner (der Agent hat es gemeldet), auto_resume (der Agent war fertig, während noch eine Nachricht unbeantwortet war — calmo.cloud hat ihn deshalb sofort neu gestartet), user_reply (jemand hat geantwortet, während die Sitzung auf Eingabe wartete), resume (eine Nachricht hat eine abgeschlossene oder fehlgeschlagene Sitzung fortgesetzt), cancel, runner_lost (der Agent hat aufgehört, ohne sich zurückzumelden), sandbox_lost (die Sandbox der Sitzung wurde gelöscht) oder null, wenn unbekannt
stop_reason Warum der Durchgang des Agenten zu Ende ist, wenn der Agent es gemeldet hat: end_turn (der Agent hat geantwortet), max_turns (er hat sein Schrittlimit erreicht — antworte, damit er weitermacht) oder interrupted. Sonst null.
last_error Der Fehler, den calmo.cloud zu diesem Zeitpunkt für die Sitzung vermerkt hatte, oder null
error Bei to: failed: {message, source}, wenn calmo.cloud weiß, was schiefgegangen ist — source ist runner, wenn der Agent den Fehler gemeldet hat, und control_plane, wenn calmo.cloud das Problem selbst festgestellt hat. Sonst null.

Eine Sitzung, deren Agent aufgehört hat, ohne sich zurückzumelden:

{
    "event": "status_changed",
    "timeline_event": null,
    "data": {
        "from": "running",
        "to": "failed",
        "cause": "runner_lost",
        "stop_reason": null,
        "last_error": "The agent stopped unexpectedly. Send a message to continue this session.",
        "error": {
            "message": "The agent stopped unexpectedly. Send a message to continue this session.",
            "source": "control_plane"
        }
    }
}

Decision requested #

decision_requested — der Agent wartet auf eine Entscheidung: die Erlaubnis für ein Werkzeug oder die Antwort auf eine Frage. data.kind sagt, welches von beiden. links.api enthält zusätzlich resolve für eine Berechtigung und answer für eine Frage.

Bei einer Berechtigung:

Schlüssel in data Inhalt
kind permission
uuid Die Berechtigungsanfrage; damit entscheidest du sie
request_id Die eigene ID des Agenten für die Anfrage
tool_name Das Werkzeug, das der Agent nutzen will, z. B. Bash, Edit oder WebFetch
summary Eine kurze, einzeilige Beschreibung des Aufrufs: bei einem Shell-Befehl die Beschreibung, die der Agent ihm gegeben hat, oder der Befehl selbst; bei Datei-Werkzeugen der Pfad; bei Web-Werkzeugen die Adresse oder die Suche
gate push, wenn der Agent seine Arbeit zu GitHub pushen will, tool bei jeder anderen Berechtigungsanfrage, null, wenn der Agent es nicht angegeben hat
{
    "event": "decision_requested",
    "timeline_event": {
        "seq": 187,
        "type": "permission.request",
        "created_at": "2026-09-25T13:58:40.000000Z"
    },
    "data": {
        "kind": "permission",
        "uuid": "3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f",
        "request_id": "b7c8d9e0-f1a2-4b3c-9d4e-5f6a7b8c9d0e",
        "tool_name": "Bash",
        "summary": "Push the rounding fix to GitHub",
        "gate": "push"
    },
    "links": {
        "panel": "https://calmo.cloud/admin/acme/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
        "api": {
            "session": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
            "events": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/events?after=186",
            "messages": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/messages",
            "cancel": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/cancel",
            "resolve": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/permissions/3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f"
        }
    }
}

Die vollständige Eingabe des Werkzeugs — den ganzen Befehl, den Dateiinhalt oder die Änderung — enthält der Body nie. Du findest sie im Ereignis permission.request, dem ersten, das links.api.events zurückgibt.

Bei einer Frage:

Schlüssel in data Inhalt
kind question
uuid Die Frage; damit beantwortest du sie
request_id Die eigene ID des Agenten für die Frage
questions Bis zu 10 Fragen, jeweils mit question, header, multi_select und bis zu 20 options aus {label, description}. Lange Texte werden gekürzt.
{
    "event": "decision_requested",
    "timeline_event": {
        "seq": 202,
        "type": "question.request",
        "created_at": "2026-09-25T14:10:03.000000Z"
    },
    "data": {
        "kind": "question",
        "uuid": "8a9b0c1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d",
        "request_id": "e2f3a4b5-c6d7-4e8f-9a0b-1c2d3e4f5a6b",
        "questions": [
            {
                "question": "Which rounding should invoices use?",
                "header": "Rounding",
                "multi_select": false,
                "options": [
                    { "label": "Per line", "description": "Round every invoice line, then add them up" },
                    { "label": "Globally", "description": "Add up the lines, then round the total once" }
                ]
            }
        ]
    },
    "links": {
        "panel": "https://calmo.cloud/admin/acme/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
        "api": {
            "session": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
            "events": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/events?after=201",
            "messages": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/messages",
            "cancel": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/cancel",
            "answer": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/questions/8a9b0c1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d"
        }
    }
}

Jede Anfrage wird verschickt: Ein Webhook bekommt jede Entscheidung, um die der Agent bittet, egal wie viele es sind.

Decision resolved #

decision_resolved — eine Berechtigungsanfrage oder Frage ist geschlossen. links.api enthält denselben Link resolve bzw. answer wie die Anfrage.

Schlüssel in data Inhalt
kind, uuid, request_id Die Anfrage, wie bei Decision requested
outcome allowed oder denied (Berechtigung), answered (Frage), expired (die Sitzung hat geendet oder wurde neu gestartet, bevor jemand entschieden hat), withdrawn (der Agent wartet nicht mehr darauf)
via panel oder api, wenn jemand entschieden hat, sonst null
{
    "event": "decision_resolved",
    "timeline_event": {
        "seq": 191,
        "type": "permission.resolved",
        "created_at": "2026-09-25T13:59:02.000000Z"
    },
    "data": {
        "kind": "permission",
        "uuid": "3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f",
        "request_id": "b7c8d9e0-f1a2-4b3c-9d4e-5f6a7b8c9d0e",
        "outcome": "allowed",
        "via": "api"
    }
}

Eine verfallene Anfrage (expired) hat kein timeline_event.

Start failed #

start_failed — calmo.cloud konnte die Sandbox oder den Agenten der Sitzung nicht starten. Die Sitzung bleibt auf pending.

Schlüssel in data Inhalt
phase sandbox oder runner
message Was schiefgegangen ist, von calmo.cloud formuliert
{
    "event": "start_failed",
    "timeline_event": null,
    "data": {
        "phase": "runner",
        "message": "Could not reach the server to start the agent runner."
    }
}

Pull request changed #

pull_request_changed — der Pull Request der Sitzung auf GitHub hat sich geändert.

Schlüssel in data Inhalt
action opened, reopened, closed (ohne Merge) oder merged
number, url Der Pull Request
previous_state open oder closed vor der Änderung, null, wenn calmo.cloud den Pull Request vorher noch nicht kannte
{
    "event": "pull_request_changed",
    "timeline_event": null,
    "data": {
        "action": "merged",
        "number": 57,
        "url": "https://github.com/acme/odoo-addons/pull/57",
        "previous_state": "open"
    }
}

Files shared #

files_shared — der Agent hat Dateien übergeben.

Schlüssel in data Inhalt
files Bis zu 50 Dateien, jeweils mit uuid, name, mime_type, size (in Bytes) und download_url
{
    "event": "files_shared",
    "timeline_event": {
        "seq": 240,
        "type": "assistant.files",
        "created_at": "2026-09-25T14:21:47.000000Z"
    },
    "data": {
        "files": [
            {
                "uuid": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a",
                "name": "rounding-comparison.csv",
                "mime_type": "text/csv",
                "size": 18234,
                "download_url": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/attachments/d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a"
            }
        ]
    }
}

Für download_url brauchst du ein API-Token. Die Dateien werden einige Zeit nach dem Ende der Sitzung zusammen mit ihrer Sandbox gelöscht — hol sie also bald ab.

Größenlimit #

Ein Body bleibt unter 64 KiB. Wäre er größer, werden nacheinander data.questions, data.summary und data.files auf null gesetzt, bis er passt, und content_omitted ist true. Über die API bekommst du trotzdem alles.

Signaturen prüfen #

Jede Anfrage trägt zwei Signaturen, beide mit dem Secret des Webhooks erstellt. Es reicht, eine davon zu prüfen:

  • webhook-signature folgt der Spezifikation Standard Webhooks. Sie deckt auch webhook-id und webhook-timestamp ab, sodass du wieder abgespielte Anfragen erkennen kannst. Nimm diese, wenn du kannst.
  • Signature ist der hexadezimale HMAC-SHA256 des rohen Bodys, mit dem Secret genau so, wie du es eingegeben hast, als Schlüssel. Die übrigen Webhooks der Seite tragen denselben Header — ein Endpunkt, der ihn dort schon prüft, funktioniert also auch hier. Einen Zeitstempel deckt er nicht ab.

Ein Secret wählen #

Wir empfehlen ein Secret im Format von Standard Webhooks — whsec_ gefolgt von Base64 aus Zufallsbytes —, damit auch die Bibliotheken der Spezifikation es akzeptieren. So erzeugst du eins:

echo "whsec_$(openssl rand -base64 32)"

Der Schlüssel für webhook-signature hängt vom Secret ab:

  • Beginnt das Secret mit whsec_, ist der Schlüssel der Rest des Secrets, aus Base64 dekodiert.
  • Jedes andere Secret ist selbst der Schlüssel, Byte für Byte (UTF-8). Das gilt auch für ein Secret, das mit whsec_ beginnt, dessen Rest aber kein gültiges Base64 ist — samt Präfix.

Signature verwendet immer das Secret, wie es ist, whsec_ eingeschlossen.

webhook-signature prüfen #

  1. Nimm den rohen Body, genau so, wie er angekommen ist — bevor du ihn als JSON einliest.
  2. Setze {webhook-id}.{webhook-timestamp}.{roher Body} zusammen — die beiden Header-Werte und den Body, getrennt durch Punkte.
  3. Berechne davon mit dem Schlüssel von oben den HMAC-SHA256 und kodiere das Ergebnis in Base64.
  4. Der Header webhook-signature enthält eine oder mehrere durch Leerzeichen getrennte Signaturen der Form v1,<Base64>. Nimm die Anfrage an, wenn eine davon deinem Ergebnis entspricht — verglichen in konstanter Zeit.
  5. Lehne die Anfrage ab, wenn webhook-timestamp mehr als 5 Minuten von deiner eigenen Uhr abweicht. So lässt sich eine mitgeschnittene Anfrage nicht später noch einmal abspielen. Jede Wiederholung wird mit einem frischen Zeitstempel neu signiert und besteht diese Prüfung deshalb.

Danach sortierst du anhand von id Wiederholungen aus (siehe Wiederholungen, Duplikate und Reihenfolge).

PHP #

<?php

/**
 * Der Schlüssel, mit dem calmo.cloud `webhook-signature` signiert: der
 * Base64-dekodierte Rest eines `whsec_`-Secrets, sonst das Secret selbst.
 */
function calmoSigningKey(string $secret): string
{
    if (str_starts_with($secret, 'whsec_')) {
        $key = base64_decode(substr($secret, strlen('whsec_')), true);

        if ($key !== false) {
            return $key;
        }
    }

    return $secret;
}

/**
 * Standard Webhooks: webhook-id, webhook-timestamp und webhook-signature.
 *
 * @param  array<string, string>  $headers
 */
function verifyCalmoWebhook(string $payload, array $headers, string $secret, int $toleranceSeconds = 300): bool
{
    $headers = array_change_key_case($headers, CASE_LOWER);
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatures = $headers['webhook-signature'] ?? '';

    if ($id === '' || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
        return false;
    }

    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$payload}", calmoSigningKey($secret), true));

    foreach (explode(' ', $signatures) as $signature) {
        [$version, $value] = array_pad(explode(',', $signature, 2), 2, '');

        if ($version === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }

    return false;
}

/**
 * Der Header Signature: hexadezimaler HMAC-SHA256 des Bodys, mit dem Secret, wie es ist.
 */
function verifyCalmoSignatureHeader(string $payload, string $signature, string $secret): bool
{
    return hash_equals(hash_hmac('sha256', $payload, $secret), $signature);
}

$payload = file_get_contents('php://input');

if (! verifyCalmoWebhook($payload, getallheaders(), getenv('CALMO_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true);

In Laravel übergibst du $request->getContent() als Payload und array_map(fn ($values) => $values[0], $request->headers->all()) als Header.

Node.js #

import crypto from 'node:crypto';
import express from 'express';

// Der Schlüssel, mit dem calmo.cloud `webhook-signature` signiert: der
// Base64-dekodierte Rest eines `whsec_`-Secrets, sonst das Secret selbst.
// Ein `whsec_`-Secret, dessen Rest kein gültiges Base64 ist, zählt ebenfalls
// so, wie es ist, samt Präfix.
export function calmoSigningKey(secret) {
    if (secret.startsWith('whsec_')) {
        const encoded = secret.slice('whsec_'.length).replace(/[\t\n\r ]/g, '');
        const data = encoded.replace(/=+$/, '');
        const padding = encoded.length - data.length;

        if (/^[A-Za-z0-9+/]*$/.test(data) && data.length % 4 !== 1 && (padding === 0 || (padding <= 2 && (data.length + padding) % 4 === 0))) {
            return Buffer.from(data, 'base64');
        }
    }

    return Buffer.from(secret, 'utf8');
}

function sameText(given, expected) {
    const a = Buffer.from(given, 'utf8');
    const b = Buffer.from(expected, 'utf8');

    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Standard Webhooks: webhook-id, webhook-timestamp und webhook-signature.
export function verifyCalmoWebhook(payload, headers, secret, toleranceSeconds = 300) {
    const id = headers['webhook-id'] ?? '';
    const timestamp = headers['webhook-timestamp'] ?? '';
    const signatures = headers['webhook-signature'] ?? '';

    if (id === '' || !/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
        return false;
    }

    const expected = crypto
        .createHmac('sha256', calmoSigningKey(secret))
        .update(`${id}.${timestamp}.`)
        .update(payload) // der rohe Body, genau so, wie er ankam
        .digest('base64');

    return signatures.split(' ').some((signature) => {
        const [version, value = ''] = signature.split(',', 2);

        return version === 'v1' && sameText(value, expected);
    });
}

// Der Header Signature: hexadezimaler HMAC-SHA256 des Bodys, mit dem Secret, wie es ist.
export function verifyCalmoSignatureHeader(payload, signature, secret) {
    const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(payload).digest('hex');

    return sameText(signature ?? '', expected);
}

const app = express();

// express.raw() lässt den Body als genau die Bytes stehen, die signiert wurden.
app.post('/webhooks/calmo', express.raw({ type: 'application/json' }), (req, res) => {
    if (!verifyCalmoWebhook(req.body, req.headers, process.env.CALMO_WEBHOOK_SECRET)) {
        return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(204);

    // `event` erst nach der Antwort verarbeiten.
});

Wiederholungen, Duplikate und Reihenfolge #

Wiederholungen. Eine Zustellung gilt als erfolgreich, wenn dein Endpunkt mit einem 2xx-Status antwortet. Ist er nicht erreichbar, lässt sich sein Hostname nicht auflösen, scheitert der Verbindungsaufbau oder der TLS-Handshake, braucht er länger als 10 Sekunden oder antwortet er mit 408, 425, 429 oder einem 5xx-Status, versucht calmo.cloud es nach 10 Sekunden, 1 Minute, 5 Minuten und 30 Minuten erneut — fünf Versuche über rund 36 Minuten. Jede andere Antwort beendet die Zustellung sofort: andere 4xx-Status, Weiterleitungen (calmo.cloud folgt ihnen nicht — trag stattdessen die endgültige URL ein) und Adressen, an die calmo.cloud nicht sendet. Jeder Versuch steht in den Logs des Webhooks.

Eine Zustellung, die auf ihren nächsten Versuch wartet, wird verworfen, wenn du den Webhook in der Zwischenzeit löschst oder Coding-Agenten für dein Team nicht mehr freigeschaltet sind.

Duplikate. Zugestellt wird mindestens einmal: Hat dein Endpunkt ein Ereignis verarbeitet, aber zu spät geantwortet, bekommt er es noch einmal. Die id (und der Header webhook-id) ist bei jeder Wiederholung und an jedem Webhook deines Teams gleich. Merk dir die IDs, die du schon verarbeitet hast — einen Tag lang reicht völlig —, und beantworte eine Wiederholung mit 2xx, ohne sie noch einmal zu verarbeiten.

Reihenfolge. Ereignisse werden unabhängig voneinander verschickt und wiederholt. Sie können also in anderer Reihenfolge ankommen, als sie passiert sind: Ein wiederholtes running kann nach dem waiting_for_input eintreffen, das darauf folgte. Verlass dich nicht auf die Reihenfolge der Ankunft.

  • data beschreibt immer die Änderung, um die es im Ereignis geht — bei einem Statuswechsel from und to.
  • agent_session ist ein Schnappschuss vom Zeitpunkt, als calmo.cloud die Anfrage vorbereitet hat, und kann schon neuer sein als data; eine Wiederholung schickt denselben Schnappschuss noch einmal. last_event_seq wächst mit jedem Ereignis in der Timeline der Sitzung, aber nicht jede Änderung erzeugt eins: Wird zum Beispiel ein Pull Request geschlossen, wieder geöffnet oder gemergt, ist eine Sitzung neu oder scheitert ihr Start, bleibt last_event_seq gleich oder ist null. Um den neuesten Schnappschuss zu behalten, vergleichst du zuerst last_event_seq, wobei null niedriger zählt als jede Zahl, und bei Gleichstand occurred_at. Behalte den Schnappschuss, der dabei vorne liegt, und ignoriere die anderen.
  • timeline_event.seq ordnet das Ereignis in die Timeline der Sitzung ein.
  • Im Zweifel fragst du den aktuellen Stand über links.api.session bei der API ab.

Logs #

Logs in der Zeile eines Webhooks öffnet Webhook Logs. Bei einem Webhook mit der Quelle Agent sessions steht dort eine Zeile pro Zustellversuch — Wiederholungen und Testereignisse eingeschlossen:

Spalte Inhalt
ID Die id des Ereignisses — alle Versuche eines Ereignisses haben also dieselbe
Status Code Der HTTP-Status, mit dem dein Endpunkt geantwortet hat; leer, wenn keine Antwort kam
Error Message Leer, wenn der Versuch geklappt hat, sonst ein kurzer Grund: HTTP 500 (der Status, mit dem dein Endpunkt geantwortet hat), Timed out, TLS error, Connection failed, DNS lookup failed, Redirect not followed, Blocked destination oder Delivery failed
Error Type failed, blocked (die Adresse ist nicht erlaubt, siehe unten) oder leer, wenn der Versuch geklappt hat
Attempts Der wievielte Versuch das war, von 1 bis 5

Was dein Endpunkt zurückschickt, wird nicht gespeichert — nur sein HTTP-Status.

Send test event #

Send test event in der Zeile eines Webhooks mit der Quelle Agent sessions schickt ihm sofort eine Testanfrage — genauso signiert und verschickt wie jede andere Anfrage, aber nie wiederholt. Eine Meldung sagt dir danach, was passiert ist: mit welchem HTTP-Status dein Endpunkt geantwortet hat und wie lange das gedauert hat — oder warum die Zustellung gescheitert ist. Der Versuch erscheint außerdem in den Logs des Webhooks. Pro Webhook sind fünf Testereignisse pro Minute möglich.

Das Testereignis hat dieselbe Form, nur ohne Sitzung:

{
    "id": "01997f3b-0a1c-7e2d-9f3a-4b5c6d7e8f90",
    "module": "AgentSession",
    "event": "webhook.test",
    "version": 1,
    "occurred_at": "2026-09-25T16:20:05.118204Z",
    "team": {
        "uuid": "9d3f6c2a-4b1e-4f7a-8c2d-5e6f7a8b9c0d",
        "slug": "acme"
    },
    "agent_session": null,
    "timeline_event": null,
    "data": {},
    "links": {
        "panel": "https://calmo.cloud/admin/acme/webhooks",
        "api": null
    },
    "content_omitted": false
}

Beantworte es wie jedes andere Ereignis mit 2xx. data ist hier ein leeres Objekt, also vom selben Typ wie bei jedem anderen Ereignis.

Wohin calmo.cloud sendet #

Webhooks werden aus dem Netz von calmo.cloud verschickt. Dein Endpunkt muss deshalb eine öffentliche Adresse im Internet sein. Es gelten dieselben Regeln wie für Benachrichtigungsziele: Die URL muss mit https:// beginnen und darf keinen Benutzernamen und kein Passwort enthalten, erlaubt sind nur die Ports 443, 8443 und 1024–65535, und abgelehnt werden Hostnamen wie localhost oder solche, die auf .local oder .internal enden, Adressen in privaten oder anderweitig reservierten Netzen, calmo.cloud selbst und Server eines anderen Teams.

Die Seite prüft die URL beim Anlegen eines Webhooks, egal mit welcher Quelle. Bei einem Webhook mit der Quelle Agent sessions wird sie vor jeder Zustellung erneut geprüft, und jede Zustellung geht an genau die Adresse, die geprüft wurde. Eine Zustellung an eine Adresse, die nicht mehr erlaubt ist — etwa weil ihr Hostname inzwischen in ein privates Netz zeigt —, wird nicht wiederholt und steht in den Logs als blocked. Lässt sich ein Hostname beim Zustellen nicht auflösen — etwa weil ein DNS-Server nicht geantwortet hat —, ist das keine Sperre: Der Versuch scheitert mit DNS lookup failed und wird wiederholt wie bei einem Endpunkt, der nicht erreichbar ist.

Was die Status bedeuten #

Status Bedeutung
pending Die Sitzung startet: Sie wurde gerade angelegt, eine Nachricht hat sie fortgesetzt, oder calmo.cloud hat sie von sich aus neu gestartet
running Der Agent arbeitet — oder wartet auf eine Entscheidung (siehe unten)
waiting_for_input Der Durchgang des Agenten ist vorbei, er wartet auf deine Nachricht; stop_reason sagt, warum
completed Der Agent hat so lange auf eine Nachricht gewartet, dass sein Lauf zu Ende ist. Nicht endgültig: Eine Nachricht setzt die Sitzung mit ihrem ganzen Kontext fort.
failed Etwas ist schiefgegangen; data.error sagt, was, sofern calmo.cloud es weiß. Eine fehlgeschlagene Sitzung lässt sich fortsetzen, solange ihre Sandbox existiert (is_resumable).
cancelled Jemand hat die Sitzung abgebrochen. Das ist endgültig.

Waiting for input ist keine Entscheidung. waiting_for_input sagt nur, dass der Durchgang des Agenten zu Ende ist. Wartet der Agent auf eine Erlaubnis oder eine Antwort, bleibt die Sitzung auf running, und du bekommst stattdessen decision_requested.

Completed ist nicht das Ende. Eine abgeschlossene Sitzung kommt als pending (cause: resume) zurück, sobald ihr jemand schreibt. Ob eine Nachricht eine Sitzung fortsetzen würde, verrät is_resumable.

Eine Entscheidung bleibt offen, bis sie erledigt ist. Nach einem decision_requested wartet der Agent, bis ein decision_resolved mit derselben data.uuid die Anfrage schließt: allowed, denied oder answered, wenn jemand entschieden hat, expired, wenn die Sitzung vorher geendet hat oder neu gestartet wurde, und withdrawn, wenn der Agent nicht mehr wartet — etwa weil sein Durchgang unterbrochen wurde. Bis dahin kann die Anfrage im Panel oder über die API beantwortet werden, danach lehnt die API das ab.

Über die API reagieren #

Mit den Links in links.api kann ein Skript oder ein anderes Tool auf ein Ereignis reagieren. Sie brauchen ein API-Token deines Teams — Team-Admins legen es unter Developer > API Tokens an, und die REST-API ist ab dem Starter-Plan enthalten —, das du als Authorization: Bearer <token> zusammen mit Accept: application/json mitschickst.

Link Methode Was er tut
session GET Die Sitzung, wie sie jetzt ist
events GET Bis zu 500 Timeline-Ereignisse, die ältesten zuerst — beginnend mit dem Ereignis, das dahintersteht, falls es eins gibt, und sonst mit dem, was als Nächstes passiert. Zum Weiterlesen rufst du ihn erneut auf, mit after auf der letzten seq, die du bekommen hast.
resolve POST Eine Berechtigungsanfrage erlauben oder ablehnen: {"decision": "allow"} oder {"decision": "deny"}
answer POST Eine Frage beantworten: {"answers": {"<Fragetext>": "<Label>"}} — eine Liste von Labels bei einer Frage, die mehrere zulässt — oder eine freie Antwort {"response": "…"}
messages POST Dem Agenten eine Nachricht schicken: {"content": "…"}. Eine Sitzung, die auf Eingabe wartet, macht sofort weiter; eine abgeschlossene oder eine fehlgeschlagene, die is_resumable ist, startet neu.
cancel POST Die Sitzung abbrechen. Eine Sitzung, die schon geendet hat, bleibt, wie sie ist.
curl -X POST "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/permissions/3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f" \
  -H "Authorization: Bearer your-api-token" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"decision": "deny"}'

So antwortet die API, wenn du eine Berechtigung entscheidest oder eine Frage beantwortest:

  • 200 — die Entscheidung wurde gespeichert. Hat vorher schon jemand anderes entschieden, bekommst du dessen Entscheidung unverändert zurück; die Antwort zeigt immer die Entscheidung, die gilt.
  • 409 — der Agent wartet nicht mehr auf diese Entscheidung: Sie ist verfallen, wurde zurückgezogen, oder die Sitzung ist zu Ende.
  • 422 — der Body der Anfrage ist ungültig, zum Beispiel weil die Antworten als Liste statt nach Fragetext geschickt wurden.

Die Schlüssel von answers sind die Fragetexte. Die Texte im Webhook können gekürzt sein — nimm sie bei sehr langen Fragen deshalb aus dem Ereignis question.request (links.api.events).

Sicherheitshinweise #

Prüf die Signatur, bevor du irgendetwas vertraust. Prüfe jede Anfrage wie oben beschrieben und verwende erst danach ihren Inhalt oder folge ihren Links. Schick dein API-Token nur an die Adresse von calmo.cloud selbst — nie an eine URL aus einer Anfrage, die du nicht geprüft hast.

Text vom Agenten ist nicht vertrauenswürdig. Der Titel der Sitzung, data.summary, data.questions, Fehlermeldungen und Dateinamen können Text enthalten, den der Agent geschrieben hat — und der Agent liest Code, Webseiten und Odoo-Daten, die jemand geschrieben haben kann, um ihn zu steuern. Zeig solchen Text als reinen Text an, escape ihn für die Stelle, an der du ihn anzeigst (HTML, Markdown, Chat-Nachrichten), und führe ihn nie aus, bau keine Befehle oder URLs daraus und folge keinen Links darin.

Genehmige Berechtigungsanfragen nie automatisch. An einer Berechtigungsanfrage entscheidet ein Mensch, ob der Agent etwas tun darf, das er allein nicht darf — etwa seine Arbeit zu GitHub pushen (gate: push). Ein Skript, das jede Anfrage erlaubt, hebelt diese Sicherung aus. summary ist die eigene Beschreibung des Agenten und muss nicht zu dem passen, was er tatsächlich ausführt. Wenn du Buttons zum Erlauben und Ablehnen in ein anderes Tool einbaust, zeig die Eingabe des Werkzeugs aus dem Ereignis permission.request an und überlass die Entscheidung einem Menschen.

Halte das Secret geheim. Bewahre es auf wie ein Passwort. Ist es in falsche Hände geraten, lösch den Webhook und leg einen neuen mit einem neuen Secret an.

Sitzungen eines Partnerteams #

Startet ein Partnerteam eine Sitzung auf einem Odoo, den dein Team mit ihm teilt, gehen die Ereignisse an deine Webhooks, nicht an die des Partners, und team ist dein Team. Sie enthalten den Titel der Sitzung, die Werkzeuge, die der Agent nutzen will, samt ihren Zusammenfassungen, seine Fragen und die Namen geteilter Dateien. Du kannst verfolgen, was passiert, aber nicht eingreifen: links.panel öffnet deinen Odoo-Dienst statt der Sitzung, links.api ist null, und ebenso jede download_url.