Operate / Approvals & steering

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

  1. A tool declares requiresPermission. For this call it evaluates to true.
  2. Glove asks the conversation store for a saved decision for this exact tool and input. None is saved.
  3. Foundry writes a pending approval record that expires in 5 minutes, emits agent.foundry.approval.requested, and the run waits (it stays running).
  4. Someone approves or denies it: the inspector, client.resolveApproval, or POST /api/approvals/:id.
  5. 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.

agents/support/agent.tstypescript
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.

agents/support/transmissions/tickets.transmission.tstypescript
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 fieldMeaning
idapproval_<uuid>
runId, definitionId, agentId, conversationId, workspaceIdExactly which run, instance and conversation is asking.
toolName, toolInputThe tool and the JSON-serialized input that will run if approved.
statusSee below.
requestedAt, expiresAt, resolvedAt?ISO timestamps; expiresAt is 5 minutes after requestedAt.
StatusWhenTool runs?
pendingWaiting for a decision.Not yet
approvedResolved with "approve".Yes
deniedResolved with "deny".No
expiredNobody decided before expiresAt.No
cancelledThe 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 resolveApproval on the same id fails with Foundry approval "…" is already approved. (HTTP 400). 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-core MemoryStore, glove-sqlite SqliteStore) answer later identical calls from the saved decision — a denial included — without a new approval record. SqliteStore persists 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

scripts/approve.tstypescript
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

EndpointInputResult
GET /api/approvals, GET /v1/approvals?runId= (or run_id), ?status= one of the five statusesFoundryApproval[], oldest first; an unknown status is 400
POST /api/approvals/:id, POST /v1/approvals/:id{ "decision": "approve" | "deny" }The resolved FoundryApproval
terminalbash
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 typedata
agent.foundry.approval.requestedThe 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 / .failedFail-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.

src/approval-inbox.tstypescript
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:

  1. If the source run is pending or running, Foundry cancels it through the execution daemon: the run is marked cancelled and its agent process is told to stop, which fires the run's abort signal — a pending approval resolves as cancelled (denied).
  2. It waits for the run to reach a terminal status, for at most the run's timeoutMs clamped to 1–30 s. If it does not get there, steering fails with Foundry could not reach a safe steering boundary for run "…". and no new run starts.
  3. It waits for the cancelled run's process to let go of the conversation. The run reads cancelled as soon as the stop is sent, but its process finishes an unAbortable tool 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.
  4. It sends your message as a new run on the same instance and conversation, with request.context containing protocol: "foundry-control", steeredFromRunId and steeringMode: "interrupt-and-restart".
  5. It records a run.steered event on the new run with fromRunId, toRunId and interrupted.

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.

scripts/steer.tstypescript
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:

agents/support/agent.tstypescript
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 / stopSteer
Callshandle.cancel(), POST /api/runs/:id/cancel, POST /v1/runs/:id/stophandle.steer(msg), POST /api/runs/:id/steer, POST /v1/runs/:id/steer
Active runEnds cancelled; pending approvals become cancelledSame, then a replacement run starts
Already finished runNothing to cancel (false)Your message becomes a follow-up turn
Returnsboolean ({ cancelled } / { id, stopped } over HTTP){ fromRunId, interrupted, run }