# Tool approval

> Pause an agent before a tool runs with needsApproval, show the pending call to a person, and resume the same session with their decision.

Mark a tool with `needsApproval` and the agent stops before running it. The call returns with `status: 'awaiting-approval'` and the list of calls waiting, you show them to a person, and a second call to the same session carries their decisions and continues the run. An approved tool runs with the input the model proposed; a denied one never runs, and the model is told it was denied.

:::note{title="Version"}
Needs `@trayai/helix-sdk` and `@trayai/helix-cli` newer than 1.6.0.
:::

## Mark a tool

`needsApproval` is `true`, or a function of the validated input that returns `true` when this particular call needs a human:

```typescript
// functions/_shared/tools/issue-refund.ts
import { z } from 'zod';
import { defineTool } from '@trayai/helix-sdk';

export default defineTool({
  name: 'issue_refund',
  description: 'Refund an order in full or in part. Amounts over 100 need a human to approve.',
  input: z.object({
    orderId: z.string(),
    amount: z.number().positive(),
    reason: z.string(),
  }),
  needsApproval: (input) => input.amount > 100,
  handler: async (ctx) => {
    await ctx.http.authed('payments').post('https://api.example.com/refunds', {
      body: { order: ctx.input.orderId, amount: ctx.input.amount },
    });
    return { refunded: ctx.input.amount };
  },
});
```

The predicate may be async. It sees input that has already passed the schema.

## The pause

When the model calls a gated tool, the loop stops before the handler runs. The turn so far is saved to the session and `generate()` returns:

```typescript
const result = await ctx.agent('support-agent').generate({
  prompt: 'Refund order 1042, the item arrived broken.',
  sessionId: ctx.input.sessionId,
});

if (result.status === 'awaiting-approval') {
  result.pendingApprovals;
  // [{
  //   id: 'apr_…',          echo this back as approvals[].id
  //   toolName: 'issue_refund',
  //   toolCallId: 'call_…',
  //   input: { orderId: '1042', amount: 180, reason: 'Item arrived broken' },
  // }]
}
```

`input` is the validated input the tool would run with, so a reviewer sees exactly what will happen. Several gated calls in one step produce several entries. Nothing else runs until they are answered, and `result.text` holds whatever the model said before it asked.

## Resume with a decision

Call `generate()` again on the same session with `approvals`. Each entry names a pending id and carries `approved`, with an optional `reason` that reaches the model:

```typescript
const resumed = await ctx.agent('support-agent').generate({
  sessionId: ctx.input.sessionId,
  approvals: [
    { id: ctx.input.approvalId, approved: ctx.input.approved, reason: ctx.input.note },
  ],
});

resumed.status; // 'completed', or 'awaiting-approval' again if the model asked for more
resumed.text;   // the model's answer after the tool ran, or after it learned of the denial
```

Send `approvals` on their own, with no `prompt` or `messages` in the same call, and answer every approval the session has pending in that one call. Both rules come from how the model loop consumes approval decisions: it acts on them only when they are the last thing in the conversation, and a tool call left without a decision cannot be sent to the model. The runtime rejects a call that breaks either rule with an error naming the problem. An approved tool runs immediately, and its result goes to the model, which continues the loop. A denied tool never runs; the model sees the denial and the reason, and usually explains to the user what it could not do. If the person also has something to say, send it as the next call's `prompt`.

## A complete approval endpoint

Two functions: one to chat, one to decide.

```typescript
// functions/api/v1/support.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  message: z.string().min(1),
  sessionId: z.string().optional(),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = await ctx.agent('support-agent').generate({
    prompt: ctx.input.message,
    sessionId: ctx.input.sessionId,
  });
  return {
    sessionId: result.sessionId,
    status: result.status,
    text: result.text,
    pendingApprovals: result.pendingApprovals ?? [],
  };
});
```

```typescript
// functions/api/v1/support/approve.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  sessionId: z.string().min(1),
  decisions: z.array(z.object({
    id: z.string().min(1),
    approved: z.boolean(),
    reason: z.string().optional(),
  })).min(1),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = await ctx.agent('support-agent').generate({
    sessionId: ctx.input.sessionId,
    approvals: ctx.input.decisions,
  });
  return {
    sessionId: result.sessionId,
    status: result.status,
    text: result.text,
    pendingApprovals: result.pendingApprovals ?? [],
  };
});
```

How to render this in a browser is in [building a chat UI for an agent](/documentation/guides/agent-chat-ui/).

## What the runtime guarantees

- **Each approval resolves once.** Sending the same id twice, from a retry or a double click, is a no-op the second time. The tool does not run twice.
- **Unknown ids are rejected.** An id that was never requested in this session throws an error naming it, rather than being silently ignored. A typo or another session's id cannot approve anything.
- **Partial answers are rejected.** When several tools are waiting, one call must decide all of them. A call that leaves any pending throws an error naming the unanswered ids, and nothing is claimed.
- **A failed resume is retryable.** If the model call fails after the approvals were accepted, they are put back as pending so the caller can try again. The approved tool has already run by then and runs again on the retry, so handlers behind `needsApproval` should be safe to run twice.
- **The paused turn always reaches the model.** However small the agent's message window, the turn holding an unanswered approval is sent whole, so the resume finds the tool call it refers to. See [agent sessions](/documentation/guides/agent-sessions/).
- **An approval waits as long as its session is kept.** Approvals have no time limit of their own, so on a session with no `ttl` a pending approval never lapses, however long the person takes. For deployed projects using the platform store this is not live yet: a session, and any approval pending on it, is still removed after 30 days without activity. See [agent sessions](/documentation/guides/agent-sessions/#retention).
- **Expiry fails loudly.** Sessions do not expire unless the agent sets a `ttl`. With one, a session that expires with approvals pending throws when reopened, rather than starting a fresh conversation as if nothing was waiting.

## Limits in this version

- **`stream()` does not take `approvals`.** A gated tool call during a stream ends the stream and records the pause in the session. Read `agent.session(sessionId).pendingApprovals()` after the stream finishes, then resume with `generate()`.
- **Delegates cannot pause.** An agent listed under another agent's `agents` must not hold a `needsApproval` tool. The definition is rejected when the parent first runs.
- **In `helix dev`, pending approvals live in memory.** Restarting the dev server loses them along with the session.

## In the terminal

`helix agent chat <name>` prompts inline when the agent pauses:

```
? you: refund order 1042, it arrived broken
? Run issue_refund({"orderId":"1042","amount":180,"reason":"Item arrived broken"})? (y/N) y

support-agent: Done. I've refunded 180.00 to the original payment method.
```

Field shapes for `pendingApprovals` and `approvals` are in the [`ctx.agent()` reference](/documentation/reference/context/agent/).

## In the dashboard

The Tools view on the project's [Agents tab](/documentation/governance/dashboard/#agents) shows each tool's approval setting: Always, Conditional, or Never. A run waiting on approval shows as awaiting approval in its execution trace. Every deployment that changes whether a tool needs approval is recorded in the [activity trail](/documentation/governance/audit-and-compliance/), so an org admin can see when a tool stopped requiring it.

---

Canonical: https://helix.tray.ai/documentation/guides/tool-approval/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt