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:
- Read
run.required_action.submit_tool_outputs.tool_calls. - Execute (or refuse) each tool call locally.
- POST tool outputs back with
runs.submitToolOutputs(run.id, {...}). - 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.
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 byguard.enforce. Your existing dispatch loop stays unchanged; you just feed it the wrapped map.executeToolCalls(guard, toolCalls, handlers)— the full-fat helper. Pass it thetool_callsarray 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.
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))
}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_deniederror 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_requiredwith thereview_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_unavailableso 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
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.tool_calls shape; the helpers work without requires_action — you just dispatch in your own loop.