FabricFabricHarness
Building Agents

Approvals

Pause an agent until a human approves a risky action.

Approvals turn an autonomous agent into one that asks for confirmation at risky moments. The framework persists the request, suspends the relevant code path, and resumes when a human (or another system) resolves it.

Declarative routing — approvalRules

Most users want approvals tied to specific tools, not scattered through agent code. Declare the rules once in policy and the loop pauses for approval before dispatch:

const fabric = await init({
  policy: {
    toolPolicy: {
      approvalRules: [
        { pattern: 'submit_*',  audience: 'reviewer',         reason: 'Review before submission of ${name}' },
        { pattern: 'delete_*',  audience: 'project-admin',    ttlSeconds: 3600 },
        { pattern: 'finalize_*', audience: 'compliance-team' },
      ],
    },
  },
});

audience is an opaque string id. fabric-harness never decides who an audience maps to — your host application's identity layer (UI, SSO, RBAC) does that mapping. Rules also work on commandPolicy.approvalRules for bash invocations.

When the agent calls a matching tool, fabric-harness emits approval_requested with the audience id, the templated reason, and the TTL. The host UI renders the request to the right humans; on approval_granted the loop continues; on denial or TTL it throws. This is sugar over the imperative session.approval.request() API — which stays available as the escape hatch for ad-hoc cases.

Approved durable responses expose ApprovalResponse.grant. The grant binds the approval id, logical tool-call id, canonical input digest, executing principal, approvers, decision time, and optional expiry. Memory, file, SQLite, Postgres, Cloudflare Durable Object, and Temporal paths preserve the same shape. Denied responses never carry a grant. A crash retry may replay the same bound operation; different input, call id, or principal fails closed.

Webhook subscriptions

Agents that should wake on inbound events (event bus, queue, external SaaS webhook) use defineWebhookSubscription:

import { defineAgent, defineWebhookSubscription } from '@fabric-harness/sdk';

export default defineAgent({
  name: 'data-validator',
  triggers: { webhook: true },
  subscriptions: [
    defineWebhookSubscription<{ recordId: string }>({
      id: 'on-record-created',
      events: ['record.created'],
      handler: async ({ payload, idempotencyKey, headers }) => {
        // ...invoke whatever your host application exposes.
      },
    }),
  ],
  async run() { /* ... */ },
});

fh dev and generated Node builds expose each subscription at POST /agents/<agent>/subscriptions/<id>. Set FABRIC_HARNESS_WEBHOOK_SECRET to require an X-Fabric-Signature: sha256=<hmac> header on every delivery. The server dedupes by Idempotency-Key for at-least-once event buses and emits a webhook_received event into the session log for audit.

fh subscriptions [agent] lists registered subscriptions across the workspace.

Request an approval

session.approval.request() returns true when the approver allows the action. It throws a FabricError (APPROVAL_DENIED or APPROVAL_REQUIRED) on denial or timeout — wrap in try/catch when you want to handle rejection gracefully.

import { FabricError } from '@fabric-harness/sdk';

try {
  await session.approval.request({
    reason: 'About to push changes to GitHub',
    subject: 'git push origin main',
    risk: 'high',
    timeoutMs: 24 * 60 * 60 * 1000, // 24h
  });
  await session.shell('git push origin main');
} catch (error) {
  if (error instanceof FabricError && error.code === 'APPROVAL_DENIED') {
    throw new Error(`Push blocked: ${error.message}`);
  }
  throw error;
}

The available fields are:

FieldTypeDescription
reasonstringHuman-readable reason shown to the approver.
subjectstring?Short subject line for UIs. Defaults to 'custom'.
risk'low' | 'medium' | 'high'?Drives escalation policy.
timeoutMsnumber?Override the session's default approval timeout.
onApprovalApprovalCallback?Per-call approval handler. Falls back to session/agent onApproval.

Resolve from the CLI

fh approvals <session-id> --pending
fh approve <session-id> <approval-id> --actor preetham
# or
fh reject  <session-id> <approval-id> --actor preetham --reason "Wrong branch"

For a deployed Node or Databricks App, use the tenant-scoped remote surface. Omitting the session id discovers approvals across only the sessions visible to the authenticated tenant:

fh approvals \
  --url "$FABRIC_HARNESS_APP_URL/api" \
  --token-env DATABRICKS_OAUTH_TOKEN \
  --tenant acme

fh approve <session-id> <approval-id> \
  --url "$FABRIC_HARNESS_APP_URL/api" \
  --token-env DATABRICKS_OAUTH_TOKEN \
  --tenant acme

The audience value is a routing label, not an authorization grant. The host must map that label to an authenticated group or role and grant approval:write only to eligible approvers. The server always enforces tenant isolation and records the authenticated voting principal.

Notify approvers

Attach approvalNotificationHandler() to the agent's onEvent callback. It receives requested, escalated, and resolved events, retries transient failures, deduplicates deliveries, and records delivery outcomes through the audit hook. Notification failures never resolve the approval.

import {
  approvalNotificationHandler,
  defineAgent,
  slackApprovalNotifier,
} from '@fabric-harness/sdk';
import { redisApprovalNotificationStore } from '@fabric-harness/node';

const notifyApproval = approvalNotificationHandler({
  notifier: slackApprovalNotifier({
    webhookUrl: process.env.SLACK_APPROVAL_WEBHOOK_URL!,
  }),
  baseUrl: 'https://agents.example.com',
  store: redisApprovalNotificationStore({
    client: redis,
    namespace: 'production',
  }),
  retries: 4,
  deadLetter: async (record) => {
    await approvalDeadLetters.insert(record);
  },
  audit: async (record) => {
    await auditLog.append('approval_notification', record);
  },
  onError: (error, notification) => {
    logger.error({ error, notificationId: notification.id });
  },
});

export default defineAgent({
  name: 'release-agent',
  onEvent: notifyApproval,
  async run({ prompt }) {
    return prompt('Prepare the approved release.');
  },
});

Use webhookApprovalNotifier() for an internal workflow service, PagerDuty bridge, or custom notification worker. Slack and generic webhook destinations receive a bounded public payload: raw tool input, environment values, actor credentials, and destination secrets are excluded. Deep links contain only the session, approval, and tenant identifiers; /admin still enforces authentication, tenant isolation, and approval permissions when opened.

inMemoryApprovalNotificationStore() is suitable for local development. Use redisApprovalNotificationStore() across replicas or implement ApprovalNotificationDeliveryStore with an atomic claim in the system that owns your notification outbox. A dead-letter callback is strongly recommended in production.

Durable waits

On the Temporal worker target, the approval wait runs as a dedicated workflow and can wait without holding a process. The workflow records its ID on the approval request so fh approve and fh reject signal the owning workflow. The first denial wins; approvals are deduplicated by actor and resume only after policy.approvals.requiredApprovals is reached. An escalation deadline emits approval_escalated with the configured notify audience and risk, while the overall timeout remains authoritative. Worker restarts do not lose the timer, votes, or pending request. On the inline runtime, the wait uses the configured session store or callback.

To make agents safe on either runtime, declare autonomy fallbacks:

await init({
  autonomy: {
    onApprovalUnavailable: 'fail',  // or 'assume-rejected' | 'assume-approved'
  },
});

Logged identities

Approvals record both the agent identity and the human actor. Combined with the two-identity actor schema (Entra Agent ID + on-behalf-of user), the audit trail answers "which agent acted, on whose behalf, who approved?"

See also