Building the Chat Components
How to connect agent tool output to the Excalidraw canvas, build MessageList and ChatPanel components, apply client-side tools, and handle the tool coverage problem.
The last post covered the hooks and message protocol. Now the actual components: how the canvas connects to the agent, how messages render, and what happens when the agent produces output.
Canvas Integration and Session Management
The Excalidraw canvas is a controlled component -- a React pattern where the component's state is owned and managed by the parent rather than internally. You can set elements on it programmatically via the updateScene method or the initialData prop. For the diagram agent, every time the agent successfully calls generateDiagram or modifyDiagram, those elements need to be pushed to the canvas.
Session management is a deliberate constraint. Every time the page reloads, the canvas clears. The agent's conversation history is preserved by the Durable Object, but the visual state is local. A user can reload, continue the conversation, and the agent will have context from the previous session. The canvas will not.
This is a trade-off worth acknowledging. You could persist canvas state to local storage and reload it on page mount. You could also sync it to the Durable Object. Both are valid approaches. For a prototype that is focused on getting the agent right before the persistence layer, clearing on reload keeps things simple.
MessageList and MessageBubble
The MessageList component iterates over the messages array from useAgentChat and renders each one. A message has a role (user or assistant) and a parts array. The component switches on role to apply the right visual treatment.
The interesting rendering is on the assistant side. An assistant message might contain a mix of:
- Text parts: the conversational response. Render as markdown.
- Tool call parts: the agent invoking a tool. Has a
statefield that transitions frompartial-calltocalltoresult. - Reasoning parts: if the model exposes reasoning tokens, they appear here.
function MessageBubble({ message }: { message: Message }) {
if (message.role === 'user') {
return <div className="message user">{message.content}</div>;
}
return (
<div className="message assistant">
{message.parts?.map((part, i) => {
if (part.type === 'text') {
return <MarkdownRenderer key={i} content={part.text} />;
}
if (part.type === 'tool-invocation') {
return <ToolCallStatus key={i} part={part} />;
}
return null;
})}
</div>
);
}The ToolCallStatus component shows what the agent is doing at each step. When state is call (the tool is being invoked), show a loading indicator and the tool name. When state is result, show a summary of what was produced.
Tool Status Display
The state field on a tool call part drives the status display. Three states matter:
partial-call: The model is still generating the arguments. Partial JSON is arriving. You cannot parse the arguments yet. Show a spinner.
call: Arguments are complete. The tool is executing. Show the tool name and a loading state: "Generating diagram...", "Modifying shape...", depending on which tool was called.
result: Tool execution is complete. The result is in part.result. Show a summary: "Generated 7 elements", "Modified 2 shapes", or whatever is meaningful for your tool.
This progression is what makes the chat feel responsive during multi-step operations. The user sees the agent working through each tool call rather than watching a blank screen until everything is done.
ChatPanel
The ChatPanel component wraps the input area and the send button. The status field from useAgentChat drives the UI state. While the agent is working, the input is disabled and the button shows a loading state.
function ChatPanel({ input, handleInputChange, handleSubmit, status }) {
const isWorking = status === 'submitted' || status === 'streaming';
return (
<form onSubmit={handleSubmit} className="chat-panel">
<input
value={input}
onChange={handleInputChange}
disabled={isWorking}
placeholder={isWorking ? 'Agent is thinking...' : 'Describe what to draw...'}
/>
<button type="submit" disabled={isWorking}>
{isWorking ? <Spinner /> : 'Send'}
</button>
</form>
);
}Four status values come from useAgentChat: idle, submitted, streaming, and error. The submitted state covers the gap between sending a message and receiving the first token. The streaming state covers everything after the first token until the response is complete.
Applying Tool Output to the Canvas
When a tool call completes with a result, those elements need to be applied to the Excalidraw canvas. The pattern is to walk the messages after each update and collect all tool results:
useEffect(() => {
if (!excalidrawAPI) return;
for (const message of messages) {
if (message.role !== 'assistant') continue;
for (const part of message.parts ?? []) {
if (
part.type === 'tool-invocation' &&
part.state === 'result' &&
(part.toolName === 'generateDiagram' || part.toolName === 'modifyDiagram')
) {
excalidrawAPI.updateScene({ elements: part.result.elements });
}
}
}
}, [messages, excalidrawAPI]);This will re-apply every tool result on every message change. A production implementation would track which results have been applied and only process new ones. For getting the agent working, running the full walk on each change is fine.
The Root App Component
The root component wires everything together: the canvas, the chat, and the state that flows between them.
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
const agent = useAgent({ agent: 'design-agent' });
const { messages, input, handleInputChange, handleSubmit, status } = useAgentChat({ agent });
// Apply tool output to canvas (see above)
return (
<div className="app">
<div className="canvas-panel">
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
</div>
<div className="chat-panel">
<MessageList messages={messages} />
<ChatPanel
input={input}
handleInputChange={handleInputChange}
handleSubmit={handleSubmit}
status={status}
/>
</div>
</div>
);
}The excalidrawAPI ref is how you programmatically control the canvas. Excalidraw calls the callback with the API object once it mounts. Until that callback fires, excalidrawAPI is null, so the effect guarded by if (!excalidrawAPI) return waits for the canvas to be ready.
TypeScript Configuration
Working with Cloudflare Workers and Excalidraw in the same project requires some TypeScript configuration. The Workers runtime type definitions (@cloudflare/workers-types) and browser type definitions conflict on certain globals. The fix is to set lib explicitly in tsconfig.json and exclude the browser globals that clash with Workers.
For the Excalidraw package, the types for ExcalidrawElement and ExcalidrawAPI are exported from @excalidraw/excalidraw but labelled as internal in some versions. If you get type errors importing them, cast through unknown (a TypeScript escape hatch: tell the compiler "trust me, I know the shape of this") rather than suppressing the error entirely.
Tool Coverage and What Happens Without It
At this point the agent works for drawing diagrams. Type "draw a login flowchart" and you get a flowchart on the canvas. Type "modify the blue box to be red" and it changes. But try something outside the tool set: "add a comment to that shape," "make this element glow on hover," "export this as a PNG." None of these have tools. The agent either makes something up, produces invalid elements, or declines politely.
This is tool coverage. The agent can only do what its tools allow. Any gap between what users will ask and what tools exist is a capability failure waiting to happen.
There are two responses to a coverage gap. The first is to add a tool. The second is to define a clear scope so the agent knows what to decline gracefully. A good system prompt tells the agent what it is and what it is not. "You are a diagram generation assistant. You can create and modify diagrams. For requests outside diagram creation and modification, explain that you can only help with diagrams."
The agent should never pretend it can do something it cannot. A polite decline that acknowledges the limitation is better than a hallucinated tool call that produces garbage.
Client-Side Tools
The Vercel AI SDK supports a distinction between server-side tools and client-side tools. Server-side tools run on the server when the tool is called. Client-side tools run in the browser.
With useAgentChat, you can pass a tools object alongside the agent. Tools defined there execute client-side. This is useful for tools that need access to browser state: the current canvas contents, the window dimensions, the user's clipboard, or anything else that only exists in the browser environment.
const { messages, ...rest } = useAgentChat({
agent,
tools: {
getCanvasState: {
description: 'Get the current elements on the canvas',
parameters: z.object({}),
execute: async () => {
const elements = excalidrawAPI?.getSceneElements() ?? [];
return { elements };
}
}
}
});The LLM decides which tool to call, the call goes through the WebSocket to the Durable Object, and if it is a client-side tool, the SDK routes execution back to the browser. This is what makes tools like "what is currently on the canvas?" possible without serialising (converting the canvas data into a text format to send across the network) the canvas state into every message.
The validation problem here is that the canvas state the client-side tool returns goes into the LLM's context. It needs to be compact. Returning the full Excalidraw scene with all element properties would flood the context. A smarter implementation would summarise: "3 rectangles, 2 arrows, 1 text element" rather than the raw JSON.
What Comes Next
The agent works. The canvas renders. Tool calls show their status in the chat. The next problem is whether the agent produces good output reliably, not just occasionally. That requires measurement.
The next post introduces evals: what they are, how a golden dataset works, and how to start measuring a non-deterministic system.
Further Reading and Watching
- Video: Why Vertical LLM Agents Are The New $1 Billion SaaS Opportunities -- Y Combinator
- Docs: useAgentChat -- Cloudflare Agents
- Docs: Excalidraw API reference
Practice
0/4 doneKeep reading