Skip to content

HARP Protocol Specification

HARP (Human Approval Request Protocol) is a three-party, end-to-end encrypted protocol for human authorization of AI agent actions. It defines how an Agent Platform sends encrypted requests through a zero-knowledge Relay Service to a HARP App on the user’s mobile device.

The relay is a zero-knowledge dumb pipe. It routes encrypted envelopes by pair_id and never sees any plaintext action data, parameters, reasoning, decisions, or form submissions. All sensitive content is encrypted before it leaves the agent platform and can only be decrypted by the HARP App using the shared key established during pairing.

HARP v1 introduces three intent types (AUTHORIZE, COLLECT, INFORM), severity levels, assurance levels, a relay state machine with webhook delivery, and audit persistence.


Agent Platform

Any system running AI agents — Claude Code, OpenAI agents, custom orchestration systems, or any software that needs human sign-off before taking an action. The platform integrates via the HARP SDK or MCP server. It holds a platform keypair and a per-pairing shared secret derived during the pairing flow. It retains full plaintext audit logs locally and is the only party (besides the user) that ever sees plaintext request content.

Relay Service

A zero-knowledge routing service. It receives encrypted envelopes from the agent platform, looks up the routing table (pair_id → device_id → push_token), and delivers push notifications to the HARP App. The relay stores only the encrypted payload (held in memory until TTL expiry), routing metadata, and request state. It implements the request lifecycle state machine and supports webhook callbacks to the agent platform on state transitions.

HARP App

A mobile application on the user’s device. On receiving a push notification, it fetches the encrypted envelope from the relay, decrypts the payload using the shared key, and displays the full context to the user. The user reviews the action and responds. All approvals are signed with a biometric-gated Ed25519 key stored in the device’s secure enclave. The app stores decision history on-device only.

The relay operates on a strict separation between routing metadata (which it must see to function) and request content (which it must never see).

Relay sees:

  • request_id, pair_id, timestamp, ttl
  • expects_response, push_priority
  • callback_url, callback_secret (for webhook delivery)
  • Request state transitions

Relay never sees:

  • action, description, parameters, agent_reasoning
  • intent, severity, assurance
  • decision, reason, form_data
  • category, schema, context

Any field that is sensitive is encrypted inside the payload blob before transmission. The relay handles only the outer envelope.


The outer envelope is what the relay receives and stores. It contains routing metadata and the encrypted payload blob. The relay never decrypts payload.

interface RequestEnvelope {
version: 1;
request_id: string; // UUIDv7
pair_id: string; // UUIDv7, established during pairing
timestamp: number; // Unix seconds (UTC)
ttl: number; // Seconds until expiry, max 86400
expects_response: boolean; // true for AUTHORIZE/COLLECT, false for INFORM
push_priority: "normal" | "high";
callback_url?: string; // Webhook URL for state change notifications
callback_secret?: string; // HMAC-SHA256 signing secret for webhook delivery
nonce: string; // Base64-encoded, 24 bytes (XChaCha20 nonce)
payload: string; // Base64-encoded, XChaCha20-Poly1305 encrypted blob
}

push_priority is derived from severity: low and medium map to "normal", high and critical map to "high".

The plaintext that the HARP App decrypts and displays. This is encrypted with XChaCha20-Poly1305 using the shared key and the nonce from the outer envelope.

interface RequestPayload {
intent: "authorize" | "collect" | "inform";
severity: "low" | "medium" | "high" | "critical";
assurance: "tap" | "biometric" | "elevated";
action: string;
description: string;
parameters?: Record<string, unknown>;
agent_reasoning?: string;
context?: Record<string, unknown>;
category?: "escalation" | "result" | "status" | "error" | "general"; // INFORM only
schema?: CollectSchema; // COLLECT only
}

The outer envelope the HARP App sends back to the relay after the user makes a decision. Includes a cryptographic signature over the encrypted payload for non-repudiation.

interface ResponseEnvelope {
version: 1;
request_id: string; // Matches the original request
pair_id: string;
timestamp: number; // Unix seconds (UTC)
nonce: string; // Base64-encoded, 24 bytes (fresh nonce for the response)
payload: string; // Base64-encoded, XChaCha20-Poly1305 encrypted blob
signature: string; // Base64-encoded, Ed25519 signature (64 bytes) over payload
}

The signature is computed over the raw payload bytes (before base64 encoding) using the app’s biometric-gated Ed25519 private key. The agent platform verifies this signature against the app’s Ed25519 public key, which was exchanged during pairing.

The plaintext decision or collected data, encrypted by the HARP App before sending.

interface ResponsePayload {
intent: "authorize" | "collect" | "inform";
request_id: string;
decision?: "approved" | "denied"; // AUTHORIZE only
form_data?: Record<string, unknown>; // COLLECT only — field_id → value
reason?: string; // Optional user-provided note
decided_at: number; // Unix seconds (UTC)
}

HARP v1 supports three distinct intent types. The intent is set by the agent platform when constructing the request and determines the interaction model on the HARP App.

Binary approve/deny. Used when an agent needs explicit human sign-off before taking an action.

  • expects_response: true
  • push_priority: derived from severity (high/critical"high")
  • Response contains decision: "approved" | "denied"
  • Agent platform should block execution until a decision arrives or TTL expires

Example use cases: executing a shell command, sending an email, making a purchase, deploying to production, deleting data.

Structured data collection. Used when an agent needs specific inputs from the user before proceeding.

  • expects_response: true
  • Request payload must include a schema field (see CollectSchema below)
  • Response contains form_data — a map of field_id to typed values
  • No free-text fields in v1 — all fields have a defined type and constraints

Example use cases: choosing a deployment target, selecting recipients for a report, confirming configuration values, picking a time slot.

interface CollectSchema {
fields: CollectField[];
}
interface CollectField {
id: string;
label: string;
type: "select" | "multiselect" | "checkbox" | "number" | "datetime";
required: boolean;
options?: string[]; // select, multiselect
min?: number; // number
max?: number; // number
step?: number; // number
minDate?: string; // datetime, ISO 8601
maxDate?: string; // datetime, ISO 8601
}

Schema constraints:

  • Maximum 20 fields per schema (MAX_COLLECT_FIELDS)
  • Maximum 50 options per select or multiselect field (MAX_SELECT_OPTIONS)
  • Free-text fields are not supported in v1
TypeMobile ComponentValue TypeConstraints
selectPicker / dropdownstringMust be one of options
multiselectCheckbox groupstring[]Must be subset of options
checkboxToggle switchbooleantrue or false
numberNumeric inputnumbermin, max, step
datetimeDate/time pickerstring (ISO 8601)minDate, maxDate

Fire-and-forget notification. Used when an agent wants to surface information to the user without requiring a decision.

  • expects_response: false
  • No decision or form_data in the response
  • Supports an optional category field to classify the notification type

Categories:

CategoryDescription
escalationAgent has encountered a situation requiring human awareness
resultAgent completed a task and is reporting the outcome
statusPeriodic progress update on a long-running task
errorAgent encountered an error or unexpected condition
generalGeneral-purpose informational message

Example use cases: reporting a completed background job, surfacing an anomaly detected during analysis, notifying the user that an agent has paused and is waiting.


Severity describes the impact level of the action being requested. It controls the mobile UX presentation and push notification behavior.

SeverityMobile UXPush BehaviorDefault Assurance
lowStandard card, muted appearanceSilent / normal prioritytap
mediumStandard card with accentNormal priority with badgebiometric
highOrange/amber card, vibrationHigh priority with soundbiometric
criticalRed full-screen takeoverCritical alert (bypasses Do Not Disturb)elevated

Push priority mapping:

  • low, mediumpush_priority: "normal"
  • high, criticalpush_priority: "high"

The default assurance column shows recommended assurance values for each severity. The agent platform may specify a higher assurance level than the default for a given severity, but not a lower one.

Assurance describes the level of authentication required from the user before a response is accepted.

AssuranceApp BehaviorRecommended Use Case
tapTap to acknowledge — no biometric promptLow-risk actions, INFORM acknowledgements
biometricFace ID / Touch ID required before submittingStandard approvals and data collection
elevatedBiometric + explicit confirmation dialogDestructive actions, production environment changes

When assurance is elevated, the HARP App presents an additional confirmation step after the biometric check, showing a summary of the action and asking the user to confirm again before signing the response.


Pairing establishes the shared cryptographic material between an agent platform and a user’s HARP App. It happens once per platform/user pair.

  1. Platform generates an ephemeral X25519 keypair and a unique pair_id (UUIDv7)
  2. Platform generates a random 32-byte pairing secret
  3. Platform registers a pairing session on the relay via POST /v1/pairs/init (relay stores a hash of the secret)
  4. Platform encodes the QR code URI (see below) and displays it to the user
  5. User scans the QR code with the HARP App
  6. App generates its own ephemeral X25519 keypair
  7. App derives the shared secret: X25519(app_private_key, platform_public_key)
  8. App derives the encryption key via HKDF-SHA256 (see Key Derivation below)
  9. App generates a persistent Ed25519 signing keypair, gated by the device’s biometric/secure enclave
  10. App registers its device on the relay via POST /v1/pairs/register (sends push token)
  11. App sends an encrypted PairResponse to the relay via POST /v1/pairs/:id/complete
  12. Platform polls GET /v1/pairs/:id/complete to retrieve the encrypted response
  13. Platform derives the same shared secret: X25519(platform_private_key, app_public_key)
  14. Platform derives the same encryption key and decrypts the PairResponse
  15. Platform stores the pair_id, encryption key, and app’s Ed25519 public key locally
harp://pair?v=1&pair_id=<uuid_v7>&pub=<base64url_x25519_public_key>&relay=<relay_url>&exp=<unix_timestamp>&secret=<base64url_32_byte_secret>
ParameterDescription
vProtocol version (1)
pair_idUUIDv7 identifying this pairing session
pubBase64url-encoded X25519 public key of the platform
relayHTTPS URL of the relay service
expUnix timestamp when the QR code expires (5 minutes from generation)
secretBase64url-encoded 32-byte pairing secret (shared over QR, not stored plaintext on relay)
shared_secret = X25519(my_private_key, their_public_key)
encryption_key = HKDF-SHA256(ikm=shared_secret, salt="harp-v1-enc", info="", length=32)

Both parties independently derive the same encryption_key from their own private key and the other party’s public key. The HKDF salt "harp-v1-enc" is a domain separation string ensuring keys derived for HARP cannot be confused with keys derived for other protocols using the same X25519 keypairs.

  • QR code expires after 5 minutes (PAIRING_EXPIRY = 300)
  • Ephemeral X25519 keypairs are generated fresh for each pairing — an attacker who intercepts the QR code after expiry cannot derive the shared secret without the platform’s ephemeral private key
  • One-time use — the relay invalidates the pairing session after successful completion
  • The pairing secret is hashed (SHA-256) before storage on the relay; the relay can verify the secret without storing it in plaintext

All cryptographic operations use audited, zero-dependency implementations from the @noble/* library family (@noble/curves, @noble/ciphers, @noble/hashes).

PrimitiveAlgorithmPurpose
Key exchangeX25519Derive shared secret from keypairs during pairing
Key derivationHKDF-SHA256Derive 32-byte encryption key from shared secret
EncryptionXChaCha20-Poly1305Authenticated encryption of request/response payloads
SigningEd25519Sign approval responses for non-repudiation
HashingSHA-256Hash pairing secrets for relay verification
Webhook signingHMAC-SHA256Sign webhook delivery bodies
PaddingPower-of-2 bucketsPrevent payload length analysis (side-channel mitigation)

Payload padding: Before encryption, payloads are padded to the next power-of-2 bucket (e.g., 128, 256, 512, 1024, 2048 bytes). This prevents an observer at the relay from inferring payload content from ciphertext length.

Nonces: Each request and response uses a fresh random 24-byte nonce. The nonce is included in the outer envelope in plaintext so the recipient can decrypt. Nonces must never be reused with the same key.


Every request stored on the relay progresses through a defined set of states. The relay enforces valid transitions and returns 409 Conflict for invalid ones.

States:

StateDescription
pendingRequest received by relay, push notification not yet confirmed
deliveredPush notification confirmed by mobile push service
viewedApp fetched the encrypted payload (GET /v1/requests/:id/payload)
decidedApp submitted a response (POST /v1/requests/:id/respond)
expiredTTL elapsed without a decision
cancelledAgent platform cancelled the request before a decision

Transitions:

pending → delivered Push notification delivery confirmed
pending → expired TTL alarm fires
pending → cancelled DELETE /v1/requests/:id
delivered → viewed App fetches encrypted payload
delivered → expired TTL alarm fires
viewed → decided App submits response
viewed → expired TTL alarm fires

Terminal states: decided, expired, cancelled. Once a request reaches a terminal state, no further transitions are accepted and the state cannot change.

Invalid transition attempts return 409 Conflict with error code INVALID_TRANSITION.

Submitting a request with the same request_id returns the current status of the existing request rather than creating a duplicate. This allows agent platforms to safely retry POST /v1/requests on network errors.

The agent platform may cancel a request only while it is in the pending state. Requests in delivered or viewed states cannot be cancelled — the user has already been notified and may be actively reviewing the request. Cancellation is performed via DELETE /v1/requests/:id.


GET /.well-known/harp

Returns a JSON document describing the relay’s protocol capabilities, supported versions, and feature flags. Agent platforms should fetch this before their first interaction to confirm version compatibility.

MethodPathDescription
POST/v1/pairs/initRegister a new pairing session; returns pair_id
POST/v1/pairs/registerApp registers its device push token for a pair_id
POST/v1/pairs/:id/completeApp submits encrypted PairResponse
GET/v1/pairs/:id/completePlatform polls for PairResponse (long-poll)
POST/v1/pairs/:id/deviceApp updates its device push token (e.g., token rotation)
DELETE/v1/pairs/:idRevoke a pairing from either party
MethodPathDescription
POST/v1/requestsSubmit an encrypted request envelope
GET/v1/requests/:idGet request status and metadata (no payload)
GET/v1/requests/:id/payloadApp fetches the encrypted payload; transitions state to viewed
GET/v1/requests/:id/responsePlatform polls for the encrypted response (long-poll)
POST/v1/requests/:id/respondApp submits encrypted response; transitions state to decided
DELETE/v1/requests/:idPlatform cancels a pending request
MethodPathDescription
GET/v1/audit/requestsList requests with filtering and pagination
GET/v1/audit/requests/:idGet full audit record for a specific request
GET/v1/audit/reports/summaryAggregated statistics over a time range
GET/v1/audit/exportExport audit records as NDJSON or CSV

Audit records contain only the envelope-level metadata (request_id, pair_id, timestamps, state transitions). The relay never stores decrypted payload content.

When a callback_url is provided in the request envelope, the relay sends an HTTP POST to that URL when the request reaches a terminal state (decided, expired, or cancelled).

Request headers:

HeaderDescription
Content-Typeapplication/json
X-HARP-Request-IdThe request_id of the completed request
X-HARP-TimestampUnix timestamp of the delivery attempt
X-HARP-SignatureHMAC-SHA256 hex digest of the request body, keyed with callback_secret

Request body:

{
"request_id": "...",
"status": "decided",
"response": {
"nonce": "<base64>",
"payload": "<base64>",
"signature": "<base64>",
"timestamp": 1712345679
}
}

For expired or cancelled status, response is null.

Retry policy: The relay attempts delivery up to 3 times with exponential backoff:

  • Attempt 1: immediately
  • Attempt 2: 1 second later
  • Attempt 3: 5 seconds later
  • Final attempt: 25 seconds later

If all attempts fail, the webhook is marked as failed. The platform can still retrieve the response via GET /v1/requests/:id/response.

Signature verification: The platform must verify X-HARP-Signature to confirm the webhook originated from the relay:

expected = HMAC-SHA256(key=callback_secret, message=raw_request_body)
valid = constant_time_compare(expected, X-HARP-Signature)
CodeHTTP StatusDescription
INVALID_PAYLOAD400Request body is malformed or fails validation
REQUEST_NOT_FOUND404No request with the given request_id exists
INVALID_TRANSITION409The state transition is not allowed from the current state
PAIR_NOT_FOUND404No pairing with the given pair_id exists
REQUEST_EXPIRED410The request TTL has elapsed
UNAUTHORIZED401Missing or invalid authentication credentials

ConstantValueDescription
PROTOCOL_VERSION1Current protocol version
MAX_TTL86400Maximum TTL in seconds (24 hours)
DEFAULT_TTL300Default TTL in seconds (5 minutes)
PAIRING_EXPIRY300QR code expiry in seconds (5 minutes)
NONCE_LENGTH24XChaCha20-Poly1305 nonce length in bytes
KEY_LENGTH32Encryption key length in bytes
HKDF_SALT"harp-v1-enc"HKDF domain separation salt
MAX_COLLECT_FIELDS20Maximum number of fields in a COLLECT schema
MAX_SELECT_OPTIONS50Maximum options per select or multiselect field