> ## 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.

# Talk to the agent from your app

> Add the agent pane to a web app, pass it what is on screen, refresh the page after a reply, build your own chat with lua.chat, test it with lua apps dev, and turn it off

The agent pane is a chat with your agent inside your web app's pages. The person using the app asks the agent something next to the ticket, order or report they are looking at, and the page can tell the agent what is on screen. After this guide, your app has the pane, sends it page context, refreshes after the agent changes something, and you can test it locally. [About web apps](/concepts/apps) explains the app model; [Build and open your first web app](/build/apps/quickstart) gets you an app.

*Verified against lua-cli 3.53.0.*

## How the pane works

* **Only the app's own agent.** The pane talks to the agent the web app belongs to, never another one. The page cannot pick a different agent or conversation.
* **Only for organization members.** Anyone who can open the app can use its pane; nobody else can.
* **One conversation per person per app.** Each person has one ongoing conversation with the app's agent, their own. Two people using the same app never see each other's messages. The conversation also appears among the person's chats in Lua Workspace, titled from its first message, and stays there if the app is deleted. There is no reset.
* **The agent knows where the person is.** Every message carries the app's name and the page's path, and any context the page adds.
* **Billed like Workspace chat.** Pane conversations are real usage, under the same plan and limits as chat in Lua Workspace.
* **On by default.** Every web app has a pane unless its builders [turn it off](#turn-it-off). A person using the app can collapse it, not remove it.
* **Not in a preview.** A [preview](/reference/cli/apps#lua-apps-preview) has no pane, because the agent it would reach is the live one.

The pane shows the agent's replies as Markdown and draws the agent's cards: lists and carousels (tapping a card sends its title), buttons (tapping one sends its label), links, images, payment cards and documents. Forms are not drawn in this version; the agent asks in text instead. Reasoning, tool calls and sources are not shown, and raw HTML shows as plain text. When the pane opens it loads the latest 50 messages, with **Load earlier messages** for the rest.

When a reply stops for an approval, the pane says the step is waiting for approval in Lua Workspace and links there. Approve or deny it in Lua Workspace; the pane cannot approve.

## New apps already have it

From lua-cli 3.53.0, `lua apps new` scaffolds the pane: the page project depends on `@lua-ai-global/app-chat`, `web/src/main.tsx` wraps the app in `<AgentPaneLayout>`, and `web/src/index.css` imports the pane's styles. Run `lua apps dev <name>` and the pane is docked on the right of the page.

## Add it to an older app

An app scaffolded before lua-cli 3.53.0 needs the package and three edits. If your app still has `web/src/lua/client.ts`, [switch to the client package](/build/apps/routes-and-pages#switch-to-the-client-package) first.

<Steps>
  <Step title="Install the packages">
    ```bash theme={null}
    cd src/apps/<name>/web
    npm install @lua-ai-global/app-chat @lua-ai-global/app-client@~0.0.3
    ```

    The pane needs `@lua-ai-global/app-client` 0.0.3 or later, React 19 and `@lua-ai-global/ui` 0.2, which the app template already has.
  </Step>

  <Step title="Wrap the app">
    In `web/src/main.tsx`, import `AgentPaneLayout` and put it around `<App />`:

    ```tsx web/src/main.tsx theme={null}
    import { StrictMode } from 'react';
    import { createRoot } from 'react-dom/client';
    import { lua } from '@lua-ai-global/app-client';
    import { AgentPaneLayout } from '@lua-ai-global/app-chat';
    import { App } from './App';
    import './index.css';

    document.documentElement.classList.toggle('dark', lua.theme === 'dark');
    lua.onInit((init) => document.documentElement.classList.toggle('dark', init.theme === 'dark'));

    createRoot(document.getElementById('root')!).render(
      <StrictMode>
        <AgentPaneLayout>
          <App />
        </AgentPaneLayout>
      </StrictMode>
    );
    void lua.start();
    ```
  </Step>

  <Step title="Import the styles">
    In `web/src/index.css`, import the pane's stylesheet after the ui one:

    ```css web/src/index.css theme={null}
    @import '@lua-ai-global/ui/styles.css';
    @import '@lua-ai-global/app-chat/styles.css';
    @source '../node_modules/@lua-ai-global/ui/dist';
    ```

    The pane has no resets or globals of its own. It follows the `dark` class the page already sets from the shell's theme.
  </Step>
</Steps>

Push and deploy the app as usual ([Release a version and roll back](/build/apps/routes-and-pages#release-a-version-and-roll-back)). The pane appears once that version is live.

## Dock it or place it

`<AgentPaneLayout>` docks the pane on the right of the page. The page sits beside it and shrinks while it is open; the pane is never drawn over the page. It collapses to a button on the edge, and below 768px wide it is a bottom sheet. Whether it is open is remembered per app in the browser.

To put the pane somewhere else, use `<AgentPane variant="inline">`. It fills its container, so give the container a height:

```tsx web/src/App.tsx theme={null}
import { AgentPane } from '@lua-ai-global/app-chat';

export function App() {
  return (
    <main className="grid grid-cols-2 gap-4 p-6">
      <section>{/* your page */}</section>
      <div className="h-[600px]">
        <AgentPane variant="inline" />
      </div>
    </main>
  );
}
```

`<AgentPane>` and `<AgentPaneLayout>` take the same props. The layout has no `variant`; it takes `children` and `layoutClassName` instead.

| Prop | Type | Description |
| - | - | - |
| `variant` | `'dock' \| 'inline'` | `<AgentPane>` only. Docked on the right, or filling its container. Default `'dock'` |
| `context` | a value, or a function that returns one | JSON sent with every message, at most 4096 bytes. A function is read when the message is sent |
| `onTurnEnd` | `({ ok, error? }) => void` | Called after every reply to a message this page sent |
| `labels` | `Partial<AgentPaneLabels>` | Replace any of the pane's English strings. `DEFAULT_LABELS` lists them |
| `workspaceUrl` | `string` | Where the approval link points. Default: the Lua Workspace that opened the app |
| `storageKey` | `string` | The browser storage key for the dock's open state. Default: one per app |
| `className` | `string` | Classes for the pane |

## Tell the agent what is on screen

Pass `context` to send the agent what the person is looking at with every message. Use a function so the value is read at send time, not when the page first rendered:

```tsx web/src/main.tsx theme={null}
<AgentPaneLayout context={() => ({ ticketId: currentTicketId(), filter: currentFilter() })}>
  <App />
</AgentPaneLayout>
```

The agent also gets the page's path, so `/tickets/42` already tells it something. Keep the context small and factual: ids, the current filter, the selected row. It may be at most 4096 bytes as JSON.

<Warning>
  The agent treats page context as unverified information from the page, never as instructions. Do not rely on it to decide what the person may do: anything that changes data must still check the person in a tool or a route.
</Warning>

To start a message from a button on the page, use `useAgentPane()` anywhere under `<AgentPaneLayout>`:

```tsx web/src/TicketActions.tsx theme={null}
import { useAgentPane } from '@lua-ai-global/app-chat';

export function TicketActions({ id }: { id: string }) {
  const pane = useAgentPane();
  return (
    <button onClick={() => pane.ask(`Summarise ticket ${id}`, { context: { ticketId: id } })}>
      Ask the agent
    </button>
  );
}
```

`useAgentPane()` returns `{ isOpen, open(), close(), ask(text, { context? }) }`. `ask` opens the dock and sends the text as the person's next message, once the pane has loaded. Only one reply runs at a time: an `ask` while the agent is still replying is not sent.

## Refresh the page after a reply

The agent's tools can change the same data your page shows. Refetch after every reply with `onTurnEnd`:

```tsx web/src/main.tsx theme={null}
<AgentPaneLayout onTurnEnd={() => window.dispatchEvent(new Event('tickets:refresh'))}>
  <App />
</AgentPaneLayout>
```

It is called once for every message this page sent, with `{ ok: true }`, or `{ ok: false, error }` when the reply failed. Outside the pane's props, `lua.chat.onTurnEnd(callback)` from `@lua-ai-global/app-client` does the same and returns a function that unsubscribes:

```ts web/src/App.tsx theme={null}
useEffect(() => lua.chat.onTurnEnd(() => load()), []);
```

## Build your own chat with lua.chat

The pane is built on `lua.chat` from `@lua-ai-global/app-client`. Use it directly for a chat of your own, or to send a message without the pane:

```ts web/src/ask.ts theme={null}
import { lua, LuaApiError } from '@lua-ai-global/app-client';

export async function askAgent(text: string, onText: (delta: string) => void): Promise<string | undefined> {
  if (!(await lua.chat.available())) return undefined; // the app turned its pane off

  const turn = lua.chat.send(text, { context: { ticketId: '42' } });
  try {
    for await (const chunk of turn) {
      if (chunk.type === 'text-delta') onText(chunk.delta);
    }
    return await turn.text; // every text delta, joined
  } catch (error) {
    if (error instanceof LuaApiError && error.code === 'TURN_IN_FLIGHT') return undefined;
    throw error;
  }
}

// The latest messages, oldest first; pass `before` back for the page before.
const { messages, before } = await lua.chat.history({ limit: 50 });
```

| Member | Description |
| - | - |
| `lua.chat.available()` | `false` when the app turned its pane off |
| `lua.chat.send(text, { context?, path?, signal? })` | Sends a message and returns the reply at once. Iterate it for the stream's chunks in order (`text-delta`, and the others as sent); await `turn.text` for the joined text and `turn.done` for the end. `path` defaults to the page's path. Aborting `signal` closes the stream |
| `lua.chat.history({ limit?, before? })` | `{ messages, before }`, oldest first. `limit` is 1 to 100; `before` is `null` when there are no older messages |
| `lua.chat.onTurnEnd(callback)` | Called once for every message this page sent, `{ ok, error? }`. A message refused before sending does not count. Returns the unsubscribe |

A failure rejects `turn.text` and `turn.done`, and throws from the loop, as a `LuaApiError` with `status`, `code` and, for `503`, `retryAfter` in seconds:

| `code` | When |
| - | - |
| `VALIDATION_FAILED` | The text, context or `limit` is out of bounds. Checked before anything is sent |
| `FORBIDDEN` | The person may not use this app's agent |
| `AGENT_PANE_OFF` | The app has `agentPane: false` |
| `PREVIEW_NO_AGENT_PANE` | The page is a preview |
| `TURN_IN_FLIGHT` | The previous reply has not finished |
| `APP_DISABLED`, `CONCURRENCY_LIMIT` | Try again after `retryAfter` |
| `STREAM_ERROR` | The agent's reply ended with an error |
| `STREAM_INTERRUPTED` | The connection dropped before the reply ended |
| `ABORTED` | Your `signal` fired |
| `NOT_IN_BROWSER` | Called outside a browser, such as during server rendering |

## Test it locally

`lua apps dev <name>` serves the pane along with the pages and routes. You chat as yourself, without signing in:

```bash theme={null}
lua apps dev ops-dashboard
lua apps dev ops-dashboard --chat-env production
```

* **Sandbox, by default.** The pane talks to your agent with the project's local skills, processors and persona, as `lua chat -e sandbox` does. They are pushed to the sandbox on the first message; when that push fails, the message fails and the next one tries again.
* **Production with `--chat-env production`.** The pane talks to the agent that is live, as `lua chat -e production` does.
* **A conversation of its own.** The local pane uses a conversation kept apart from your conversation in the live app, so testing never mixes into it.
* **Restart after changing a skill.** Your local skills are pushed once per run. Stop `lua apps dev` with Ctrl+C and start it again to chat with the change. Edits to the page and to routes reload as usual.

The local pane follows the same rules as the live one: one reply at a time, the same limits, and `agentPane: false` turns it off. On lua-cli before 3.53.0, `lua apps dev` does not serve the pane.

## Turn it off

Set `agentPane: false` in `defineWebApp`:

```ts src/apps/ops-dashboard/app.ts theme={null}
export default defineWebApp({
  name: 'ops-dashboard',
  agentPane: false,
  pages: { root: './web' },
  routes: {
    // …
  },
});
```

The setting travels with each pushed version, so the change takes effect once that version is live, and rolling back to an earlier version brings back its setting. With the pane off, `<AgentPane>` and `<AgentPaneLayout>` draw no pane (the layout still renders your page), `lua.chat.available()` is `false`, and chat calls fail with `AGENT_PANE_OFF`. Remove the setting, or set it to `true`, to turn the pane back on.

## Show the agent's images

A web app's pages load images only from the hosts the app allows. Add the hosts your agent's images come from, such as a product photo CDN, to `pages.csp.imgSrc`, or the pane's image and card blocks show broken images:

```ts src/apps/ops-dashboard/app.ts theme={null}
export default defineWebApp({
  name: 'ops-dashboard',
  pages: {
    root: './web',
    csp: { imgSrc: ['https://images.example-shop.com'] },
  },
  routes: {
    // …
  },
});
```

## Limits

| Limit | Value |
| - | - |
| One message | 1 to 8000 characters after trimming. The pane shows a counter near the limit |
| Page context | 4096 bytes as JSON |
| Page path | Starts with `/`, at most 256 characters |
| Replies at a time | One per person per app. A second message while the agent is replying is refused |
| History page | 1 to 100 messages; the pane loads 50 |

## If it isn't working

| What the person sees | Why | What to do |
| - | - | - |
| No pane at all | The app has `agentPane: false`, the live version predates the pane, or the page does not render `<AgentPaneLayout>` or `<AgentPane>` | Check `defineWebApp` and `main.tsx`, then push and deploy the app. With the pane off, the browser console says `[lua] agent pane hidden` |
| "Not available in preview" | Previews have no pane | Test the pane with `lua apps dev`, or once the version is live |
| "You do not have access to this app's agent." | The person may not use this agent | Check that they are a member of the organization and can see the agent |
| "The agent is still replying in another window." | The person's previous message is still being answered, in this window or another | Wait for that reply, then select **Retry** |
| "The agent is busy. Try again in N s." | Too many replies are running at once | Select **Retry** after the wait |
| "This app is paused. Try again in N s." | An admin switched the app off | Switch the app on again from the agent's **Apps** page in the admin console ([Switch an app off](/build/apps/routes-and-pages#switch-an-app-off)) |
| "The connection dropped before the answer ended." or "The agent could not finish its answer." | The connection dropped, or the agent's reply failed. Any partial reply stays | Select **Retry** to send the message again. If it fails again, check [`lua logs`](/reference/cli/logs) |
| "This step is waiting for approval in Lua Workspace" | The reply needs an approval | Select **Open Lua Workspace** and approve or deny it there |
| "The conversation could not be loaded." | The history could not be read | Select **Retry** |
| Broken images in a reply | The image host is not in `pages.csp.imgSrc` | [Add the host](#show-the-agents-images), then push and deploy the app |
| Locally, the agent does not use a skill you just changed | Local skills are pushed once per `lua apps dev` run | Restart `lua apps dev` |

## Next steps

<Columns cols={2}>
  <Card title="Write routes and pages" href="/build/apps/routes-and-pages">The full `defineWebApp` shape, the page client, and releases.</Card>
  <Card title="lua apps reference" href="/reference/cli/apps">`lua apps dev --chat-env` and the other commands.</Card>
  <Card title="About web apps" href="/concepts/apps">Where apps open and who can open them.</Card>
  <Card title="Chat with your agent" href="/reference/cli/chat">`lua chat` in sandbox and production.</Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.