Build apps
Embedded chat
Build an embedded chat that stores conversations in Hane, lets users choose an agent per message, and streams progress over SSE.
This page explains how to embed an “ask an agent” chat in your app. Conversations and messages are stored in Hane, and the chat agent receives the user’s text and calls the business agents it needs. No seats for an external chat service are required.
How it works
Section titled “How it works”- When a user sends a message to a conversation, Hane stores it and, in the same transaction, starts an AgentRun of the chat agent (the agent with
default: truein its definition). - The chat agent checks which business agents it can call with
agents.list, calls them withagents.run, receives their results, and replies. One request can call several business agents. - If input is missing, it asks back in the same conversation.
- Changes such as reply generation, tool starts and ends, and pending approvals arrive as conversation events (SSE). When you reconnect, you continue from the history and the current state.
External actions go to approval Tasks, just as with AgentRuns. A chat reply is never treated as an approval.
Define the chat agent
Section titled “Define the chat agent”The active release has exactly one chat agent. Its only tools are agents.list and agents.run, and agents lists the business agents it may call.
apiVersion: agents/v1alpha1kind: Agentmetadata: name: assistantspec: purpose: Call the business agent that fits the request and answer with its result default: true input: type: object required: [request] properties: request: { type: string, title: Request, format: message } documents: { type: string, title: Attached documents, format: document } driver: llm instructions: |- Don't do business work yourself. Check callable agents with agents.list, call them with agents.run, and answer briefly with the result. If a required value is in neither the text nor the conversation, ask for it instead of calling. tools: [agents.list, agents.run] agents: [lease-qa, quote-request, vehicle-check] output: type: object required: [status, answer] properties: status: oneOf: - { const: answered, title: Answered } - { const: needs_input, title: Asked for missing values } - { const: cannot, title: Could not help } answer: { type: string, maxLength: 2000 }The chat agent runs with a model driver (llm, codex, or claude). If the active release has no chat agent, the chat does not work.
Talk through the API
Section titled “Talk through the API”-
Create a conversation
const conv = await unwrap(hane.POST("/v1/agents/conversations", { body: {} }));Only the user who created a conversation can read it. Without a title, the first line of the first message becomes the title.
-
Send a message
const msg = await unwrap(hane.POST("/v1/agents/conversations/{id}/messages", {params: { path: { id: conv.conversation_id } },body: {content: "When does contract C-1001 end?",client_message_id: crypto.randomUUID(), // reuse the same value on retriesagent: "lease-qa", // optional. Only this agent is called for this message},}),);// 202. msg.run_id is the chat agent's run- Without
agent, the chat agent picks business agents from the text. - If you name an agent the chat agent cannot call, the request fails with
VALIDATION_FAILEDand nothing is stored. - Until the reply finishes, the next message in the same conversation fails with
CONFLICT. - You can attach up to five previously uploaded documents with
attachments: [{ document_id }].
- Without
-
Receive events
const res = await hane.GET("/v1/agents/conversations/{id}/events", {params: { path: { id: conv.conversation_id }, ...(lastId ? { header: { "Last-Event-ID": lastId } } : {}) },parseAs: "stream",});// res.data is a ReadableStream. When reconnecting, pass the last id you received as Last-Event-ID
Events (SSE)
Section titled “Events (SSE)”Events arrive as text/event-stream.
| event | data |
|---|---|
snapshot |
The conversation snapshot: conversation, items (messages), turns (runs and their ActionRequests), activities (conversation activity), and cursor |
failure |
A refusal during the connection (status, code, message). The connection closes |
- The first event is a
snapshotof the current state; after that, one arrives whenever something changes. The SSEidis thecursor. activitiescontains activity afterLast-Event-ID. Reconnecting never delivers the same activity twice.- A comment heartbeat arrives every 15 seconds, and the connection closes within 45 seconds. Authentication and read permission are checked again on every reconnect.
- Internal reasoning, credentials, and the model’s internal JSON are never sent.
The activity kind values are message.accepted, agent.queued, agent.started, tool.started, tool.completed, tool.failed, approval.pending, action.updated, and agent.completed.
Relay through the BFF
Section titled “Relay through the BFF”Browsers don’t call Hane directly, so the BFF relays the event connection. The reference app passes the browser’s Last-Event-ID to Hane and closes the upstream connection when the browser disconnects. Send the relayed response with content-type: text/event-stream and cache-control: no-cache, no-transform so that nothing buffers it.
Use the UI component
Section titled “Use the UI component”AgentChat from the UI library @hane/ui provides the conversation list, the composer (choose an agent with /, attach files, stop a run), step display, and approval and result cards. You pass how to call your app’s server as a ChatTransport. In this example, bff is your app’s function that calls the BFF’s /bff/*.
import { AgentChat, type ChatTransport, subscribeEventSource } from "@hane/ui";
const transport: ChatTransport = { createConversation: () => bff("/agents/conversations", { method: "POST", body: {} }), sendMessage: (id, body) => bff(`/agents/conversations/${id}/messages`, { method: "POST", body }), subscribe: (id, listener) => subscribeEventSource(`/bff/agents/conversations/${id}/events`, listener), cancelRun: (id) => bff(`/agents/runs/${id}/cancel`, { method: "POST" }),};
<AgentChat agents={agents} conversations={conversations} conversationId={id} onOpen={open} transport={transport} />;Pass the same transport instead of creating it on every render (recreating it reconnects). If you pass approvalTasks and decideApproval, users can decide approvals they are allowed to decide from the cards. Decisions are recorded as Task completions.