Human Approval
What you'll learn
- The
ApprovalChannelinterface and how to implement custom approval backends - The complete
ApprovalRequestandApprovalResultdata structures - How to use
CallbackApprovalChannelfor flexible approval flows - How to use
WebhookApprovalChannelfor HTTP-based approval - How to build a custom approval channel (e.g., Slack)
The human approval system provides a human-in-the-loop mechanism for high-value or sensitive transactions. When a policy rule requires approval, the SDK sends a request through an ApprovalChannel and blocks until a human responds or the request times out.
ApprovalChannel Interface
// Import the types needed for the human approval system.
// - ApprovalChannel: the interface that approval backends (Slack, email, etc.) must implement
// - ApprovalRequest: the data object describing what the agent wants to do
// - ApprovalResult: the human's decision (approved, rejected, or timed out)
// - ApprovalDecision: the union type of possible decisions
import type { ApprovalChannel, ApprovalRequest, ApprovalResult, ApprovalDecision } from "@kova-sdk/wallet";// The ApprovalChannel interface defines how the SDK communicates with a human
// approver. Implementations can use any messaging platform (Slack, Discord,
// email, SMS, etc.) as long as they can send a request and wait for a response.
interface ApprovalChannel {
/** Name of this channel (e.g., "slack", "webhook") */
// A human-readable identifier for this channel, used in audit logs
// to record which approval channel was used for a given transaction.
readonly name: string;
/** Send an approval request and wait for a decision */
// This method blocks (awaits) until the human responds or the request
// times out. The SDK calls this during policy evaluation when an
// ApprovalGateRule is triggered. The returned ApprovalResult determines
// whether the transaction proceeds (approved) or is denied (rejected/timeout).
requestApproval(request: ApprovalRequest): Promise<ApprovalResult>;
}ApprovalRequest
The request object sent to the human approver:
// The ApprovalRequest contains all the information a human needs to make
// an informed approve/reject decision about a pending transaction.
interface ApprovalRequest {
/** Unique identifier for this approval request */
// A UUID generated by the SDK, used to match the human's response
// back to the correct pending transaction.
id: string;
/** What the agent wants to do (human-readable) */
// A natural-language summary like "Transfer 15 SOL to 9WzDXwBb..."
// displayed prominently in the approval message.
summary: string;
/** Amount in human-readable format */
// The transaction amount as a string (e.g., "15.5"), not in raw units like lamports.
amount: string;
/** Token symbol */
// The token being transacted (e.g., "SOL", "USDC").
token: string;
/** USD value (if available) */
// The dollar equivalent of the transaction, computed by the chain adapter's
// getValueInUSD() method. May be undefined if no price oracle is available.
usdValue?: number;
/** Recipient or target address */
// The destination address for transfers, or the target contract for other operations.
target: string;
/** Agent's stated reason for this transaction */
// Optional context from the AI agent explaining why it wants to execute
// this transaction. Helps the human approver make a more informed decision.
reason?: string;
/** Agent identifier */
// Optional identifier for which AI agent initiated this request.
// Useful when multiple agents share the same wallet.
agentId?: string;
/** Current daily spend vs limit */
// Budget context showing how much the agent has already spent today
// relative to its daily limit, helping the approver assess risk.
budgetContext?: {
dailySpent: string; // How much has been spent so far today
dailyLimit: string; // The configured daily spending cap
token: string; // The token the budget is denominated in
};
/** User ID of the person who initiated this transaction */
// Used for self-approval prevention — if set, this user cannot also
// approve the request. The value must be in the same identity space as
// the approval channel (e.g., Slack user ID, webhook user ID).
requestedByUserId?: string;
/** When this request expires */
// Unix timestamp (milliseconds) after which the request automatically
// times out and is treated as rejected. Prevents indefinite blocking.
expiresAt: number;
/** SHA-256 hash of the transaction parameters */
// Cryptographically binds the approval to the specific transaction,
// preventing TOCTOU attacks where the intent could be modified after
// approval but before execution.
intentHash: string;
}| Field | Type | Description |
|---|---|---|
id | string | Unique request ID (used to match responses) |
summary | string | Human-readable summary (e.g., "transfer 15 SOL") |
amount | string | Transaction amount |
token | string | Token symbol |
usdValue | number? | USD equivalent (if price oracle is available) |
target | string | Recipient address |
reason | string? | Why the agent wants to do this (from intent metadata) |
agentId | string? | Which agent initiated the request |
requestedByUserId | string? | User ID of requester; used for self-approval prevention |
budgetContext | object? | Current spending vs limits |
expiresAt | number | Unix timestamp when the request expires |
intentHash | string | SHA-256 hash of transaction parameters; binds approval to specific transaction |
ApprovalResult and ApprovalDecision
// The three possible outcomes of an approval request.
// - "approved": The human explicitly approved the transaction.
// - "rejected": The human explicitly rejected the transaction.
// - "timeout": No response was received before the request expired.
type ApprovalDecision = "approved" | "rejected" | "timeout";
// The result returned by an ApprovalChannel after the human responds (or times out).
interface ApprovalResult {
// The ID of the original ApprovalRequest, used to match this result
// back to the pending transaction in the SDK's execution pipeline.
requestId: string;
// The human's decision -- determines whether the transaction proceeds.
decision: ApprovalDecision;
// Optional identifier of who made the decision (e.g., a Slack user ID
// or webhook caller). Recorded in the audit log for accountability.
decidedBy?: string;
// Unix timestamp (milliseconds) of when the decision was made.
decidedAt: number;
// Echo back the intent hash that was approved. The approval gate verifies
// this matches the original intent to prevent TOCTOU attacks.
intentHash?: string;
}| Decision | Meaning | Effect |
|---|---|---|
approved | Human approved the transaction | Policy returns ALLOW |
rejected | Human rejected the transaction | Policy returns DENY |
timeout | No response within the timeout period | Policy returns DENY |
Built-in Approval Channels
The SDK ships two generic approval channels that cover the most common integration patterns. Both handle timeout, fail-closed behavior, and intent hash echoing automatically.
CallbackApprovalChannel
The most flexible option -- you provide two callbacks: one to notify a human, and one to wait for their decision. Works with any notification mechanism (Slack, Discord, Telegram, email, SMS, in-app UI, push notifications).
import { CallbackApprovalChannel } from "@kova-sdk/wallet";
import type { CallbackApprovalChannelConfig } from "@kova-sdk/wallet";
const approval = new CallbackApprovalChannel({
// Optional channel name for audit logs. Defaults to "callback".
name: "my-approval",
// Called when an approval request is created. Use this to notify a human.
// If this throws, the transaction is DENIED (fail-closed).
onApprovalRequest: async (request) => {
await notifyApprover(request); // e.g., send a Slack message, email, or push notification
},
// Called to wait for the human's decision. Must return a Promise that
// resolves with an ApprovalResult when the human approves or rejects.
// The channel races this against the timeout automatically.
waitForDecision: async (request) => {
return pollForResponse(request.id); // e.g., poll a database or listen on a webhook
},
// Default timeout in milliseconds. Defaults to 300_000 (5 minutes).
defaultTimeout: 300_000,
});| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | No | "callback" | Channel name for audit logs |
onApprovalRequest | (request: ApprovalRequest) => Promise<void> | Yes | -- | Callback to notify a human approver |
waitForDecision | (request: ApprovalRequest) => Promise<ApprovalResult> | Yes | -- | Callback to wait for the human's decision |
defaultTimeout | number | No | 300,000 (5 min) | Timeout in milliseconds |
WebhookApprovalChannel
HTTP webhook-based approval for systems that communicate via HTTP callbacks (e.g., external approval dashboards, Slack webhooks, custom internal tools).
Flow:
- SDK POSTs the
ApprovalRequestas JSON to yourwebhookUrlwith anX-Kova-Signatureheader (HMAC-SHA256) - Your external system presents the request to a human
- Your system POSTs the decision back to the channel's callback server with an
X-Kova-Signatureheader
import { WebhookApprovalChannel } from "@kova-sdk/wallet";
const approval = new WebhookApprovalChannel({
// URL to POST approval requests to. Must be HTTPS (HTTP allowed for localhost).
webhookUrl: "https://your-approval-service.com/approve",
// Shared secret for HMAC-SHA256 signing (at least 16 characters).
hmacSecret: process.env.APPROVAL_HMAC_SECRET!,
// Port for the callback HTTP server (0 = OS-assigned ephemeral port).
callbackPort: 0,
// Path for incoming decision callbacks.
callbackPath: "/approval/callback",
// Default timeout.
defaultTimeout: 300_000,
});
// Start the callback server before using the channel.
await approval.start();
// After setup, discover the callback URL to share with your external system:
console.log("Callback URL:", approval.getCallbackUrl());| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | No | "webhook" | Channel name for audit logs |
webhookUrl | string | Yes | -- | URL to POST approval requests to |
hmacSecret | string | Yes | -- | Shared secret for HMAC-SHA256 signing (min 16 chars) |
callbackPort | number | No | 0 | Port for callback server (0 = OS-assigned) |
callbackPath | string | No | "/approval/callback" | Path for incoming decision callbacks |
defaultTimeout | number | No | 300,000 (5 min) | Timeout in milliseconds |
Security features:
- HMAC-SHA256 signatures on both outbound requests and inbound callbacks prevent tampering
- SSRF protection blocks webhook URLs that resolve to private/reserved IP addresses
- HTTPS enforcement for non-localhost URLs
- Fail-closed timeout -- no response means DENY
WARNING
Call await approval.start() before using the channel, and await approval.destroy() when shutting down to clean up the HTTP server and pending requests.
Implementing a Custom ApprovalChannel
You can implement approval via Slack, email, SMS, or any other channel:
// Import the approval-related types from kova.
import type {
ApprovalChannel,
ApprovalRequest,
ApprovalResult,
} from "@kova-sdk/wallet";
// Example: A custom approval channel that sends approval requests to Slack
// using Slack's Block Kit for rich interactive messages.
export class SlackApprovalChannel implements ApprovalChannel {
// The channel name, used in audit logs to identify this approval backend.
readonly name = "slack";
// The Slack webhook URL for posting messages to a channel.
private readonly webhookUrl: string;
// The Slack channel ID where approval messages will be posted.
private readonly channelId: string;
// Accept Slack-specific configuration: the webhook URL and target channel.
constructor(config: { webhookUrl: string; channelId: string }) {
this.webhookUrl = config.webhookUrl;
this.channelId = config.channelId;
}
// Send an approval request to Slack and block until a human responds.
// This is the main method required by the ApprovalChannel interface.
async requestApproval(request: ApprovalRequest): Promise<ApprovalResult> {
// 1. Format the approval request as a Slack Block Kit message
// with interactive Approve/Reject buttons.
const message = this.formatMessage(request);
// 2. Post the message to the Slack channel and get the message timestamp (ID).
const messageId = await this.postMessage(message);
// 3. Wait for a button click via Slack's interactivity webhook.
// The timeout is calculated as the difference between the request's
// expiration time and the current time.
const decision = await this.waitForResponse(
request.id,
messageId,
request.expiresAt - Date.now(), // Remaining time until timeout
);
// 4. Return the result in the format expected by the SDK.
return {
requestId: request.id, // Link back to the original request
decision, // "approved", "rejected", or "timeout"
decidedAt: Date.now(), // Timestamp of the decision
};
}
// Format the approval request as a Slack Block Kit message.
// Block Kit provides a rich, structured layout with interactive elements.
private formatMessage(request: ApprovalRequest): object {
return {
channel: this.channelId, // Target Slack channel
blocks: [
{
type: "section", // A text section showing the transaction details
text: {
type: "mrkdwn", // Slack's markdown format
text: `*Approval Required*\n${request.summary}\nAmount: ${request.amount} ${request.token}\nTo: \`${request.target}\``,
},
},
{
type: "actions", // An actions block containing interactive buttons
elements: [
// Approve button -- action_id includes the request ID so the
// webhook handler can match the click to the correct pending request.
{ type: "button", text: { type: "plain_text", text: "Approve" }, action_id: `approve:${request.id}` },
// Reject button -- same pattern with the request ID embedded.
{ type: "button", text: { type: "plain_text", text: "Reject" }, action_id: `reject:${request.id}` },
],
},
],
};
}
// Post the formatted message to Slack via the webhook URL.
// Returns the message timestamp (ts) which serves as the message ID in Slack.
private async postMessage(message: object): Promise<string> {
// Send an HTTP POST to the Slack webhook endpoint.
const response = await fetch(this.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
// Parse the response to get the message timestamp (ts).
// The ts field uniquely identifies this message in the Slack channel.
const data = await response.json() as { ts: string };
return data.ts;
}
// Wait for a human to click Approve or Reject in Slack.
// In a real implementation, this would listen for Slack interactivity
// webhook callbacks and match the action_id to the pending request.
private async waitForResponse(
requestId: string,
messageId: string,
timeoutMs: number,
): Promise<"approved" | "rejected" | "timeout"> {
// Implement polling or webhook listener for Slack interactivity.
// This is a simplified placeholder -- a real implementation would
// set up an HTTP server to receive Slack's interactive message callbacks.
return "timeout";
}
}Using a Custom Channel
// Import the policy engine components and the custom Slack approval channel.
import { PolicyEngine, ApprovalGateRule, MemoryStore } from "@kova-sdk/wallet";
import { SlackApprovalChannel } from "./slack-approval";
// Create a Slack approval channel with your Slack webhook URL and channel ID.
const approval = new SlackApprovalChannel({
webhookUrl: process.env.SLACK_WEBHOOK_URL!, // Slack incoming webhook URL
channelId: "C0123456789", // Slack channel ID (starts with "C")
});
// Create a PolicyEngine with an ApprovalGateRule.
// The ApprovalGateRule triggers human approval for transactions above a threshold.
const engine = new PolicyEngine(
[
new ApprovalGateRule({
above: { amount: "10", token: "SOL" }, // Require approval for transfers > 10 SOL
timeout: 300_000, // Wait up to 5 minutes for a response
}),
],
new MemoryStore(), // The store for persisting policy state (use SqliteStore in production)
approval, // The approval channel -- the third argument to PolicyEngine
);