Skip to content

Human Approval

What you'll learn

  • The ApprovalChannel interface and how to implement custom approval backends
  • The complete ApprovalRequest and ApprovalResult data structures
  • How to use CallbackApprovalChannel for flexible approval flows
  • How to use WebhookApprovalChannel for 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

typescript
// 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";
typescript
// 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:

typescript
// 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;
}
FieldTypeDescription
idstringUnique request ID (used to match responses)
summarystringHuman-readable summary (e.g., "transfer 15 SOL")
amountstringTransaction amount
tokenstringToken symbol
usdValuenumber?USD equivalent (if price oracle is available)
targetstringRecipient address
reasonstring?Why the agent wants to do this (from intent metadata)
agentIdstring?Which agent initiated the request
requestedByUserIdstring?User ID of requester; used for self-approval prevention
budgetContextobject?Current spending vs limits
expiresAtnumberUnix timestamp when the request expires
intentHashstringSHA-256 hash of transaction parameters; binds approval to specific transaction

ApprovalResult and ApprovalDecision

typescript
// 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;
}
DecisionMeaningEffect
approvedHuman approved the transactionPolicy returns ALLOW
rejectedHuman rejected the transactionPolicy returns DENY
timeoutNo response within the timeout periodPolicy 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).

typescript
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,
});
FieldTypeRequiredDefaultDescription
namestringNo"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
defaultTimeoutnumberNo300,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:

  1. SDK POSTs the ApprovalRequest as JSON to your webhookUrl with an X-Kova-Signature header (HMAC-SHA256)
  2. Your external system presents the request to a human
  3. Your system POSTs the decision back to the channel's callback server with an X-Kova-Signature header
typescript
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());
FieldTypeRequiredDefaultDescription
namestringNo"webhook"Channel name for audit logs
webhookUrlstringYes--URL to POST approval requests to
hmacSecretstringYes--Shared secret for HMAC-SHA256 signing (min 16 chars)
callbackPortnumberNo0Port for callback server (0 = OS-assigned)
callbackPathstringNo"/approval/callback"Path for incoming decision callbacks
defaultTimeoutnumberNo300,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:

typescript
// 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

typescript
// 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
);

Released under the MIT License.