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.
Status: planned Phase: 1
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'
const { needsApproval, guardTools, resolveInterruptions, permdock } = createPermDock(policy, {
subject: (context) => context.user, // RunContext -> principal
actor: (context) => ({ id: context.agentId, kind: 'openai-agent' }),
tools: {
delete_post: { permission: permissions.post.delete, data: (args) => loadPost(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) {
await db.save(runId, result.state.toString()) // RunState; tokens live in the store, keyed by callId
const pending = await resolveInterruptions(result.state, result.interruptions, { context })
// pending: ApprovalRequest[] written to the store; surface them to a person
}
// Later, in another process
const state = await RunState.fromString(agent, await db.load(runId))
await resolveInterruptions(state, state.interruptions ?? [], { context }) // applies approved / rejected records
result = await run(agent, state)needsApproval(permission)returns the SDK predicate(context, args) => Promise<boolean>. It runsdecideon the resolved resource:approval-requiredreturnstrue(pause),grantedreturnsfalse(run),deniedreturnstrueand records a rejection so the interruption is auto-rejected with the reason; the tool never executes on a denial.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 })has two jobs: for new interruptions it createsApprovalRequestrecords (one percallId) and returns them; for interruptions whose record is resolved it callsstate.approve(i)orstate.reject(i, { message })so the run can resume.permdock(context)returns a request-scopedPermDockfor checks insideexecute.subjectandactorread from theRunContextthe application passes torun; nothing is read from model output.
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 is stored with the request, keyed bycallId; 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.
- The application restores
RunState, callsresolveInterruptions, and resumes withrun(agent, state). Beforeexecute, the adapter re-runsdecide, recomputestokenand compares it with the stored one; a mismatch or an expired record rejects the call. - 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.
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 or expired records reject the 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 (commercial landscape).
Example app
apps/examples/openai-agent: an agent over post tools with a member and an admin, a file-backed RunState between two processes, approvalsHandler for the approver, and tests asserting that a removed tool is never offered, a denied tool is auto-rejected with alternatives, an approval from the actor is refused, and a resumed call whose arguments changed is rejected on token mismatch.
Related standards
- Approvals and ADR 0022.
- 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.
Open questions
- Whether
needsApprovalshould returntruefordenied(current, so the SDK's approval item is the vehicle for the rejection message) or whether the adapter should throw insideexecuteinstead; the former keeps the tool from ever being invoked. agent.asTool()nesting: interruptions surface on the outer run, sosubjectandactorare the outer run's; whether an inner agent should ever be a distinct actor.- Computer-tool interruptions covering a batch of actions: one approval for a sequence does not fit per-call tokens; the adapter may treat the batch as one resource or refuse to gate computer tools.
- The Python SDK: whether a
permdockPython client that speaks the hosted ADS is the right answer for non-TypeScript agents rather than a second adapter.
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.