For vendors

Integration guide

One hook before the tool call, one signed statement after it. Heron judges the action and answers a verdict you honour before executing — and it never sees the arguments you pass to a tool.

The contract in one line. You tell us what an agent is about to do, we judge it against your published policy and sign a verdict — ALLOW, DENY or STEP_UP; you honour it, then tell us what you actually did, signed with your key. We publish the pair. Enforcement is fail-closed, with break-glass for incidents.

Quickstart

  1. Get an API key. The Heron operator mints one per environment in Settings → API keys. It is readable once, at the moment it is minted: we store only its SHA-256 and its prefix.
  2. Generate an Ed25519 key pair on your side and give us the public half — either by publishing a JWKS we can import, or by pasting the key into Settings → Vendor keys. The private half never leaves your infrastructure. That asymmetry is the entire point of execution evidence: we could not forge your statement even if we wanted to, and you cannot later disown it.
  3. Open a session when an agent run starts (POST /v1/sessions).
  4. Call the hook before every tool call (POST /v1/actions), and do not wait on it beyond a short timeout.
  5. Post execution evidence after the tool returns (POST /v1/executions), fire-and-forget.
  6. Close the session when the run ends (POST /v1/sessions/{id}/close). This freezes the chain head.

The fastest path is our SDK: npm i @theonaai/heron-sdk. It is published to the public npm registry, so the install resolves with no registry configuration. You declare how each tool is reduced (a small contract per tool) and wrap your tools — the guard carries the whole loop below and threads the chain for you.

import { HeronClient, openGuardedSession } from "@theonaai/heron-sdk";

const heron = new HeronClient({ baseUrl, apiKey, vendorKid, vendorSeed, pseudonymSecret });

const guard = await openGuardedSession({
  heron, contracts,            // how each tool is reduced, one entry per tool
  agent, principal, request, sessionExternalId,
  store,                       // where the chain position lives, if a run outlives one process
});

// The primitive: an answer you act on. It never blocks on a person, and never throws
// because we are unreachable — if we cannot decide, the decision is "blocked".
const decision = await guard.decide({ name, args });
// run · blocked · step_up · modify · defer

// Or, if a tool call really does begin and end in one function call:
const guardedTools = guard.wrap(myTools);

If your platform answers an approval by ending the run and resuming it later — most do — surface step_up through your own approval UI and call guard.resolveStepUp(…) when the answer arrives, from whichever process is handling it by then. Nothing is held open on our side.

Everything the SDK does is specified in the API reference, so you can also call the endpoints directly and owe us nothing — the rest of this page walks that protocol, which is what the SDK implements under the hood.

Authentication

Authorization: Bearer hrn_live_<env>_<secret>

The environment is derived from the key, and through it the project whose policy judges the call — from nothing else. No request can name its own organization or project, so a leaked staging key cannot write into production’s evidence, and cannot reach a different product of the same vendor at all.

Keys can be revoked at any time. A revoked key gets 401 — which means the middleware still using it takes actions that go unrecorded, so rotate by minting the new key first, deploying it, and only then revoking the old one.

The before-action hook

Called before the agent’s tool call actually happens. It answers in a few milliseconds (p95 under 50 ms): the synchronous path links the action into the chain, judges it against your active policy (a pure engine over a cached bundle), signs the decision, and answers. Everything else — the signed recompute basis, the reconciliation, the webhooks — is a queued job.

const before = await heron
  .beforeAction({
    sessionExternalId: run.id,
    seq,                       // 1, 2, 3 … within the session
    tool: { name: "gmail.send", provider: "composio" },
    args,                      // hashed locally, never sent
    argsRedacted,              // what you are willing to store
    prevHash,                  // record_hash of your previous receipt (optional)
  })
  .catch(() => null);          // Heron unreachable → treat as no answer (fail-closed)

The response carries the action_id, the signed decision, and the chain pointers. Keep the action_id and decision_id: the execution evidence you post next refers to them, and without them the pair cannot be formed.

The verdict is real, and you honour it. ALLOW runs; DENY does not (report BLOCKED); STEP_UP waits for a human — see the step-up loop. Heron is in your critical path, so wait for the answer and act on it, fail-closed when it does not come (below).

Commit to the agent’s instructions while you are here. Send signals.instructions_hash — a sha256:… digest over the instruction slot in force for this call: the system prompt plus whatever plan block your runtime keeps beside it. It changes nothing about the verdict; it is not in the signal vocabulary the classifier reads, and no rule can turn on it. What it buys is the one thing your evidence page otherwise cannot say: every other hash there covers a call, so a session whose instructions were rewritten mid-run — by compaction, by a plan update, or by an injection that reached whatever writes them — reads exactly like one that carried on. With the commitment, the change is a published fact between two calls.

Send it on every action or not at all: your page publishes how often you did, and a rewrite you did not commit to is one nobody can see. We never receive the text and never store it — only the digest, compared with the last one in the same session. A value that is not a sha256: digest is refused with a 400 rather than stored, because a digest that cannot be compared would read as a change on every call.

Tokenise your recipients on both sides, or on neither. The SDK already does it: openSession takes the request text, keeps it, and sends only the anchors anchor() returns; a tool contract’s anchors map does the same to the arguments. Because both are keyed with yoursecret, which never reaches us, your page can answer “did the agent act on the recipient the user named?” without us holding a word of the conversation. Tokenise one side only and the two sets can never match: your page will report every call as reaching beyond the request, which is a false statement about your own agent.

An address in original_request.anchors is refused with a 400 for the same reason principal.ref is — it is a person, not a reference to one. We do not quietly tokenise it for you: our key is not your key, and the fix that looked polite would leave the comparison broken for a reason neither side could see.

What these two buy you, concretely. Both feed findings on your own screens: a call that reached somebody the request did not name, and a session whose instructions were rewritten between two calls (findings about the agent). Neither can reach a verdict — that is still the rule, and it is what stops not sending them from being the cheapest way to avoid one. And neither is published to a reviewer: findings about what your agent did stay with you, in the report and on the dashboard, because a permanent signed record of them is the thing you would later be asked to explain rather than the thing that helps you fix it.

Asking your own model, and sending the answer as testimony

Everything above is a measurement: your contract states what a tool does, your catalogue states it under your key, the SDK’s edge classifier counts recipients in the arguments. Heron publishes all of it as declared. A model’s answer is not that, and sending it the same way is the one substitution nobody can undo afterwards — the source is sealed into an immutable classification, so a reviewer who cannot separate a count of recipients from a model’s opinion today cannot separate them a year of receipts later.

So mark it, and it is published as inferred — its own column on your evidence page. The SDK carries the whole loop:

const session = await openGuardedSession({
  /* … */
  intent: {
    model: "claude-sonnet-5",
    slice: "last_turn",                 // a label for how much it saw — never any of it
    ask: async ({ prompt }) => fork(run).complete(prompt),
  },
});

const decisions = await session.decideTurn(calls);   // one question for the whole turn

The question is ours, and that is what makes the answer worth recording. INTENT_PROMPTis a constant in the SDK, not a string assembled from your agent’s system prompt — a judge that shares the attacker’s channel proves nothing if the attacker also writes the question. Its hash rides on every claim, so a reviewer holding one of your receipts can look up exactly what was asked.

It can only resolve an ignorance. Heron reads an inferred value strictly where its own classification came out unknown: a model cannot move a dimension your catalogue stated, and cannot overturn what we derived from the tool name. A judge that has been talked into lying buys back the friction that ignorance created, and nothing that evidence established. Where it contradicts a measurement the measurement stands, and the disagreement is published as INTENT_CONTRADICTION — a question about which side is wrong, never a bypass.

Everything about it fails to silence. A fork that throws, declines or answers something unparseable costs that turn its claims and nothing else. A missing claim leaves the dimension unknown, which is the strict direction — so a broken judge can never loosen anything.

It is off unless you configure it, and the unit is the model turn rather than the call: decide() never asks, decideTurn() asks once for the group. Skip a turn that is not worth the tokens — a page of reads — by calling decide() for it, and measure how often it fires on your own traffic before you turn it on everywhere.

Honouring a STEP_UP: the human answers on a new action

A STEP_UP means a human must decide before the tool runs. You do not keep the action open and patch it later — a decision is immutable. Instead you close it honestly and bring the answer back as a fresh action:

  1. Do not execute. Post execution evidence with outcome: "ESCALATED" — the signed statement that you paused for a person.
  2. When the human answers, submit a new before-action for the same tool call, carrying the decision in signals: resolves_action(the step-up’s action_id), human_decision ("APPROVE" or "DENY"), and an opaque approver token — never a name.
  3. Heron re-judges from scratch. With the step-up resolved it returns ALLOW, and you execute and report EXECUTED as usual.

The approval can only lift the step-up it names — it can never turn a DENY (a money or credential rule) into an ALLOW. And running the stepped-up action directly, instead of pausing, is a finding: EXECUTED_DESPITE_STEP_UP.

Commit to what you showed the person. Send signals.shown_text_hash on the resolving action — a sha256:… digest over the bytes your confirmation UI actually rendered. Without it, an approval given to “Send this contract to 240 recipients outside your company?” and one given to “The agent would like to continue — OK?” leave the identical record, and nothing a reviewer reads can separate a decision from a rubber stamp. With it, you cannot restate afterwards what you put in front of them: produce the text later and it either hashes to what you committed to or it does not.

Like instructions_hash, it is outside the signal vocabulary the classifier reads, so no rule can turn on it and it moves no verdict — deliberately, because a rule that fired on it would make sending nothing the cheapest way out. A value that is not a sha256: digest is refused with a 400: a digest nobody can reproduce from your own text later would publish the approval as bound to a prompt it is not bound to. Send it with human_authorized too — the prompts your own UI collected consent through are exactly where this question lives.

Do not build the digest yourself. The SDK takes the text and keys it with the secret you already pass as pseudonymSecret, which never reaches us:

await guard.resolveStepUp({
  actionId, call: { name, args }, approved, approver,
  shownText: promptYouRendered,        // the text — the digest is computed on your side
});

// and where you build your own signals, for an approval your UI collected first:
await session.decide(call, {
  human_authorized: true,
  approver,
  shown_text_hash: heron.shownTextHash(promptYouRendered),
});

The key is the whole privacy property, which is why it is a method that already holds it rather than an argument you remember to pass. Your evidence page publishes only that you committed, never the digest — but signals_hash is published, and a confirmation prompt is a template with a name and an amount dropped into it. An unkeyed digest of one is a confirmation oracle for anyone already holding a candidate address. A keyed one proves everything this is for and confirms nothing to a stranger.

Keep the text. The commitment fixes what you showed; it does not store it. It is worth exactly what your own retention of that prompt is worth, and an auditor who asks you to produce it a year later is asking the question the commitment was made to answer.

An approval your own UI collected before Heron ever saw the call is a different thing, and it has its own signal: human_authorized, with an opaque approver. Send it — otherwise the record cannot tell a call a person cleared from one the agent ran by itself, which is the first thing a reviewer looks for. It is published and it never lifts a verdict: it names no action of ours, so there is nothing to check it against, and a claim that cannot be checked must not open a human gate. Sending human_decision without resolves_action is refused with a 400 rather than accepted and quietly ignored.

Execution evidence

After the tool returns, you state — under your own signature — what you did with our decision. This is the half of the record we cannot write for you, and it is what makes the evidence page worth reading.

const payload = {
  action_id, decision_id,
  outcome: "EXECUTED",         // EXECUTED | SKIPPED | FAILED | BLOCKED
                               // | ESCALATED | ABANDONED | NOT_ATTEMPTED
  result_hash: sha256(JCS(result)),
  error_code: null,
  executed_at: new Date().toISOString(),
};

POST /v1/executions
{ ...payload, signature: { kid, alg: "Ed25519", value: sign(JCS(payload)) } }

The signed bytes are the RFC 8785 canonical form of exactly that object minus the signature field. Build it with buildExecutionEvidencePayload() so both sides agree on the bytes — a signature over JSON.stringify output would depend on key order and would not verify anywhere else.

A bad signature is never a rejection. If the signature does not verify, we store the statement anyway and raise INVALID_VENDOR_SIGNATURE. Refusing the request would delete the evidence that something is wrong — so a 200 from this endpoint does not mean “all good”; check signature_valid in the response.

Honouring the verdict, fail-closed

Heron is in your critical path: wait for the answer and act on it. The default posture is fail-closed — a DENY, a STEP_UP not yet cleared by a human, or no answer at all means the tool does not run — and either way you report what you did.

const before = await heron.beforeAction({ … }).catch(() => null);

if (mayExecute(before?.decision.verdict)) {  // true only for ALLOW
  const result = await executeTool(tool);
  await heron.execution({
    actionId: before.action_id,
    decisionId: before.decision.decision_id,
    outcome: "EXECUTED",
    result,
  });
} else if (before) {
  await heron.execution({                     // honoured the block — report it
    actionId: before.action_id,
    decisionId: before.decision.decision_id,
    outcome: "BLOCKED",
    errorCode: "heron_denied",
  });
}

Fail-closed applies to slow, not only down. Heron bounds the one step that can hang — reading the active policy on a cold cache — and if it blows the budget it answers a signed DENY with reason policy_timeout instead of holding your call open. So keep a short client-side timeout and treat a slow Heron exactly like a DENY.

Fail-closed means a Heron outage can stop your agents — the deliberate cost of enforcement. The escape hatch is break-glass: an operator suspends enforcement during an incident, and that window is published on the evidence page as a fact, not hidden.

Idempotency and retries

Idempotency-Key: <uuid> is required on /v1/actions and /v1/executions. A replay returns the original result and never forks the chain, so a retry after a network timeout is safe — and is the right thing to do, since the alternative is a hole in the record.

What you send, and what you keep

  • Hashes are computed by you, over the unredacted data. They travel as sha256:<64 hex>. The data itself never crosses to us, which is why a hash can prove “this is the same argument object” without us ever holding it.
  • args_redacted is what you are willing to have stored.We redact it again on our side — emails, cards, tokens, JWTs, phones and IPs are scrubbed, and fields outside the project’s allowlist collapse to <redacted:field>. Two layers, because neither of us should be holding your customers’ personal data in an evidence system, and a single missed field on either side would put it there.
  • principal.ref must be opaque. An email or a phone number is rejected with 400, not quietly scrubbed. Send user_881, never [email protected].

The details, and what the operator can configure: redaction and retention, and the personal-data section of the security model.

Echoing the chain

chain.prev_hash in the before-action body is optional but cheap: echo the record_hash from your previous receipt, and Heron can tell you that you dropped an event — BROKEN_CHAIN with source: vendor_claim — instead of silently accepting a hole in the session.

Getting told about findings

Register an endpoint in Settings → Webhooks and you get anomaly.detected and evidence.published pushed to you. They are signed with the same scheme Stripe uses, on purpose: your engineers already know how to verify it.

X-Heron-Signature: t=<unix-seconds>,v1=<hex hmac-sha256 of "<t>.<body>">
X-Heron-Event: anomaly.detected

Five attempts with exponential backoff (1 s → 10 s → 1 m → 10 m → 1 h). Verification rejects a stale timestamp, so a captured delivery cannot be replayed at you later.

Before you call it done

  • The before-action call has a timeout, and a timeout or error blocks the tool call — it does not proceed. Heron fails closed on its own side (it answers DENY when it cannot decide), and an integration that executed anyway would reopen the bypass the whole design exists to close. See what happens when Heron is down.
  • Execution evidence is posted for every action, including failures — an action that failed is still an action, and a missing statement reads as MISSING_EXECUTION.
  • The private signing key lives in your secret store, not in your repository.
  • principal.ref is opaque, and the arguments you send are already redacted.
  • signals.instructions_hash is sent on every action, or on none — the coverage is published, and a partial habit is the shape that hides a rewrite. See the before-action hook. Build it with instructionsHash(): a digest in any other shape is refused with a 400.
  • If a model classifies a call for you, it is marked as one — the fork, or the four inference_* keys by hand. A model’s word sent as declared is the one mislabelling that cannot be corrected afterwards.
  • Recipients are tokenised with anchor() on both sides — the request at openSession and the arguments of every call that carries one. Half-tokenised evidence compares nothing, and reads as an agent that went beyond its instructions.
  • You checked the live feed and see your actions pairing up with their evidence.