Agent provider
An agent provider is the object you write to connect Loquix to a real backend. It is the only integration point: the agent controller calls it, and it calls your API. See the integration overview for how the two relate.
A provider does two things:
- Converts the conversation history and send options into whatever shape your backend expects.
- Converts your backend’s response into a
ReadableStream<string>of plain text chunks.
Nothing else about your backend — auth, request format, streaming protocol — is visible outside the provider. The controller only ever sees AgentMessage[] in and a stream of strings out.
The AgentProvider interface
Section titled “The AgentProvider interface”interface AgentProvider { readonly name: string;
send(messages: AgentMessage[], options: AgentSendOptions): Promise<AgentResponse>;
listModels?(): Promise<Array<{ id: string; name: string; description?: string }>>;}-
name— a human-readable label for the provider (for example"Claude API"or"my-ai"). It is not used for routing; it exists for logging and for display. -
send(messages, options)— the one required method. It receives the full conversation history and returns a promise for anAgentResponse. The controller awaits this promise once per send; everything after that point is the stream inside the response. -
listModels()— optional, and a convention rather than a hook: nothing in@loquix/corecalls it. Neither the controller nor any component invokeslistModels()— if you implement it, your own application code is what calls it and feeds the result to<loquix-model-selector>. The shapes do not match either, so that call has to map one to the other:const models = await provider.listModels(); // { id, name, description }[]modelSelector.models = models.map(m => ({value: m.id,label: m.name,description: m.description,})); // ModelOption[]If you do not implement
listModels(), drive the selector from static configuration instead.
send should throw on failure — network error, non-2xx response, auth failure, rate limit — rather than returning a response with an error inside it. The controller catches the rejection and moves to its error state.
AgentMessage
Section titled “AgentMessage”interface AgentMessage { id: string; role: 'user' | 'assistant' | 'system' | 'tool'; content: string; attachments?: AgentMessageAttachment[]; metadata?: Record<string, unknown>;}This is the shape the controller keeps for conversation history and the shape send receives, one per message. It is deliberately minimal — a provider maps it to its backend’s own message format internally rather than the reverse.
attachments, when present, is an array of:
interface AgentMessageAttachment { url: string; mimeType: string; filename?: string;}These are references to already-uploaded files, not file contents — Loquix does not send binary data through AgentMessage.
metadata is an open bag for anything provider-specific, such as tool call results or token counts. Loquix does not read it.
AgentSendOptions
Section titled “AgentSendOptions”interface AgentSendOptions { signal?: AbortSignal; model?: string; params?: Record<string, unknown>; systemPrompt?: string;}signal— the controller always passes one. It combines its own internal abort with its send timeout into a single signal, but the send-timeout side only bounds the wait for yoursend()to resolve: the instant it does, the controller clears that timer, and the signal cannot fire fromsendTimeoutagain. Forward it tofetch, as the example below does, and a deliberate stop —abort(),reset(),setProvider()swapping providers, orhostDisconnected()when the host element is removed from the DOM — still cancels the request and its body at any point, mid-stream included. A slow-starting request times out; a request that is already streaming does not, no matter how long it runs. To bound silence within a stream that has started, the controller has a separatestreamIdleTimeoutoption — see the agent controller page — enforced by the controller’s own stream consumption, not through this signal at all.model— the model identifier configured on the controller, if any.params— inference parameters such astemperature,top_p, ormax_tokens, passed through as-is.systemPrompt— a system prompt override, if the controller was configured with one.
A provider is free to ignore any option it does not support.
AgentResponse
Section titled “AgentResponse”interface AgentResponse { id: string; stream: ReadableStream<string>; metadata?: Record<string, unknown>;}id— a unique identifier for this response message.stream— a standardReadableStream<string>. The controller reads it chunk by chunk and accumulates the result; it does not know or care what streaming protocol your backend used to produce those chunks.metadata— provider-specific information about the response, such as the model that actually served it or a finish reason.
The stream carries plain text chunks, not tokens and not raw SSE frames. If your backend streams newline-delimited JSON, server-sent events, or a token-by-token protocol, decoding that into text is the provider’s job, done before each chunk reaches the stream.
A complete example
Section titled “A complete example”This provider calls a /api/chat endpoint that streams newline-delimited JSON events ({ "text": "..." }) and turns them into a plain-text stream:
import type { AgentProvider, AgentMessage, AgentSendOptions, AgentResponse,} from '@loquix/core/providers/agent-provider';
class MyBackendProvider implements AgentProvider { readonly name = 'my-backend';
async send(messages: AgentMessage[], options: AgentSendOptions): Promise<AgentResponse> { const response = await fetch('/api/chat', { method: 'POST', signal: options.signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ messages: messages.map(m => ({ role: m.role, content: m.content })), model: options.model, system: options.systemPrompt, }), });
if (!response.ok) throw new Error(`Chat request failed: ${response.status}`);
const body = response.body!; const stream = new ReadableStream<string>({ async start(controller) { const reader = body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += value; const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (line.trim()) controller.enqueue(JSON.parse(line).text); } } controller.close(); }, });
return { id: crypto.randomUUID(), stream }; }}Hand an instance of this class to the agent controller in place of a stub, and messages sent through Loquix components reach /api/chat and stream back into the conversation.
Where this fits
Section titled “Where this fits”The agent controller is the only consumer of this interface — nothing in the component catalog calls a provider directly. See the integration overview for how the provider, the controller, and Loquix components divide the work.