The Chat API Contract

This page is the contract between Smarter Chat and the Smarter backend: every endpoint the chat calls, and every header, field, event and error that it depends on. It is written for backend developers. If you change anything in smarter.apps that ends up in one of these responses, for example an LLMClient field, a prompt’s messages, a provider’s error handling, a progress signal, or CORS or authentication, read this page first.

Where the Contract Lives

The contract is code, in smarter.apps.prompt.contract:

Module

Role

models

Pydantic models of every request, response and event, in both versions of the contract. The single source of truth.

builders

Contract negotiation, the error envelope, and the prompt’s version 2 result.

schema

The JSON Schema of the models, which manage.py export_chat_contract_schema writes to chat-contract.schema.json, beside them.

The models are used, not just documented:

  • PromptConfigView.chat_config() builds a PromptChatApiConfig, and every configuration response is derived from it, by its methods.

  • The prompt api builds its version 2 result as a PromptChatApiPromptResult.

  • Tests validate the views’ real responses, in both versions, against the models, and fail if chat-contract.schema.json is out of date.

  • Smarter Chat keeps a copy of the schema, in contract/chat-contract.schema.json, generates its TypeScript types from it (src/contract.gen.ts), and validates its test fixtures against it (src/contract.test.ts), so its tests can’t pass against responses that the backend never sends.

After changing a model, run make chat-contract, which regenerates the schema in the smarter-app container, copies it into Smarter Chat, and regenerates the TypeScript types. Commit both repositories.

chat-contract.schema.json
{
  "$defs": {
    "JsonValue": {},
    "PromptChatApiAudioPart": {
      "additionalProperties": true,
      "description": "A message's audio part.",
      "properties": {
        "input_audio": {
          "$ref": "#/$defs/PromptChatApiInputAudio"
        },
        "type": {
          "const": "input_audio",

Versions

The contract is versioned, because Smarter Chat is published to npm, and pages run whichever version they installed against whichever Smarter platform they call.

Version

Shape

1

Deprecated. The original. The configuration’s LLMClient is chatbot, with url_chatbot, because every key that contains llmclient is renamed to chatbot, at every depth (and account_number to accountNumber). A prompt’s result is an AWS Lambda style response, whose body is a JSON string.

2

The default. Keys are not renamed: the LLMClient is llmclient, with url_llmclient. A prompt’s result is plain JSON: its messages, its completion, and its error. Both responses have contract: 2.

requested_contract() decides, from the X-Smarter-Capabilities request header, a comma-separated list of capabilities:

  • chat-contract-v2: version 2. Smarter Chat sends it.

  • chat-contract-v1: version 1.

  • neither, from Smarter Chat (X-Smarter-Client: @smarter.sh/ui-chat): version 1, because the versions of Smarter Chat that send no capability, up to 0.7, read version 1 only. The pages that installed them keep working.

  • neither, from any other client: version 2.

The header is in CORS_ALLOW_HEADERS on every Smarter platform, so a new client can send it to an older platform, which ignores it and answers with version 1; Smarter Chat reads both.

Version 1 is marked with typing_extensions.deprecated: its models, and PromptChatApiConfig.to_v1(), raise DeprecationWarning when they are used, and type checkers flag their callers. CHAT_CONTRACT_V1, a constant, can’t be marked, so its docstring says so.

The smarter cli calls both views internally: the configuration with as_view(legacy_keys=False, include_diagnostics=True), which is version 2, and the prompt with as_view(include_diagnostics=True, forced_contract=CHAT_CONTRACT_V1), because the cli’s clients parse version 1. Both receive the diagnostics that browsers never receive.

Overview

Browser (Smarter Chat)                         Smarter

on load, on "new chat", and after every prompt:
  POST <apiUrl>config/?session_key=<key>  ───▶  PromptConfigView
                                          ◀───  the LLMClient's configuration, the session's
                                                key, and the session's history

on each message:
  POST <llmclient.url_llmclient>?session_key=<key>  ───▶  DefaultLLMClientApiView
       {"session_key", "messages": [...]}                 (LLMClientApiBaseViewSet)
                                          ◀───  the new messages, as JSON, or as Server-Sent
                                                Events: progress events, then the result

while the Console's Server Logs tab exists:
  GET <logStreamUrl>[?level=DEBUG]        ───▶  stream_user_logs
                                          ◀───  Server-Sent Events: a "bulk" history, then
                                                one event per log record

The configuration tells the chat where to send prompts, so apiUrl is the only url a page has to know.

Endpoints

Url

Served by

Configuration, workbench

https://<platform>/workbench/llm-clients/<hashed_id>/config/

PromptConfigView

Configuration, deployed LLMClient

https://<name>.<account-number>.api.<domain>/config/ (and /), or the same paths on its verified custom domain

PromptConfigView: smarter.urls.llmclients for the default host, and the root config/ route of smarter.urls.console for a custom domain

Configuration, platform api

https://<platform>/api/v1/llm-clients/<hashed_id>/config/, plus the legacy aliases <llmclient_id>/config/, <hashed_id>/prompt/config/ and <llmclient_id>/prompt/config/

PromptConfigView

Prompt

The configuration’s url_llmclient: /prompt/ on the host that served the configuration, for a deployed LLMClient’s own host or custom domain (prompt_url()), else https://<platform>/api/v1/llm-clients/<hashed_id>/prompt/ (LLMClient.url_llmclient)

DefaultLLMClientApiView, a LLMClientApiBaseViewSet

Server logs

The smarter-log-stream-url that the workbench passes, or the logStreamUrl prop

smarter.apps.dashboard.views.terminal_emulator.api.streams.stream_user_logs

A page that embeds the chat therefore calls one host only: its LLMClient’s.

Note

/workbench/llm-clients/<hashed_id>/prompt/ is the workbench page (PromptWorkbenchView), not the prompt api.

Note

smarter.hosts routes a deployed LLMClient’s default host, <name>.<account-number>.[<environment>.]api.<domain>, to smarter.urls.llmclients, which serves /, /config/ and /prompt/ only. A custom domain matches no host pattern, so it falls through to the default host’s smarter.urls.console, whose root config/ and prompt/ routes serve it.

Requests

Every request is a POST with a JSON body, sent with fetch(), credentials: "include" and mode: "cors". GET returns 405, unless the ALLOW_API_GET waffle switch is active for the configuration.

Header

Value

Accept

application/json; for a prompt with streamProgress, text/event-stream, application/json.

Content-Type

application/json

X-Smarter-Capabilities

chat-contract-v2. See Versions.

X-CSRFToken

The value of the CSRF cookie, or the csrftoken prop, or empty. Both views are CSRF exempt.

X-Smarter-Client, X-Smarter-ClientVersion, X-Smarter-ClientType

@smarter.sh/ui-chat, the package’s version, and react.

X-Smarter-RequestId

The smarterRequestId prop, if set.

Authorization

Token <apiKey>, if the apiKey prop is set.

Every custom header must be in CORS_ALLOW_HEADERS, in smarter.settings.base, or browsers refuse to send it to another origin.

CORS

Preflight OPTIONS requests, and the CORS headers of the responses, come from the CORS middleware (SmarterCorsMiddleware, which extends django-cors-headers), with credentials allowed. An origin is allowed if:

  • the platform’s settings allow it (CORS_ALLOWED_ORIGINS, CORS_ALLOWED_ORIGIN_REGEXES), or

  • the LLMClient whose api the request calls lists it in spec.config.allowedOrigins, in its manifest. smarter.apps.llmclient.cors applies these, through django-cors-headers’ check_request_enabled signal, for the configuration and prompt apis only: /, /config/ and /prompt/ on a deployed LLMClient’s own host, and /api/v1/llm-clients/<id>/config/, .../prompt/ and .../prompt/config/.

allowedOrigins are origins, http(s)://host[:port], without a path or a wildcard; the manifest lowercases them and removes duplicates.

Important

Traefik’s CORS middleware answers preflight requests itself, before they reach Django, with a fixed list of origins and headers, which includes none of the X-Smarter-* headers. Each deployed LLMClient’s Ingress (smarter/apps/llmclient/k8s/ingress.yaml.tpl) therefore attaches only the https-redirect middleware, so that Django applies the platform’s settings and allowedOrigins. An LLMClient deployed before this change keeps the old Ingress until it is deployed again. The platform’s own hosts are defined in the smarter-infrastructure repository; a page that calls their /api/v1/ urls from another origin needs them to drop the Traefik CORS middleware too.

Authentication

Caller

How it is authenticated

The workbench

The web console’s Django session cookie.

A page with an apiKey

Authorization: Token <apiKey>, which SmarterTokenAuthenticationMiddleware authenticates for api urls: those under /api/, or on the api subdomain, which includes every deployed LLMClient’s host.

An anonymous page

Allowed for a deployed LLMClient without an active api key (LLMClient.is_authentication_required). Its prompts run, and are charged, as the LLMClient’s owner. An LLMClient with an api key answers an anonymous request with 403, an undeployed one with 404.

Both views extend SmarterOptionallyAuthenticatedApiView, which, unlike the web console’s views, never redirects to the login page: it lets every request through to the view, which authorizes it for the LLMClient it asks for, and answers with the error envelope if not. It is never cached, and CSRF exempt.

A url with an id, a workbench or platform api url, requires a user who may read the LLMClient, and answers anyone else with 404, so that the ids of other accounts’ LLMClients can’t be discovered.

Chat Sessions

A chat session is identified by a 64-character hexadecimal key, generated by the configuration api (SmarterRequestMixin.generate_session_key()). The chat:

  1. sends the key from its session cookie, if any, as the session_key query parameter and in the body of the configuration request,

  2. saves the returned data.session_key in its session cookie, for the page’s path,

  3. sends the key, as the session_key query parameter and body field, with each prompt,

  4. deletes the cookie on new chat, so that the next configuration request gets a new key.

The backend looks for the key in the url, the body, a header and a cookie, in that order (SmarterRequestMixin.find_session_key()), and generates one if there is none. The session’s history is stored by the backend; the chat stores nothing but the key.

The Configuration

Request body: {"session_key": "<key or empty>"} (PromptChatApiConfigRequest).

PromptConfigView.chat_config() builds a PromptChatApiConfig, and PromptConfigView.config() derives the response from it:

Request

Response

A workbench or platform api url, which finds the LLMClient by its id and checks that the user may read it, or the cli

The complete configuration: every LLMClient field (LLMClientConfigSerializer), the session’s diagnostic histories, and the lists of plugins and functions.

A deployed LLMClient’s own host, or custom domain

The public configuration, PromptChatApiConfig.public(): the LLMClient’s public fields only (PUBLIC_LLMCLIENT_FIELDS, without e.g. its owner’s profile), the session’s own messages, the public meta_data fields, and the plugins’ and functions’ counts, without their lists.

Then, in version 2, the model is dumped as is; in version 1, PromptChatApiConfig.to_v1() renames its keys.

No field of the contract is an untyped list. Every list holds a named model, and the few values that are stored as arbitrary JSON, such as a tool’s result, are PromptChatApiJson, which is exactly JSON. In particular, the lists of the complete configuration are models of their Django ORM rows (PromptChatApiOrmModel), built from the rows by from_model(), with exactly the rows’ fields:

Field

Model, and its row

plugins.plugins

PromptChatApiLLMClientPlugin: LLMClientPlugin, with its PluginMeta, without its owner

functions.functions

PromptChatApiLLMClientFunction: LLMClientFunctions

history.prompt_tool_call_history

PromptChatApiPromptToolCall: PromptToolCall

history.prompt_plugin_usage_history

PromptChatApiPromptPluginUsage: PromptPluginUsage

history.plugin_selector_history

PromptChatApiPluginSelection: PluginSelectorHistory

history.llmclient_request_history

PromptChatApiLLMClientRequest: LLMClientRequests

A plugin’s class and a function’s name are the ORM’s choices, and a test fails if the two differ. Messages (PromptChatApiMessage) declare their content parts (text, image, audio, file and refusal, by type) and their tool calls.

The fields that Smarter Chat reads are the declared fields of PromptChatApiConfig, PromptChatApiLLMClient, PromptChatApiHistory and PromptChatApiMetaData. llmclient.url_llmclient and session_key are required. Everything else is displayed, as is, in the Console’s Config tab.

Note

The system prompt, default_system_role, is public: the chat sends it as the first message of a new session. Never put a secret in a system prompt.

The Prompt

Request body (PromptChatApiPromptRequest):

{
  "session_key": "e019a5c1cb2c1cb87992d2d03bccaf44c9d98406b46de5516a4fa03650038ac8",
  "messages": [
    { "role": "system", "content": "You are a helpful assistant. DO NOT GUESS." },
    { "role": "assistant", "content": "Welcome to Stackademy! How can I help you today?" },
    { "role": "user", "content": "Do you offer any courses on AI?" }
  ]
}

messages is the whole thread, with only the roles that LLMs accept: system, assistant, user and tool. Messages that came from the backend keep all of their original fields, for example an assistant message’s tool_calls. Smarter’s own smarter and smarter_error messages are never sent.

The view runs the prompt with the LLMClient’s provider (smarter_compatible_client), which calls the LLM, its tools, plugins and MCP servers, and saves the new messages in the session’s history. The provider adds the complete requests to and responses from the LLM to the completion, as smarter.first_iteration and smarter.second_iteration, and a metadata object, whose tool calls describe each plugin with its owner’s profile. They include the system prompt and every tool result, so without_diagnostics() removes them from every response except the cli’s.

Version 2

PromptChatApiPromptResponse. The http status is the prompt’s own: 200, or the LLM provider’s error status.

{
  "data": {
    "contract": 2,
    "status": 200,
    "messages": [
      { "role": "smarter", "content": "Smarter selected the stackademy_sql plugin." },
      { "role": "assistant", "content": "We offer CS210 Artificial Intelligence, for $700.00." }
    ],
    "completion": { "id": "chatcmpl-123", "object": "chat.completion", "choices": [], "usage": {} },
    "plugins": ["stackademy_sql"],
    "tools": ["smarter_plugin_0000000016"]
  },
  "api": "smarter.sh/v1",
  "metadata": { "command": "prompt", "thing": "LLMClient" }
}

messages are the messages that the prompt added to the thread, in order. completion (PromptChatApiCompletion) is the LLM’s chat completion, in the format of the OpenAI api, with only its declared fields; the chat reads its choices[0].finish_reason and usage, to say when the LLM reached the LLMClient’s max tokens.

A failed prompt has the error envelope beside data, and its messages include the smarter_error message that is saved in the session’s history:

{
  "data": {
    "contract": 2,
    "status": 401,
    "messages": [{ "role": "smarter_error", "content": "401 error: Incorrect API key provided." }],
    "completion": null,
    "plugins": [],
    "tools": []
  },
  "error": { "errorClass": "PromptChatApiErrorLLMProvider", "description": "Incorrect API key provided.", "status": 401 },
  "api": "smarter.sh/v1",
  "metadata": { "command": "prompt", "thing": "LLMClient" }
}

Version 1

PromptChatApiPromptResponseV1: data is an AWS Lambda style response (isBase64Encoded, statusCode, headers, body), whose body is a JSON string. Parsed, a successful body is a chat completion with Smarter’s messages in smarter.messages (PromptChatApiCompletionV1), and a failed one is {"error": {"status", "message"}, "response": <the completion>} (PromptChatApiFailureV1).

Streaming the prompt’s progress

When the request’s Accept header includes text/event-stream (wants_event_stream()), the response is Server-Sent Events, in either version. Its http status is always 200; the prompt’s own status is in its result event.

retry: 3000

event: progress
data: {"type": "llm_request", "message": "Sending the prompt to the LLM", "iteration": 1}

event: progress
data: {"type": "tool_requested", "message": "Calling tool stackademy_sql", "tool": "stackademy_sql", "function": "smarter_plugin_0000000016", "arguments": "{...}"}

: keepalive

event: result
data: {"status": 200, "response": {"data": {"contract": 2, "status": 200, "messages": [...]}, "api": "smarter.sh/v1", "metadata": {}}}
  • retry: 3000 comes first, once.

  • progress events (PromptChatApiProgressEvent) describe the prompt’s steps, as they happen. Every one has a type and a message, a short sentence that the chat displays as is.

  • : keepalive comments are sent every 15 seconds while the prompt waits.

  • result (PromptChatApiProgressResult) comes last, once. response is exactly the JSON of a non-streaming response.

Progress event types (PromptProgressEvents)

type

Sent when

Other fields

llm_request

The prompt, or the results of its tool calls, is sent to the LLM.

iteration: 1 for the prompt, 2 and up for tool results.

tool_requested

The LLM calls a tool.

tool: its name as users know it. function: the function name that the LLM called. arguments: a JSON string, truncated to 500 characters.

tool_responded

A tool returns its result to the LLM.

tool, function

plugin_called

A plugin runs.

plugin

mcp_tool_called

An MCP server’s tool is called.

mcpclient, tool, arguments

mcp_tool_responded

An MCP server’s tool returns.

mcpclient, tool, is_error

mcp_tool_failed

An MCP server’s tool can’t be called.

mcpclient, tool, error

The events come from the signals that a prompt already sends, which smarter.apps.prompt.progress forwards to the stream of the prompt that sent them. To add an event type, add it to PromptProgressEvents and to the contract’s PromptChatApiProgressEventType; a test fails if they differ. Older versions of the chat display any event’s message, so new types are backward compatible.

Streaming needs Smarter’s ASGI server (uvicorn), and any proxy in front of it must not buffer the response. The stream sends X-Accel-Buffering: no for nginx.

Errors

Every error of both apis, in both versions, is one envelope, PromptChatApiErrorResponse, built by chat_error_response():

{
  "error": { "errorClass": "PromptChatApiErrorNotFound", "description": "LLMClient not found.", "status": 404 },
  "api": "smarter.sh/v1",
  "metadata": { "command": "prompt_config", "thing": "PromptConfig" }
}

It never includes a stack trace: errors are logged with theirs, and the response’s SmarterJournaledJsonErrorResponse is created with include_stack_trace=False.

errorClass

Status

When

PromptChatApiErrorNotFound

404

The LLMClient doesn’t exist, the user can’t read it, it isn’t deployed (to an anonymous client, or on its own host), or the chat session can’t be found.

PromptChatApiErrorForbidden

403

The LLMClient has an active api key, and the request isn’t authenticated.

PromptChatApiErrorNotReady

400

The LLMClient can’t be initialized.

PromptChatApiErrorLLMProvider

provider’s

The LLM provider rejected the prompt (version 2; version 1 keeps its Lambda style failure).

PromptChatApiErrorInternal

500

Anything unexpected, including a configuration that breaks the contract. The description is generic.

Smarter Chat displays error.description. A response that isn’t JSON, such as a proxy’s error page, becomes “Unexpected response from the Smarter api (http <status>)”.

The Server Log Stream

The Console’s Server Logs tab opens an EventSource to logStreamUrl, with credentials:

  • the first event is named bulk, and its data is a JSON array of the recent log records,

  • each later, unnamed event is one record, as JSON, with message (the formatted line, including its ANSI colors), level, logger and timestamp,

  • the level query parameter sets the minimum level; without it, the stream sends the records at smarter_settings.log_level and above. In sandbox mode the chat adds level=DEBUG.

This stream is not part of the versioned contract: only the web console’s own pages use it.

Changing the Contract Safely

  1. Change the models in smarter.apps.prompt.contract.models first, then the code that builds the response.

  2. Adding a field, a progress event type, or a message role that is only displayed is backward compatible. Declare it in the models only if Smarter Chat reads it.

  3. Removing or renaming a declared field, or changing its type, breaks the installed versions of the chat. Add the new shape beside the old one, and remove the old one only in a new version of the contract, which clients ask for with a new capability.

  4. A new request header must be added to CORS_ALLOW_HEADERS, or pages on other domains break.

  5. Run make chat-contract, and update Smarter Chat’s code and fixtures until make test passes there, then update this page.

Technical Reference