Skip to content
HaneDOCS日本語

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.

  1. 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: true in its definition).
  2. The chat agent checks which business agents it can call with agents.list, calls them with agents.run, receives their results, and replies. One request can call several business agents.
  3. If input is missing, it asks back in the same conversation.
  4. 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.

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/v1alpha1
kind: Agent
metadata:
name: assistant
spec:
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.

  1. 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.

  2. 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 retries
    agent: "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_FAILED and 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 }].
  3. 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 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 snapshot of the current state; after that, one arrives whenever something changes. The SSE id is the cursor.
  • activities contains activity after Last-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.

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.

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.