Human-in-the-loop for Mastra
Human-in-the-loop for Mastra agents and workflows.
Your Mastra agent stops before a risky tool runs, and your user approves it on their phone.
What Mastra gives you
suspend() and run.resume()
A workflow step can suspend, and your code calls run.resume() later. An agent tool can set requireApproval, which suspends the run before the tool executes. Waits that outlive a process need persistent Mastra storage and the saved run id. Check resumeData before it lets anything run.
const approval = createStep({
id: "approval",
inputSchema: z.object({}),
outputSchema: z.object({ approved: z.boolean() }),
resumeSchema: z.object({ approved: z.boolean() }),
execute: async ({ suspend, resumeData }) => {
if (!resumeData) return await suspend({})
return { approved: resumeData.approved }
},
})Where it stops
- A suspended step is silent. Storage knows the run is waiting; no person does.
- Something has to call run.resume(). You build the route that gets the answer and finds the right runId.
- The snapshot holds workflow state. It does not record who approved or what they saw.
The fix
Pushary gets your user's answer.
Mastra can suspend a run before a tool executes, but it tells nobody. Pushary sends each paused call to your user's phone and approves or declines it in Mastra.
issueRefund sets Mastra's own requireApproval, so the run suspends before it executes. resolvePusharyApprovals asks your user, then approves or declines the call in Mastra. Deny, or no answer in time, and the refund never runs. If one of your Pushary rules covers the refund, the rule answers first.
npm install @pushary/mastra @mastra/core zodimport { createTool } from "@mastra/core/tools"
import { resolvePusharyApprovals } from "@pushary/mastra"
import { z } from "zod"
export const issueRefund = createTool({
id: "issue-refund",
description: "Refund an order",
inputSchema: z.object({ orderId: z.string(), amount: z.number().positive() }),
requireApproval: true,
execute: async (input) => refundOrder(input),
})
const output = await agent.generate("Refund order 1234", {
memory: { thread: conversation.id, resource: user.id },
})
if (output.finishReason === "suspended") {
// Your user taps Approve or Deny. No answer means declined.
const outcome = await resolvePusharyApprovals({ externalId: user.id }, { agent, resourceId: user.id })
await handlePendingRuns(outcome.pending)
}In a workflow, use the approval step
pusharyApprovalStep creates the request, suspends the workflow and returns at once. Pushary sends the answer to your callback URL as a signed webhook. Your route checks it and calls run.resume(). A later step checks approved before the risky action runs.
import { createWorkflow } from "@mastra/core/workflows"
import { pusharyApprovalStep, pusharyApprovalStepInputSchema, pusharyAnswerSchema } from "@pushary/mastra"
const config = { apiKey: process.env.PUSHARY_API_KEY! }
const review = pusharyApprovalStep(config, { callbackUrl, externalId: user.id })
const workflow = createWorkflow({
id: "customer-review",
inputSchema: pusharyApprovalStepInputSchema,
outputSchema: pusharyAnswerSchema,
}).then(review).commit()How it works
From install to the first answer
Connect your user once. Put the approval on the risky step. Pushary sends the question, waits for the answer and saves it.
- 01
Install the adapter
Run npm install @pushary/mastra @mastra/core zod and put your API key in PUSHARY_API_KEY. Give Mastra persistent storage for answers that come later.
- 02
Connect your user once
connect() returns a link. Show it inside your product. Your user opens it on their phone with the free Pushary app. No account, no payment.
- 03
Put the approval on the risky tool
Set requireApproval: true on the tool. In a workflow, add pusharyApprovalStep. For a plain question the model may choose to ask, add createPusharyAskTool.
- 04
Act on the answer
resolvePusharyApprovals approves or declines the paused call and resumes the run. In a workflow, your callback route calls run.resume(). A no, expiry or no answer never runs the action.
A question can be a yes or no, a choice from a list, or a typed answer. Every answer is saved.
Before you ship
Things that bite on Mastra
- An ask tool is not a gate
- createPusharyAskTool is a tool the model chooses to call. It cannot force approval before another tool, and a prompt that says “ask first” does not either. Put requireApproval on the risky tool itself.
- You need a correlationId to runId map
- The answer reaches your webhook knowing the request, not your workflow. Save the mapping when you suspend, or you will hold an answer with no idea which suspended run it belongs to.
- resumeData is undefined on the first pass
- The step's execute function runs once to suspend and again to resume. Forget the undefined branch on the first pass and it throws before it ever suspends.
The Partner plan
Your agent asks your users, not just you
Ship your agent to your users. Each of them approves its risky actions from their phone, under your brand.
Your users never make a Pushary account or pay us.
Your users answer on their own phones
Connect each user once with a link. They install the free Pushary app and answer from the lock screen, with no account of their own.
No answer means no
Run the action only after an explicit Approve. A request that is still waiting, expired or cancelled never counts as a yes. Pushary keeps every request, so your code can pick it up again after a restart.
Reaches them on their phone
The app comes first, because only the app puts Approve and Deny on the lock screen. Browser notifications come next. You build no delivery system.
Every answer saved
Every answer is saved with who answered and when. You can search and export it, and with a callback URL, each answer is also sent to your server as a signed webhook.
FAQ
Questions, answered
Is there a Mastra package for Pushary?
Should I use the tool or the workflow step?
What happens when nobody answers?
Can each of my end-users approve on their own phone?
Does the wait survive a restart or a deploy?
When should I use suspend() instead of a blocking ask tool?
Let your Mastra agent ask before it acts.
Connect a user once, ask with one call, and run the action only on a yes. Every answer is saved.
Start freePartner plan, $99 a month. Your users never make a Pushary account or pay us.