OpenAI · Guide

OpenAI Assistants requires_action: what it covers, what it doesn't

The OpenAI Assistants API exposes a requires_action run status that fires when the model wants to invoke a function-calling tool. It pauses the run until you submit a tool output. This is a real interrupt point. It is not, by itself, a policy engine — and it's not a review queue, an audit chain, or an observe-mode rollout. Here is what each piece does, and how to wire mcpguard-sdk in.

What requires_action gives you

When the Assistants run reaches a tool call, the run pauses in status requires_action. Your code is expected to:

  1. Read run.required_action.submit_tool_outputs.tool_calls.
  2. Execute (or refuse) each tool call locally.
  3. POST tool outputs back with runs.submitToolOutputs(run.id, {...}).
  4. The run resumes; the model uses the outputs to continue.

That is a perfectly good interrupt primitive. You control the dispatch, you control the timing, you control what gets sent back. The pause is real, durable, and survives client reconnects.

What requires_action does not give you

It is, however, a pause — not a policy framework. What you still own:

  • The policy. Is this refund over $5,000? Is this user on a free plan? Is the recipient on the allowlist? You write the if-statements yourself, in code, with no shared DSL.
  • The review queue. If the answer is "a human should look at this", you need somewhere to put the pending call, an SLA on resolution, and a way to resume.
  • The reviewer UI. The raw tool_call JSON is not a review surface.
  • The audit chain. Hash-chained, per-tenant, exportable, with the matched rule and reviewer identity attached.
  • Observe mode. A way to deploy the policy in "evaluate but never block" mode so you can tune rules against real traffic before flipping to enforce.
  • Cross-framework. The same gate has to work for your Chat Completions tool_calls, your background Anthropic worker, and your MCP tools.
Where requires_action belongs
Use requires_action for the durable pause; let MCP Guard handle policy, review, audit, and mode. The two compose cleanly — they're solving different problems.

The withMCPGuardOpenAI pattern

mcpguard-sdk/openai exports two helpers (see packages/sdk/src/openai.ts):

  • withMCPGuardOpenAI(guard, handlers) — takes your flat {name: handler} map and returns a new map where every handler is gated by guard.enforce. Your existing dispatch loop stays unchanged; you just feed it the wrapped map.
  • executeToolCalls(guard, toolCalls, handlers) — the full-fat helper. Pass it the tool_calls array off a Chat Completion or Assistants run; it evaluates each, dispatches the allows, and returns an array of {tool_call_id, output} in the shape OpenAI's next request expects.

The library does not depend on openai — the input shapes are structural (we read call.function.name and call.function.arguments). That means it works with the official SDK, vendored copies, or your own thin wrapper.

Working example

A Chat Completions loop with two tools — billing.refund and email.send — both gated by MCP Guard.

agent.tsts
import OpenAI from 'openai'
import { MCPGuard } from 'mcpguard-sdk'
import { executeToolCalls } from 'mcpguard-sdk/openai'

const client = new OpenAI()
const guard = new MCPGuard({ apiKey: process.env.MCPGUARD_API_KEY! })

// Your actual tool implementations.
const handlers = {
  'billing.refund': async (args: any) => stripe.refunds.create(args),
  'email.send':    async (args: any) => postmark.email.send(args),
}

const resp = await client.chat.completions.create({
  model: 'gpt-4o-mini',
  messages,
  tools,
})

const toolCalls = resp.choices[0].message.tool_calls ?? []
if (toolCalls.length > 0) {
  // One call per row in the audit log; each runs through guard.evaluate
  // → handler on allow / structured error on deny / review.
  const outputs = await executeToolCalls(
    guard,
    toolCalls as any,
    handlers,
    { userContext: { user_id: req.userId, plan: req.plan } },
  )
  // Feed the outputs back to the model on the next turn.
  messages.push(resp.choices[0].message, ...outputs.map(toToolMessage))
}
Why this works for Assistants too
The Assistants requires_action payload has the same tool_calls shape under run.required_action.submit_tool_outputs.tool_calls. Pass it to the same executeToolCalls; submit the returned {tool_call_id, output} rows back with runs.submitToolOutputs.

Batch behaviour: one row per call, no all-or-nothing

executeToolCalls evaluates each call independently and produces one row per call in the audit log. The behaviour is deliberately partial-progress:

  • If a call is denied in enforce mode, the result is a structured policy_denied error in the output channel — the model sees the rejection through its normal feedback path and can re-plan.
  • If a call needs review in enforce mode, the result is policy_review_required with the review_id. Your agent can either short-circuit and wait, or pass the error to the model and resume on the next turn.
  • If the handler itself throws, the error is JSON-stringified into the tool output — the model gets the stack trace, not a 500.
  • Transport errors against the guard come back as guard_unavailable so the model can fail safe.

The reason for partial-progress: if the model fired four tool calls and three of them are fine, we want the three to run and the one to be visibly rejected. All-or-nothing forces the model to redo work it already got right.

FAQ

Do I need both withMCPGuardOpenAI and requires_action?
They're solving different problems. requires_action is OpenAI's server-side pause. withMCPGuardOpenAI is the client-side policy gate. The clean pattern is: requires_action gives you the dispatch hook; withMCPGuardOpenAI is what you put in that hook.
What about non-Assistants chat?
Identical. Chat Completions returns the same tool_calls shape; the helpers work without requires_action — you just dispatch in your own loop.
What if my handler is sync?
Both signatures accept sync or async — handlers are awaited either way.
Ready to drop this in? Free up to 10k evaluations / month — no card.