kavachOS
Core concepts

Approval flows

CIBA-style async human-in-the-loop approval for actions that should not run unattended.

What approval flows solve

Some agent actions are too sensitive to run without a human saying yes first. File deletions, financial transfers, permission escalations — these benefit from a checkpoint before execution.

KavachOS supports this with a permission constraint called requireApproval. When the permission engine sees it, authorization is denied with the reason "This action requires human approval before execution". Your application catches that signal, creates an approval request, notifies a human, and retries the action after a response comes back.

The flow is async. The agent does not block waiting. The human can respond minutes or hours later, within the request's TTL.

KavachOS creates the approval request and persists it. Delivering the notification to the human — email, Slack, push notification — is your application's job. Use webhookUrl or onApprovalNeeded to hook into your existing notification stack.

How the flow works

Agent triggers approval

The agent calls an action protected by requireApproval: true. Authorization is denied. Your application detects the denial reason and calls kavach.approval.request().

Human gets notified

KavachOS fires your webhookUrl or onApprovalNeeded handler with the request details. Your app sends an email, opens a Slack DM, or surfaces a notification in your dashboard.

Human approves or denies

The human clicks a button in your UI. Your UI calls your backend, which calls kavach.approval.approve(requestId) or kavach.approval.deny(requestId).

Agent retries

Once approved, your application retries the original action. For maximum simplicity, pass the request ID back to the agent so it can poll or be woken up when a decision arrives.

ApprovalRequest fields

Prop

Type

Configuration

Pass approval config to createKavach:

const kavach = await createKavach({
  database: { provider: 'sqlite', url: 'kavach.db' },
  approval: {
    ttl: 600,                                          // 10-minute window (seconds)
    webhookUrl: 'https://your-app.com/webhooks/approval',
  },
});

Or use a custom handler for full control over delivery:

const kavach = await createKavach({
  database: { provider: 'sqlite', url: 'kavach.db' },
  approval: {
    ttl: 300,
    onApprovalNeeded: async (request) => {
      await sendSlackDm(request.userId, {
        text: `Agent ${request.agentId} wants to ${request.action} on ${request.resource}.`,
        approveUrl: `https://your-app.com/approvals/${request.id}/approve`,
        denyUrl: `https://your-app.com/approvals/${request.id}/deny`,
      });
    },
  },
});

Both webhookUrl and onApprovalNeeded fire asynchronously so the request() call is not delayed by notification latency.

How webhooks work

When webhookUrl is set, KavachOS sends a POST to that URL with a JSON body:

{
  "event": "approval_needed",
  "request": {
    "id": "apr_...",
    "agentId": "agt_...",
    "userId": "user-123",
    "action": "delete",
    "resource": "file:prod-data/*",
    "arguments": { "path": "/prod/dataset.csv" },
    "status": "pending",
    "expiresAt": "2026-03-21T10:15:00.000Z",
    "createdAt": "2026-03-21T10:10:00.000Z"
  }
}

Webhook delivery failures are non-fatal. The request is already persisted — your app can poll listPending() as a fallback.

Code examples

Set up a permission that requires approval

const agent = await kavach.agent.create({
  ownerId: 'user-123',
  name: 'file-manager',
  type: 'autonomous',
  permissions: [
    {
      resource: 'file:prod-data/*',
      actions: ['read'],
    },
    {
      resource: 'file:prod-data/*',
      actions: ['delete'],
      constraints: { requireApproval: true },
    },
  ],
});

Catch the denial and create a request

const result = await kavach.authorize(agent.id, {
  action: 'delete',
  resource: 'file:prod-data/dataset.csv',
  arguments: { path: '/prod/dataset.csv' },
});

if (!result.allowed && result.reason?.includes('requires human approval')) {
  const approvalRequest = await kavach.approval.request({
    agentId: agent.id,
    userId: agent.ownerId,
    action: 'delete',
    resource: 'file:prod-data/dataset.csv',
    arguments: { path: '/prod/dataset.csv' },
  });

  return { pending: true, approvalId: approvalRequest.id };
}

Approve or deny from your UI handler

// In your API route handler
app.post('/approvals/:id/approve', async (req, res) => {
  const updated = await kavach.approval.approve(req.params.id, req.user.email);
  res.json({ status: updated.status });
});

app.post('/approvals/:id/deny', async (req, res) => {
  const updated = await kavach.approval.deny(req.params.id, req.user.email);
  res.json({ status: updated.status });
});

List pending requests for a user

// All pending approvals across all users
const all = await kavach.approval.listPending();

// Pending approvals for a specific user
const forUser = await kavach.approval.listPending('user-123');

console.log(`${forUser.length} approvals waiting on user-123`);

Expire stale requests

Requests that exceed their TTL are still stored with status: 'pending' until you run cleanup. Call this from a cron job:

const result = await kavach.approval.cleanup();
console.log(`Expired ${result.expired} stale approval requests`);

Check the status before retrying

const request = await kavach.approval.get('apr_...');

if (request?.status === 'approved') {
  // Safe to retry the action
  await deleteFile(agent.id, '/prod/dataset.csv');
} else if (request?.status === 'denied') {
  console.log('Human denied the request.');
} else if (request?.status === 'expired') {
  console.log('Request expired without a response.');
}

Next steps

On this page