@hitl-kit/gates
v0.1.0
Published
Composable decision gates (confidence, cost, scope, approval chain, rate limit) for HITL Kit. Pure functions that wrap any adapter's emit point and decide allow / deny / escalate.
Maintainers
Readme
@hitl-kit/gates
Composable decision gates for HITL Kit. Pure functions that wrap any adapter's emit point and decide allow / deny / escalate.
pnpm add @hitl-kit/core @hitl-kit/gates@hitl-kit/core is a peer; the version you install is up to you.
Why gates exist
The first 15 HITL primitives are UI. They render whatever the agent emits. That's good for showing humans what's happening, but it doesn't change what the agent is allowed to do.
A gate is the missing decision layer. It evaluates before the adapter surfaces an event:
- Block when model confidence is below a threshold
- Block when projected cost exceeds budget
- Block when the agent reaches into a scope it shouldn't
- Require N approvers before a tool call goes through
- Throttle calls per window
Gates are pure functions — they don't import React, they don't render, they don't know about LangGraph or AI SDK. Adapter integration is a thin wrapper (withGates in each adapter package) that runs the gates and decides what to do with a deny: throw, return error, or surface an escalate event so the human can override.
The five gates
import {
composeGates,
confidenceGate,
costGate,
scopeGate,
approvalChainGate,
rateLimitGate,
inMemoryStore,
} from "@hitl-kit/gates";
const store = inMemoryStore();
const gates = composeGates([
confidenceGate({ min: 0.85 }),
costGate({ maxUsd: 0.10 }),
scopeGate({ allowed: ["read:files", "read:web"] }),
rateLimitGate({ store, key: (ctx) => ctx.signals?.userId ?? "anon", max: 30, windowSec: 60 }),
approvalChainGate({
store,
key: (ctx) => ctx.signals?.userId ?? "anon",
approvers: ["alice", "bob"],
ordered: false,
}),
]);composeGates runs the array sequentially; first deny wins.
Gate API
type Gate = (ctx: GateContext) => Promise<GateDecision> | GateDecision;
interface GateContext {
event?: HitlEvent;
input?: unknown;
signals?: GateSignals;
adapter: "langgraph" | "ai-sdk" | "mcp" | "core";
}
type GateDecision =
| { allow: true; meta?: Record<string, unknown> }
| { allow: false; reason: string; code: GateCode; escalate?: HitlEvent; meta?: Record<string, unknown> };When allow: false and escalate is set, the adapter surfaces the escalation event to the human — render it via <HitlEventRenderer /> and let the user override. This is the killer pattern: the same render pipeline handles happy-path and blocked-path with no special code.
Storage
Stateful gates (rateLimitGate, approvalChainGate, optional cumulative costGate) need a GateStore. Ship inMemoryStore() is single-process. For production:
const redisStore: GateStore = {
async get(key) { /* … */ },
async set(key, value, ttlSec) { /* … */ },
async incr(key, ttlSec) { /* … */ },
async delete(key) { /* … */ },
};Plug it into the gate factories that take a store field. No other changes needed.
Adapter integration
Each adapter exports a withGates wrapper:
// LangGraph
import { withGates } from "@hitl-kit/langgraph";
const gated = await withGates(payload, [confidenceGate({ min: 0.85 })], { signals });
const approval = interrupt(gated);
// AI SDK
import { withGates } from "@hitl-kit/ai-sdk";
const tool = withGates(hitlCardTool(), [costGate({ maxUsd: 0.05 })], { signals });
// MCP — gates configured at server creation time
import { createHitlKitServer } from "@hitl-kit/mcp";
const server = createHitlKitServer({ gates: [rateLimitGate({ /* … */ })], onDeny: "escalate" });See each adapter's README for full integration details.
Part of HITL Kit
- Schemas → @hitl-kit/core
- Renderer → @hitl-kit/react
- LangGraph adapter → @hitl-kit/langgraph
- AI SDK adapter → @hitl-kit/ai-sdk
- MCP adapter → @hitl-kit/mcp
- Paper → hitlkit.dev/paper
MIT © Ieuan King.
