Skip to content
Pilotoo
v0.1

Tools that run in the browser

You will declare two tools in the browser: one that answers the agent with the reader’s chosen theme, and one that shows an email draft as a card. Both are declared by the client for each run, so no server code or agent file changes.

A blocking tool pauses the run until serve answers, and the answer is what the model reads. A one-way tool is handed over once its arguments are complete, and the run carries on.

src/chat/client-tools.ts
import type { ClientTool } from '@pilotoo/ui/chat';
export const READ_CLIENT_APPEARANCE = 'read_client_appearance';
export const EMAIL_DRAFT = 'email_draft';
export function appClientTools(currentTheme: () => 'system' | 'light' | 'dark'): ClientTool[] {
return [
{
kind: 'blocking',
name: READ_CLIENT_APPEARANCE,
description: 'Reports the appearance this reader chose for the chat: light, dark, or the system default.',
arguments: { type: 'object', properties: {}, additionalProperties: false },
result: {
type: 'object',
properties: { appearance: { type: 'string', enum: ['system', 'light', 'dark'] } },
required: ['appearance'],
},
serve: () => ({ appearance: currentTheme() }),
},
{
kind: 'one-way',
name: EMAIL_DRAFT,
description:
'Shows an email draft to the user as a card. Call it whenever you write an email for the user, with the whole email in the arguments. Put a {{Readable label}} placeholder where something is unknown, and list each one in `variables`.',
arguments: {
type: 'object',
properties: {
subject: { type: 'string' },
body: { type: 'string', description: 'Plain text, not markdown.' },
variables: {
type: 'array',
items: {
type: 'object',
properties: { name: { type: 'string' }, description: { type: 'string' } },
required: ['name'],
},
},
},
required: ['subject', 'body'],
},
},
];
}

The registry maps a tool name to a component. EmailDraftCard and parseEmailDraft ship with @pilotoo/ui/chat.

src/chat/client-tool-views.tsx
import { EmailDraftCard, parseEmailDraft, type ClientToolRegistry } from '@pilotoo/ui/chat';
import { EMAIL_DRAFT } from './client-tools';
export const CLIENT_TOOL_VIEWS: ClientToolRegistry = {
[EMAIL_DRAFT]: ({ arguments: args }) => <EmailDraftCard {...parseEmailDraft(args)} />,
};

Build the list once. ChatProvider reads clientTools at mount and declares it in every run it opens.

src/assistant/AssistantPage.tsx
import { useState } from 'react';
import { ChatPanel, ChatProvider } from '@pilotoo/ui/chat';
import { appClientTools } from '../chat/client-tools';
import { CLIENT_TOOL_VIEWS } from '../chat/client-tool-views';
import { currentTheme } from '../theme';
export function AssistantPage() {
const [clientTools] = useState(() => appClientTools(currentTheme));
return (
<ChatProvider baseUrl={import.meta.env.VITE_ASSISTANT_URL} clientTools={clientTools}>
<ChatPanel clientToolViews={CLIENT_TOOL_VIEWS} />
</ChatProvider>
);
}

A blocking tool with no serve stops the run and waits for the reader. Register a component for it in the same registry; it receives onAnswer, and the panel shows it in place of the composer.

src/chat/client-tool-views.tsx
import type { ClientToolCallProps } from '@pilotoo/ui/chat';
function DatePicker({ onAnswer, disabled }: ClientToolCallProps) {
return (
<input
type="date"
disabled={disabled}
onChange={(event) => onAnswer?.({ date: event.target.value })}
/>
);
}

Add pick_date: DatePicker to CLIENT_TOOL_VIEWS, and declare pick_date as a blocking tool without serve.

  • The list travels in the input of every run and is stored nowhere on the server. No agent definition has to enable it, and the next run may declare a different list.
  • The server validates both ways: the arguments the model produces against arguments, and your answer against result.
  • A tool whose name collides with a registered server tool is refused rather than shadowing it.
  • A name with no view renders as readable text, never as a silent drop.