Skip to content

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.

tsx
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

ts
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

ts
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

tsx
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

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

tsx
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

ExportDescription
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