MINARA

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:

SurfaceWhat it does
POST /v1/chat/streamstarts 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/answeranswers questions the agent asks back

Two npm packages wrap the contract so a TypeScript client writes none of the plumbing:

  • @minara/types carries the wire types (ChatStreamEvent, ChatAttachment, question payloads).
  • @minara/gateway-client carries 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:

  1. POST /v1/chat/stream with the user message. The gateway responds immediately with { session_id, kind, is_new } and runs the turn in the background.
  2. Subscribe to the chat channel on the multiplexed WebSocket, keyed by the returned session_id. The turn's events arrive in order with per-channel sequence numbers; a reconnect resumes from the last seq seen. The frame format, subscribe handshake, replay, and control frames are specified on the stream protocol page.
  3. Fold each event onto the in-progress assistant turn until done or error arrives.

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:

FieldPurpose
messagethe user's text (or a voice transcript)
session_idcontinue an existing conversation; omit to start a new one
session_kindsurface bucket for a new session (chat default, institution, markets, workflow, strategy-studio, data-studio, messaging)
attachmentsuploaded file references, see Attachments
voice_input_keyFileStore key of the original mic recording
modelrun this turn on a specific model id, see Model selection
reasoning_effortthinking tier for this turn (minimal / low / medium / high)
retrytrue replaces the session's latest turn instead of stacking a duplicate
surfaceweb (default) or cli; cli omits the custom-URI wire protocol from the system prompt
prompt_modefull (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:

GroupEventsClient obligation
Handshakestartcheck protocol_version (see Versioning); read supported_blocks
Session identitysession, session_titlebind the session id, update titles
Turn texttext_delta, reasoning_delta, responseappend text; response is a fallback that only matters when no text_delta arrived
Tool callstool_call_start, tool_call_resultrender call cards; calls can interleave with text
Sub-tool progresssub_tool_call, sub_tool_text_delta, sub_tool_thinking_delta, tool_progressoptional; long-running tools stream their internals here
Rich UIui_blockrender or ignore; see the UI Block Protocol
Interactionpending_question_added, pending_question_resolvedsee Interactions
Steeringuser_interjection, context_compactedinsert the injected user bubble; show a compaction hint
Progressphase, skill_activated, todo_snapshot, goaloptional status affordances
Terminaldone, errorexactly 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/:id returns the full transcript as message rows. Assistant rows carry a metadata blob with the ordered segments and tool-call records the stream produced, so a rebuild renders exactly what the user saw live. The response also carries is_streaming (reattach to the live WebSocket buffer) and pending_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:

  1. Sends POST /v1/chat/stream and binds the returned session_id.
  2. Subscribes chat:<session_id> on /v1/stream, renders text_delta, and finishes on done / error. Skips unknown event types.
  3. Rebuilds history from GET /v1/sessions/:id on 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.

On this page