@pushary/openai-agents
v0.2.0
Published
Human-in-the-loop for the OpenAI Agents SDK: a function tool that asks a real human on their phone and blocks on a fail-closed answer.
Maintainers
Readme
@pushary/openai-agents
Full walkthrough: Human-in-the-loop for the OpenAI Agents SDK. Reaching your own end-users on their phones is the Pushary Partner plan.
Human-in-the-loop for the OpenAI Agents SDK (TypeScript). A function tool that asks a real human to approve, delivered to their phone, and blocks on a fail-closed answer.
Requires the Pushary Partner plan.
Install
npm i @pushary/openai-agents @openai/agents zodSet PUSHARY_API_KEY (get it in your dashboard).
Connect a phone once
import { connect } from '@pushary/openai-agents'
const { universalLink } = await connect({ apiKey: process.env.PUSHARY_API_KEY! }, user.id)The tool
import { Agent, run } from '@openai/agents'
import { pusharyTool } from '@pushary/openai-agents'
const agent = new Agent({
name: 'Support',
instructions: 'Call ask_human before issuing any refund.',
tools: [pusharyTool({ apiKey: process.env.PUSHARY_API_KEY! }, { externalId: user.id })],
})
const result = await run(agent, 'Refund order 5?')When the model calls the tool, Pushary delivers the question to that user's phone and
the call blocks until they answer. The tool returns a fail-closed instruction ("The
human declined. Do not proceed."). externalId is bound in code, never taken from
model input, so a prompt-injected model cannot ask the wrong person.
Gating a tool the model cannot skip
pusharyTool is a tool the model chooses to call. That is right for "go ask someone
about this", and wrong for "this must not happen without a yes", because a model that
does not want to be interrupted can decline to call it.
The SDK's own gate splits in two: needsApproval decides whether a human is needed,
and the run then stops with result.interruptions. Nothing asks anyone. Resolving
those interruptions is the caller's job, and resolvePusharyInterruptions is that job
done:
import { Agent, run, tool } from '@openai/agents'
import { z } from 'zod'
import { pusharyNeedsApproval, resolvePusharyInterruptions } from '@pushary/openai-agents'
const issueRefund = tool({
name: 'issue_refund',
description: 'Refund an order',
parameters: z.object({ amount: z.number() }),
needsApproval: pusharyNeedsApproval(),
execute: async ({ amount }) => chargeBack(amount),
})
let result = await run(agent, 'Refund order 1234')
while (result.interruptions?.length) {
const outcome = await resolvePusharyInterruptions(
{ externalId: user.id },
{ interruptions: result.interruptions, state: result.state },
)
if (!outcome.allApproved) break
result = await run(agent, result.state)
}Each interruption becomes one decision on the phone, resolved in order so the person sees one question at a time. A denial is handed back to the model as the rejection message, so it knows why it was stopped rather than retrying blindly.
Fail-closed: a denial, an expiry, or nobody answering all reject. For a multi-tenant product, resolve the end-user per interruption:
resolvePusharyInterruptions(
{ externalId: (item) => ownerOf(item.rawItem.callId) },
{ interruptions: result.interruptions, state: result.state },
)Pass runId when you replay a run under ids you mint yourself, so a replay resolves
to the same decisions instead of paging twice.
Durable approvals
For a wait longer than a request can hold, don't block. Two options:
- Native park/resume. Mark real tools
needsApproval: true, serialize the run state (result.state.toString()), and open a Pushary decision per interruption with acallbackUrl. On the signed callback,resolvePusharyCallbackgives you the answer; approve or reject on the restored state (RunState.fromString(agent, saved)) and re-run(agent, state). Pin your@openai/agentsversion, as the RunState API is pre-1.0. - Webhook-only. Skip the SDK's park and drive your own flow off
createDurableDecision+resolvePusharyCallback.
import { resolvePusharyCallback } from '@pushary/openai-agents'
// POST /api/pushary/callback
export async function POST(req: Request) {
const raw = await req.text()
const cb = resolvePusharyCallback(raw, req.headers.get('x-pushary-signature'), process.env.PUSHARY_WEBHOOK_SECRET!)
if (!cb) return new Response('bad signature', { status: 401 })
// look up the parked run by cb.correlationId, then approve/reject and resume
return new Response('ok')
}Python
A Python port of the blocking tool ships in python/ and on PyPI:
pip install pushary-openai-agentsSee python/README.md for the Python API.
API
connect(config, externalId)— enroll an end-user's phone.pusharyTool(config, { externalId })— an OpenAI Agents function tool that blocks on a human.pusharyNeedsApproval()— aneedsApprovalpredicate that routes every call to a human.resolvePusharyInterruptions(config, { interruptions, state })— ask about each interruption, then approve or reject it on the run state.createDurableDecision(config, input)— open a decision with a callbackUrl for the durable path.resolvePusharyCallback(raw, signature, secret)— verify + parse a callback into{ correlationId, answer, approved, ... }.createPusharyGate(config)— the raw fail-closed gate, for anything the helpers above do not cover.askExternalUser,describeAnswer,isAffirmative,deterministicKey,SIGNATURE_HEADER.
Example
A runnable example is in examples/.
License
MIT
