Zum Hauptinhalt springen

Verarbeiten Sie den Urteils-Webhook

Drei ausgearbeitete Verbraucher des Ereignisstroms eines Workspaces: ein LangGraph-Thread, der unterbricht und durch eine Zustellung fortgesetzt wird, ein Lauf des OpenAI Agents SDK, der an seinem Freigabepunkt serialisiert wird, und ein Jira-Vorgang, der bei Revise oder Stop angelegt wird. Das sind Rezepte zur Übernahme in den eigenen Dienst, keine Pakete, die RateLoop für Sie pflegt.

Die folgenden Paketbeispiele beschreiben die noch nicht veröffentlichte API von @rateloop/agents 0.2.0. Bei der letzten Registry-Prüfung war nur 0.1.0 verfügbar; deren Exporte können diese Beispiele nicht ausführen. Prüfen Sie vor der Installation die öffentliche Version.

Bis 0.2.0 öffentlich verfügbar ist, verbinden Sie einen Agenten über Workspace-MCP oder empfangen Sie signierte Ereignisse über Webhooks in Ihrem eigenen Dienst. Das Anwendungsrepository ist privat; ein Quellcode-Checkout ist kein öffentlicher Installationsweg.

Der Zustellvertrag

Eine Eigentümerschaft oder Administration des Workspaces registriert einen Endpunkt im angemeldeten Zustand. Das Signaturgeheimnis wird genau einmal bei der Anlage zurückgegeben und danach nie wieder.

POST /api/account/workspaces/{workspaceId}/assurance/event-streams
{ "url": "https://example.com/hooks/rateloop",
  "eventTypes": ["ai.rateloop.review.decision_needed", "ai.rateloop.review.verdict"] }

201 { "endpointId": "whe_…", "eventTypes": [...], "signingSecret": "rlwhsec_…", "url": "…" }

GET    /api/account/workspaces/{workspaceId}/assurance/event-streams
DELETE /api/account/workspaces/{workspaceId}/assurance/event-streams/{endpointId}

Die Auflistung gibt niemals ein Signaturgeheimnis zurück. Das Löschen deaktiviert einen Endpunkt und behält seinen Datensatz, sodass die URL belegt bleibt und nicht erneut registriert werden kann – siehe Webhooks dazu, was das bedeutet, wenn ein Geheimnis verloren geht.

Die URL muss HTTPS verwenden, darf weder Benutzernamen noch Passwort noch Fragment enthalten und darf nicht auf eine private oder lokale Adresse auflösen. In der Produktion muss sie den Standard-HTTPS-Port verwenden. RateLoop löst den Hostnamen einmal je Versuch auf und bindet die aufgelöste Adresse an die Verbindung, sodass eine Neuzuordnung zwischen Prüfung und Zustellung sie nicht umleiten kann.

Jeder Versuch trägt dieselben fünf Kopfzeilen.

content-typeImmer der CloudEvents-JSON-Medientyp.
rateloop-signatureDas Präfix v1, gefolgt vom HMAC in Hexadezimalform.
rateloop-timestampUnix-Sekunden, gemeinsam mit dem Rumpf signiert.
rateloop-delivery-idJe Zustellung stabil über alle Wiederholungen hinweg.
rateloop-event-idJe Ereignis stabil und für alle abonnierten Endpunkte identisch.

Die Signatur deckt den Zeitstempel und den rohen Rumpf ab, verbunden durch genau einen Punkt.

rateloop-signature: v1=<hex>
  where <hex> = HMAC_SHA256(signingSecret, "<rateloop-timestamp>.<raw request body>")

RateLoop unternimmt insgesamt höchstens acht Versuche. Nach dem ersten Fehler wartet RateLoop 30 Sekunden und verdoppelt den Abstand danach bis auf höchstens eine Stunde. Jeder Versuch verwendet dieselbe Zustellungs-ID; nutzen Sie sie, um Duplikate zu erkennen. Eine fehlgeschlagene Zustellung ändert weder die Prüfung noch die Freigabe.

Was die beiden Ereignisse tragen

Das eine Ereignis markiert den Moment, in dem die benannten Prüfenden antworten können. Das andere markiert den Moment, in dem eine Workspace-Eigentümerschaft Go, Revise oder Stop gegen das Nachweispaket erfasst hat.

ai.rateloop.review.decision_needed
  subject                        the opportunity ID
  data.lifecycle.state           "pending"
  data.evidenceReference.kind    "gate_transition"   (no packet exists yet)

ai.rateloop.review.verdict
  subject                        the assurance run ID
  data.lifecycle.state           "go" | "revise" | "stop"
  data.lifecycle.reasonCodes     ["client_signoff"] plus "decision_note_recorded"
  data.evidenceReference.kind    "decision_packet"
  data.packetHash                sha256:… digest of the packet the owner signed off

Der Umschlag ist CloudEvents 1.0. Eine Urteils-Zustellung sieht so aus.

{
  "specversion": "1.0",
  "id": "aev_…",
  "source": "urn:rateloop:assurance:workspace:ws_…",
  "type": "ai.rateloop.review.verdict",
  "subject": "run_…",
  "time": "2026-08-11T09:14:02.000Z",
  "datacontenttype": "application/json",
  "dataschema": "urn:rateloop:schema:assurance-event:v2",
  "rateloopevidencekind": "decision_packet",
  "rateloopevidencedigest": "sha256:…",
  "ratelooppackethash": "sha256:…",
  "rateloopchainhash": "sha256:…",
  "data": {
    "schemaVersion": "rateloop.assurance-event.v2",
    "evidenceReference": { "kind": "decision_packet", "digest": "sha256:…" },
    "packetHash": "sha256:…",
    "lifecycle": { "state": "revise", "reasonCodes": ["client_signoff", "decision_note_recorded"] },
    "evidenceChain": { "eventHash": "sha256:…", "previousHash": "sha256:…", "sequence": 412 },
    "ocsf": { "class_uid": 2003, "…": "… the full OCSF compliance finding" }
  }
}

Die schriftliche Begründung verlässt RateLoop nie. Ein Grundcode gibt an, dass eine Anmerkung vorliegt; ihr Text bleibt im Nachweispaket, hinter der eigenen Anmeldung der lesenden Person. Was auf diesem Ereignis aufbaut, verweist auf die Entscheidung zurück, statt sie zu zitieren.

Zuerst die Signatur prüfen

Es werden die rohen Bytes gelesen, nicht ein neu serialisiertes Objekt: Der HMAC deckt genau den gesendeten Rumpf ab. Der Vergleich erfolgt in konstanter Zeit, und ein Zeitstempel außerhalb eines kurzen Fensters wird abgewiesen, damit eine mitgeschnittene Zustellung später nicht erneut eingespielt werden kann. Alle drei Rezepte importieren dieses eine Modul.

// rateloopWebhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const REPLAY_WINDOW_SECONDS = 300;

export type RateLoopEvent = {
  id: string;
  source: string;
  type: string;
  subject: string;
  time: string;
  data: {
    packetHash?: string;
    evidenceReference: { kind: "decision_packet" | "gate_transition"; digest: string };
    lifecycle?: { state: string; reasonCodes: string[] };
  };
};

export function verifyRateLoopDelivery(headers: Headers, rawBody: string, secret: string): RateLoopEvent {
  const timestamp = headers.get("rateloop-timestamp") ?? "";
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > REPLAY_WINDOW_SECONDS) {
    throw new Error("RateLoop delivery timestamp is outside the replay window.");
  }
  const expected = Buffer.from(`v1=${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`);
  const received = Buffer.from(headers.get("rateloop-signature") ?? "");
  if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
    throw new Error("RateLoop delivery signature does not verify.");
  }
  return JSON.parse(rawBody) as RateLoopEvent;
}

Reservieren Sie die Zustellungs-ID vor der Verarbeitung über einen eindeutigen Index, damit ein erneuter Versuch dieselbe Arbeit nicht wiederholt. Schlägt die Verarbeitung fehl, geben Sie die Reservierung für den nächsten Versuch frei.

// The same entry point for every recipe below.
export async function POST(request: Request) {
  const rawBody = await request.text();
  let event: RateLoopEvent;
  try {
    event = verifyRateLoopDelivery(request.headers, rawBody, process.env.RATELOOP_WEBHOOK_SECRET!);
  } catch {
    return new Response("invalid signature", { status: 401 });
  }

  const deliveryId = request.headers.get("rateloop-delivery-id")!;
  // INSERT … ON CONFLICT DO NOTHING against a unique delivery_id column.
  if (!(await deliveries.claim(deliveryId))) return new Response(null, { status: 204 });
  try {
    await handle(event);
  } catch {
    await deliveries.release(deliveryId);
    return new Response("handler failed", { status: 500 });
  }
  return new Response(null, { status: 204 });
}

LangGraph

Der Knoten eröffnet die Prüfung, hält fest, welcher Thread auf die Opportunity wartet, und unterbricht. Er hält den Framework-Aufruf nie über das menschliche Antwortfenster offen.

// reviewGate.ts — LangGraph's interrupt is injected, which is how the shipped
// adapter takes it and keeps this node testable without the graph runtime.
import {
  beginRateLoopFrameworkApproval,
  interruptForRateLoopApproval,
  refreshRateLoopFrameworkApproval,
} from "@rateloop/agents";
import { driver, threads } from "./rateloop"; // driver wraps the workspace MCP review operations

export function createReviewGate(interrupt: (value: unknown) => unknown) {
  return async function reviewGate(state: GraphState) {
    // LangGraph restarts this node on resume, so evaluate and prepare must be idempotent.
    const gate = await beginRateLoopFrameworkApproval({
      driver,
      evaluation: { agentId: state.agentId, candidate: state.draft },
      preparation: { question: state.question },
    });
    if (gate.action === "interrupt") await threads.bind(gate.pending.opportunityId, state.threadId);

    const outcome = interruptForRateLoopApproval(gate, interrupt);
    if (outcome.action === "cancelled") return { settled: true, released: false };

    const settled =
      outcome.action === "resume_requested"
        ? await refreshRateLoopFrameworkApproval({ driver, pending: outcome.pending })
        : outcome;

    if (settled.action === "block") throw new Error(`RateLoop blocked the output: ${settled.reason}`);
    if (settled.action === "release") return { settled: true, released: true };
    return { settled: false }; // still pending: route back in and interrupt again
  };
}

Ein Modul kapselt das Framework. Es importiert LangGraph, übersetzt den Graphen mit einem dauerhaften Checkpointer und einer bedingten Kante zurück in das Gate, solange der Zustand offen ist, und exportiert eine einzige Fortsetzungsfunktion. Die Zustellung ruft anschließend diese Funktion auf.

// graph.ts — the only module that imports @langchain/langgraph
//   const graph = new StateGraph(annotation)
//     .addNode("reviewGate", createReviewGate(interrupt))
//     .addEdge(START, "reviewGate")
//     .addConditionalEdges("reviewGate", s => (s.settled ? END : "reviewGate"))
//     .compile({ checkpointer });
//
//   export const resumeThread = (threadId: string) =>
//     graph.invoke(new Command({ resume: { action: "resume" } }), {
//       configurable: { thread_id: threadId },
//     });

// handle(event) for the LangGraph recipe
const RESUME_TYPES = new Set([
  "ai.rateloop.review.completed",
  "ai.rateloop.review.failed",
  "ai.rateloop.review.expired",
  "ai.rateloop.review.inconclusive",
  "ai.rateloop.review.cancelled",
  "ai.rateloop.gate.blocked",
]);

async function handle(event: RateLoopEvent) {
  if (!RESUME_TYPES.has(event.type)) return;
  const threadId = await threads.lookup(event.subject); // subject is the opportunity ID
  if (threadId) await resumeThread(threadId);
}

Der Fortsetzungswert ist ein Wecksignal, keine Befugnis. Er besagt nur, dass RateLoop erneut geprüft werden soll: Der Knoten führt genau einen begrenzten Abgleich aus, und die Ausgabe wird erst freigegeben, sobald der Treiber signierte Terminalnachweise geprüft hat, die genau an diese Ausgabefestlegung und Richtlinienbindung gebunden sind. Eine gefälschte Zustellung gibt daher nichts frei — schlimmstenfalls kostet sie einen Abgleich. Dasselbe gilt für die Human-in-the-Loop-Middleware, die über eine eigene Zustimmungs- oder Ablehnungsnutzlast entscheidet; ihre Verarbeitung sollte den Abgleich aufrufen und auf das zurückgegebene Gate reagieren statt auf die Nutzlast.

OpenAI Agents SDK

Der Adapter beantwortet den Freigabe-Callback eines Werkzeugs und legt einen RateLoop-Checkpoint je Tool-Call-Kennung ab. Dieser Speicher wird neben dem Laufzustand des SDK serialisiert, und beide werden über die Opportunity indexiert, damit eine Zustellung den Lauf wiederfindet.

// agent.ts
import { Agent, RunState, run, tool } from "@openai/agents";
import { createOpenAiAgentsApprovalAdapter } from "@rateloop/agents";
import { begin, refresh, store } from "./rateloop";

const approval = createOpenAiAgentsApprovalAdapter({ begin, refresh, store });

export const agent = new Agent({
  name: "Editor",
  tools: [
    tool({
      name: "publish_draft",
      description: "Publish the reviewed draft.",
      parameters: publishParameters,
      needsApproval: approval.needsApproval,
      execute: async ({ draft }) => publish(draft),
    }),
  ],
});

const result = await run(agent, input);
for (const interruption of result.interruptions ?? []) {
  const toolCallId = interruption.rawItem.callId;
  const stored = await store.load(toolCallId);
  if (!stored) continue;
  await runs.save({
    opportunityId: stored.pending.opportunityId,
    toolCallId,
    serializedState: JSON.stringify(result.state),
  });
}
// handle(event) for the OpenAI Agents recipe, with RESUME_TYPES as above
async function handle(event: RateLoopEvent) {
  if (!RESUME_TYPES.has(event.type)) return;
  const record = await runs.byOpportunity(event.subject);
  if (!record) return;

  // SDK approval alone never satisfies RateLoop. This re-reads the gate and
  // returns true only for a release backed by signed terminal evidence.
  if (!(await approval.readyToApproveSdkInterruption(record.toolCallId))) return;

  const state = await RunState.fromString(agent, record.serializedState);
  const interruption = state.getInterruptions().find(item => item.rawItem.callId === record.toolCallId);
  if (!interruption) return;
  state.approve(interruption);

  const resumed = await run(agent, state);
  await runs.save({ ...record, serializedState: JSON.stringify(resumed.state) });
}

Die Versionen gehören festgelegt. Im Python-SDK wurde der werkzeugbezogene Freigabe-Callback erst um Release 0.8 zuverlässig; dort ist mindestens diese Version festzulegen. Der RateLoop-Adapter selbst ist ausschließlich TypeScript: Ein Python-Host ruft die Workspace-API direkt auf und wendet dieselbe Regel an, nämlich vor der Zustimmung abzugleichen und einen blockierten oder abgebrochenen Terminalzustand als Verweigerung der Freigabe zu behandeln.

Jira-Vorgang bei Revise oder Stop

Dieses Rezept verarbeitet das Urteil selbst. Ein Go eröffnet nichts; Revise und Stop eröffnen jeweils einen Vorgang, der auf die Entscheidung zurückverweist. Die Zugangsdaten gelten je Kunde — die Site-URL, eine Konto-E-Mail-Adresse, ein API-Token, der Projektschlüssel und optional ein benutzerdefiniertes Feld für den Digest des Nachweispakets. Sie gehören je Workspace und verschlüsselt abgelegt, wie jede andere ausgehende Zugangsberechtigung.

// handle(event) for the Jira recipe
const ISSUE_TYPE = { revise: "Task", stop: "Bug" } as const;

async function handle(event: RateLoopEvent) {
  if (event.type !== "ai.rateloop.review.verdict") return;
  const state = event.data.lifecycle?.state;
  if (state !== "revise" && state !== "stop") return;

  const jira = await credentials.forWorkspace(event.source); // urn:rateloop:assurance:workspace:…
  const runUrl = `${process.env.RATELOOP_APP_URL}/agents/results?run=${encodeURIComponent(event.subject)}`;
  const noted = event.data.lifecycle?.reasonCodes.includes("decision_note_recorded") ?? false;

  const response = await fetch(`${jira.site}/rest/api/3/issue`, {
    method: "POST",
    headers: {
      authorization: `Basic ${Buffer.from(`${jira.email}:${jira.apiToken}`).toString("base64")}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      fields: {
        project: { key: jira.projectKey },
        issuetype: { name: ISSUE_TYPE[state] },
        summary: `RateLoop ${state}: assurance run ${event.subject}`,
        labels: ["rateloop", `rateloop-${state}`],
        // The packet digest and a link. The reviewers' and owner's written
        // words are deliberately absent: the event does not carry them, and
        // a Jira project is a wider audience than the review.
        description: {
          type: "doc",
          version: 1,
          content: [
            {
              type: "paragraph",
              content: [
                { type: "text", text: `Sign-off recorded ${state} against packet ${event.data.packetHash}. ` },
                {
                  type: "text",
                  text: "Open the decision in RateLoop",
                  marks: [{ type: "link", attrs: { href: runUrl } }],
                },
                { type: "text", text: noted ? ". A written reason was recorded and is readable there." : "." },
              ],
            },
          ],
        },
        ...(jira.packetHashField && event.data.packetHash
          ? { [jira.packetHashField]: event.data.packetHash }
          : {}),
      },
    }),
  });
  if (!response.ok) throw new Error(`Jira rejected the issue: HTTP ${response.status}`);
}

Das Auslösen einer Ausnahme ist hier Absicht. Der gemeinsame Einstiegspunkt gibt den Anspruch frei, RateLoop wiederholt die Zustellung, und ein Ausfall von Jira kostet eine Verzögerung statt eines verlorenen Vorgangs.

Wenn nur eine Benachrichtigung gewünscht ist

Ein Teams- oder Slack-Kanal braucht nichts davon. RateLoop sendet dieselben zwei Ereignisse in einen im Workspace eingerichteten Kanal; Teams & Slack beschreibt die Einrichtung für beide. Diese Kanal-URLs sind Zugangsgeheimnisse und tragen keine Signatur, die ein Empfänger prüfen könnte — genau deshalb verwendet ein Ablauf, der dem Empfangenen vertrauen muss, stattdessen den signierten Ereignisstrom.

Lesen Sie die Webhook-Referenz zur Registrierung und zu den Zustellregeln, die Belegreferenz zu den Grenzen der Überprüfbarkeit, oder Agenten und MCP für den aktuellen gehosteten Prüfpfad.

Integrationsrezepte | RateLoop