Human-in-the-loop (HITL)

Trust: ★★★☆☆ (0.90) · 0 validations · developer_reference

Published: 2026-05-10 · Source: crawler_authoritative

Tình huống

Mastra SDK guide for pausing workflows to collect human input before continuing, targeting developers building approval workflows, manual gates, or any step requiring human oversight.

Insight

Human-in-the-loop (HITL) in Mastra workflows allows execution to pause for human input before continuing. When a workflow is suspended using suspend(), it returns a payload with context or guidance for the user on how to proceed. The workflow can then either resume or bail based on the input received. This pattern differs from standard suspend by accepting a payload that provides user-facing feedback.

A step defines three Zod schemas for HITL: inputSchema for initial input, resumeSchema for data passed when resuming (e.g., { approved: z.boolean() }), and suspendSchema for the payload returned when suspended (e.g., { reason: z.string() }). The execute function receives inputData, resumeData, suspend(), and optionally bail() as context.

The suspend() function returns a payload that can be accessed via the run result: result.suspended[0] identifies the suspended step, and result.steps[suspendStep[0]].suspendPayload retrieves the payload for UI display. Each step must be resumed in sequence with separate resume() calls, passing the step ID and resumeData. Multi-turn workflows maintain this pattern across multiple steps, with each step defining its own resumeSchema and suspendSchema.

Hành động

To implement HITL in a Mastra workflow: 1) Import createWorkflow and createStep from @mastra/core/workflows along with z from zod. 2) Define step schemas: inputSchema for initial data, resumeSchema for human input (typically { approved: z.boolean() }), and suspendSchema for the suspension payload (typically { reason: z.string() }). 3) In the execute function, check resumeData.approved — if false or undefined, call suspend({ reason: 'Human approval required.' }) with context. 4) Access the suspend payload from run results via result.steps[stepName].suspendPayload to display to users. 5) Resume the workflow with run.resume({ step: 'step-id', resumeData: { approved: true } }). 6) For rejections, use bail({ reason: 'User rejected the request.' }) which completes the workflow with ‘success’ status without error.

Kết quả

Workflow execution pauses at the suspended step and returns status: 'suspended' with the suspend payload accessible. When resumed with resumeData: { approved: true }, execution continues from that step. When bail() is called, execution stops cleanly with ‘success’ status, skipping all subsequent logic. Multi-step workflows require sequential resume calls for each suspended step.

Điều kiện áp dụng

Requires @mastra/core/workflows package. Zod schemas must be defined for inputSchema, resumeSchema, and suspendSchema on each step requiring human input.


Nội dung gốc (Original)

Human-in-the-loop (HITL)

Some workflows need to pause for human input before continuing. When a workflow is suspended, it can return a message explaining why it paused and what’s needed to proceed. The workflow can then either resume or bail based on the input received. This approach works well for manual approvals, rejections, gated decisions, or any step that requires human oversight.

Pausing workflows for human input

Human-in-the-loop input works much like pausing a workflow using suspend(). The key difference is that when human input is required, you can return suspend() with a payload that provides context or guidance to the user on how to continue.

Pausing a workflow with suspend()

import { createWorkflow, createStep } from '@mastra/core/workflows'
import { z } from 'zod'
 
const step1 = createStep({
  id: 'step-1',
  inputSchema: z.object({
    userEmail: z.string(),
  }),
  outputSchema: z.object({
    output: z.string(),
  }),
  resumeSchema: z.object({
    approved: z.boolean(),
  }),
  suspendSchema: z.object({
    reason: z.string(),
  }),
  execute: async ({ inputData, resumeData, suspend }) => {
    const { userEmail } = inputData
    const { approved } = resumeData ?? {}
 
    if (!approved) {
      return await suspend({
        reason: 'Human approval required.',
      })
    }
 
    return {
      output: `Email sent to ${userEmail}`,
    }
  },
})
 
export const testWorkflow = createWorkflow({
  id: 'test-workflow',
  inputSchema: z.object({
    userEmail: z.string(),
  }),
  outputSchema: z.object({
    output: z.string(),
  }),
})
  .then(step1)
  .commit()

Providing user feedback

When a workflow is suspended, you can access the payload returned by suspend() by identifying the suspended step and reading its suspendPayload.

const workflow = mastra.getWorkflow('testWorkflow')
const run = await workflow.createRun()
 
const result = await run.start({
  inputData: {
    userEmail: '[email protected]',
  },
})
 
if (result.status === 'suspended') {
  const suspendStep = result.suspended[0]
  const suspendedPayload = result.steps[suspendStep[0]].suspendPayload
 
  console.log(suspendedPayload)
}

Example output

The data returned by the step can include a reason and help the user understand what’s needed to resume the workflow.

{
  reason: 'Confirm to send email.'
}

Resuming workflows with human input

As with restarting a workflow, use resume() with resumeData to continue a workflow after receiving input from a human. The workflow resumes from the step where it was paused.

Restarting a workflow with resume()

const workflow = mastra.getWorkflow('testWorkflow')
const run = await workflow.createRun()
 
await run.start({
  inputData: {
    userEmail: '[email protected]',
  },
})
 
const handleResume = async () => {
  const result = await run.resume({
    step: 'step-1',
    resumeData: { approved: true },
  })
}

Handling human rejection with bail()

Use bail() to stop workflow execution at a step without triggering an error. This can be useful when a human explicitly rejects an action. The workflow completes with a success status, and any logic after the call to bail() is skipped.

const step1 = createStep({
  execute: async ({ inputData, resumeData, suspend, bail }) => {
    const { userEmail } = inputData
    const { approved } = resumeData ?? {}
 
    if (approved === false) {
      return bail({
        reason: 'User rejected the request.',
      })
    }
 
    if (!approved) {
      return await suspend({
        reason: 'Human approval required.',
      })
    }
 
    return {
      message: `Email sent to ${userEmail}`,
    }
  },
})

Multi-turn human input

For workflows that require input at multiple stages, the suspend pattern remains the same. Each step defines a resumeSchema, and suspendSchema typically with a reason that can be used to provide user feedback.

const step1 = createStep({...});
 
const step2 = createStep({
  id: "step-2",
  inputSchema: z.object({
    message: z.string()
  }),
  outputSchema: z.object({
    output: z.string()
  }),
  resumeSchema: z.object({
    approved: z.boolean()
  }),
  suspendSchema: z.object({
    reason: z.string()
  }),
  execute: async ({ inputData, resumeData, suspend }) => {
    const { message } = inputData;
    const { approved } = resumeData ?? {};
 
    if (!approved) {
      return await suspend({
        reason: "Human approval required."
      });
    }
 
    return {
      output: `${message} - Deleted`
    };
  }
});
 
export const testWorkflow = createWorkflow({
  id: "test-workflow",
  inputSchema: z.object({
    userEmail: z.string()
  }),
  outputSchema: z.object({
    output: z.string()
  })
})
  .then(step1)
  .then(step2)
  .commit();

Each step must be resumed in sequence, with a separate call to resume() for each suspended step. This approach helps manage multi-step approvals with consistent UI feedback and clear input handling at each stage.

const handleResume = async () => {
  const result = await run.resume({
    step: 'step-1',
    resumeData: { approved: true },
  })
}
 
const handleDelete = async () => {
  const result = await run.resume({
    step: 'step-2',
    resumeData: { approved: true },
  })
}

Liên kết

Xem thêm: