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

# Write routes and pages for a web app

> Define a web app with defineWebApp, write typed routes with params, query and body schemas, call them from the page, release versions, and control who opens the app

After this guide, your web app has typed routes that read and write your agent's data as the signed-in person, a page that calls them and follows the shell's theme, and a release you can roll back. Start from [Build and open your first web app](/build/apps/quickstart) if you have no app yet; [About web apps](/concepts/apps) explains the model.

*Verified against lua-cli 3.44.0.*

**Before you begin**

* An app created with `lua apps new <name>`, with `npm install` run in its `web/` folder.
* `@lua-ai-global/app-client` installed in `web/` (see [Switch to the client package](#switch-to-the-client-package) if your app has `web/src/lua/client.ts`).

## Define the app

A web app is one file, `src/apps/<name>/app.ts`, that default-exports `defineWebApp({...})`. List it under `webApps` on your `LuaAgent`; `lua apps new` does both.

```ts src/index.ts theme={null}
import { LuaAgent } from 'lua-cli';
import opsDashboard from './apps/ops-dashboard/app';

export const agent = new LuaAgent({
  name: 'Ops dashboard example',
  persona: 'You help the operations team keep track of their work.',
  webApps: [opsDashboard],
});
```

`defineWebApp` takes:

| Field | Description |
| - | - |
| `name` | The app's name on the agent: lower-case letters, digits, and dashes, starting with a letter. It is part of the Workspace URL |
| `description` | Optional. A short line about the app |
| `pages.root` | The page project, relative to `app.ts`. Usually `'./web'` |
| `pages.nav` | Optional. Navigation entries, each `{ path, label }` with a `path` starting with `/` |
| `pages.spa` | Optional. Serve the page for every path that is not an asset, for client-side routing. Default `true` |
| `routes` | The route handlers, keyed by `'<METHOD> /<path>'` |

`lua compile` fails with a clear message when the name, a route key, or a nav entry is wrong, or when the app has no routes.

## Write a route

A route key is a method (`GET`, `POST`, `PUT`, `PATCH`, or `DELETE`), one space, and a path. A path segment that starts with `:` is a parameter. Wrap each handler in `route()` so the schemas are checked and the handler's arguments are typed.

```ts src/apps/ops-dashboard/app.ts theme={null}
import { Data, defineWebApp, env, json, route } from 'lua-cli';
import { z } from 'zod';

export default defineWebApp({
  name: 'ops-dashboard',
  pages: { root: './web', nav: [{ path: '/', label: 'Tickets' }] },
  routes: {
    'GET /tickets': route({
      description: 'Lists tickets with one status',
      query: z.object({ status: z.enum(['open', 'closed']).default('open') }),
      handler: async ({ query }) => {
        const page = await Data.get('tickets', { status: query.status ?? 'open' }, 1, 50);
        return { items: page.data.map((entry) => ({ id: entry.id, ...entry.data })) };
      },
    }),

    'POST /tickets/:id/close': route({
      description: 'Closes a ticket and records who closed it',
      params: z.object({ id: z.string().min(1) }),
      body: z.object({ note: z.string().max(500).optional() }),
      responses: {
        204: z.undefined().describe('Closed; no body'),
        404: z.object({ message: z.string() }),
      },
      handler: async ({ params, body, auth }) => {
        try {
          await Data.getEntry('tickets', params.id);
        } catch {
          return json({ message: `No ticket ${params.id}` }, 404);
        }
        await Data.patch('tickets', params.id, {
          set: { status: 'closed', closedBy: auth.userId, note: body.note ?? null },
        });
        return json(undefined, 204);
      },
    }),

    'GET /status': route({
      description: 'Checks the ticketing backend',
      handler: async () => {
        const res = await fetch(`${env('TICKETS_API_URL')}/health`);
        return { ok: res.ok };
      },
    }),
  },
});
```

`route()` takes:

| Field | Description |
| - | - |
| `params` | Zod schema for the path parameters. A failure answers 400 `VALIDATION` |
| `query` | Zod schema for the query string. Values arrive as strings, and a repeated key as an array. A failure answers 400 `VALIDATION` |
| `body` | Zod schema for the JSON body. A failure answers 400 `VALIDATION` |
| `responses` | Optional Zod schemas by status code. They describe the route; they are not checked at runtime |
| `description` | Optional. What the route does |
| `handler` | Your function. It receives `{ params, query, body, headers, method, path, auth, app, request }` |

The handler's return value is the response:

* A value (an object, an array) answers 200 with it as JSON.
* `undefined` answers 204 with no body.
* `json(value, status, headers?)` sets the status. `json(undefined, 204)` answers with no body.
* A Web `Response` is sent as it is.

A handler that throws answers with an error whose `code` is `HANDLER_ERROR`. Every error the platform produces has the body `{ code, message }`; a body your handler returns is passed through as it is.

A Zod `.default()` in a `query` or `body` schema fills the value in at runtime, but the handler's type still allows `undefined`. Add `?? <default>` where you read it, as `query.status ?? 'open'` above.

### What a route can use

Routes run in the Lua sandbox, the same place as your tools and webhooks:

* `auth` is the person using the page: `userId`, `orgId`, `agentId`, `roles`, and `name` and `email` when known. It is never an API key and never anonymous, so you can record who made a change.
* `Data` and the other [runtime objects](/reference/sdk/overview) work as they do in a tool.
* `env('KEY')` reads the agent's environment variables. Set them with [`lua env`](/reference/cli/env).
* `fetch` calls your own APIs. The page never sees the keys you use here.

## Call routes from the page

The page project in `web/` is a normal Vite + React project. The template uses `@lua-ai-global/ui` components and Tailwind v4; add any npm package you need. The page talks to your routes through `@lua-ai-global/app-client`:

```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 { App } from './App';
import './index.css';

// Follow the shell's theme: first the one the app opened with, then every change.
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>
    <App />
  </StrictMode>
);
void lua.start();
```

```tsx web/src/App.tsx theme={null}
import { useEffect, useState } from 'react';
import { lua, LuaApiError } from '@lua-ai-global/app-client';

interface Ticket {
  id: string;
  title: string;
}

export function App() {
  const [tickets, setTickets] = useState<Ticket[]>([]);
  const [error, setError] = useState<string>();

  useEffect(() => {
    lua
      .api<{ items: Ticket[] }>('GET', '/tickets?status=open')
      .then((r) => setTickets(r.items))
      .catch((e) => setError(e instanceof LuaApiError ? `${e.message} (${e.status})` : String(e)));
  }, []);

  const close = (id: string) => lua.api('POST', `/tickets/${encodeURIComponent(id)}/close`, { note: 'Done' });

  return (
    <ul>
      {error && <li>{error}</li>}
      {tickets.map((t) => (
        <li key={t.id}>
          {t.title} <button onClick={() => close(t.id)}>Close</button>
        </li>
      ))}
    </ul>
  );
}
```

The client gives the page:

| API | Description |
| - | - |
| `lua.api<T>(method, path, body?)` | Calls a route of this app as the signed-in person and resolves the JSON body as `T`. A non-2xx rejects with `LuaApiError` |
| `LuaApiError` | `status`, `code` (for example `VALIDATION`), and `message` |
| `lua.onInit(listener)` | Called when the shell sends its settings: `{ v, theme: 'light' \| 'dark', density?, locale?, path? }` |
| `lua.theme` | The theme the app opened with |
| `lua.appName` | The app's name |
| `lua.start()` | Opens the session and tells the shell the page is ready. `lua.api()` calls it for you; call it once at startup so the shell knows the page loaded |

The session lives in memory only. The page keeps no token in cookies or storage, and a reload asks the shell for a new session.

## Run and test locally

`lua apps dev <name>` serves the page on the app's own Vite server with hot reload, and runs each route in the sandbox on your machine as you. `Data` and `env()` are the live ones, so writes are real. `lua test webapp` calls a single route:

```bash theme={null}
lua apps dev ops-dashboard
lua test webapp --name ops-dashboard --route 'GET /tickets?status=closed'
lua test webapp --name ops-dashboard --route 'POST /tickets/abc123/close' --input '{"body":{"note":"Done"}}'
```

Locally `auth.roles` is empty: who may open an app is decided by the platform, not on your machine. See [`lua apps`](/reference/cli/apps) and [`lua test`](/reference/cli/test).

## Release a version and roll back

`lua push webapp` builds the page with the app's Vite and creates a web-app version. It is staged, not live. The [agent version](/concepts/releases-and-versions) you create next pins it, and promoting that agent version makes it live:

```bash theme={null}
lua push webapp --name ops-dashboard --force
lua version create -m "Close tickets from the dashboard"
lua version promote <n>
```

Bare `lua push` and `lua push all --force` push web apps too. To roll back, promote the previous agent version: the app goes back to the version that agent version pinned. See [Release an agent to production](/ship/releasing).

## Who can open the app

Open a live app by URL. There is no menu entry for apps yet, and the native desktop app does not open them yet:

| Where | List of apps | One app |
| - | - | - |
| Lua Workspace in the browser | `https://workspace.heylua.ai/apps` | `https://workspace.heylua.ai/apps/<agentId>/<appName>` |
| Admin console | `https://admin.heylua.ai/admin/agents/<agentId>/apps` | `https://admin.heylua.ai/admin/agents/<agentId>/apps/<appId>` |

The app opens full page. Anyone who can read the agent can open it: for an agent that is not private, every member of its organization; for a private agent, only the people who can see that agent. Anyone else, including people in other organizations, sees that the app does not exist or that they cannot open it.

## Switch an app off

An admin can switch an app off, and on again, from the agent's **Apps** page in the admin console. The change takes up to about 30 seconds to reach everyone. While it is off, the app does not open and its routes answer with the code `APP_DISABLED`.

## Limits

`lua push webapp` refuses a build that breaks one of these before it uploads anything:

| Limit | Value |
| - | - |
| Entry HTML (`index.html` after the build) | 256 Ki characters |
| Assets | 1000 files |
| One asset | 8 MiB |
| Asset file names | Letters, digits, `.`, `_`, `-`, and `/` |
| Routes | 200 per app |

Keep large data out of `index.html`, and rename source files whose built names would use other characters.

## Switch to the client package

lua-cli 3.42 to 3.45 copy the page client into your app as `web/src/lua/client.ts`. To use the package instead:

```bash theme={null}
cd src/apps/<name>/web
npm install @lua-ai-global/app-client
rm src/lua/client.ts
```

Then change `from './lua/client'` to `from '@lua-ai-global/app-client'` in `src/main.tsx` and `src/App.tsx`, and push the app again.

## If it isn't working

<AccordionGroup>
  <Accordion title="400 VALIDATION from a route">
    The request did not match the route's `params`, `query`, or `body` schema. `LuaApiError.message` says which field. Check the body you pass to `lua.api()` against the schema.
  </Accordion>

  <Accordion title="No web app named … in the compiled agent">
    The app is not listed under `webApps` on your `LuaAgent`, or `lua compile` failed. Add the import and the `webApps` entry that `lua apps new` prints.
  </Accordion>

  <Accordion title="The agent build loads files from web/">
    `tsconfig.json` must exclude `src/apps/*/web`. `lua apps new` adds it when your `tsconfig.json` is plain JSON; add it by hand when the file has comments.
  </Accordion>

  <Accordion title="The app opens, but nothing changed after a push">
    A push is staged. Create an agent version and promote it.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="About web apps" href="/concepts/apps">Where apps open and how access works.</Card>
  <Card title="lua apps reference" href="/reference/cli/apps">`lua apps new` and `lua apps dev`.</Card>
  <Card title="Data reference" href="/reference/sdk/data">Every `Data` method your routes can call.</Card>
  <Card title="Release an agent" href="/ship/releasing">Agent versions, promote, and rollback.</Card>
</Columns>


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