For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://modelgates.ai/docs/_mcp/server.

Tool Approval & State Persistence

Why Approval Gates?

Some tools — sending emails, making payments, deleting records — should not auto-execute without human review. The SDK provides two mechanisms to control this:

  • requireApproval — pause execution when the model calls sensitive tools, giving users a chance to approve or reject each call
  • StateAccessor — persist conversation state between callModel invocations so approval decisions, message history, and tool results survive across runs

Together, these enable human-in-the-loop workflows where a user reviews tool calls before they execute, even across separate request/response cycles (e.g., in a web application).

Tool-Level Approval

Add requireApproval directly on a tool definition. It accepts a boolean or a function:

Always Require Approval

typescript
import { tool } from '@modelgates/agent';import { z } from 'zod'; const sendEmailTool = tool({  name: 'send_email',  description: 'Send an email to a recipient',  inputSchema: z.object({    to: z.string().email(),    subject: z.string(),    body: z.string(),  }),  outputSchema: z.object({ sent: z.boolean() }),  requireApproval: true,  execute: async (params) => {    await sendEmail(params);    return { sent: true };  },});

Conditional Approval

Pass a function to require approval only in certain cases:

typescript
const deleteRecordTool = tool({  name: 'delete_record',  description: 'Delete a record from the database',  inputSchema: z.object({    id: z.string(),    environment: z.enum(['staging', 'production']),  }),  outputSchema: z.object({ deleted: z.boolean() }),  requireApproval: (params, context) => {    // Only require approval for production deletions    return params.environment === 'production';  },  execute: async (params) => {    await deleteRecord(params.id);    return { deleted: true };  },});

The function receives the parsed tool arguments and a TurnContext, and can return a boolean or Promise<boolean>.

Call-Level Approval

Override tool-level settings with a requireApproval callback on callModel itself:

typescript
const result = modelgates.callModel({  model: 'openai/gpt-4o',  input: 'Send an email and search for documents',  tools: [sendEmailTool, searchTool],  state: myStateAccessor,  requireApproval: (toolCall, context) => {    // Require approval for any tool that modifies data    return toolCall.name === 'send_email' || toolCall.name === 'delete_record';  },});

The call-level callback takes priority over tool-level requireApproval settings when both are present.

How the Approval Flow Works

When tools with approval gates are called by the model, the SDK follows this flow:

  1. Model generates tool calls — the model decides which tools to invoke
  2. SDK partitions tool calls — each call is checked against requireApproval and split into two groups: those requiring approval and those that can auto-execute
  3. Auto-execute tools run immediately — tools that don't need approval execute in parallel as normal
  4. State saves with pending approvals — the conversation state updates to status: 'awaiting_approval' with the pending tool calls stored
  5. Control returns to the caller — check result.requiresApproval() and inspect pending calls with result.getPendingToolCalls()
  6. Resume with decisions — call callModel again with the same state, passing approveToolCalls and/or rejectToolCalls arrays of tool call IDs
  7. Approved tools execute — the SDK runs approved tools and sends results to the model. Rejected tools send an error message to the model explaining the rejection
  8. Conversation continues — the model processes tool results and generates the next response

StateAccessor Interface

The StateAccessor interface enables any storage backend:

typescript
import type { StateAccessor, ConversationState } from '@modelgates/agent'; interface StateAccessor<TTools> {  /** Load the current conversation state, or null if none exists */  load: () => Promise<ConversationState<TTools> | null>;  /** Save the conversation state */  save: (state: ConversationState<TTools>) => Promise<void>;}

In-Memory Implementation

typescript
const conversations = new Map<string, ConversationState>(); function createStateAccessor(conversationId: string): StateAccessor {  return {    load: async () => conversations.get(conversationId) ?? null,    save: async (state) => {      conversations.set(conversationId, state);    },  };}

For production use, implement StateAccessor with a persistent backend like Redis, a database, or file storage to survive process restarts.

ConversationState

The state object tracks everything needed to resume a conversation:

FieldTypeDescription
idstringUnique conversation identifier
messagesOpenResponsesInputUnionFull message history
previousResponseIdstring?Previous response ID for server-side chaining
pendingToolCallsParsedToolCall[]?Tool calls awaiting human input, such as approval/rejection or HITL output
unsentToolResultsUnsentToolResult[]?Executed results not yet sent to model
partialResponsePartialResponse?Data captured during interruption
interruptedBystring?Signal from a new request that interrupted this conversation
statusConversationStatusCurrent state of the conversation
createdAtnumberCreation timestamp (Unix ms)
updatedAtnumberLast update timestamp (Unix ms)

Status Values

StatusMeaning
'in_progress'Conversation is actively processing
'awaiting_approval'Paused, waiting for tool call approval/rejection
'awaiting_hitl'Paused by a HITL tool whose onToolCalled hook returned null; resume by supplying a function_call_output for each paused call
'complete'Conversation finished normally
'interrupted'Conversation was interrupted and can be resumed

Complete Example

Here is an end-to-end example showing approval gates with state persistence:

typescript
import { ModelGates, tool } from '@modelgates/agent';import type { ConversationState, StateAccessor } from '@modelgates/agent';import { z } from 'zod'; // 1. Define a tool with approval requiredconst sendEmailTool = tool({  name: 'send_email',  description: 'Send an email',  inputSchema: z.object({    to: z.string().email(),    subject: z.string(),    body: z.string(),  }),  outputSchema: z.object({ sent: z.boolean(), messageId: z.string() }),  requireApproval: true,  execute: async (params) => {    const result = await sendEmail(params);    return { sent: true, messageId: result.id };  },}); // 2. Create a state accessor (in-memory for this example)const store = new Map<string, ConversationState>();const conversationId = 'conv-123'; const state: StateAccessor = {  load: async () => store.get(conversationId) ?? null,  save: async (s) => { store.set(conversationId, s); },}; const modelgates = new ModelGates({ apiKey: process.env.MODELGATES_API_KEY }); // 3. First callModel — model will try to call the toolconst result = modelgates.callModel({  model: 'openai/gpt-4o',  input: 'Send a welcome email to alice@example.com',  tools: [sendEmailTool] as const,  state,}); // 4. Check if approval is neededif (await result.requiresApproval()) {  const pending = await result.getPendingToolCalls();   for (const call of pending) {    console.log(`Tool: ${call.name}`);    console.log(`To: ${call.arguments.to}`);    console.log(`Subject: ${call.arguments.subject}`);    console.log(`ID: ${call.id}`);  }   // 5. Present to user for decision, then resume  const approved = await askUserForApproval(pending);   const approvedIds = approved.filter(a => a.decision === 'approve').map(a => a.id);  const rejectedIds = approved.filter(a => a.decision === 'reject').map(a => a.id);   // 6. Second callModel — resume with approval decisions  const resumed = modelgates.callModel({    model: 'openai/gpt-4o',    input: [], // No new user input needed for resumption    tools: [sendEmailTool] as const,    state,    approveToolCalls: approvedIds,    rejectToolCalls: rejectedIds,  });   // 7. Get the final response  const text = await resumed.getText();  console.log(text);  // "I've sent the welcome email to alice@example.com."} else {  // No approval needed — tool ran automatically  const text = await result.getText();  console.log(text);}

Resumption Patterns

Resuming from Approval

When the state has status: 'awaiting_approval', pass approveToolCalls and/or rejectToolCalls to resume:

typescript
// Load existing stateconst loaded = await state.load(); if (loaded?.status === 'awaiting_approval') {  const pending = loaded.pendingToolCalls ?? [];   // Approve all pending calls  const result = modelgates.callModel({    model: 'openai/gpt-4o',    input: [],    tools: [sendEmailTool] as const,    state,    approveToolCalls: pending.map(c => c.id),  });   const text = await result.getText();}

Resuming from Interruption

If a conversation was interrupted (status: 'interrupted'), calling callModel with the same state resumes automatically. The SDK clears the interruption flag and continues where it left off:

typescript
const loaded = await state.load(); if (loaded?.status === 'interrupted') {  // Resume — the SDK picks up from the interruption point  const result = modelgates.callModel({    model: 'openai/gpt-4o',    input: 'Continue where you left off',    tools: myTools,    state,  });   const text = await result.getText();}

Multi-Run Conversations

Messages accumulate automatically across callModel runs that share the same StateAccessor. Each run appends its input and response to the state's message history:

typescript
const state: StateAccessor = createStateAccessor('conv-456'); // Turn 1const r1 = modelgates.callModel({  model: 'openai/gpt-4o',  input: 'What is the weather in Tokyo?',  tools: [weatherTool] as const,  state,});console.log(await r1.getText());// "The weather in Tokyo is 22°C and sunny." // Turn 2 — state has full history from turn 1const r2 = modelgates.callModel({  model: 'openai/gpt-4o',  input: 'And in Paris?',  tools: [weatherTool] as const,  state,});console.log(await r2.getText());// "The weather in Paris is 15°C and cloudy." // Turn 3 — state has history from both prior turnsconst r3 = modelgates.callModel({  model: 'openai/gpt-4o',  input: 'Which city is warmer?',  tools: [weatherTool] as const,  state,});console.log(await r3.getText());// "Tokyo is warmer at 22°C compared to Paris at 15°C."

Next Steps