OpenAI Agents SDK
permdock/openai turns PermDock decisions into OpenAI Agents SDK needsApproval predicates, guards tool lists per caller, resolves interruptions against a pluggable ApprovalStore, and binds the replay-safe token to the serialised RunState.
permdock/openai connects the Decision model to the OpenAI Agents SDK for JavaScript. The SDK pauses a run when a tool needs approval, returns interruptions, and resumes from the same RunState after state.approve() or state.reject(). What it does not do is decide whether a call needs approval from anything richer than a boolean, nor record who approved, nor guarantee that the approved call is the one that runs. The adapter supplies the predicate from the typed policy, the record through an ApprovalStore, and the binding through the token.
Purpose
The SDK's human-in-the-loop guide defines needsApproval as true or an async function returning a boolean, evaluated after the tool arguments parse; malformed arguments fail closed by requesting approval without calling the predicate. Pending approvals surface as result.interruptions, each resolvable with approve or reject (optionally { message } for the model), and the run resumes with runner.run(agent, state). RunState serialises with toString() and restores with RunState.fromString(agent, s), so approvals can wait for days. Hosted MCP tools have the parallel requireApproval and onApproval. All of this is state plumbing; the decision is the application's. permdock/openai is that decision.
API
import { Agent, run, tool, RunState } from "@openai/agents";
import { createPermDock } from "permdock/openai";
import { z } from "zod";
const PostArgs = z.object({ id: z.string() }); // tool input arrives as `unknown`
const { needsApproval, guardTools, resolveInterruptions, permdock } =
createPermDock(policy, {
subject: (context) => context.user, // RunContext -> principal
actor: (context) => ({ id: context.agentId, kind: "openai-agent" }),
delegation: (context) => ({ scopes: context.scopes }),
tools: {
delete_post: {
permission: permissions.post.delete,
data: (args) => loadPost(PostArgs.parse(args).id),
},
list_posts: { permission: permissions.post.list },
},
store, // ApprovalStore; memoryApprovalStore() when omitted
});
const deletePost = tool({
name: "delete_post",
parameters: z.object({ id: z.string() }),
needsApproval: needsApproval(permissions.post.delete), // decide() per call; denied → approval never granted
execute: async ({ id }, ctx) => {
(await permdock(ctx)).assert(permissions.post.delete, await loadPost(id));
return remove(id);
},
});
const agent = new Agent({
name: "Posts",
tools: guardTools([deletePost, listPosts], context),
});// After a run pauses
let result = await run(agent, input, { context });
if (result.interruptions.length) {
const pending = await resolveInterruptions(
result.state,
result.interruptions,
{ context },
);
// pending: ApprovalRequest[] written to the store; surface them to a person
await db.save(runId, result.state.toString()); // RunState; tokens live in the store, not in the state
}
// Later, in another process: the context comes from the resuming session, not from the stored state
const context = { user: await currentUser(request) };
const state = await RunState.fromStringWithContext(
agent,
await db.load(runId),
new RunContext(context),
);
const pending = await resolveInterruptions(state, state.getInterruptions(), {
context,
});
if (pending.length === 0) result = await run(agent, state); // every interruption approved or rejectedneedsApproval(permission)returns the SDK predicate(runContext, args) => Promise<boolean>; it reads the application context fromrunContext.context. It returnsfalseonly whencan(permission, row)holds for the resolved resource, andtrue(pause) forapproval-required,denied, a thrown or emptydataloader and anything else; the check does not touch the store. The tool never executes on a denial:resolveInterruptionsrejects it.guardTools(tools, context)filters the tool array to those whose permission has any grant for this subject, so the model cannot plan with tools it may not use (thecapabilityMiddlewareequivalent).resolveInterruptions(state, interruptions, { context })decides each interruption in order.granted(including an approved record, which it consumes) callsstate.approve(i);denied(including a rejected, consumed or expired record) callsstate.reject(i, { message })with the Decision's reason;approval-requiredwrites or finds the pendingApprovalRequestand returns it. An interruption whose name or JSON arguments cannot be read is rejected. The token is recomputed from permission, resource, principal, actor and arguments, so any process with the same store finds the record.permdock(context)returns a request-scopedPermDockfor checks insideexecute.subjectandactorread from theRunContextthe application passes torun; nothing is read from model output.delegationreads from the same context and has no default: anactorwith nodelegationis denied every tool with reasonno-delegation.
Request lifecycle
- Before the run,
guardToolsremoves tools the subject has no grant for. - The model calls a tool. The SDK parses the arguments; on parse failure it requests approval without calling the predicate (its own fail-closed rule). Otherwise it calls
needsApproval(context, args). - The adapter validates
argsagainst the resource schema whendatais declared, loads the resource, and callsdecide:
| Decision outcome | needsApproval returns | Then |
|---|---|---|
granted | false | Tool executes |
approval-required | true | Interruption created; ApprovalRequest with token written to the store on resolveInterruptions |
denied | true | resolveInterruptions immediately calls state.reject(i, { message }) with the Decision's reason and alternatives |
| unmapped tool, validation error, thrown resolver | true then reject | Fail closed |
- The application persists
result.state.toString()and shows the pending requests (its own UI,approvalsHandler, or the PermDock Cloud inbox). The token lives only in the store; it is not placed insideRunState, because serialised state travels with the request and may be logged. - A person resolves the request; the store records the approver. The actor (the agent) cannot approve its own call, and the principal cannot either unless the grant sets
approval: { distinct: false }. - The application restores
RunStatewith a fresh context, callsresolveInterruptions, and resumes withrun(agent, state)once nothing is pending. The adapter re-runsdecideand recomputestoken: an approved record resumes one call and is consumed, so restoring the same state again rejects the call; another principal's resume never matches the token. - Every step emits
on('decision'); ask, answer and resumed decision sharetoken.
Sticky decisions (alwaysApprove, alwaysReject) are never issued by the adapter: each call gets its own decision and its own record. A denial returns true from needsApproval rather than throwing in execute, so the SDK's approval item carries the rejection message and the tool is never invoked. Tools of an inner agent nested with agent.asTool() interrupt the outer run and are decided with the outer run's subject and actor.
What it validates
- Arguments against the resource's Standard Schema when
dataexists; the SDK's own parse-failure path already fails closed for malformed JSON, and the adapter's validation covers well-formed but wrong data. - The subject comes from
RunContext, never from arguments orinterruption.rawItem. - Approvals on resume: token recomputed and compared; unknown, pending, rejected, expired or already consumed records reject the call. An approval resumes one call.
- Tool coverage: in development,
guardToolswarns about tools missing from thetoolsmap, becauseneedsApprovalwill reject them at runtime. - Serialised state:
RunStateis treated as opaque; the adapter readscallIdand tool name from interruptions and nothing else.runContext.contextis persisted data per the SDK's own warning, sosubjectandactorshould be re-derived from authentication on resume, not trusted from the deserialised context;RunState.fromStringWithContext(agent, s, freshContext)is the recommended path.
How denials surface
state.reject(i, { message })withmessagebuilt from the Decision: failing roles, reason kind andalternatives, phrased for the model. The SDK sends it back as the tool result so the model can pick a permitted action.guardToolsremovals are silent to the model; they appear inon('decision')withsource: 'adapter'.- Resume failures use
messagevaluesapproval-expired,approval-mismatch,approval-rejectedandapproval-not-found. - Hosted MCP tools reached through the SDK's
requireApprovalandonApprovalcan callneedsApprovalinsideonApproval; the mapping is the same, with the MCP server name and tool name as the lookup key intools. - The SDK's tool guardrails (
toolInputGuardrails,toolOutputGuardrails) are a second hook: an input guardrail can calldecideand return a tripwire with the same Decision-derived message, which is useful when a team already routes all argument checks through guardrails.needsApprovalremains the primary hook because it is the only one that can pause forapproval-required; a guardrail can only allow or reject. OpenAI's Agent Builder is being retired on 30 November 2026 and does not affect this adapter, which targets the SDK (landscape).
Why
- An inner
asTool()agent inherits the outer subject and actor. The nested agent runs inside the outer run, on the outer run's context, for the same person. Giving it an actor of its own would need a second delegation to deny against, which the application has no verified source for; inheriting keeps the outer delegation as the ceiling, so nesting an agent can never widen what the run may do. - One approval per computer-tool action, never a batch. Each click, keystroke or navigation is a separate tool call with its own arguments, so each gets its own decision and its own token. A batch approval would bind a token to a sequence the reviewer cannot see in advance, and the screen state that justified the first action may not hold for the fifth.
- No Python client. The adapter is the TypeScript SDK integration. A Python agent reaches the same policy through the AuthZEN endpoint over HTTP, so there is one wire contract rather than a second client to keep in step.
Example app
apps/examples/openai-agent: HTTP harness on 127.0.0.1:3475 with GET /health. GET /list_posts calls needsApproval and returns false (run). GET /delete_post returns true (pause for approval). No OpenAI API key. tests/integration runs a real Runner with a scripted model against a Postgres ApprovalStore: the run interrupts, RunState.toString() is resumed in a child process after an owner approves, the approved call runs once, and replaying the same state or resuming as another user runs nothing.
Related standards
- Approvals and approvals.
- Delegation: the actor half of the subject for agent runs.
- OWASP Agentic Top 10: ASI02 tool misuse mitigations.
- MCP authorization: hosted MCP tools through the SDK.
- AI SDK adapter and Eve adapter: the same Decision in other runtimes.
Last updated on
Eve
permdock/eve turns PermDock decisions into Eve tool approval policies and approval response policies, takes principal and actor from the durable session's auth, and stores pending approvals in a pluggable ApprovalStore.
WebMCP
permdock/webmcp registers browser-exposed WebMCP tools only for actions the current snapshot allows, with hints from action metadata and automatic unregistration when permissions change.