Skip to main content

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:

FrameMeaning
{ 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: mux event frames carry tool_approval_request, history keeps awaiting_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 + sessionId plus top-level approvalResponses:
{
"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): mux event frames carry interrupt_request with a kinded spec; history keeps awaiting_interrupt / interruptSpec. Resume with the same input + sessionId plus top-level interruptResponses:
{
"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? } (or x-session-id). Prefer top-level batches, not options.*.
  • In-process hosts can still use ProcessOptions.onApprovalRequired / onInterruptRequired for a real mid-loop await; 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_messages as the restore source. Store StoredHistoryV2 (blob or one JSONB row per segment). Use projectSegmentsToFlatMessages only 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 — use askHuman / interruptResponses for choice, file, encrypted, etc.