# Apps (/cli/resource/apps)



An **app** is an interactive mini-app: a React single-page app rendered in a sandboxed iframe that calls named server-side handlers for data. Use it for dashboards, reports, internal tools, and search interfaces. Anywhere a user opens a page, sees data, and acts on it.

Apps are **interactive-only**. No triggers, no workflow-level input. The lifecycle is: the user opens the app, clicks around, and handlers respond. For scheduled or one-shot work, use a [code](/cli/resource/code) or [agent](/cli/resource/agent) workflow.

## How it runs [#how-it-runs]

1. The user opens the app. The iframe loads, the React bundle hydrates, and the providers (TanStack Query, Router) wire up.
2. Pages call handlers with `useHandler('name', input)` (reads) or `useHandlerMutation('name')` (writes).
3. A read is served from a 15-minute server-side cache when available; otherwise the platform boots a fresh Bun sandbox, runs `handlers/<name>.ts`, and returns JSON. A mutation always runs the sandbox and invalidates the app's cache on success.
4. A handler error is logged to the platform; the iframe shows the user a generic error. Errors are never cached.
5. Refreshing the iframe reloads the bundle. Server-side state persists; in-memory React state does not.

Two halves: a **React client** in the iframe and **server-side handlers** in Bun. They never share memory; the only channel between them is `useHandler` / `useHandlerMutation`.

<Callout type="warn" title="No UI preview from the CLI">
  An app renders in the dashboard's iframe, which the CLI can't show. Drive
  correctness headlessly: run handlers with [`geni app
    run-handler`](/cli/resource/app/run-handler), watch the build with [`geni app
    build-status`](/cli/resource/app/build-status), and read browser errors with
  [`geni app errors`](/cli/resource/app/errors). Then open the finished app in
  your dashboard.
</Callout>

## The scaffold [#the-scaffold]

`geni resource create --type app` ships a working Vite + React + Tailwind project. Most of it is **platform-owned and overwritten on every build**. Editing these does nothing but cause drift:

* `vite.config.ts`, `tsconfig.json`, `postcss.config.js`
* `index.html` (the iframe shell; CSP, fonts, and the runtime shim are injected by the platform)
* `src/index.css` (font imports, base styles)
* `src/main.tsx` (providers: QueryClient, Router, telemetry beacon)
* `src/lib/api.ts` (the `useHandler` and `useHandlerMutation` hooks)
* `src/lib/HandlerView.tsx`, `src/lib/utils.ts`, `src/lib/telemetry.ts`, `src/lib/ErrorBoundary*.tsx`
* `handlers/general-input-core.d.ts` (type declarations for `@general-input/core`)

You edit:

* `app.json` (the manifest)
* `src/App.tsx` (top-level routes)
* `src/pages/*.tsx` (one file per page)
* `src/components/*.tsx` (your own components — the scaffold ships no UI kit, so every visual component is one you write)
* `src/lib/*.ts` (your own shared helpers, outside the platform-owned files above)
* `handlers/*.ts` (server-side handlers)

## Project layout [#project-layout]

| Path                 | Required | Purpose                                                                |
| -------------------- | -------- | ---------------------------------------------------------------------- |
| `app.json`           | yes      | Manifest: title, declared credentials, handler list.                   |
| `package.json`       | yes      | Deps. The scaffold ships the pinned stack.                             |
| `src/App.tsx`        | yes      | Root component with routes.                                            |
| `src/pages/Home.tsx` | yes      | At least one page.                                                     |
| `handlers/<name>.ts` | yes      | At least one handler with a default async export and an `inputSchema`. |

`workflow.json`, `main.ts`, `agent.json`, `instructions.md`, and `scripts/*` are **forbidden**.

## `app.json` [#appjson]

```json
{
  "title": "Customer Triage",
  "description": "Browse and tag customers from Stripe and HubSpot.",
  "credentials": [
    { "inputKey": "stripe", "service": "stripe", "scopes": ["read_customers"] },
    { "inputKey": "hubspot", "service": "hubspot" }
  ],
  "handlers": [
    { "name": "listCustomers", "path": "handlers/listCustomers.ts" },
    { "name": "tagCustomer", "path": "handlers/tagCustomer.ts" }
  ]
}
```

| Field                 | Required | Purpose                                                                                                 |
| --------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `title`               | yes      | Shown in the dashboard chrome above the iframe.                                                         |
| `description`         | no       | One-line summary in the workflow list.                                                                  |
| `credentials`         | no       | User credentials, `{ inputKey, service, scopes? }`. Resolved via `getCredential`.                       |
| `platformCredentials` | no       | Legacy. Redundant declared slot. `chat-completion` uses top-level `platformApiKey` / `platformBaseUrl`. |
| `handlers`            | yes      | Explicit manifest. Each `path` must export a default async function and `inputSchema`.                  |

Handler `name` is kebab-case or camelCase, unique, and is the typed key used in `useHandler('name', input)`.

## Handlers [#handlers]

Each handler is a Bun TypeScript file in `handlers/`. It default-exports an async function taking parsed input and a `WorkflowContext`, and returns JSON.

```ts
import { z } from 'zod'
import type { WorkflowContext } from '@general-input/core'

export const inputSchema = z.object({
  cursor: z.string().optional(),
  limit: z.number().min(1).max(100).default(50),
})

export default async function handler(
  rawInput: unknown,
  context: WorkflowContext
) {
  const input = inputSchema.parse(rawInput)
  const stripe = context.getCredential('stripe')

  const res = await fetch(
    `https://api.stripe.com/v1/customers?limit=${input.limit}` +
      (input.cursor ? `&starting_after=${input.cursor}` : ''),
    { headers: { Authorization: `Bearer ${stripe.secretKey}` } }
  )
  if (res.status === 401 || res.status === 403) {
    throw new Error(
      'Your Stripe connection expired. Reconnect Stripe and try again.'
    )
  }
  if (!res.ok) throw new Error(`Stripe returned ${res.status}`)

  const data = await res.json()
  return {
    customers: data.data,
    nextCursor: data.has_more ? data.data.at(-1)?.id : null,
  }
}
```

The handler `context` is a [`WorkflowContext`](/cli/resource/code#the-workflowcontext-api). In handlers you use `getCredential(inputKey)` (returns the credential's stored fields, `{ secretKey }` for an API key or `{ accessToken }` for OAuth, refreshed for you) and the top-level `platformBaseUrl` / `platformApiKey` for first-party endpoints like the database proxy and `chat-completion`.

Rules:

* **Always export `inputSchema` and parse `rawInput` first.** The platform generates client-side types from the schema. A handler without it fails validation.
* **One handler per file.** No branching on an `action` field.
* **Cold start per call.** Each call boots a fresh Bun sandbox. Nothing in `/tmp/`, `process.env`, or module scope survives between calls. Read platform auth and credentials from `context`, never `process.env` (it is empty in the sandbox).
* **Up to 15 minutes per call.** Long fan-outs and agent loops are fine within that ceiling. `useHandler` / `useHandlerMutation` resolve with the final result regardless of duration; the platform waits inline briefly and falls through to polling for longer work. Past 15 minutes the call is killed.
* **Enrich on the server, not per row.** If a list page renders N rows that each need more than the list endpoint returns, the *list handler* fetches the per-row data with `Promise.all` and returns enriched rows. Never call a per-row `useHandler` from each rendered item; every call cold-starts a sandbox and most rows will show "failed to load".
* **Throw readable errors, never secrets.** Thrown messages are logged and readable via [`geni app invocations`](/cli/resource/app/invocations); the iframe shows the user a generic error. Strip tokens, full credential payloads, and PII from messages and from responses.

Shared utility code goes in `lib/` and is imported by handlers (`import { joinByEmail } from '../lib/joins'`). There is no compile step.

## The React side [#the-react-side]

Pages live in `src/pages/`, one component per file, wired as routes in `src/App.tsx`:

```tsx
import { Routes, Route } from 'react-router'
import { Home } from './pages/Home'
import { CustomerDetail } from './pages/CustomerDetail'

export function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/customers/:id" element={<CustomerDetail />} />
    </Routes>
  )
}
```

Use `<Link>` and `useNavigate` for navigation, and put filters and view state in URL search params (`useSearchParams`) so pages are shareable, not in component `useState`.

**Reads** go through `HandlerView`, which wraps `useHandler` with default loading, error, and empty states. Write only the success branch:

```tsx
import { HandlerView } from '@/lib/HandlerView'
;<HandlerView name="listCustomers" input={{ limit: 50 }}>
  {(data) => <CustomerTable customers={data.customers} />}
</HandlerView>
```

The `children` callback is a render prop, not a component. Never call hooks inside it; if you need hooks with the loaded data, extract a child component (`{(data) => <Dashboard data={data} />}`). Override any state with the `loading`, `empty`, or `error` props. Reach for `useHandler` directly only when you must render across loading and data at once, and then you still render loading, error, and empty states yourself.

**Writes** go through `useHandlerMutation`, and you invalidate the affected reads on success:

```tsx
import { useHandlerMutation } from '@/lib/api'
import { useQueryClient } from '@tanstack/react-query'

const queryClient = useQueryClient()
const tag = useHandlerMutation('tagCustomer', {
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listCustomers'] }),
})

<SaveButton onClick={() => tag.mutate({ id, tag: 'lead' })} disabled={tag.isPending}>
  {tag.isPending ? 'Tagging...' : 'Tag as lead'}
</SaveButton>
```

Caching is automatic. Reads are cached server-side for 15 minutes per user per handler and args, so repeated views return instantly. Mutations bypass the cache and invalidate it on success. Don't roll your own caching on top.

## Pinned stack [#pinned-stack]

The scaffold chose the libraries. Deps are pre-installed in the platform image and there is no install step, so adding to `package.json` does nothing. If a need isn't covered, build it from Tailwind and React primitives in `src/components/`. Do not add alternates.

| Need          | Library                                   |
| ------------- | ----------------------------------------- |
| Framework     | React 19 + Vite                           |
| Data fetching | TanStack Query 5 (wired in the scaffold)  |
| Routing       | React Router 7 (declarative)              |
| Styling       | Tailwind 4                                |
| Icons         | `@phosphor-icons/react` (square variants) |
| Charts        | Recharts                                  |
| Validation    | Zod                                       |
| Dates         | date-fns                                  |

There is no component library — no shadcn, no Radix, no headless UI. Every button, input, table, card, and dialog is one you write yourself in `src/components/` with JSX + Tailwind (the `cn` helper from `@/lib/utils` combines clsx and tailwind-merge). Build components once and reuse them so the app has one consistent visual system.

### Forbidden imports [#forbidden-imports]

* `react-dom/server` (apps are client-rendered, no SSR).
* Alternate UI libraries (`@chakra-ui/*`, `@mui/*`, `antd`, `react-bootstrap`).
* Alternate query libraries (`swr`, `react-query@v3`) or routing (`@tanstack/react-router`, `wouter`, `next/router`).
* Direct `fetch` to handler URLs from React. Use `useHandler` / `useHandlerMutation`.
* Any `@/lib/api` symbol other than `useHandler` / `useHandlerMutation`. The rest (e.g. `callHandler`) is private and importing it fails the build.

## Render constraints [#render-constraints]

Apps render inside a **resizable side panel next to the editor chat, not full-screen**. That panel is frequently narrower than the `sm:` (640px) breakpoint, and on a phone it is full mobile width. Treat the app as a &#x2A;*narrow, single-column mobile surface (\~380px baseline)**. `sm:` styles are a bonus that often never fires.

* **You own the page container.** Wrap each page's content in a centered, width-capped container with its own padding — the seeded `src/pages/Home.tsx` uses `mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-8`, a good default. Without a cap, content stretches edge-to-edge and looks wrong.
* **Single column is the baseline.** Use `grid-cols-1`; add `sm:grid-cols-2` only as progressive enhancement. Don't let width drive the page's whole structure.
* **No horizontal or board layouts.** Kanban columns and side-by-side panes force sideways scroll. Render a board as one vertical column grouped by status, or a segmented toggle showing one group at a time.
* **Prevent text overflow.** Put `min-w-0` on flex children and `truncate` or `line-clamp-2` on long values (names, emails, urls). Wide tabular data goes in its own `overflow-x-auto` wrapper so the table scrolls; the page itself must never scroll sideways.

## Style [#style]

You design the app. Two rules are machine-enforced by the validator, the rest is judgment:

* **Grayscale on `neutral`.** Never `zinc`, `slate`, `stone`, or `gray` — the validator rejects them.
* **No gradient surfaces.** `bg-gradient-to-*` on panels, tiles, or banners is rejected; gradient belongs in a chart's `linearGradient` fill only.
* **Status colors signal state, not decoration.** `green-600`/`green-50` success, `red-600`/`red-50` danger, `yellow-700`/`yellow-50` warning.
* **Icons** are Phosphor square variants (`MagnifyingGlassIcon`, `CheckSquareIcon`). Pick the square version when both exist.
* **No em dashes in user-facing copy.** Use periods or commas.

## Persistent state [#persistent-state]

**Apps are stateless across refreshes. React state is not storage.** Anything the user should still see after a reload (an action log, history, "recently added", completion checkmarks) must be written to and read back from a real data source. Accumulating it in `useState` looks right until the user refreshes, then it is gone.

Where state lives depends on what it is:

* **A connected service the user already uses.** Save the lead to their HubSpot, Salesforce, Airtable, or Google Sheets. Don't duplicate state the user can already see in their own tools.
* **A user database.** When the data is structured, app-specific, and belongs nowhere else, use the platform DB proxy from a handler (same shape as a code workflow: `POST ${context.platformBaseUrl}/database/user-database`). Don't reach for this by default.
* **Regenerated.** For derived data (search results, aggregates), don't persist; re-fetch on demand. The 15-minute read cache covers repeat views.
* **Client-local.** Form values, scroll position, current tab live in `useState` or URL search params.

## Reliability [#reliability]

* **Every handler call boots a sandbox.** Never trigger calls on keystrokes, input changes, or intervals. Search uses an explicit button or Enter, not `onChange`. The only exception is `useHandler` on mount with static input.
* **No N+1 fetches.** A list followed by per-item details in a sequential `await` loop is the most common cause of a slow handler. Use a batch endpoint or `Promise.all`.
* **Respect rate limits.** For more than 10 parallel calls to one host, batch in groups of 5 to 10. On 429, throw "Too many requests, try again in a minute"; don't auto-retry inside the handler.
* **Cap pagination depth.** Don't loop until `has_more` is false without a ceiling. Cap at roughly 10 pages / 2k rows / 2MB and return `{ rows, nextCursor }`; the React side loads more via `useInfiniteQuery`.
* **Idempotent mutations.** Use the integration's idempotency key (Stripe's `Idempotency-Key`) or check-then-create. A double-click shouldn't create duplicate rows.

## Access model [#access-model]

Handlers run with the **workflow owner's** credentials. Every viewer sees the same data: the owner's Stripe customers, the owner's HubSpot pipeline, regardless of who opens the app. Don't design apps that imply per-viewer filtering ("your assigned leads") unless a handler explicitly receives a viewer identifier and filters server-side. There is no built-in row-level access control.

## Building [#building]

[`geni resource validate`](/cli/resource/validate) checks the required files, `app.json`, credential wiring, and the style rules (no `zinc`/`slate`/`stone`/`gray`, no `bg-gradient-to-*` surfaces). [`geni resource publish`](/cli/resource/publish) validates and triggers the Vite build, reporting whether it passed. The [`geni app`](/cli/resource/app) tools then let you run and inspect handlers against the live app: [`run-handler`](/cli/resource/app/run-handler), [`build-status`](/cli/resource/app/build-status), [`invocations`](/cli/resource/app/invocations), and [`errors`](/cli/resource/app/errors).
