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

# Build and open your first web app

> Scaffold a web app on your agent, write a typed route, call it from a React page, run it locally, push it, go live, and open it in Lua Workspace

In this tutorial you build `ops-dashboard`, a [web app](/concepts/apps) on your agent. It has a page that lists tickets and opens new ones, and two routes that read and write a `Data` collection as the person using the page. At the end, the app is live and open in Lua Workspace in your browser. Allow 20 minutes. You need a project created with `lua init` and a user session from `lua auth configure` ([Install the CLI and sign in](/get-started/install)), and Node.js 20 or later.

*Verified against lua-cli 3.44.0.*

The complete project is in the examples repository: [`lua-apps-ops-dashboard`](https://github.com/lua-ai-global/lua-dev-examples/tree/master/lua-apps-ops-dashboard). It adds a third route that closes a ticket.

## What you'll build

* A web app listed on your agent, with a page project in `src/apps/ops-dashboard/web`.
* A `GET /tickets` route and a `POST /tickets` route with a Zod body schema.
* A React page that calls both routes with `lua.api()` and follows the shell's theme.
* A local run with hot reload, a route test from the terminal, and a pushed version.
* An agent version that makes the app live, opened by its URL.

<Steps>
  <Step title="Scaffold the app">
    Run this in the root of your agent project, where `lua.skill.yaml` is:

    ```bash theme={null}
    lua apps new ops-dashboard
    ```

    ```text Output theme={null}
      created src/apps/ops-dashboard/app.ts
      created src/apps/ops-dashboard/web/index.html
      created src/apps/ops-dashboard/web/package.json
      …
      updated src/index.ts                     webApps: [opsDashboard]
      updated tsconfig.json                    exclude: "src/apps/*/web"

    ✅ Web app ops-dashboard created
    ```

    The command writes two things. `app.ts` holds the routes, which run on Lua. `web/` is a Vite + React project with the `@lua-ai-global/ui` components and Tailwind; it runs in the browser. The command also lists the app under `webApps` on your `LuaAgent` and keeps the page project out of the agent's TypeScript build. It never overwrites an existing app.
  </Step>

  <Step title="Install the page project">
    The page project has its own dependencies:

    ```bash theme={null}
    cd src/apps/ops-dashboard/web
    npm install
    npm install @lua-ai-global/app-client
    cd -
    ```

    `@lua-ai-global/app-client` is the page side of the app. It opens the session that the shell hands over, talks to the shell, and calls your routes.

    <Note>
      lua-cli 3.42 to 3.45 copy the client into `web/src/lua/client.ts` instead of adding the package. To switch, install the package as above, change `from './lua/client'` to `from '@lua-ai-global/app-client'` in `web/src/main.tsx` and `web/src/App.tsx`, and delete `web/src/lua/client.ts`.
    </Note>
  </Step>

  <Step title="Write the routes">
    Replace `src/apps/ops-dashboard/app.ts`:

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

    const Ticket = z.object({
      id: z.string(),
      title: z.string(),
      createdBy: z.string(),
      createdAt: z.string(),
    });

    export default defineWebApp({
      name: 'ops-dashboard',
      description: 'Tickets for the team',
      pages: {
        root: './web',
        nav: [{ path: '/', label: 'Tickets' }],
      },
      routes: {
        'GET /tickets': route({
          description: 'Lists the newest tickets',
          responses: { 200: z.object({ items: z.array(Ticket) }) },
          handler: async () => {
            const page = await Data.get('tickets', {}, 1, 20);
            return { items: page.data.map((entry) => ({ id: entry.id, ...entry.data })) };
          },
        }),

        'POST /tickets': route({
          description: 'Opens a ticket as the signed-in person',
          body: z.object({ title: z.string().trim().min(1).max(200) }),
          handler: async ({ body, auth }) => {
            const fields = {
              title: body.title,
              createdBy: auth.name ?? auth.email ?? auth.userId,
              createdAt: new Date().toISOString(),
            };
            const entry = await Data.create('tickets', fields, body.title);
            return json({ id: entry.id, ...fields }, 201);
          },
        }),
      },
    });
    ```

    A route key is a method and a path. The handler gets the request already checked against the route's schemas: a body without a `title` is answered with 400 before your code runs. `auth` is the person using the page, never an API key. A handler that returns a value answers 200 with it as JSON; `json(value, status)` sets another status.

    Run `lua compile --ci`. The summary counts the app:

    ```text Output theme={null}
    ✅ Compiled 2 primitives (1 agent, 1 web-app) in 397ms
    ```
  </Step>

  <Step title="Call the routes from the page">
    Replace `src/apps/ops-dashboard/web/src/App.tsx`:

    ```tsx src/apps/ops-dashboard/web/src/App.tsx theme={null}
    import { useEffect, useState, type FormEvent } from 'react';
    import { lua } from '@lua-ai-global/app-client';
    import { Button } from '@lua-ai-global/ui/button';
    import { Card } from '@lua-ai-global/ui/card';
    import { Input } from '@lua-ai-global/ui/input';

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

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

      const load = () => {
        lua
          .api<{ items: Ticket[] }>('GET', '/tickets')
          .then((r) => setTickets(r.items))
          .catch((e: Error) => setError(e.message));
      };
      useEffect(load, []);

      const add = async (event: FormEvent) => {
        event.preventDefault();
        try {
          await lua.api('POST', '/tickets', { title });
          setTitle('');
          load();
        } catch (e) {
          setError((e as Error).message);
        }
      };

      return (
        <main className="min-h-screen bg-background p-6 text-foreground">
          <Card className="mx-auto max-w-md space-y-4 p-6">
            <h1 className="text-lg font-semibold">{lua.appName}</h1>
            <form className="flex gap-2" onSubmit={add}>
              <Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="New ticket" />
              <Button type="submit" disabled={!title.trim()}>Add</Button>
            </form>
            {error && <p className="text-sm text-destructive">{error}</p>}
            <ul className="space-y-1">
              {tickets.map((t) => (
                <li key={t.id}>
                  {t.title} <span className="text-muted-foreground">· {t.createdBy}</span>
                </li>
              ))}
            </ul>
          </Card>
        </main>
      );
    }
    ```

    Then make `src/apps/ops-dashboard/web/src/main.tsx` start the client and follow the shell's light or dark theme:

    ```tsx src/apps/ops-dashboard/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';

    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();
    ```

    `lua.api(method, path, body?)` calls a route of this app as the signed-in person and resolves the JSON it answers. In `web/`, `npx tsc --noEmit` reports no errors.
  </Step>

  <Step title="Run it locally">
    ```bash theme={null}
    lua apps dev ops-dashboard
    ```

    The command compiles the agent, starts the page on the app's own Vite server with hot reload, and prints its URL. Open it, add a ticket, and it appears in the list. Locally the routes run in the sandbox on your machine as you, against the agent's live `Data`, so the ticket is a real entry in the `tickets` collection. Edit `App.tsx` and the page reloads; edit `app.ts` and the routes recompile. Press Ctrl+C to stop.
  </Step>

  <Step title="Test a route from the terminal">
    `lua test webapp` calls one route the way the platform would, with you as `auth`:

    ```bash theme={null}
    lua test webapp --name ops-dashboard --route 'POST /tickets' --input '{"body":{"title":"Restock the fridge"}}'
    lua test webapp --name ops-dashboard --route 'GET /tickets'
    ```

    Each call prints the status, the response headers, and the body. The first answers `201` with the new ticket; the second lists it. A body that fails the schema answers `400`. Add `--json` to get the result as JSON for a script.
  </Step>

  <Step title="Push a version">
    ```bash theme={null}
    lua push webapp --name ops-dashboard --force
    ```

    The push builds the page with the app's own Vite, uploads it with the route bundle, and creates a web-app version. `--force` picks the next version number without asking. The version is staged, not live: the command ends with `Staged but NOT live yet` and the two commands of the next step.
  </Step>

  <Step title="Go live">
    A web app has no deploy of its own. It goes live with the [agent version](/concepts/releases-and-versions) that pins it:

    ```bash theme={null}
    lua version create -m "Add the ops dashboard"
    lua version promote <n>
    ```

    `lua version create` prints `✓ Created v<n> (staged)`; pass that number to `promote`. To roll back, promote the previous agent version.
  </Step>

  <Step title="Open the app">
    Open it by URL in your browser. `<agentId>` is `agent.agentId` in `lua.skill.yaml`:

    ```text theme={null}
    https://workspace.heylua.ai/apps/<agentId>/ops-dashboard
    ```

    The app opens full page in Lua Workspace, signed in as you, and lists the tickets you added locally. The admin console lists the agent's apps at `https://admin.heylua.ai/admin/agents/<agentId>/apps`. Neither has a menu entry for apps yet.
  </Step>
</Steps>

<Check>
  The app opens at its Workspace URL, a ticket you add there shows your name, and `lua test webapp --name ops-dashboard --route 'GET /tickets'` lists it.
</Check>

## What you learned

* A web app is part of an agent: `defineWebApp` in `src/apps/<name>/app.ts`, listed under `webApps`. [About web apps](/concepts/apps) explains where it opens and who can open it.
* Routes are typed handlers that run on Lua as the person using the page, with `Data` and `env()`. The page calls them with `lua.api()`. [Write routes and pages](/build/apps/routes-and-pages) covers params, query, errors, and limits.
* `lua apps dev` and `lua test webapp` run the routes locally against live data.
* `lua push webapp` stages a version; an agent version makes it live, and promoting an earlier one rolls it back.

## 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 limits.</Card>
  <Card title="lua apps reference" href="/reference/cli/apps">`lua apps new` and `lua apps dev`.</Card>
  <Card title="Store and search data" href="/build/store-and-search-data">More on the `Data` collections your routes use.</Card>
  <Card title="lua-apps-ops-dashboard example" href="https://github.com/lua-ai-global/lua-dev-examples/tree/master/lua-apps-ops-dashboard">The full project, ready to clone and run.</Card>
</Columns>


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