Approvals & steering
Keep a human in the loop for the calls that matter. A permission-gated tool call pauses its run and becomes an expiring approval record that you approve or deny from the inspector, your own app or a single HTTP call. When an agent heads the wrong way, steer it: Foundry stops the run at a safe boundary and restarts it in the same conversation with your new direction.
How an approval works
- A tool declares
requiresPermission. For this call it evaluates totrue. - Glove asks the conversation store for a saved decision for this exact tool and input. None is saved.
- Foundry writes a
pendingapproval record that expires in 5 minutes, emitsagent.foundry.approval.requested, and the run waits (it staysrunning). - Someone approves or denies it: the inspector,
client.resolveApproval, orPOST /api/approvals/:id. - Approved: the tool runs with that input. Anything else (denied, expired, run cancelled): the tool is not called and the model receives the result
Permission denied for tool "…". The user has not granted permission to run this tool., then the run carries on.
Gate a tool
requiresPermission is a standard Glove tool field (GloveFoldArgs), so it works on defineAgent tools, on the tool of a defineSharedTool, and on tools an application contributes. Use true to gate every call, or a function of the validated input: return true to require approval for this call, false to run it immediately.
import { createAdapter } from "glove-core/models/providers";
import { defineAgent } from "glove-foundry";
import { z } from "zod";
export default defineAgent({
description: "Acme customer support",
model: () => createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini", stream: true }),
systemPrompt: "You are Acme's support agent. Refunds over $50 need a human's approval.",
tools: [
{
name: "issue_refund",
description: "Refund part or all of an order to the customer's original payment method.",
inputSchema: z.object({
orderId: z.string(),
amountUsd: z.number().positive(),
reason: z.string(),
}),
// true for this call = ask a human first; false = run immediately.
requiresPermission: ({ amountUsd }) => amountUsd > 50,
async do({ orderId, amountUsd }) {
// your code: await payments.refund(orderId, amountUsd)
return { status: "success" as const, data: { orderId, refundedUsd: amountUsd } };
},
},
],
});Gate an outbound transmission
An installed application turns each outbound transmission into a tool. Set outbound.requiresPermission to a boolean or to a predicate over the schema-decoded payload, and that tool asks first. A payload that fails to decode, or a route the playbook did not select, never creates an approval — the tool rejects it instead.
import { Effect, Schema } from "effect";
import { defineTransmission } from "glove-foundry";
export const createTicket = {
id: "tickets:create",
description: "Open a helpdesk ticket",
account: "required",
effect: "write",
} as const;
export default defineTransmission({
name: "Tickets",
description: "Helpdesk ticket queue",
account: { required: true, metadata: Schema.Struct({ workspace: Schema.String }) },
capabilities: [createTicket],
outbound: {
config: Schema.Struct({ queue: Schema.String }),
input: Schema.Struct({
subject: Schema.String,
priority: Schema.Literal("low", "normal", "urgent"),
}),
output: Schema.Struct({ ticketId: Schema.String }),
// Decoded payload in, boolean out: only urgent tickets wait for a human.
requiresPermission: (input) => input.priority === "urgent",
adapter: {
// your code: call the helpdesk API
deliver: (input) => Effect.succeed({ ticketId: `T-${input.subject.length}-${Date.now()}` }),
},
},
});How applications and transmissions are installed is covered in Apps & transmissions.
The approval record
| FoundryApproval field | Meaning |
|---|---|
id | approval_<uuid> |
runId, definitionId, agentId, conversationId, workspaceId | Exactly which run, instance and conversation is asking. |
toolName, toolInput | The tool and the JSON-serialized input that will run if approved. |
status | See below. |
requestedAt, expiresAt, resolvedAt? | ISO timestamps; expiresAt is 5 minutes after requestedAt. |
| Status | When | Tool runs? |
|---|---|---|
pending | Waiting for a decision. | Not yet |
approved | Resolved with "approve". | Yes |
denied | Resolved with "deny". | No |
expired | Nobody decided before expiresAt. | No |
cancelled | The run was cancelled, stopped or steered while waiting. | No |
- Exact-input identity. One record per gated call, bound to its run, instance, conversation, tool and serialized input. Approving
{ amountUsd: 80 }never approves{ amountUsd: 800 }. - First decision wins. Resolution is an atomic create; a second
resolveApprovalon the same id fails withFoundry approval "…" is already approved.(HTTP400). So does resolving an expired one. - Fixed expiry. 5 minutes, not configurable from
defineAgent. - Local to one process. Records are files in a private directory under the OS temp dir, created fresh by each Foundry runtime. They are not shared between Foundry processes and do not survive a restart; runs waiting on them stop with the process.
Fail-closed
Only an explicit approve runs the tool. Foundry denies when the approval channel is unavailable (agent.foundry.approval.unavailable), when the record cannot be written or read (agent.foundry.approval.failed), on expiry, and when the run is cancelled. GET /v1/capabilities advertises this as approvals.default: "deny".
Foundry routes permission prompts through its own display manager. If you set displayManager on defineAgent, you replace it: prompts go to your display manager and no approval records are created.
Remembered decisions
After a decision, Glove saves it with store.setPermission(toolName, "granted" | "denied", input). Whether it is reused depends on your conversation store:
- Stores that implement
getPermission/setPermission(glove-coreMemoryStore, glove-sqliteSqliteStore) answer later identical calls from the saved decision — a denial included — without a new approval record.SqliteStorepersists it per store identifier (typically one per conversation), so it holds across runs. - Stores without those methods are wrapped by Foundry with
withFoundryPermissions: an in-memory, exact-input map that lasts for the current run only.
withFoundryPermissions(store) and FoundryApprovalDisplayManager are exported, but Foundry already applies both to every agent run; you only need them when hosting a Glove agent outside the Foundry runtime.
Resolve approvals
From your code
import { createFoundryClient } from "glove-foundry/client";
const foundry = createFoundryClient();
const handle = await foundry.send("support-acme", "conversation-1042", "Refund the full order 1042.");
// Later, or from another process:
const pending = await foundry.approvals({ runId: handle.id, status: "pending" });
for (const approval of pending) {
console.log(approval.toolName, approval.toolInput); // issue_refund { orderId: "1042", amountUsd: 129, … }
await foundry.resolveApproval(approval.id, "approve"); // or "deny"
}Over HTTP
| Endpoint | Input | Result |
|---|---|---|
GET /api/approvals, GET /v1/approvals | ?runId= (or run_id), ?status= one of the five statuses | FoundryApproval[], oldest first; an unknown status is 400 |
POST /api/approvals/:id, POST /v1/approvals/:id | { "decision": "approve" | "deny" } | The resolved FoundryApproval |
curl -s 'http://127.0.0.1:4141/v1/approvals?status=pending'
curl -s -X POST http://127.0.0.1:4141/v1/approvals/APPROVAL_ID \
-H 'content-type: application/json' -d '{"decision":"deny"}'In the inspector
The overview page shows a Decision required rail listing every pending request — tool name, the exact input, a link to the run, when it was requested and when it expires — with Deny and Approve this input buttons. The waiting run's detail page shows the same rail for that run, and the “Attention needed” metric counts waiting effects. See Inspector & WebChat.
Events
| Event type | data |
|---|---|
agent.foundry.approval.requested | The full FoundryApproval |
agent.foundry.approval.settled | { approvalId, toolName, status } |
approval.resolved | { approvalId, toolName, decision, status } — written when someone resolves via the API |
agent.foundry.approval.unavailable / .failed | Fail-closed denials |
Subscribe with GET /api/events and Accept: text/event-stream (details) to push notifications instead of polling.
Recipe: an approval inbox
Put approvals where your reviewers already are. Three functions cover a web page or a chat bot; the loop at the bottom is a minimal notifier.
import type { FoundryApproval } from "glove-foundry";
import { createFoundryClient } from "glove-foundry/client";
const foundry = createFoundryClient({
baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
});
/** Everything waiting for a human, oldest first. */
export function pendingApprovals(): Promise<ReadonlyArray<FoundryApproval>> {
return foundry.approvals({ status: "pending" });
}
/** One line a reviewer can act on. Show the exact input: that is what gets approved. */
export function describeApproval(approval: FoundryApproval): string {
const secondsLeft = Math.max(0, Math.round((Date.parse(approval.expiresAt) - Date.now()) / 1000));
return `${approval.toolName} for instance ${approval.agentId} ` +
`(run ${approval.runId}, ${secondsLeft}s left): ${JSON.stringify(approval.toolInput)}`;
}
/** Returns null when someone else decided first or the request expired. */
export async function decide(
approvalId: string,
decision: "approve" | "deny",
): Promise<FoundryApproval | null> {
try {
return await foundry.resolveApproval(approvalId, decision);
} catch (error) {
console.warn(`approval ${approvalId}: ${(error as Error).message}`);
return null;
}
}
// Notify reviewers once per request. Poll well inside the 5-minute expiry.
const notified = new Set<string>();
setInterval(async () => {
for (const approval of await pendingApprovals()) {
if (notified.has(approval.id)) continue;
notified.add(approval.id);
console.log(describeApproval(approval)); // your code: post to Slack/email with approve and deny actions
}
}, 2_000);The FoundryApproval import is type-only, so a browser bundle pulls in only the client. If the inbox runs on another machine, Foundry must be reachable there — and a non-loopback Foundry requires requestAuthorization, so pass authorization to createFoundryClient.
Programmatic tools cannot bypass approvals
When a REPL exposes tools to a sandboxed program (defineRepl with programmaticTools), Foundry evaluates requiresPermission on the validated input before each call. If it is true, the call throws Tool "…" requires interactive approval and cannot run inside a programmatic workflow. Call it directly so the approval can be shown. and emits agent.foundry.repl.programmatic-tool.denied. The model then calls the tool normally and the approval flow above applies. See Working environments.
Steering
Redirect an agent mid-task — “stop, only refund the shipping fee” — without leaving it half-way through a side effect. Foundry calls this interrupt-and-restart:
- If the source run is
pendingorrunning, Foundry cancels it through the execution daemon: the run is markedcancelledand its agent process is told to stop, which fires the run's abort signal — a pending approval resolves ascancelled(denied). - It waits for the run to reach a terminal status, for at most the run's
timeoutMsclamped to 1–30 s. If it does not get there, steering fails withFoundry could not reach a safe steering boundary for run "…".and no new run starts. - It waits for the cancelled run's process to let go of the conversation. The run reads
cancelledas soon as the stop is sent, but its process finishes anunAbortabletool and its cleanup before exiting. Foundry waits for that, for at most 10 s (the execution daemon kills the process 5 s after the stop), so the replacement never reads history while the old process is still writing it. - It sends your message as a new run on the same instance and conversation, with
request.contextcontainingprotocol: "foundry-control",steeredFromRunIdandsteeringMode: "interrupt-and-restart". - It records a
run.steeredevent on the new run withfromRunId,toRunIdandinterrupted.
Foundry never injects text into an in-flight model call or tool. If the source run had already finished, nothing is cancelled (interrupted: false) and the message is simply the next turn. The replacement run loads the history the conversation store holds once the interrupted process has finished, including the result of a tool that could not be interrupted.
import { createFoundryClient } from "glove-foundry/client";
const foundry = createFoundryClient();
const handle = await foundry.send("support-acme", "conversation-1042", "Refund the full order 1042.");
// Changed your mind while the run is still working:
const steered = await handle.steer("Stop. Refund only the $12 shipping fee, not the whole order.");
console.log(steered.fromRunId === handle.id); // true
console.log(steered.interrupted); // true if an active run was cancelled
const result = await steered.run.wait({ timeoutMs: 120_000 }); // steered.run is a new FoundryRunHandle
console.log(result.status, result.output?.value);handle.steer(message) resolves a FoundrySteerHandleResult: { fromRunId, interrupted, run: FoundryRunHandle }. client.steerRun(runId, message) returns the same shape with a plain FoundryRun record. Over HTTP, POST /api/runs/:id/steer and POST /v1/runs/:id/steer take message, input or guidance (a string or content parts) and answer 202; an unknown run is 404.
Tell the agent it was redirected
Lazy assembly sees the steering context, so the prompt can stop the model from resuming the abandoned plan:
systemPrompt: (_agent, { request }) => [
"You are Acme's support agent. Refunds over $50 need a human's approval.",
request.context?.steeredFromRunId
? "The user redirected you mid-task. Follow their latest message instead of resuming the abandoned plan."
: "",
].filter(Boolean).join("\n"),Redirect in WebChat
In the inspector's Chat page, while a run is active the send button becomes Redirect: your message goes to POST /api/runs/:id/steer and the page shows “Direction updated”. Stop cancels without a new run.
Cancel or steer?
| Cancel / stop | Steer | |
|---|---|---|
| Calls | handle.cancel(), POST /api/runs/:id/cancel, POST /v1/runs/:id/stop | handle.steer(msg), POST /api/runs/:id/steer, POST /v1/runs/:id/steer |
| Active run | Ends cancelled; pending approvals become cancelled | Same, then a replacement run starts |
| Already finished run | Nothing to cancel (false) | Your message becomes a follow-up turn |
| Returns | boolean ({ cancelled } / { id, stopped } over HTTP) | { fromRunId, interrupted, run } |