> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Chat

> Send a turn to an agent and read or clear its conversation history over HTTP, streamed or as one response

The chat routes run one conversation turn for the end user the credential belongs to: the message goes through the agent's preprocessors, model, tools, and postprocessors, and the reply is stored in the thread. Use them from a service, app, or device of your own; inside a tool, job, or webhook call [`Agents.invoke`](/reference/sdk/agents) instead, which runs the same pipeline without a token.

*Verified against lua-cli 3.33.0.*

## Base URL and authentication

Chat routes require a valid credential and no scope; the key's owner is the end user of the conversation, so one key is one end user. Clearing another end user's history is the exception and needs `org:manage` on the agent. The host, the bearer header, and the error envelope are on the [REST API overview](/reference/rest/overview).

## Threads

Every turn belongs to a [thread](/concepts/execution-contexts). The default thread is one per end user and agent; a `threadId` in the body is appended to it as a suffix, so two callers with the same key and the same `threadId` share one conversation and a fresh value starts a clean one. The history routes take the same `threadId` as a query parameter.

Equivalent: `lua chat -e production -m "<text>" -t <threadId>` sends a turn; `lua chat clear` clears history.

## Endpoints

### POST /chat/generate/:agentId

Runs one turn and answers when the reply is complete.

<ParamField path="agentId" type="string" required>
  The agent to talk to.
</ParamField>

<ParamField query="channel" type="string" default="unknown">
  Sets `Lua.request.channel` for the turn, which tools, processors, and the model resolver read, and `metadata.channel` on the turn's log lines. Any string is accepted; use `api` for a direct integration. The value also picks the reply's formatting rulebook: `web` selects the plain-Markdown rulebook the desktop and admin dashboard chat use, and every other value, including `pop`, which the web widget sends, selects the messaging rulebook whose replies may carry `:::` [formatting component](/channels/formatting/overview) blocks. `clientCapabilities: ["plain-markdown"]` forces plain Markdown on any channel.
</ParamField>

<ParamField query="identifier" type="string">
  A free-form tag stored on the request, for example the sender's address on the channel you are bridging. It is not an end-user id; the end user comes from the credential.
</ParamField>

<ParamField query="interactive" type="string">
  Pass `false` to mark the turn as not interactive, the way channel bridges and automations do; an interactive turn is one an end user is typing live.
</ParamField>

<ParamField query="background" type="string">
  Pass `true` to run the turn as a background turn.
</ParamField>

**Body**

<ParamField body="messages" type="array" required>
  The user turn as an array of content parts. Each part is `{ "type": "text", "text" }`, `{ "type": "image", "image", "mediaType"? }`, or `{ "type": "file", "data", "mediaType" }`; `image` and `data` take a URL or base64. An empty array, a null element, or a text part whose `text` is not a string answers `400`.
</ParamField>

<ParamField body="threadId" type="string">
  Thread suffix, see [Threads](#threads).
</ParamField>

<ParamField body="clientContext" type="object">
  `{ "timezone" }`, the caller's IANA timezone (at most 64 characters) used for date and time answers. Falls back to the end user's stored timezone, then country, then UTC.
</ParamField>

<ParamField body="clientCapabilities" type="string[]">
  What your client renders. `["plain-markdown"]` asks for plain Markdown with no `:::` [formatting component](/channels/formatting/overview) markers; without it the reply may contain them for you to parse. The CLI always sends it.
</ParamField>

<ParamField body="webhookPayload" type="any">
  Arbitrary data exposed to tools as `Lua.request.webhook.payload` for this turn.
</ParamField>

<ParamField body="runtimeContext" type="string">
  Extra text injected into the prompt for this turn.
</ParamField>

<ParamField body="systemPrompt" type="string">
  Replaces the persona for this turn.
</ParamField>

<ParamField body="options" type="object">
  `reasoning: { effort?, show? }` and `verbosity`. `effort` is one of `off`, `minimal`, `low`, `medium`, `high`, `max`, clamped to what the resolved model supports; `show` defaults to `true` and `false` drops the reasoning trace from the response. `verbosity` is `low`, `medium`, or `high`. The request wins over the agent's `modelSettings.reasoning`, field by field.
</ParamField>

<ParamField body="model" type="string">
  A `provider/model` [model code](/concepts/models) for this turn. An unknown or unapproved code falls back to the agent's model.
</ParamField>

<ParamField body="version" type="integer">
  Preview an [agent version](/concepts/releases-and-versions) that is not promoted, at least 1. Needs `agents:write` on the agent; runs in an isolated thread, is billed, and is kept out of production history and analytics.
</ParamField>

<ParamField body="navigate" type="boolean" default="false">
  Lets the reply carry navigation components for the web widget.
</ParamField>

<ParamField body="personaOverride" type="string or object">
  A persona text, or `{ base?, voice?, text? }` for channel-aware variants, used instead of the deployed persona.
</ParamField>

<ParamField body="skillOverride" type="array">
  `[{ skillId, sandboxId }]` runs sandbox versions of skills; `preprocessorOverride` and `postprocessorOverride` take `[{ preprocessorId, sandboxId }]` and `[{ postprocessorId, sandboxId }]`; `envOverride` is a map of environment variables that beats every stored value. This is what `lua chat -e sandbox` sends.
</ParamField>

<ParamField body="audio" type="object">
  `{ "url" }`, a permanent recording of a voice note. Stored as message metadata and never sent to the model; the transcript goes in `messages`.
</ParamField>

Fields not listed here, such as `humanMentions`, `replyTo`, `sharedThreadId`, `roomId`, `clientTools`, `artefactIds`, and `desktopSessionId`, serve the Lua desktop app and Spaces and are ignored or refused on a plain API turn.

**Response**

`201` with the model output when the turn ran, or with an in-band outcome when it did not. Check `type` before reading `text`.

<ResponseField name="text" type="string">
  The reply, after postprocessors.
</ResponseField>

<ResponseField name="finishReason" type="string">
  Why the model stopped, for example `stop` or `tool-calls`.
</ResponseField>

<ResponseField name="usage" type="object">
  Tokens of the last step: `inputTokens`, `outputTokens`, `totalTokens`. `totalUsage` carries the sum over every step.
</ResponseField>

<ResponseField name="toolCalls" type="array">
  The tool calls the model made, with `toolResults` beside them.
</ResponseField>

<ResponseField name="steps" type="array">
  One entry per model step, each with its own text, tool calls, and usage.
</ResponseField>

<ResponseField name="reasoning" type="array">
  The reasoning trace when the model produced one and `show` was not `false`; `reasoningText` joins it.
</ResponseField>

<ResponseField name="sources, files, warnings, request, response" type="various">
  Sources the model cited, files it produced, provider warnings, and the raw request and response metadata. `citations` is added when the agent cited documents.
</ResponseField>

When a preprocessor or a governance rule stops the turn, the body is `{ "type": "preprocessor_blocked" | "governance_blocked", "text", "finishReason", "usage", "timestamp" }`, and a governance block adds `governanceBlock: { stage, ruleId?, reason? }`. When the turn fails after it was admitted, the body is `{ "type": "error", "textDelta", "error", "code"?, "timestamp" }`: `textDelta` is a line safe to show the end user, `error` the developer line, and `code` is `USER_CODE_ERROR` when your tool threw, `TURN_INTERRUPTED` when a restart cut the turn, or `PROVIDER_REJECTED` with `reason`, `providerStatus`, `keyOwner`, and `transient: false` when the model provider refused the request. A billing refusal (the organization has no credits, or the caller needs a seat on this agent) is also reported as `type: "error"`, never as a status.

**Errors**

| Status | Code or message                                                    | Meaning                                                                              | Fix                                |
| ------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------- |
| `400`  | `Messages array is required and must contain at least one message` | Empty or missing `messages`; other messages name a null element or a non-string text | Send at least one well-formed part |
| `401`  | `Invalid or expired token`                                         | The credential was refused                                                           | Send a valid key                   |
| `403`  | `Insufficient permissions`                                         | `version` without `agents:write`, or a thread you have no access to                  | Grant the scope, or drop `version` |
| `404`  | `Agent not found`                                                  | No such agent                                                                        | Check the id                       |
| `423`  | `This agent is disabled.`                                          | The agent is turned off for this end user                                            | Enable the agent                   |
| `503`  | `CORE_DRAINING`                                                    | Lua is restarting; the turn never started                                            | Retry once after `Retry-After`     |

Once the turn is admitted, `/chat/generate` answers `201` and reports failures in the body. No chat route answers `424`: a preprocessor block and a provider refusal are both in-band outcomes; the `424` body belongs to `POST /ai/generate/:agentId`.

**Example**

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/chat/generate/<<YOUR_AGENT_ID>>?channel=api', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      messages: [{ type: 'text', text: 'What is the status of order 1042?' }],
      threadId: 'ticket-1042',
      clientCapabilities: ['plain-markdown'],
    }),
  });
  const turn: { type?: string; text: string; finishReason: string } = await response.json();
  if (turn.type === 'error') throw new Error(turn.text);
  console.log(turn.text);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/chat/generate/<<YOUR_AGENT_ID>>?channel=api" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{
      "messages": [{ "type": "text", "text": "What is the status of order 1042?" }],
      "threadId": "ticket-1042",
      "clientCapabilities": ["plain-markdown"]
    }'
  ```
</CodeGroup>

### POST /chat/stream/:agentId

Runs one turn and streams it. Same path, query, and body as `generate`, plus one query parameter.

<ParamField query="protocol" type="string">
  `ui` switches the body to the AI SDK UI message stream, which `useChat` consumes and which carries reasoning parts and typed `data-lua-*` parts. Without it the body is the newline-delimited format this section documents.
</ParamField>

**Response**

`200` with `Content-Type: text/event-stream`, but the body is not SSE: it is a sequence of JSON objects separated by blank lines, with no `data:` prefix. Split on newlines, skip empty lines, and parse each line as JSON. Every chunk carries `type`, and most carry `timestamp` (Unix time in milliseconds).

| `type`                         | Fields                                                                   | Meaning                                                                                          |
| ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `text-delta`                   | `textDelta`                                                              | The next piece of reply text; also carries a preprocessor's or a governance rule's end-user text |
| `tool`                         | `tool` (JSON string)                                                     | A tool call the model made                                                                       |
| `tool-input-delta`             | `toolName`, `toolCallId`, `delta`                                        | Streamed tool arguments                                                                          |
| `tool-result`                  | `toolResult` (JSON string)                                               | The tool's result                                                                                |
| `heartbeat`                    | —                                                                        | Sent every 15 seconds while the stream is open, so a proxy never sees an idle connection         |
| `postprocess-complete`         | `originalResponse`, `modifiedResponse`                                   | A postprocessor changed the text; `modifiedResponse` is the final reply                          |
| `preprocessor_blocked`         | `message`                                                                | A preprocessor stopped the turn                                                                  |
| `governance_blocked`           | `textDelta`, `reason`, `ruleId`, `stage`?                                | A governance rule stopped the turn                                                               |
| `governance_approval_required` | `textDelta`, `reason`, `ruleId`, `approvalId`, `approval`                | A governance rule parked the turn for approval                                                   |
| `batch-abort`, `batch-handled` | `message`                                                                | This message was absorbed into a batch of later messages and answered there                      |
| `error`                        | `textDelta`, `error`, `code`?, `reason`?, `providerStatus`?, `keyOwner`? | The turn failed; same fields as the `generate` error body                                        |
| `system`                       | `data` (JSON string)                                                     | A model chunk of another kind, passed through                                                    |

The server closes the response when the turn ends; there is no closing chunk on a normal turn, so treat the close as the end. The only `{ "type": "finish" }` line the stream ever writes closes the two short-circuits that never run the model: an `APPROVE`/`DENY` reply to a governance approval, and a turn that only delivers a room message. Postprocessors run on both routes: the model's text streams first, then `postprocess-complete` carries the final text when one changed it.

**Errors**

Statuses are the same as `generate` and are answered before the stream opens; after the first chunk a failure arrives as an `error` chunk and the response ends.

**Example**

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/chat/stream/<<YOUR_AGENT_ID>>?channel=api', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages: [{ type: 'text', text: 'Summarize my open tickets.' }] }),
  });
  if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let reply = '';
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';
    for (const line of lines) {
      if (!line.trim()) continue;
      const chunk = JSON.parse(line) as { type: string; textDelta?: string; modifiedResponse?: string; error?: string };
      if (chunk.type === 'text-delta') reply += chunk.textDelta ?? '';
      if (chunk.type === 'postprocess-complete') reply = chunk.modifiedResponse ?? reply;
      if (chunk.type === 'error') throw new Error(chunk.error);
    }
  }
  console.log(reply);
  ```

  ```bash cURL theme={null}
  curl -N -X POST "https://api.heylua.ai/chat/stream/<<YOUR_AGENT_ID>>?channel=api" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "messages": [{ "type": "text", "text": "Summarize my open tickets." }] }'
  ```
</CodeGroup>

### GET /chat/history/:agentId

Returns the thread's messages, oldest first.

<ParamField path="agentId" type="string" required>
  The agent.
</ParamField>

<ParamField query="threadId" type="string">
  Thread suffix; omit it for the default thread.
</ParamField>

**Response**

`200` with an array of messages.

<ResponseField name="id" type="string">Message id.</ResponseField>
<ResponseField name="role" type="string">`user` or `assistant`.</ResponseField>
<ResponseField name="createdAt" type="string">ISO 8601 timestamp.</ResponseField>

<ResponseField name="content" type="array">
  Typed parts: `type` is `text`, `reasoning`, `tool`, `image`, `video`, `audio`, `file`, `source-url`, `source-document`, or a `data-lua-*` part, with the matching field (`text`, `image`, `data` and `mediaType`, `toolName`, `toolCallId`, `input`, `output`, `url`, `title`).
</ResponseField>

<ResponseField name="source" type="string">`chat` or `voice`; absent on older rows.</ResponseField>
<ResponseField name="model" type="string">The model that served an agent turn, when recorded.</ResponseField>

Equivalent: `User.getChatHistory()` inside a tool.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/chat/history/<<YOUR_AGENT_ID>>?threadId=ticket-1042', {
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>' },
  });
  const history: Array<{ id: string; role: 'user' | 'assistant'; createdAt: string }> = await response.json();
  console.log(history.length);
  ```

  ```bash cURL theme={null}
  curl "https://api.heylua.ai/chat/history/<<YOUR_AGENT_ID>>?threadId=ticket-1042" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

### DELETE /chat/history/:agentId

Deletes the caller's messages in one thread, or in every thread with this agent.

<ParamField path="agentId" type="string" required>
  The agent.
</ParamField>

<ParamField query="threadId" type="string">
  Thread suffix; omit it to clear the default thread.
</ParamField>

<ParamField query="targetIdentifier" type="string">
  Another end user's id, email address, or mobile number. Needs `org:manage` on the agent; an organization-admin grant does not reach a private agent.
</ParamField>

**Response**

`200` with `{ "success": true, "deletedMessages": <count> }`.

**Errors**

| Status | Code or message                                                                                                  | Meaning                                                                                         | Fix                                        |
| ------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `400`  | `A valid user ID, email address, or mobile number is required`, `Invalid email address`, `Invalid mobile number` | `targetIdentifier` is empty, over 512 characters, or malformed                                  | Send an id, an address, or 10 to 15 digits |
| `403`  | `Missing scope: org:manage for this agent`                                                                       | `targetIdentifier` without `org:manage`                                                         | Grant the scope                            |
| `404`  | `User conversation not found`                                                                                    | The identifier matches no end user of this agent; a user outside the agent gets the same answer | Check the identifier                       |

Equivalent: `lua chat clear --thread <id>` and `lua chat clear --user <identifier>`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/chat/history/<<YOUR_AGENT_ID>>?threadId=ticket-1042', {
    method: 'DELETE',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>' },
  });
  const cleared: { success: boolean; deletedMessages: number } = await response.json();
  console.log(cleared.deletedMessages);
  ```

  ```bash cURL theme={null}
  curl -X DELETE "https://api.heylua.ai/chat/history/<<YOUR_AGENT_ID>>?threadId=ticket-1042" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

### GET /chat/last-message/:agentId

Returns the last message of the thread, or `null` when it is empty. Same parameters as `GET /chat/history/:agentId`; the body is one message in the history shape.

### POST /chat/welcome/:agentId

Records a message as the agent's own on the web channel, without running a model turn, so a widget can open with a greeting that later turns remember.

<ParamField body="message" type="string" required>
  The text to record. Missing text answers `400` with `Message content is required`.
</ParamField>

**Response**

`201` with an empty body.

### POST /chat/messages/:messageId/feedback

Marks an agent reply as good or bad, for the feedback analytics in the admin dashboard.

<ParamField path="messageId" type="string" required>
  The message id from the history.
</ParamField>

<ParamField body="feedbackType" type="string" required>
  `good` or `bad`.
</ParamField>

<ParamField body="agentId" type="string">
  The agent, for messages the platform cannot map to a stored turn. Send it.
</ParamField>

<ParamField body="userMessage" type="string">
  The end user's text that prompted the reply.
</ParamField>

<ParamField body="reason" type="string">
  Why the reply was bad.
</ParamField>

<ParamField body="expectedReply" type="string">
  What a better reply would have said.
</ParamField>

**Response**

`201` with `{ "status": "success", "message": "Feedback saved successfully", "feedbackId" }`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/chat/messages/<<MESSAGE_ID>>/feedback', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({ feedbackType: 'bad', agentId: '<<YOUR_AGENT_ID>>', reason: 'Quoted the wrong delivery date' }),
  });
  const feedback: { status: string; feedbackId?: string } = await response.json();
  console.log(feedback.status);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/chat/messages/<<MESSAGE_ID>>/feedback" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "feedbackType": "bad", "agentId": "<<YOUR_AGENT_ID>>", "reason": "Quoted the wrong delivery date" }'
  ```
</CodeGroup>

The remaining `/chat/*` routes (reactions, client telemetry, approval and client-op resumes, the `?protocol=ui` companions) serve the Lua desktop, the web widget, and Spaces and are not part of the public contract.

## Long turns

A turn that runs research tools or delegates to other agents can take minutes. Prefer `/chat/stream` for any agent with tools, so heartbeats keep the connection open and you see progress. If a connection is cut mid-turn the platform still finishes the turn and stores the reply in the thread; do not resend the same message, send a follow-up on the same `threadId`.&#x20;

## See also

* [REST API overview](/reference/rest/overview) — authentication, the error envelope, and the `424` body
* [`Agents`](/reference/sdk/agents) — `Agents.invoke()` for agent-to-agent calls
* [`lua chat`](/reference/cli/chat) — the same routes from the terminal
* [Execution contexts](/concepts/execution-contexts) — threads, `Lua.request`, and channels
* [Web widget](/channels/web-widget/quickstart) — a drop-in client for these routes
