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.

native
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.

bash
npm install @pushary/mastra @mastra/core zod
approval on the risky tool
import { 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.

workflow step
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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

$99/ month, every framework included
Start free

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?
Yes. @pushary/mastra is on npm. It gives you resolvePusharyApprovals for Mastra's requireApproval, deferred reviews for answers that come later, pusharyApprovalStep for workflows, and createPusharyAskTool for questions. It needs Mastra 1.59 or later and Node.js 22.13 or later.
Should I use the tool or the workflow step?
Use requireApproval on the tool when an agent decides to act, and pusharyApprovalStep when a workflow does. Use createPusharyAskTool only for a short question the model may choose to ask. For answers that come later, use the deferred review helpers with persistent storage, and route each answer to its saved run.
What happens when nobody answers?
The action does not run. No answer is never a yes, so the call is declined when the wait ends. Keep “nobody answered” apart from “your user said no”. Pushary keeps the request, but a saved answer cannot restart a Mastra run on its own.
Can each of my end-users approve on their own phone?
Yes. Your code picks who answers, using your own user id. That user installs the free Pushary app and opens your connect link once, with no API key or paid plan. The person who asks for an action is not always the one who should approve it.
Does the wait survive a restart or a deploy?
A suspended run does; a blocking ask does not. With persistent Mastra storage, a saved run id and your own resume code, the run picks back up after a restart. Test restarts and duplicate answers on your real storage. A saved answer does not prove the refund itself went through.
When should I use suspend() instead of a blocking ask tool?
Use suspend() when your user may answer after the request or worker has ended. Set up Mastra storage and keep the run id before you wait. Your code checks the answer and calls resume; a callback URL alone does not do that.

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 free

Partner plan, $99 a month. Your users never make a Pushary account or pay us.