Client tools & Tool UI
Human-in-the-loop flows split into three patterns. Pick one per tool — do not mix suspend models on the same tool.
When to use
| Pattern | Tool kind | Server | Client |
|---|---|---|---|
| (A) Assistant UI human tools | .annotate(ToolUI.Schema, …) | No handler — execute throws | clientToolkit.toAssistantUiTools() |
| (B) Server-tool suspend | Server tool handler | yield* Suspend.execute(schema, state) | clientToolkit.renderToolUI / renderChunk |
| Stream cards | Server tool handler only | yield* ToolUI.present / update | clientToolkit.renderStreamToolUI / renderChunk |
(A) Annotated tools — Assistant UI human tools
Annotate a tool with a Tool UI schema (schema.tag === tool.id). The agent calls the tool; Mastra suspends; Assistant UI fulfills it on the client. No server handler on this tool.
import { Schema } from "effect"
import { MastraTool, ToolUI } from "mastra-effect"
const PickAssetSchema = ToolUI.make("pick_asset", {
stateSchema: Schema.Struct({ prompt: Schema.String, candidates: Schema.Array(Schema.String) }),
answerSchema: Schema.String,
})
const PickAsset = MastraTool.make("pick_asset", {
description: "Let the user pick an asset",
inputSchema: Schema.Struct({ prompt: Schema.String }),
outputSchema: Schema.String,
}).annotate(ToolUI.Schema, PickAssetSchema)import { Tools } from "@assistant-ui/react"
import { ClientToolkit } from "mastra-effect/assistant-ui"
const clientToolkit = ClientToolkit.of(toolkit, {
pick_asset: ({ toolUI, resolve, busy }) => (
<AssetPicker state={toolUI.state} disabled={busy} onConfirm={resolve} />
),
})
Tools({ toolkit: clientToolkit.toAssistantUiTools() })toAssistantUiTools derives human tool entries from annotated tool handlers. Tool call args become toolUI.state; when args are a tagged suspend wire ({ _tag, state }), they are decoded through toolkit Tool UI schemas before rendering.
(B) Server-tool suspend
See HITL suspend. The server handler calls Suspend.execute; the client renders the suspend payload with renderToolUI / renderChunk.
Stream-only cards
Stream cards are emitted from server tool handlers via ToolUI.present (schemas without answerSchema). Add views as extra handler keys on the client:
const clientToolkit = ClientToolkit.of(toolkit, {
pick_asset: ({ toolUI, resolve }) => <AssetPicker … />,
status: ({ toolUI }) => <StatusCard text={toolUI.state.text} />,
})ClientToolkit.of
- First arg — server
Toolkit(typing + annotated Tool UI schemas; not executed in the browser) - Second arg — handlers for every annotated tool id, plus optional stream-only views keyed by
_tag
clientToolkit.renderChunk(chunk, { resume: (toolCallId, answer) => resumeStream(toolCallId, answer) })TypeScript requires a handler for each annotated tool. toolUI.state and resolve are typed from toolkit.schemas. Stream-only handler keys are optional extras with loosely typed state.
Server — Tool UI operations
Inside server tool handlers (ToolUI.Runtime auto-provided):
const answer = yield* Suspend.execute(PickAsset, { prompt, candidates })
const id = yield* ToolUI.present(Status, { text: "Working…" })
yield* ToolUI.update(Status, id, { text: "Still going…" }, { transient: true })Suspend.execute suspends for Tool UI schemas with answerSchema. ToolUI.present / update wrap Mastra's context.writer.custom({ type: "data-mastra-effect-block", data, transient? }).
ToolUI coupling boundary
.annotate(ToolUI.Schema, schema) is the client registration seam — not "UI owns the tool."
| Concern | Where it lives |
|---|---|
| Tool definition (id, input/output schemas) | MastraTool.make — server-pure |
| HITL suspend in server handlers | Suspend.execute(schema, state) — pass schema directly; annotate not required |
| Stream cards in server handlers | ToolUI.present / ToolUI.update — pass schema directly |
| Mark tool as client-fulfilled | .annotate(ToolUI.Schema, schema) where schema.tag === tool.id |
| Client view registration | ClientToolkit.of(toolkit, { [toolId]: view }) |
| Schema index for client decode | toolkit.schemas — derived from annotations; used by ClientToolkit |
Verdict: annotate + toolkit.schemas is not redundant with ClientToolkit.of. Annotate marks which tools are client-fulfilled and indexes schemas for wire decode. ClientToolkit.of registers React views. Server HITL via Suspend.execute does not require annotate unless the tool is also client-fulfilled (pattern A).