Skip to main content

Client tools

Server-defined components let the server ship a widget to the client. Client tools are the mirror image: the frontend declares the things it can do — render one of its own UI cards, open a native picker, read the device — as tools, described well enough for a server-side model to call. A node invokes them exactly like server tools, and the result streams back into the run.

The wire rules are normative in PROTOCOL.md §11; this page is the authoring guide.

Declaring (the chativa side)​

import { MekikConnector } from "@chativa/connector-mekik";

const connector = new MekikConnector({
url: "wss://bot.example.com/ws",
tools: [
{
name: "pick_date",
description: "Open the in-app date picker and let the user choose a delivery date.",
parameters: {
type: "object",
properties: { min: { type: "string", description: "Earliest selectable ISO date" } },
required: ["min"],
},
handler: async (params) => ({ date: await openDatePicker(params?.min as string) }),
},
{
name: "show_confetti",
mode: "notify", // fire-and-forget — never blocks the run
tags: ["fun"], // only nodes asking for "fun" see this one
handler: () => fireConfetti(),
},
],
});

The definition (everything but handler) travels in the hello handshake and is re-declared on every reconnect; the handler never leaves the page. A thrown handler error becomes the server-side call's error.

:::info The registry is sealed on purpose Definitions and handlers live in true-private (#) fields — console access or injected script cannot read or replace them through the connector instance. Definitions are deep-cloned and frozen at registration, and the toolset is fixed at construction unless you pass allowDynamicTools: true (which unlocks registerTool / unregisterTool, each re-announcing the full set with a client_tools frame). :::

Accepting (the server side — off by default)​

Declarations are ignored entirely unless the app opts in — the same default-drop posture as acceptClientMeta, because tool names, descriptions and schemas reach a model's context and are a prompt-injection surface.

const app = mekik({ graph, clientTools: true }); // accept everything well-formed

const app = mekik({ // …or allowlist
graph,
clientTools: (tools) => tools.filter((t) => ["pick_date", "show_confetti"].includes(t.name)),
});

Leaving the option unset is the kill switch — the whole feature is inert, wire and all. A declaration is capability, not authority: it changes what the server may ask the client to do, never what the server itself does. Validate tool results like any other client input.

Reading the toolbox — and tags​

At run start the engine snapshots the union of every live connection's declared tools into ctx.meta.clientTools:

const all = mekik.clientTools(ctx);
const scoped = mekik.clientTools(ctx, { tags: ["billing"] });

The tag rule — how one node sees a tool another does not:

  • a tool with no tags is unrestricted: every query returns it;
  • a tagged tool is returned only by queries whose tags intersect its own.

So the frontend tags the tools it wants scoped and leaves general-purpose ones untagged. The returned definitions (name, description, parameters as JSON Schema) are ready to hand to a model as its tool list.

Calling​

The round-trip (mode: "call", default)​

.node("schedule", async (s, ctx) => {
const when = await mekik.callClientTool<{ date: string }>(ctx, "pick_date", { min: s.earliest });
return { reply: `Booked for ${when.date}.` };
})

Under the hood this is a real ilmek pause wearing tool metadata, so it inherits everything a pause guarantees: the interrupt frame carries data.tool = {name, params} (the client renders no chips — the handler answers), the wait survives a disconnect or restart (welcome.pending re-announces the open call and a reconnecting client retries it), and the answer is the result envelope — {ok:true, result} resolves the await, {ok:false, error} makes it throw. The call also surfaces as a normal tool_call running → completed/error trace.

The node re-runs from the top on resume, so wrap pre-call side effects in mekik.tool exactly as around any other pause.

Fire-and-forget (mode: "notify")​

await mekik.callClientTool(ctx, "show_confetti", { level: 3 }); // resolves immediately with undefined

No pause: the invocation streams as a genui event chunk under the reserved name client_tool and the handler's result is discarded. A fresh tab replaying history re-renders it (right for UI); the connector dedupes by chunk id within a session so a resume replay does not fire twice.

When the handler fails​

An {ok:false, error} answer makes the await throw — a plain Error in TypeScript, an InvalidOperationException in .NET — carrying the client's message, and the tool_call trace ends in error. Catch it in the node to recover, or let it end the run in run{error}; the agent wrappers below turn it into an observation instead, so a model-driven loop keeps going.

Handing the toolbox to a model​

import { withMekikTools, withClientTools, runAgent } from "@mekik/langchain";

const tools = [
...withMekikTools(ctx, serverTools, policy),
...withClientTools(ctx, { tags: ["billing"] }),
];
return { reply: await runAgent(ctx, model(), { system, input: s.input, tools }) };

A call-mode tool parks the loop durably; a notify tool returns a delivery note; a handler error comes back as an error observation, so the agent loop stays alive and the model can route around it.

Dynamic toolsets​

A client_tools frame replaces the connection's whole set ([] withdraws everything) and takes effect on the next turn — the snapshot is taken at run start, so a set changing mid-run does not shift under the node's feet. Multi-tab: the snapshot is the union across live connections, deduped by name, the most recent declaration winning.

Runnable examples​

The whole page is executable in both languages, with the client's part scripted so no browser is needed — ts/examples/client-tools.ts and dotnet/examples/Mekik.ClientTools:

node ts/examples/client-tools.ts # self-test: allowlist, tag scoping, round-trip, notify, error envelope
node ts/examples/client-tools.ts --serve # ws://localhost:8807 for a real chativa client
dotnet run --project dotnet/examples/Mekik.ClientTools # the same self-test, byte-identical frames

See also Examples → The frontend owns the tool, and the integration pages for the model wrappers: LangChain → withClientTools, Microsoft.Extensions.AI → ClientToolFunctions.Wrap.