FabricFabricPlatform
Platform referenceReference

Agent HITL

Human-in-the-loop policy registry and evaluator for agent actions.

The @fabricorg/platform/agent-hitl module decides how an agent's proposed action is routed: auto-execute, needs-approval, escalate, or rejected. The platform supplies the registry and types; each vertical supplies the actual policy evaluator.

Install

pnpm add @fabricorg/platform
dotnet add package FabricOrg.Platform.Abstractions
dotnet add package FabricOrg.Platform

Types

export type AgentActionRoute = "auto-execute" | "needs-approval" | "escalate" | "rejected";
export type AgentActionRiskTier = "low" | "medium" | "high";

export interface AgentActionPolicyContext {
  actionId: string;
  actorId: string;
  actorType: string;
  tenantId: string;
  spaceId: string;
  parameters: Record<string, unknown>;
  confidence?: number;
  riskTier?: AgentActionRiskTier;
  agentSessionId?: string;
  agentRunId?: string;
}

export interface AgentActionPolicyDecision {
  route: AgentActionRoute;
  riskTier: AgentActionRiskTier;
  reason: string;
}

export type AgentActionPolicyEvaluator = (
  context: AgentActionPolicyContext,
) => AgentActionPolicyDecision | Promise<AgentActionPolicyDecision>;
using FabricOrg.Platform.Actions;
using FabricOrg.Platform.Policies;

// AgentActionRoute is an abstract discriminated union
public abstract class AgentActionRoute
{
    public sealed class AutoExecute : AgentActionRoute { }
    public sealed class NeedsApproval : AgentActionRoute { }
    public sealed class Escalated : AgentActionRoute { }
    public sealed class Rejected : AgentActionRoute { }
}

public enum AgentActionRiskTier { Low, Medium, High }

public sealed class AgentActionPolicyContext
{
    public ActionId ActionId { get; }
    public string ActorId { get; }
    public ActorType ActorType { get; }
    public string TenantId { get; }
    public string SpaceId { get; }
    public IReadOnlyDictionary<string, object?> Parameters { get; }
    public double? Confidence { get; }
    public AgentActionRiskTier? RiskTier { get; }
    public string? AgentSessionId { get; }
    public string? AgentRunId { get; }
}

public sealed class AgentActionPolicyDecision
{
    public AgentActionRoute Route { get; }
    public AgentActionRiskTier RiskTier { get; }
    public string Reason { get; }
}

public delegate Task<AgentActionPolicyDecision> AgentActionPolicyEvaluator(
    AgentActionPolicyContext context, CancellationToken ct);

Registry

Register one evaluator per action ID. The registry throws if you try to register a different evaluator for the same action (fail-closed). Re-registering the same evaluator is idempotent.

import { AgentActionPolicyRegistry } from "@fabricorg/platform/agent-hitl";

const registry = new AgentActionPolicyRegistry();

registry.register({
  actionId: "orders.send_quote",
  evaluator: (ctx) => {
    const { amount } = ctx.parameters as { amount: number };
    if (amount > 50_000) {
      return {
        route: "needs-approval",
        riskTier: "high",
        reason: "Amount exceeds $50k threshold",
      };
    }
    return { route: "auto-execute", riskTier: "low", reason: "Under threshold" };
  },
});

const def = registry.resolve("orders.send_quote");
const decision = await def!.evaluator({
  actionId: "orders.send_quote",
  actorId: "agent-1",
  actorType: "agent",
  tenantId: "tenant-1",
  spaceId: "space-1",
  parameters: { amount: 75_000 },
});
// decision.route === "needs-approval"
using FabricOrg.Platform.Policies;
using FabricOrg.Platform.Registries;

var registry = new AgentActionPolicyRegistry();

registry.Register(new AgentActionPolicyDefinition(
    new ActionId("orders", "send_quote"),
    async (ctx, ct) =>
    {
        var amount = (double)(ctx.Parameters["amount"] ?? 0);
        if (amount > 50_000)
        {
            return new AgentActionPolicyDecision(
                new AgentActionRoute.NeedsApproval(),
                AgentActionRiskTier.High,
                "Amount exceeds $50k threshold");
        }
        return new AgentActionPolicyDecision(
            new AgentActionRoute.AutoExecute(),
            AgentActionRiskTier.Low,
            "Under threshold");
    }));

var def = registry.Resolve(new ActionId("orders", "send_quote"));
var decision = await def!.Evaluator(new AgentActionPolicyContext(
    new ActionId("orders", "send_quote"),
    "agent-1",
    ActorType.Agent,
    "tenant-1",
    "space-1",
    new Dictionary<string, object?> { ["amount"] = 75_000 }), CancellationToken.None);
// decision.Route is AgentActionRoute.NeedsApproval

Singleton bridge pattern

Most applications don't use the registry directly. Instead, the platform layer exposes a singleton pointer that verticals wire up at boot:

// platform layer — vertical-agnostic
import { type AgentActionPolicyEvaluator } from "@fabricorg/platform/agent-hitl";

let registered: AgentActionPolicyEvaluator | null = null;

export function registerAgentActionPolicy(evaluator: AgentActionPolicyEvaluator): void {
  registered = evaluator;
}

export async function evaluateRegisteredAgentActionPolicy(
  input: Partial<AgentActionPolicyContext> & Pick<AgentActionPolicyContext, "actionId">,
): Promise<AgentActionPolicyDecision> {
  if (registered) {
    return await registered({
      actorId: "system",
      actorType: "system",
      tenantId: "system",
      spaceId: "system",
      parameters: {},
      ...input,
    });
  }
  return {
    route: "auto-execute",
    riskTier: "low",
    reason: "No agent action policy registered; defaulting to auto-execute.",
  };
}
// platform layer — vertical-agnostic
using FabricOrg.Platform.Policies;

public static class AgentActionPolicyBridge
{
    private static AgentActionPolicyEvaluator? _registered;

    public static void Register(AgentActionPolicyEvaluator evaluator)
        => _registered = evaluator;

    public static async Task<AgentActionPolicyDecision> EvaluateAsync(
        AgentActionPolicyContext context,
        CancellationToken ct = default)
    {
        if (_registered is not null)
            return await _registered(context, ct);

        return new AgentActionPolicyDecision(
            new AgentActionRoute.AutoExecute(),
            AgentActionRiskTier.Low,
            "No agent action policy registered; defaulting to auto-execute.");
    }
}

Vertical evaluator example

A real lending evaluator might look like this:

export function evaluateAgentActionPolicy(
  context: AgentActionPolicyContext,
): AgentActionPolicyDecision {
  const { actionId, confidence } = context;

  // Blacklist — underwriting actions are never delegable
  if (FORBIDDEN_ACTION_IDS.has(actionId)) {
    return { route: "rejected", riskTier: "high", reason: "Never delegable to agents." };
  }

  switch (actionId) {
    case "lending.update_data_from_borrower_response": {
      if (confidence == null) return { route: "needs-approval", riskTier: "medium", reason: "Missing confidence" };
      if (confidence >= 0.85) return { route: "auto-execute", riskTier: "low", reason: `confidence=${confidence}` };
      if (confidence >= 0.6) return { route: "needs-approval", riskTier: "medium", reason: "Low confidence" };
      return { route: "escalate", riskTier: "high", reason: "Unreliable extraction" };
    }
    case "lending.satisfy_stipulation":
      return { route: "needs-approval", riskTier: "high", reason: "Financial impact" };
    default:
      return { route: "rejected", riskTier: "high", reason: "Not in whitelist" };
  }
}
public static AgentActionPolicyDecision EvaluateAgentActionPolicy(AgentActionPolicyContext ctx)
{
    var actionId = ctx.ActionId.ToString();
    var confidence = ctx.Confidence;

    // Blacklist — underwriting actions are never delegable
    if (ForbiddenActionIds.Contains(actionId))
    {
        return new AgentActionPolicyDecision(
            new AgentActionRoute.Rejected(),
            AgentActionRiskTier.High,
            "Never delegable to agents.");
    }

    switch (actionId)
    {
        case "lending.update_data_from_borrower_response":
            if (confidence is null)
                return new AgentActionPolicyDecision(
                    new AgentActionRoute.NeedsApproval(),
                    AgentActionRiskTier.Medium,
                    "Missing confidence");
            if (confidence >= 0.85)
                return new AgentActionPolicyDecision(
                    new AgentActionRoute.AutoExecute(),
                    AgentActionRiskTier.Low,
                    $"confidence={confidence}");
            if (confidence >= 0.6)
                return new AgentActionPolicyDecision(
                    new AgentActionRoute.NeedsApproval(),
                    AgentActionRiskTier.Medium,
                    "Low confidence");
            return new AgentActionPolicyDecision(
                new AgentActionRoute.Escalated(),
                AgentActionRiskTier.High,
                "Unreliable extraction");

        case "lending.satisfy_stipulation":
            return new AgentActionPolicyDecision(
                new AgentActionRoute.NeedsApproval(),
                AgentActionRiskTier.High,
                "Financial impact");

        default:
            return new AgentActionPolicyDecision(
                new AgentActionRoute.Rejected(),
                AgentActionRiskTier.High,
                "Not in whitelist");
    }
}

Best practices

  • Whitelist model. Any action not explicitly listed should return rejected. This prevents new actions from silently becoming auto-executable.
  • Confidence-gate inferred data. Actions that consume LLM-extracted data should gate on a confidence threshold.
  • Never auto-allow financial mutations. satisfy_stipulation, create_offer, record_funding — always needs-approval or rejected.
  • Keep the evaluator pure. No DB calls, no side effects. The evaluator receives context and returns a decision.
  • Register at boot. Call registerAgentActionPolicy(evaluator) in your module initialization so the platform endpoint can dispatch through it.

On this page