Skip to content

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 ​

PatternTool kindServerClient
(A) Assistant UI human tools.annotate(ToolUI.Schema, …)No handler — execute throwsclientToolkit.toAssistantUiTools()
(B) Server-tool suspendServer tool handleryield* Suspend.execute(schema, state)clientToolkit.renderToolUI / renderChunk
Stream cardsServer tool handler onlyyield* ToolUI.present / updateclientToolkit.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.

ts
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)
tsx
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:

tsx
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
tsx
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):

ts
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."

ConcernWhere it lives
Tool definition (id, input/output schemas)MastraTool.make — server-pure
HITL suspend in server handlersSuspend.execute(schema, state) — pass schema directly; annotate not required
Stream cards in server handlersToolUI.present / ToolUI.update — pass schema directly
Mark tool as client-fulfilled.annotate(ToolUI.Schema, schema) where schema.tag === tool.id
Client view registrationClientToolkit.of(toolkit, { [toolId]: view })
Schema index for client decodetoolkit.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).