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:
| Field | Type | Description |
|---|---|---|
reason | string | Human-readable reason shown to the approver. |
subject | string? | Short subject line for UIs. Defaults to 'custom'. |
risk | 'low' | 'medium' | 'high'? | Drives escalation policy. |
timeoutMs | number? | Override the session's default approval timeout. |
onApproval | ApprovalCallback? | 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 acmeThe 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?"