HITL suspend
Complete human-in-the-loop inside a server tool handler: the agent calls a server tool, the handler yield*s Suspend.execute, Mastra pauses the run, and the client renders with clientToolkit.renderToolUI / clientToolkit.renderChunk.
This page covers pattern (B) — server-tool suspend. For Assistant UI human tools (annotated tools), see Client tools.
Flow
Server tool handler Mastra Client
─────────────────── ────── ──────
yield* Suspend.execute(schema, state) → agent.suspend → clientToolkit.renderChunk
yield* ToolUI.present(stream card) → writer.custom(data-*) → clientToolkit.renderStreamToolUIToolUI.Schema annotations mark client-fulfilled tools (schema.tag === tool.id). Server-tool suspend uses Suspend.execute with Tool UI schemas on the server; the client renders suspend payloads with plain views.
1. Shared definitions
// definitions.ts
import { Schema } from "effect"
import { MastraTool, Toolkit, ToolUI } from "mastra-effect"
export const PickAsset = ToolUI.make("pick_asset", {
stateSchema: Schema.Struct({
prompt: Schema.String,
candidates: Schema.Array(Schema.String),
}),
answerSchema: Schema.String,
})
export const Status = ToolUI.make("status", {
stateSchema: Schema.Struct({ text: Schema.String }),
})
export const SearchDocs = MastraTool.make("search_docs", {
description: "Search docs and optionally ask the user to pick an asset",
inputSchema: Schema.Struct({ query: Schema.String }),
outputSchema: Schema.String,
})
export const toolkit = Toolkit.make(SearchDocs)Answer equality (equals / equalsBy)
ToolUI.make accepts an optional equals function for comparing HITL resume answers. It defaults to Object.is (referential equality).
Default is enough when answers are primitives (string, number, boolean) or you only care whether two values are the same reference. The PickAsset schema above uses answerSchema: Schema.String, so omitting equals is correct.
Pass custom equals when the answer is an object and you want value semantics — for example matching by id even when resume reconstructs a new object instance:
const PickRecord = ToolUI.make("pick_record", {
stateSchema: Schema.Struct({ candidates: Schema.Array(Schema.String) }),
answerSchema: Schema.Struct({ id: Schema.String, label: Schema.String }),
equals: (left, right) => left.id === right.id,
})ToolUI.equalsBy is optional sugar for the common “compare by projected key” case — equivalent to writing the function above by hand:
equals: ToolUI.equalsBy((answer) => answer.id)
// same as: (left, right) => left.id === right.idDo not pass equals: ToolUI.equalsBy((answer) => answer) — that compares object identity and is identical to omitting equals.
Each Def from schema.make(state) carries the schema's equals. Use ToolUI.answersEqual(toolUI, left, right) (or toolUI.equals(left, right)) when comparing answers in client or server code.
2. Server agent + handler
ToolUI.Runtime is auto-provided in tool handlers.
import { Context, Effect, Layer } from "effect"
import { Agent, Suspend, ToolUI } from "mastra-effect"
import { PickAsset, Status, toolkit } from "./definitions.js"
class DocsAgent extends Context.Service<DocsAgent>()("app/DocsAgent", {
make: Effect.gen(function* () {
return yield* Agent.of("docs-agent", {
name: "Docs Agent",
instructions: "Search docs and ask the user to pick when needed.",
model: "openai/gpt-4o",
toolkit,
handlers: {
search_docs: Effect.fn(function* ({ query }) {
const statusId = yield* ToolUI.present(Status, { text: "Waiting for your pick…" })
const answer = yield* Suspend.execute(PickAsset, {
prompt: query,
candidates: ["asset-a", "asset-b", "asset-c"],
})
yield* ToolUI.update(Status, statusId, { text: `Picked ${answer}` })
return `User picked ${answer} for query: ${query}`
}),
},
})
}),
}) {
static readonly layer = Layer.effect(DocsAgent, DocsAgent.make)
}3. Client
Add suspend and stream card views as handler keys:
// client.tsx
import { ClientToolkit } from "mastra-effect/assistant-ui"
import { toolkit } from "./definitions"
export const clientToolkit = ClientToolkit.of(toolkit, {
pick_asset: ({ toolUI, resolve, busy }) => (
<ul>
{(toolUI.state as { candidates: string[] }).candidates.map((id) => (
<li key={id}>
<button disabled={busy} onClick={() => resolve!(id)}>
{id}
</button>
</li>
))}
</ul>
),
status: ({ toolUI }) => <p>{(toolUI.state as { text: string }).text}</p>,
})Handler keys are plain views keyed by _tag. toolUI.state is loosely typed unless you also annotate the same schema on a client tool for schema lookup. Resume encoding keys off the suspend payload _tag via clientToolkit.encodeResume.
4. Stream consumer
import { clientToolkit } from "./client"
export function handleStreamChunk(
chunk: { type: string; payload?: unknown; data?: unknown },
resume: (toolCallId: string, answer: unknown) => void,
) {
return clientToolkit.renderChunk(chunk, { resume })
}Wire reference
| Operation | Mastra primitive | Wire |
|---|---|---|
Suspend.execute | context.agent.suspend + resumeData on re-run | { _tag, state } via tool-call-suspend |
ToolUI.present | context.writer.custom | data-mastra-effect-block (+ optional transient) |
ToolUI.update | context.writer.custom | data-mastra-effect-block (+ optional transient) |
| Client resume | resume(toolCallId, encodedAnswer) | keyed by _tag via clientToolkit.encodeResume |