Chat Protocol
The complete contract for building a third-party chat client on the Minara gateway, covering the turn loop, streaming events, history rebuild, interactions, attachments, and model selection
This page is the integration whitepaper for the Minara chat protocol. It describes everything a client needs to run a full conversation against the gateway with its own UI: a community web terminal, a mobile app, a messaging-channel bridge, or an automation script. The built-in web UI uses exactly this contract, with no private endpoints.
A conforming client touches four surfaces:
| Surface | What it does |
|---|---|
POST /v1/chat/stream | starts an agent turn |
GET /v1/stream (WebSocket) | delivers the turn's events on the chat channel |
GET /v1/sessions* | lists, reads, and searches persisted history |
POST /v1/interactions/:id/answer | answers questions the agent asks back |
Two npm packages wrap the contract so a TypeScript client writes none of the plumbing:
@minara/typescarries the wire types (ChatStreamEvent,ChatAttachment, question payloads).@minara/gateway-clientcarries the typed HTTP client, the multiplexed WebSocket client, and the turn-model SDK (reduceAgentEvent,sessionRowsToTurns) described below.
Both packages are plain ESM with zero UI dependencies. A non-TypeScript client implements the same JSON contract directly; the OpenAPI spec covers every endpoint.
Authentication
When the gateway runs with GATEWAY_AUTH_TOKEN set, HTTP requests carry
Authorization: Bearer <token> and the WebSocket upgrade carries
?token=<token> (a browser WebSocket cannot set headers). Without the
env var the gateway accepts anonymous local requests.
The turn loop
One conversational exchange runs in three steps:
POST /v1/chat/streamwith the user message. The gateway responds immediately with{ session_id, kind, is_new }and runs the turn in the background.- Subscribe to the
chatchannel on the multiplexed WebSocket, keyed by the returnedsession_id. The turn's events arrive in order with per-channel sequence numbers; a reconnect resumes from the lastseqseen. The frame format, subscribe handshake, replay, and control frames are specified on the stream protocol page. - Fold each event onto the in-progress assistant turn until
doneorerrorarrives.
The response arrives before the WebSocket subscription only in theory: the
gateway buffers every event of an in-flight turn per session, and a
subscribe with fromSeq: 0 replays the buffer from the start. Subscribing
after the POST returns is therefore always safe.
Request fields
message is the only required field. The full body:
| Field | Purpose |
|---|---|
message | the user's text (or a voice transcript) |
session_id | continue an existing conversation; omit to start a new one |
session_kind | surface bucket for a new session (chat default, institution, markets, workflow, strategy-studio, data-studio, messaging) |
attachments | uploaded file references, see Attachments |
voice_input_key | FileStore key of the original mic recording |
model | run this turn on a specific model id, see Model selection |
reasoning_effort | thinking tier for this turn (minimal / low / medium / high) |
retry | true replaces the session's latest turn instead of stacking a duplicate |
surface | web (default) or cli; cli omits the custom-URI wire protocol from the system prompt |
prompt_mode | full (default) or minimal for headless callers that want the lean prompt skeleton |
Complete parameter semantics live on the endpoint reference.
Stream events
Every event is { type, data }. The authoritative union is
ChatStreamEvent in
packages/types/src/chat-stream.ts,
re-exported by the SDK as AgentEvent. Grouped by concern:
| Group | Events | Client obligation |
|---|---|---|
| Handshake | start | check protocol_version (see Versioning); read supported_blocks |
| Session identity | session, session_title | bind the session id, update titles |
| Turn text | text_delta, reasoning_delta, response | append text; response is a fallback that only matters when no text_delta arrived |
| Tool calls | tool_call_start, tool_call_result | render call cards; calls can interleave with text |
| Sub-tool progress | sub_tool_call, sub_tool_text_delta, sub_tool_thinking_delta, tool_progress | optional; long-running tools stream their internals here |
| Rich UI | ui_block | render or ignore; see the UI Block Protocol |
| Interaction | pending_question_added, pending_question_resolved | see Interactions |
| Steering | user_interjection, context_compacted | insert the injected user bubble; show a compaction hint |
| Progress | phase, skill_activated, todo_snapshot, goal | optional status affordances |
| Terminal | done, error | exactly one arrives; flip the turn to its final state |
A client that only appends text_delta and stops on done / error is
already conforming. Every other group upgrades the experience without being
load-bearing: unknown event types must be skipped, never treated as errors,
because the union grows over time.
done carries two optional fields worth honoring: interrupted: true when
the user stopped the turn via POST /v1/chat/interrupt, and
pending_interjections: string[] listing steering messages that arrived too
late to inject (re-send them as normal messages).
The SDK reducer
@minara/gateway-client exports the exact reducer the built-in web UI
runs. It folds one event at a time onto an AssistantTurn (ordered
segments of text, tool cards, and UI blocks, plus tool-call state) and
routes side effects through optional callbacks:
import {
createMultiplexClient,
createAgentStreamState,
reduceAgentEvent,
type AssistantTurn,
} from "@minara/gateway-client";
let turn: AssistantTurn = {
id: "t1", toolCalls: [], segments: [], skills: null,
todos: [], status: "streaming",
};
const state = createAgentStreamState();
const ws = createMultiplexClient({ baseUrl, apiKey });
const sub = ws.subscribe("chat", sessionId, {
onEvent: (ev) =>
reduceAgentEvent("t1", ev, {
sessionKind: "chat",
patchAssistant: (fn) => { turn = fn(turn); render(turn); },
onPendingQuestionAdded: (q) => showQuestionPanel(q),
onDone: () => sub.unsubscribe(),
}, state),
});Using the reducer guarantees your turn state matches the web UI byte-for-byte, including edge cases like duplicate-suppressed tool starts on replay and interleaved segment ordering.
History
Persisted sessions are the source of truth after any reload or reconnect gap:
GET /v1/sessions?kind=&origin=&limit=&offset=lists sessions, most recently updated first.GET /v1/sessions/:idreturns the full transcript as message rows. Assistant rows carry ametadatablob with the ordered segments and tool-call records the stream produced, so a rebuild renders exactly what the user saw live. The response also carriesis_streaming(reattach to the live WebSocket buffer) andpending_questions(re-render open questions).GET /v1/sessions/search?q=runs full-text search over message content across sessions and returns best-match snippets.
The SDK's sessionRowsToTurns(detail.messages) converts the rows into the
same Turn[] shape the live reducer builds, so one rendering path serves
both live streaming and history:
import { sessionRowsToTurns, type SessionDetail } from "@minara/gateway-client";
const detail: SessionDetail = await fetch(`${baseUrl}/v1/sessions/${id}`)
.then((r) => r.json());
const turns = sessionRowsToTurns(detail.messages);
if (detail.is_streaming) {
// reattach: subscribe the chat channel with fromSeq 0 and keep reducing
}Interactions: the agent asks back
When the agent needs input mid-turn (a clarifying question, a decision
between options, or a confirmation before a fund-moving action) it emits
pending_question_added and keeps working on whatever else it can. The
payload carries the question id, a request kind (select, confirm,
input, or secret), the question list with typed options, and asked_by
provenance.
The client renders the prompt and answers through HTTP, one answers[]
entry per question keyed by its 0-based questionIndex:
POST /v1/interactions/:id/answer
{ "answers": [ { "questionIndex": 0, "selected_labels": ["Confirm"] } ] }The SDK wraps the endpoint as answerInteraction:
import { createGatewayClient } from "@minara/gateway-client";
const gateway = createGatewayClient({ baseUrl, apiKey });
const outcome = await gateway.answerInteraction(q.id, [
{ questionIndex: 0, selected_labels: ["Confirm"] },
]);
if (outcome === "not_pending") closeWidget(); // answered elsewhere or timed out"not_pending" (HTTP 404 on the wire) is an expected outcome when several
clients have the same question open, not an error.
select and confirm answers put the chosen option's label text in
selected_labels; free-form and input answers use free_text instead.
secret requests are never answered through this endpoint: the client
writes the value to the payload's writeEndpoint credentials route and
answers only the outcome. The full semantics live on the
endpoint reference.
After a valid answer the gateway emits pending_question_resolved to every
subscriber, so multiple open clients converge without polling. On reload,
open questions come back on GET /v1/sessions/:id as pending_questions.
Confirmations for fund-moving actions ride the same mechanism with
kind: "confirm". A client that cannot render a confirmation UI must not
answer programmatically; an unanswered confirm times out and the action is
cancelled, which is the safe default.
Attachments and voice
File input is a two-step flow. First upload the bytes:
POST /v1/files (multipart/form-data, field name "file")
→ { "key": "chat/files/2026/07/chart.png", "url": "/v1/files/…",
"mediaType": "image/png", "size": 48213, "uploaded_at": "2026-07-17T…" }Then reference the returned key in the turn request:
{
"message": "what does this chart show?",
"attachments": [
{ "key": "chat/files/2026/07/chart.png", "filename": "chart.png",
"media_type": "image/png", "size": 48213, "kind": "image" }
]
}kind is one of image, pdf, spreadsheet, text, office. Limits:
8 attachments per turn, 5 MiB per image, 20 MiB per other file.
GET /v1/files/:key serves the bytes back, which is also how history
renders image attachments.
Voice input rides the same store: POST /v1/voice/transcribe?persist
transcribes a recording and returns both the transcript and a FileStore
key. Send the transcript as message and the key as voice_input_key, so
history can replay the original audio.
Model selection
GET /v1/llm/available-models returns the models served by the active
provider connection. A turn request may pin any of them via model, and
tune the thinking budget via reasoning_effort. The override applies to
that turn only: it never mutates the deployment default
(PUT /v1/llm/default-model), so concurrent clients of one gateway keep
independent choices. Invalid values fail with 400 before the turn starts.
Versioning and compatibility
The protocol evolves additively. New event types and new optional fields appear over time; a conforming client skips unknown event types and ignores unknown fields. Existing events and fields never change meaning within a protocol version.
The start event announces the version explicitly:
{ "type": "start", "data": { "session_id": "chat_...", "protocol_version": 1 } }protocol_version bumps only on a breaking change to existing events or
fields; additive changes never bump it. A start event without the field
means version 1. Reject the stream only when the announced version is
greater than the one your client was built against. Lower or equal versions
are always safe to consume.
The npm packages follow the same discipline: a breaking wire change bumps
the major version of @minara/types and @minara/gateway-client. A
TypeScript client pins a major and upgrades minors freely; the SDK reducer
absorbs additive changes without client code changes.
Conformance checklist
A minimal conforming client:
- Sends
POST /v1/chat/streamand binds the returnedsession_id. - Subscribes
chat:<session_id>on/v1/stream, renderstext_delta, and finishes ondone/error. Skips unknown event types. - Rebuilds history from
GET /v1/sessions/:idon load.
A full-featured client adds tool-call cards, ui_block rendering, the
interaction answer flow, attachments, voice, per-turn model override, and
session search. Each addition is independent; adopt them in any order.