Chat with tools in minutes
Two short paths: browser (useChat) and edge / server (createChatRoute).
Both use segment history and the same bot-route mux frames for streaming.
Browser (client-side tools)
import { useChat } from '@baleybots/react';
import { tool, openai } from '@baleybots/core';
import { z } from 'zod';
const weather = tool(
'get_weather',
'Get the weather for a city',
z.object({ city: z.string() }),
async ({ city }) => `Sunny in ${city}`,
);
function App() {
const { segments, sendStreaming, isStreaming } = useChat({
model: openai('gpt-5.6-luna'),
tools: { weather },
});
return (
<button
disabled={isStreaming}
onClick={() => sendStreaming('Weather in Tokyo?')}
>
Ask
</button>
);
}
Add durable history with storage — prefer segment blobs via kvLikeHistoryStorage or createHistoryStorage (see Chat History).
Edge / server (tools run on the server)
import { Baleybot, tool, MemoryStorage } from '@baleybots/core';
import { createChatRoute } from '@baleybots/proxy-server';
import { z } from 'zod';
const weather = tool(
'get_weather',
'Get the weather for a city',
z.object({ city: z.string() }),
async ({ city }) => `Sunny in ${city}`,
);
const bot = Baleybot.create({
name: 'asst',
goal: 'Help the user. Use tools when useful.',
tools: { weather },
});
// Per-session in-memory store for local demos; swap for KV in production
const sessions = new Map<string, MemoryStorage>();
export default {
fetch: createChatRoute({
bot,
getStorage: (sessionId) => {
let storage = sessions.get(sessionId);
if (!storage) {
storage = new MemoryStorage();
sessions.set(sessionId, storage);
}
return storage;
},
format: 'ndjson', // or omit for SSE (default)
}),
};
Client against the route
import { httpWorker } from '@baleybots/cloudflare';
import { consumeBotRouteStream } from '@baleybots/core';
const chat = httpWorker<string, string>('https://my-worker.example/chat', {
format: 'ndjson',
bodyExtras: { sessionId: 'user-123' },
onDraft: (draft) => console.log('draft', draft),
});
const answer = await chat.process('Weather in Tokyo?', {
onToken: (botName, event) => {
if (event.type === 'text_delta') process.stdout.write(event.content);
},
});
Or demux any fetch body with consumeBotRouteStream(res.body, { onEvent, onDraft, onResult }, { format: 'ndjson' }).
Wire format (mux)
Same frames for createBotRoute and createChatRoute:
| Frame | Meaning |
|---|---|
{ type: 'event', botName, event } | BaleybotStreamEvent (tokens, tools, …) |
{ type: 'draft', draft } | Optional product side-channel |
{ type: 'result', result } | Final turn output |
{ type: 'done' } | Clean end |
{ type: 'error', error } | Failure |
Framing: SSE (default) or NDJSON (format: 'ndjson' / ?format=ndjson).
Legacy host dialects that used { bb \| final } map to { event \| result }.
Draft side-channel
createChatRoute({
bot,
getStorage,
getDrafts: async function* (input, sessionId) {
yield { status: 'thinking', sessionId };
// … product-specific progressive state
},
});
Scope notes
- Tools that run without approval work end-to-end on
createChatRoute(server executes them during the turn). - Gated tools (
requiresApproval) pause the turn: muxeventframes carrytool_approval_request, history keepsawaiting_approval/approvalRequests, and the HTTP request ends (Workers-safe — no open request waiting on a human). - Resume with a second POST of the same
input+sessionIdplus top-levelapprovalResponses:
{
"input": "Delete temp.txt",
"sessionId": "demo",
"approvalResponses": [{ "approvalId": "…", "approved": true }]
}
Baleybots still owns tool execute and the final typed process() result after resume — same output schema as an ungated turn. Deny with { approved: false, reason?: "…" } so the model continues without executing the tool.
- Mid-execute
askHuman(any tool — confirm, text, choice, number, file, encrypted): muxeventframes carryinterrupt_requestwith a kindedspec; history keepsawaiting_interrupt/interruptSpec. Resume with the sameinput+sessionIdplus top-levelinterruptResponses:
{
"input": "Deploy staging",
"sessionId": "demo",
"interruptResponses": [
{ "interruptId": "tc-1:region", "value": "eu" },
{ "interruptId": "tc-1:replicas", "value": 3 },
{
"interruptId": "tc-1:logo",
"value": { "name": "logo.png", "mimeType": "image/png", "contentBase64": "…" }
},
{ "interruptId": "tc-1:secret", "value": "…" }
]
}
Cancel with { "interruptId": "…", "cancelled": true, "reason": "…" }. Kind validation uses the stored interrupt spec on resume — never trust a client-claimed kind. Pre-askHuman code in execute must be safe to re-run (idempotent).
import { askHuman, tool } from '@baleybots/core';
import { z } from 'zod';
const deploy = tool(
'deploy',
'Deploy to a region',
z.object({ env: z.string() }),
async ({ env }) => {
const region = await askHuman({
kind: 'choice',
id: 'region',
prompt: 'Which region?',
options: [
{ value: 'us', label: 'US' },
{ value: 'eu', label: 'EU' },
],
});
const replicas = await askHuman({
kind: 'number',
id: 'replicas',
prompt: 'Replica count',
min: 1,
max: 10,
});
return `Deployed ${env} to ${region} x${replicas}`;
},
);
- Request body:
{ input | message, sessionId, approvalResponses?, interruptResponses? }(orx-session-id). Prefer top-level batches, notoptions.*. - In-process hosts can still use
ProcessOptions.onApprovalRequired/onInterruptRequiredfor a real mid-loopawait; that does not span cold Workers — edge always uses pause → persist → respond → resume. - Optional hardening: AI SDK’s
experimental_toolApprovalSecret(pass-through when you wire it) for signing approval requests across serverless instances.
What not to do
- Do not flatten segments into relational
chat_messagesas the restore source. StoreStoredHistoryV2(blob or one JSONB row per segment). UseprojectSegmentsToFlatMessagesonly for analytics / legacy UIs. - Migrating old flat rows:
migrateFlatMessagesToHistory(rows)once, then persist segments. - Do not fake freeform HITL as AI SDK
tool-approval-response— useaskHuman/interruptResponsesfor choice, file, encrypted, etc.