Stark-Kit / Docs

Human-in-the-Loop

Mark any tool with requiresApproval: true and the run loop will pause before executing it — giving you a chance to inspect the arguments, approve, reject, or modify them before proceeding.

Defining an approval-gated tool

Set requiresApproval: true on any defineTool() call. The tool's execute function will only run if the human approves the pending call.

hitl.ts
import "dotenv/config";
import { defineTool, Agent, run, isHITLPause, resumeRun, GeminiProvider } from "@mehularora/stark-kit";
import z from "zod";
const provider = new GeminiProvider();
// Mark this tool as requiring approval before it executes
const sendEmailTool = defineTool({
name: "sendEmail",
description: "Send an email notification to a recipient.",
requiresApproval: true, // ← the key flag
parameters: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
execute: async ({ to, subject, body }) => {
// This only runs if the human approves
return `Email sent to ${to} with subject "${subject}".`;
},
});

Running an agent with HITL tools

The API is identical to a normal run() call. The difference is in how you handle the result.

typescript
const agent = new Agent({
name: "Notifier",
provider,
instructions: "Draft and send email notifications when asked.",
tools: [sendEmailTool],
});
let result = await run({
agent,
messages: "Send a welcome email to alice@example.com",
});

Detecting a pause

Use the type guard isHITLPause(result) to check whether the run loop stopped for human approval. The returned object contains pendingToolCalls — an array of the tool invocations that need review.

typescript
import { isHITLPause } from "@mehularora/stark-kit";
if (isHITLPause(result)) {
console.log("Run paused. Pending tool calls:");
for (const call of result.pendingToolCalls) {
console.log(" Tool:", call.toolName);
console.log(" Args:", call.args);
console.log(" ID: ", call.toolCallId);
}
}

Resuming the loop

Call resumeRun(pause, decisions) to continue execution. The decisions map is keyed by toolCallId and each entry specifies an action.

Approve

The tool executes with exactly the arguments the model chose. This is the default happy path.

typescript
import { resumeRun } from "@mehularora/stark-kit";
// Resume with approval — the tool executes as-is
result = await resumeRun(result, {
[result.pendingToolCalls[0].toolCallId]: { action: "approve" },
});
if (result.status === "complete") {
console.log(result.content);
}

Reject

The tool does not execute. The optional reason is passed back to the model so it can respond to the user accordingly — for example, explaining why the action was blocked.

typescript
// Resume with rejection — the tool is not executed
// The model receives the rejection reason and can respond accordingly
result = await resumeRun(result, {
[result.pendingToolCalls[0].toolCallId]: {
action: "reject",
reason: "This recipient is not on the approved list.",
},
});

Modify arguments

The tool executes, but with your modifiedArgs instead of the model's original arguments. This lets you fix typos, enforce data policies, or add mandatory fields before the tool runs.

typescript
// Resume with modified arguments — tool runs with new args
result = await resumeRun(result, {
[result.pendingToolCalls[0].toolCallId]: {
action: "modify",
modifiedArgs: {
to: "alice@example.com",
subject: "Welcome to the platform",
body: "Hi Alice, welcome! [reviewed and approved by admin]",
},
},
});

Multiple pending tool calls

If the model requested several tool calls at once and all have requiresApproval: true, all of them appear in pendingToolCalls. Provide a decision for each toolCallId in a single resumeRun() call.

typescript
// Multiple pending tool calls — handle each by its toolCallId
if (isHITLPause(result)) {
const decisions: Record<string, { action: string }> = {};
for (const call of result.pendingToolCalls) {
// Your approval UI determines the decision per call
decisions[call.toolCallId] = { action: "approve" };
}
result = await resumeRun(result, decisions);
}
Note: resumeRun() returns the same union type as run(): either a RunResult or another HITLPause (if further tools with requiresApproval are triggered downstream). Always check with isHITLPause() again after resuming.
Tip: HITL works seamlessly with runStream() too. The stream emits a hitl_pause event instead of stopping silently — inspect event.result (a HITLPause) and call resumeRun() on it.