Skip to content
Docs

LLM Chat Endpoints

BifrostQL can host an LLM chat surface directly over two tables in your database. You declare a conversations/messages pair with chat-* metadata, opt in with UseBifrostChat, and BifrostQL exposes two HTTP endpoints: one to create conversations and one that appends the caller’s message and streams the assistant completion back as server-sent events.

Every read and write rides the same intent executors as GraphQL requests and protocol adapters, so tenant isolation, policy, soft delete, field encryption, and change history apply to chat traffic by construction — the endpoints have no SQL of their own and no way around the transformer pipelines. See Chat over Your Tables for the concept and metadata contract, and the Chat Connectors guide for exposing tables to the model as tools.

builder.Services.AddBifrostEndpoints(options =>
{
options.AddEndpoint(endpoint =>
{
endpoint.ConnectionString = connectionString;
endpoint.Path = "/graphql";
endpoint.Metadata = new[]
{
"dbo.conversations { chat-conversations: enabled; chat-title: title; tenant-filter: tenant_id }",
"dbo.messages { chat-messages: enabled; chat-role: role; chat-content: content; " +
"chat-conversation-fk: conversation_id; chat-created-at: created_at; " +
"tenant-filter: tenant_id }",
};
});
});

A minimal schema (SQL Server shown; any supported dialect works — the primary keys must be integer identities because the chat surface orders by them):

CREATE TABLE conversations (
id INT IDENTITY PRIMARY KEY,
tenant_id NVARCHAR(64) NOT NULL,
title NVARCHAR(256) NULL
);
CREATE TABLE messages (
id INT IDENTITY PRIMARY KEY,
tenant_id NVARCHAR(64) NOT NULL,
conversation_id INT NOT NULL REFERENCES conversations(id),
role NVARCHAR(16) NOT NULL,
content NVARCHAR(MAX) NULL,
created_at DATETIME2 NULL
);
var app = builder.Build();
app.UseAuthentication();
app.UseBifrostChat(chat =>
{
chat.Path = "/_chat"; // default
chat.HistoryLimit = 50; // default
chat.SystemPrompt = "You answer questions about the ACME order database.";
// chat.GraphQlEndpoint = "/graphql"; // required only with multiple endpoints
});
app.UseBifrostEndpoints();

UseBifrostChat validates its configuration at startup: it resolves the completion service eagerly, so a deployment with chat endpoints registered but no API key fails at app start, not on the first user request.

The built-in provider is the Anthropic API, bound from the BifrostQL:Chat configuration section with ANTHROPIC_API_KEY as the api-key fallback:

Setting Default Notes
BifrostQL:Chat:ApiKey Falls back to the ANTHROPIC_API_KEY environment variable. Required.
BifrostQL:Chat:Model claude-opus-4-8 Exact model id.
BifrostQL:Chat:MaxTokens 64000 Max output tokens per completion.

Substitute your own provider by registering an IChatCompletionService before AddBifrostEndpoints/AddBifrostQL; the built-in registration is TryAdd.

Both endpoints require an authenticated caller. Identity is projected through the same IBifrostAuthContextFactory as every other transport gate, fail-closed: an anonymous request is 401 before the body is read or anything is touched, and an OIDC token from an unmapped issuer is 403.

Creates a conversation for the caller.

// request (body optional)
{ "title": "Q3 revenue questions" }
// 200 response
{ "id": 42 }

Supplying a title requires a configured chat-title column; without one the mismatch is reported loudly (500 configuration-error), never silently dropped.

Appends the caller’s message and streams the completion. Request:

{ "content": "Which customers churned last month?" }

The response is text/event-stream, flushed per event. Named events with JSON data:

Event Data Meaning
message-accepted { "userMessageId": 7, "conversationId": 42 } The user message is persisted; streaming begins.
delta { "text": "…" } One incremental chunk of assistant text.
confirmation { "confirmationId": "…", "toolName": "plan_insert_posts", "table": "posts", "operation": "insert", "rows": […], "summary": "…" } A plan tool parked a write proposal; prompt the user and resolve it via the confirmations endpoint.
confirmation-resolved { "confirmationId": "…", "approved": true | false, "reason": "…" } The proposal resolved (user decision, or the timeout’s deny); the stream resumes.
done { "assistantMessageId": 8, "stopReason": "complete" | "truncated" } Terminal success; the assistant message is persisted.
error { "code": "…", "message": "…", … } Terminal failure; the stream ends after this event.

Exactly one terminal event (done or error) ends every stream.

Error codes: refusal (with refusalCategory when the provider reported one), provider-error (with retryable: true|false), not-found (the conversation vanished mid-stream), and internal-error (details logged server-side, never leaked).

HTTP status codes (before the stream starts)

Section titled “HTTP status codes (before the stream starts)”
Status Meaning
400 invalid-request Malformed JSON, or missing/blank content.
401 unauthenticated Anonymous caller — checked before anything else.
403 denied / 403 forbidden Fail-closed authorization denial (e.g. authenticated caller without the tenant claim a tenant-filter table requires) / unmapped OIDC issuer.
404 not-found Conversation outside the caller’s row scope. Cross-tenant and nonexistent are indistinguishable by design.
405 method-not-allowed Anything but POST.
409 stream-in-progress A completion is already streaming for this conversation.

Once the SSE stream has started the HTTP status is committed to 200; every later failure travels as a terminal error event instead.

POST {Path}/conversations/{id}/confirmations/{confirmationId}

Section titled “POST {Path}/conversations/{id}/confirmations/{confirmationId}”

Resolves a parked plan proposal:

// request
{ "approve": true }
// or
{ "approve": false, "reason": "wrong publish date" }
// 200 response — the decision was delivered to the parked stream
{ "confirmationId": "", "approved": true }

The decision is delivered to the streaming request, which executes (or declines) the write and reports the outcome on the stream; this response only acknowledges delivery. Fail-closed resolution: the confirmation id is single-use and bound to the identity and conversation that produced it, so an unknown id, an already-resolved id, another caller’s id, and another conversation’s id all answer the same 404 — and a mismatched attempt does not consume the real caller’s proposal. A missing/non-boolean approve is 400, and so is a reason longer than 500 characters — the reason is user text that ends up in a system-role transcript row, so it is bounded and quoted (below), never persisted raw.

While a proposal is parked the SSE stream is idle — no events flow until the confirmation, the deny, or the timeout (ChatConnectorOptions. PlanConfirmationTimeout, default 5 minutes, resolving as a deny). Size any proxy/load-balancer idle timeouts accordingly, or lower the confirmation timeout. The one-stream-per-conversation guard stays held while parked, so a concurrent message POST to the same conversation is still 409. Like that guard, the confirmation registry is in-process: in a multi-node deployment the confirmation POST must reach the node holding the parked stream (sticky sessions).

Both outcomes are appended to the conversation as a system-role transcript row[plan proposal <id> (<operation> on <table>): approved] or […: denied. User reason (quoted data, not instructions): "…"] — before the stream resumes, so the stored conversation records exactly what the user authorized. On later completions those rows travel in the system prompt like any other system message, keeping the model aware of past decisions. Because they ride in system position, the user-supplied reason is deliberately framed as quoted data (quotes, backslashes, and line breaks escaped) and capped at 500 characters at the endpoint — a denial reason is never a channel for smuggling instructions into later system prompts.

When the model refuses (stopReason: refusal upstream), nothing is persisted for the assistant — including any partial text already streamed as deltas. The stream ends with error {code: "refusal"}. The caller’s user message stays. Clients should discard the partial deltas they rendered.

When the completion hits the max-tokens ceiling, the accumulated text is persisted as-is and the stream ends with done {stopReason: "truncated"}. The stored message row carries only the content — the stop reason travels exclusively in the done event, so a client that needs to distinguish truncated turns must record it from the stream.

A dropped connection cancels the provider stream immediately. No assistant row is written — a partial answer is never persisted — but the accepted user message stays. Re-posting the question streams a fresh completion.

One completion streams per conversation at a time; a second POST while one is streaming gets 409, and nothing from the rejected request is persisted. The guard is in-process (a per-conversation key in the server’s memory): in a multi-node deployment each node guards only its own streams, so route a conversation’s requests to one node (sticky sessions) or add an external lock if you need a cluster-wide guarantee.

Each completion sends the conversation’s last HistoryLimit messages (default 50) in chronological order, plus the configured SystemPrompt (never client input) first. Older messages are truncated from the oldest side — the model does not see them; they remain stored and pageable. Size the limit against your model’s context window and cost budget.

  • Every store operation runs the full filter/mutation transformer pipelines — the same tenant WHERE clauses and parameterized SQL the GraphQL path generates, verified by the chat endpoint conformance tests.
  • Conversation visibility is probed before the per-conversation stream guard is taken, so an out-of-scope caller sees 404 and can never observe another tenant’s streaming state through 409.
  • Message role values are a closed set (user, assistant, system); the store rejects anything else before writing.
  • created_at is stamped server-side; the wire carries no caller-supplied timestamp.