Send messages

POST /api/v1/assistants/{assistantId}/messages runs one assistant turn and returns the reply.

HTTP
POST /api/v1/assistants/{assistantId}/messages
Authorization: Bearer sk_live_...
Content-Type: application/json

Request

JSON
{
  "externalUserId": "user:42",
  "externalUserName": "Alice",
  "externalUserEmail": "[email protected]",
  "identified": true,
  "sessionId": "thread-789",
  "message": "How does the proposal vote work?"
}
FieldTypeRequiredDescription
externalUserIdstringYesStable id for the end user in your system. Opaque to Use Brian, so namespace it however you want (e.g. user:42, wallet:addr1q...).
externalUserNamestringNoDisplay name for the visitor. Shown in the assistant's member list.
externalUserEmailstringNoEmail for the visitor. Implies identified=true and enables auto-merge if the same human later signs up via OAuth.
identifiedbooleanNoOpt into Tier 1 (memory on) without an email. See Identity & memory.
sessionIdstringNoConversation key. Reuse to continue a thread; pass a new value to start fresh. Defaults to externalUserId (one thread per user).
messagestringYesThe user's text. Max 16,000 characters.

Response (200)

JSON
{
  "sessionId": "thread-789",
  "messageId": "9f1e7c2a-...",
  "reply": "Proposals reach a vote 7 days after submission. Quorum is 30% of staked supply...",
  "model": "gemini-3-flash-preview"
}
FieldTypeDescription
sessionIdstringEcho of the session id (or the default if you didn't pass one).
messageIdstringStable id of the stored assistant turn.
replystringThe assistant's reply text.
modelstringWhich model produced the reply (e.g. gemini-3-flash-standard for Standard, gemini-3-flash-preview for Pro, gemini-3.5-flash for Max, gemini-3-pro-research for Research).

Example: curl

bash
curl -X POST "https://api.usebrian.ai/api/v1/assistants/<ASSISTANT_ID>/messages" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user:42",
    "externalUserName": "Alice",
    "externalUserEmail": "[email protected]",
    "sessionId": "thread-789",
    "message": "How does the proposal vote work?"
  }'

Example: fetch (Node.js / Deno)

JavaScript
const reply = await fetch(
  `https://api.usebrian.ai/api/v1/assistants/${assistantId}/messages`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.USEBRIAN_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      externalUserId: `user:${userId}`,
      externalUserEmail: userEmail,           // optional, Tier 1 + auto-merge
      sessionId: threadId,                    // optional, defaults to externalUserId
      message: userText,
    }),
  }
).then((r) => r.json());

console.log(reply.reply);

Example: Python

Python
import os
import requests

resp = requests.post(
    f"https://api.usebrian.ai/api/v1/assistants/{assistant_id}/messages",
    headers={
        "Authorization": f"Bearer {os.environ['USEBRIAN_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "externalUserId": f"user:{user_id}",
        "externalUserEmail": user_email,   # optional, Tier 1 + auto-merge
        "sessionId": thread_id,            # optional
        "message": user_text,
    },
    timeout=30,
)
resp.raise_for_status()
print(resp.json()["reply"])

Errors

HTTPerrorWhen it happens
400invalid_inputBody missing required field, malformed email, or message too long.
401invalid_api_keyAuthorization header missing, malformed, hash mismatch, or the key doesn't match the assistant in the URL.
403key_revokedKey exists but has been revoked.
404assistant_not_foundKey valid but the assistant no longer exists.
429budget_exhaustedThe workspace has no active plan (trial ended or plan lapsed). Ask the owner to pick a plan, or self-host.
502upstream_failedLLM provider error after retries. Treat as transient.
500internalAnything else. Open a bug report.

Idempotency

Not implemented in v1. Retrying a failed request may produce a duplicate assistant turn in the same session. Plan for this by storing your own request id and checking your DB before retrying. Idempotency-Key header support is on the roadmap.

Latency

Synchronous endpoint. Typical response time is 3–15 s; allow at least 30 s of timeout in your backend. Streaming (SSE) is on the roadmap if your UX needs sub-second token latency.

Follow-up suggestions: <followup> tag

When enabled for a client, the assistant may append a machine-readable block of 2–4 short follow-up questions at the very end of reply. The v1 public API does not inject the instruction that produces the tag, so API consumers won't normally see it; Use Brian's own clients do. The format is documented so any client can render the suggestions as chips or strip them, deterministically.

Tag shape

When present, the tag is the very last thing in reply (no trailing text). The contents are a JSON array of 2–4 strings, each a short stand-alone question. Anything before the opening <followup is the user-visible body.

Example reply with a tag:

JSON
{
  "sessionId": "thread-789",
  "messageId": "9f1e7c2a-...",
  "reply": "CIP-1694 governance relies on three groups: Delegate Representatives (DReps), Stake Pool Operators (SPOs), and the Constitutional Committee. You participate by delegating your ADA's voting power to a DRep, who votes on your behalf regarding treasury withdrawals, parameter changes, and more. For an action to pass, it must meet specific consensus thresholds from these governing bodies.\n\n<followup>[\"What is a DRep?\", \"How do I delegate my voting power?\", \"What are governance thresholds?\"]</followup>",
  "model": "gemini-3-flash-preview"
}

Handling in your client

Two valid strategies: parse and render (split on the tag, show the prose, render each question as a chip that re-sends its text), or strip and ignore (drop everything from <followup onward). Always handle the partial-tag case: an opening <followup with no closing tag should hide everything from the marker on, so raw markup never flashes.

Reference parser

Match /<followup>\s*(\[[\s\S]*?\])\s*<\/followup>/ against reply, then JSON.parse the captured group and filter to non-empty strings (max 4). The display text is reply.slice(0, indexOf('<followup')).trimEnd(). The same logic ships in @use-brian/shared as parseFollowUps(text). The JS snippet below mirrors it.

JavaScript
// Reference parser. Matches the @use-brian/shared parseFollowUps() helper.
const TAG = /<followup>\s*(\[[\s\S]*?\])\s*<\/followup>/;

function parseFollowUps(reply) {
  const open = reply.indexOf("<followup");
  if (open === -1) return { display: reply, questions: [] };

  // Hide the partial tag during streaming so raw markup never flashes.
  const display = reply.slice(0, open).trimEnd();

  const match = reply.match(TAG);
  if (!match) return { display, questions: [] };

  try {
    const parsed = JSON.parse(match[1]);
    const questions = Array.isArray(parsed)
      ? parsed.filter((q) => typeof q === "string" && q.trim().length > 0).slice(0, 4)
      : [];
    return { display, questions };
  } catch {
    return { display, questions: [] };
  }
}

// Render: show `display` as the assistant's body, render each `questions[i]`
// as a clickable chip that re-sends the question text. If your surface has
// no chip affordance, ignore `questions` and only show `display`.