Client tools & UiToolkit
Client-fulfilled tools use requiredClient: true. They are type-gated at UiToolkit.make and rendered in Assistant UI — not executed on the server.
UiToolkit.make
Every requiredClient tool in the toolkit must have a React component. Missing keys are a TypeScript error.
import { Tools } from "@assistant-ui/react"
import { Schema } from "effect"
import { MastraTool, Toolkit } from "@mastra-effect/core"
import { UiToolkit } from "@mastra-effect/assistant-ui"
const PickAsset = MastraTool.make({
id: "pick_asset",
description: "Pick an asset",
requiredClient: true,
inputSchema: Schema.Struct({ assetId: Schema.String }),
})
const SearchDocs = MastraTool.make({
id: "search_docs",
description: "Search docs",
inputSchema: Schema.Struct({ query: Schema.String }),
})
const toolkit = Toolkit.make(PickAsset, SearchDocs)
const ui = UiToolkit.make(toolkit, {
pick_asset: ({ args, resolve, busy }) => (
<button disabled={busy} onClick={() => resolve({ assetId: args.assetId })}>
Confirm
</button>
),
})
// Mount client tool UI in Assistant UI
Tools({ toolkit: ui.toComponents() })Server + client together
import { Agent, MastraAgent } from "@mastra-effect/core"
import { Effect, Layer } from "effect"
const agentLayer = MastraAgent.layer({
id: "agent",
name: "Agent",
instructions: "...",
model: "openai/gpt-4o",
toolkit,
})
const handlersLayer = toolkit.toLayer(
Effect.fn(function* () {
return {
search_docs: Effect.fn(function* ({ query }) {
return `results for ${query}`
}),
}
})(),
)
await Effect.runPromise(
Agent.stream("Find assets").pipe(Effect.provide(Layer.merge(agentLayer, handlersLayer))),
)Client tools are fulfilled only via Assistant UI (resolve / addResult). The server program does not provide a client handler layer.
Suspend with blocks (HITL)
For requiredClient tools that need typed UI state while suspended, define an AiBlock schema on the server, suspend with it, and let the client parse the payload, render via a block registry, and resolve back to the agent loop.
Server: suspend with a typed block
import { Effect, Schema } from "effect"
import { AiBlock, Suspend } from "@mastra-effect/core"
const PickAsset = AiBlock.make({
tag: "pick_asset",
stateSchema: Schema.Struct({ assetId: Schema.String }),
answerSchema: Schema.String,
equals: AiBlock.equalsBy((answer) => answer),
})
const pickAssetHandler = Effect.fn(function* ({ assetId }: { assetId: string }) {
const block = PickAsset.make({ assetId })
return yield* Effect.fail(Suspend.withBlock("pick_asset", block))
})Suspend.withBlock wraps AiBlock.toSuspendPayload(block) in ClientToolSuspended args so Mastra's agent loop emits a tool-call-suspend chunk.
Client: parse chunk → render → resolve
import { AiBlock, Suspend } from "@mastra-effect/core"
import { UiToolkit } from "@mastra-effect/assistant-ui"
const ui = UiToolkit.make(toolkit, components, {
blocks: {
pick_asset: ({ block, resolve, busy }) => (
<AssetPicker state={block.state} disabled={busy} onConfirm={resolve} />
),
},
})
// In your stream consumer:
if (Suspend.isToolCallPart(chunk)) {
const block = AiBlock.fromToolCallSuspend(PickAsset, chunk, { toolId: "pick_asset" })
if (block !== undefined) {
ui.renderBlock(chunk.payload.args, {
resolve: (answer) => resume(answer),
})
}
}Resume helpers
import { AiBlock } from "@mastra-effect/core"
const parsed = AiBlock.fromSuspendArgs(PickAsset, suspended.args)
const answered = AiBlock.resume(parsed!, "picked-a1")
AiBlock.answersEqual(parsed!, "picked-a1", answered.answer!)Block registry
Lower-level registry when not using UiToolkit.renderBlock:
import { BlockRegistry } from "@mastra-effect/assistant-ui"
const registry = BlockRegistry.make({
pick_asset: ({ block, resolve }) => <AssetPicker state={block.state} onConfirm={resolve} />,
})
registry.renderBlock(suspendPayload, { resolve: (answer) => resume(answer) })UiToolkit API
| Export | Description |
|---|---|
UiToolkit.make(toolkit, components, options?) | Bind toolkit + required client components |
ui.toComponents() | Assistant UI Toolkit for Tools({ toolkit }) |
ui.renderBlock(payload, { resolve, busy }) | Optional suspend/resume block rendering |