Everything HESO produces comes down to one artifact: the Action Receipt. When you wrap an agent, each action it takes — an LLM call, a tool call, a payment, a data export — is captured, checked against policy, optionally routed to a human, redacted, signed, and emitted as a receipt. The receipt is self-contained JSON. Anyone with the bytes can verify it offline, in any browser, with no HESO infrastructure.
A receipt has two parts: the content (what happened and why it was allowed) and the signatures over a hash of that content. The rest of this page walks the JSON from the outside in.
The envelope
The outermost object is the ActionReceipt. It is deliberately thin: an algorithm tag, the content, the signatures, and an optional transparency section (the proofs that the receipt was logged).
ActionReceipt { alg: "heso-action/v2+ed25519", content: ActionContent, signatures: SignatureEntry[], transparency?: unknown[]}
- alg"heso-action/v2+ed25519"required
- The algorithm identifier. It names both the receipt format (
heso-action/v2) and the signature scheme (ed25519). The verifier rejects anything it does not recognize before any other check runs. - contentActionContentrequired
- The signed payload — the action, the policy verdict, any approval, any redaction, the trust level, and the
action_hash. Detailed in The content below. - signaturesSignatureEntry[]required
- One or more Ed25519 signatures over the content hash. An operator entry is always present; an approver entry is present at L1. See The signatures.
- transparencyunknown[]
- Optional inclusion and consistency proofs from the transparency log (an RFC-6962 Merkle tree over the receipt log). Omitted when the receipt has not been logged.
The alg string is fixed at heso-action/v2+ed25519. It is the first thing the verifier checks — an unrecognized algorithm fails right away with wrong_algorithm, before any cryptography runs.
The content
The ActionContent object is the part that gets hashed and signed. It records what the agent did, the verdict that gated it, and — when they apply — the human decision and the redaction markers. On the wire, every field name is snake_case.
- action_version"heso-action/2.0"required
- The content schema version. Checked second during verification; an unsupported version fails with
unsupported_version. - captured_atstring (ISO 8601)required
- The timestamp the action was intercepted, in ISO 8601.
- agent_identitystringrequired
- The operator’s Ed25519 public key, base64-encoded. This is the identity whose signature gates the action at L0.
- actionActionDetailrequired
- What was attempted: the verb, tool name, target host, workflow, account, and the field map — all after redaction. See below.
- policyPolicyOutcomerequired
- The policy verdict: the rule that matched, its natural-language display, the conditions it matched on, and the decision path.
- approver_decisionApproverRecord
- Present only when the action was routed to a person. Records the human decision and the approver’s own key. See Redaction and approval.
- redactionRedactionRecord
- Present only when fields were redacted before signing. Carries the mode and the per-field markers.
- trust_level"L0" | "L1"required
- The claimed trust level. The verifier re-derives this from which signatures pass and rejects a receipt that claims more than its signatures support, with
trust_mismatch. - action_hashstring (64-hex)required
- The lowercase 64-hex BLAKE3 hash of the canonical content. Recomputing it is how tampering is detected. See The action hash.
Inside ActionDetail
The action object describes the call itself. fields is a flat string map of the arguments that were captured, and it always reflects the post-redaction values — a secret that was redacted never appears here in the clear.
verb— one of the seven action verbs.tool_name— the tool or function that was called.target_host— the host the action reached; omitted forllm_calland other internal actions.workflowandaccount— the run context the policy subject matches against.fields— the captured arguments, post-redaction.result_hashanderror— optional outcome markers.
The action hash
The action_hash is what makes a receipt tamper-evident — change any byte and it shows. It is the BLAKE3 hash of the canonical bytes of content, with the action_hash field itself stripped before hashing — so the hash never has to commit to its own value.
Canonical here means RFC-8785 (JCS): keys are sorted, whitespace is fixed, and numbers are normalized, so the same content always serializes to the same bytes no matter who wrote the JSON. The verifier re-serializes content under JCS, recomputes the BLAKE3 hash, and compares it to the embedded value. If even one byte of the content was altered, the hashes differ and verification fails with hash_mismatch.
The hash and the canonicalization are computed by the Rust core. Python, Node, and the browser WASM all call that same core, so the recomputed action_hash is byte-identical everywhere — there is no separate JavaScript or Python implementation of the crypto to drift.
This is gate three of the seven-gate verify order: algorithm and version are checked first, then the hash, then the signatures.
The signatures
Each entry in signatures is one Ed25519 signature over the action_hash. The key_id names whose key it is.
- algorithm"Ed25519"required
- The signature scheme. Ed25519 for both the operator and the approver.
- key_id"operator" | "approver"required
- Which signer this entry is.
operatoris always present;approveris present only at L1. - public_keystringrequired
- The signer’s Ed25519 public key, base64-encoded.
- signaturestringrequired
- The base64-encoded Ed25519 signature.
At L0 there is a single entry, operator. At L1 a second approver entry is added — and that signature is made with the approver’s own key, held on their device. The cloud holds no signing key, so a human co-signature is real proof that that person approved the action.
"signatures": [ { "algorithm": "Ed25519", "key_id": "operator", "public_key": "ed25519:uP3…b1", "signature": "3a9f…04af" }, { "algorithm": "Ed25519", "key_id": "approver", "public_key": "ed25519:7Qm…9c", "signature": "be20…7d11" }]
Verification checks the operator signature at gate four (invalid_signature on failure) and the approver signature, when present, at gate five (invalid_approver). The trust level is then re-derived from which signatures actually passed — a receipt cannot call itself L1 without a valid approver signature behind the claim.
Redaction and approval
Two optional content blocks attach when the action took those paths. Both are signed along with everything else, so they cannot be added or stripped after the fact without breaking the hash.
ApproverRecord
When the policy decision is require_approval and a person responds, an approver_decision block records the outcome. The decision is one of approved, rejected, or escalated, with the approver’s public key, a reason, the decision time, and an optional SLA. The matching approver signature is what lifts the receipt to L1. See Human approval for the routing flow and how the device-held key signs.
RedactionRecord
When fields were redacted before signing, a redaction block records it. The mode is either destructive (the value is gone) or commit_and_reveal (a BLAKE3 commitment is kept so the value can be revealed later and checked). Each marker names the field_path it covers — for example action.fields.member_id — its algorithm, and its commitment.
In commit_and_reveal mode the marker carries a 64-hex BLAKE3 commitment and the record carries a merkle_root. In destructive mode the commitment is the empty string — it is still present, just empty. The verifier checks that every marker is well-formed; malformed markers fail with redaction_malformed.
For the full mechanics of both modes — and what each one does and does not let a reviewer recover — see Redaction.
A full receipt
Here is a complete receipt for a vendor payment that exceeded the cap, was routed to a human, approved, and had one field redacted before signing. It is an L1 receipt: two signatures, an approver record, and a redaction record. Hashes and keys are truncated with an ellipsis for readability.
{ "alg": "heso-action/v2+ed25519", "content": { "action_version": "heso-action/2.0", "captured_at": "2026-01-14T18:04:31Z", "agent_identity": "ed25519:uP3…b1", "action": { "verb": "payment", "tool_name": "stripe.transfers.create", "target_host": "api.stripe.com", "workflow": "vendor-payouts", "account": "acct_19", "fields": { "amount_usd": "8200", "payee": "Globex LLC", "member_id": "[REDACTED]" }, "result_hash": "c41d…0bb2" }, "policy": { "rule_id": "pay-cap", "rule_display": "Require approval to pay over $5,000", "matched_conditions": [ { "field": "amount_usd", "op": "gt", "value": 5000 } ], "decision_path": "require_approval" }, "approver_decision": { "decision": "approved", "approver_identity": "ed25519:7Qm…9c", "reason": "Confirmed invoice with finance.", "decided_at": "2026-01-14T18:11:52Z", "sla_minutes": 60 }, "redaction": { "mode": "commit_and_reveal", "markers": [ { "field_path": "action.fields.member_id", "algorithm": "blake3", "commitment": "a7f0…12d4" } ], "merkle_root": "5e9b…cc31" }, "trust_level": "L1", "action_hash": "9f2c…e1c0" }, "signatures": [ { "algorithm": "Ed25519", "key_id": "operator", "public_key": "ed25519:uP3…b1", "signature": "3a9f…04af" }, { "algorithm": "Ed25519", "key_id": "approver", "public_key": "ed25519:7Qm…9c", "signature": "be20…7d11" } ]}
Read it top to bottom and you have the whole story: a payment of amount_usd matched the pay-cap rule on amount_usd gt 5000, the decision path was require_approval, a person approved it within a 60-minute SLA, member_id was committed-and-revealed rather than stored in the clear, and two Ed25519 signatures back the L1 claim.
This receipt proves the operator authorized the payment under the pay-cap rule, and that a person approved it with a device-held key. It records what was authorized — the $8,200 transfer under that rule — not whether the transfer settled or was the right amount. HESO records authorization; the outcome is a separate question.
Next steps
For the exhaustive field-by-field reference — every type, every optional field, every enum value — see the receipt schema. To see how a receipt is checked, read the verification page.
