Docs
Resource

Apps

The full contract for an app. An interactive React SPA in a sandboxed iframe that calls named server-side handlers for data, with a pinned component stack and per-handler caching.

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 or agent workflow.

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.

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, watch the build with geni app build-status, and read browser errors with geni app errors. Then open the finished app in your dashboard.

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 (theme tokens, 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
  • src/components/ui/** (the vendored design system)
  • 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, outside src/components/ui/)
  • src/lib/*.ts (your own shared helpers, outside the platform-owned files above)
  • handlers/*.ts (server-side handlers)

Project layout

PathRequiredPurpose
app.jsonyesManifest: title, declared credentials, handler list.
package.jsonyesDeps. The scaffold ships the pinned stack.
src/App.tsxyesRoot component with routes.
src/pages/Home.tsxyesAt least one page.
handlers/<name>.tsyesAt 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

{
  "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" }
  ]
}
FieldRequiredPurpose
titleyesShown in the dashboard chrome above the iframe.
descriptionnoOne-line summary in the workflow list.
credentialsnoUser credentials, { inputKey, service, scopes? }. Resolved via getCredential.
platformCredentialsnoLegacy. Redundant declared slot. chat-completion uses top-level platformApiKey / platformBaseUrl.
handlersyesExplicit 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

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

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

Pages live in src/pages/, one component per file, wired as routes in src/App.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 Skeleton / ErrorBanner / Empty states. Write only the success branch:

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 Spinner / ErrorBanner / Empty yourself.

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

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

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

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

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

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.

NeedLibrary
FrameworkReact 19 + Vite
Data fetchingTanStack Query 5 (wired in the scaffold)
RoutingReact Router 7 (declarative)
StylingTailwind 4
Components@/components/ui (vendored primitives)
Icons@phosphor-icons/react (square variants)
ChartsRecharts
ValidationZod
Datesdate-fns

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.
  • Per-file or relative imports of vendored primitives ('@/components/ui/Button'). Import every primitive from the @/components/ui barrel in one statement.
  • Any @/lib/api symbol other than useHandler / useHandlerMutation. The rest (e.g. callHandler) is private and importing it fails the build.

Vendored components

All from the @/components/ui barrel. Mobile-first defaults, tap targets at least 44px, sized for the narrow content area.

  • Layout: Page, PageHeader, Stack, Row, Divider
  • Typography: Heading, Text, Kbd, FormattedNumber
  • Containers / data: Card (+ CardHeader / CardFooter), Stat, Metric, MetricStrip, SplitCard, Table (+ head/body/row/cell), SortableTable, List (+ ListItem), DescriptionList (+ DescriptionItem)
  • Data viz: TrendBadge, PercentBar, HeatCell, StatusDot
  • Inputs: Button, Input, Textarea, Select, DateInput, DateRangePicker, SearchInput, Checkbox, Switch, RadioGroup (+ Radio)
  • Forms: Label, FormField
  • Dashboard tools: FilterChips, ColumnToggle, ExportButton
  • Feedback: Spinner, Skeleton (+ SkeletonText / SkeletonCard / SkeletonTable), Empty, ErrorBanner, Progress, Toaster (+ toast)
  • Overlays: Dialog (+ header/body/footer), Popover, DropdownMenu (+ item/divider), Tooltip
  • Navigation: Tabs, SegmentedControl, Breadcrumbs, Pagination
  • Misc: Badge, Avatar

Every primitive forwards native props (onClick, id, aria-*, data-*, style). For a clickable row, prefer <ListItem onClick> over <Card onClick>; ListItem ships hover, focus ring, and keyboard handling.

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 narrow, single-column mobile surface (~380px baseline). sm: styles are a bonus that often never fires.

  • Wrap every page in <Page>. It applies max-w-2xl mx-auto plus responsive padding. If you write your own <main> or container, content stretches and looks wrong. Don't add another max-w-* or mx-auto inside it.
  • Single column is the baseline. Use grid-cols-1; add sm:grid-cols-2 only as progressive enhancement. Never lg: or xl:; the panel never gets that wide.
  • 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 SegmentedControl 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 the vendored Table, which scrolls horizontally on its own; the page itself must never scroll sideways.
import { Page, PageHeader } from '@/components/ui'

export function Home() {
  return (
    <Page>
      <PageHeader title="Customers" />
      {/* everything else */}
    </Page>
  )
}

Style

The scaffold's Tailwind config matches the dashboard's visual language.

  • Grayscale on neutral. Never zinc, slate, stone, or gray. Primary action: bg-neutral-900 text-white.
  • Status colors signal state, not decoration. green-600/green-50 success, red-600/red-50 danger, yellow-700/yellow-50 warning, blue-600/blue-50 info.
  • Rounded to match the primitives: rounded-xl for surfaces, rounded-lg for controls, rounded-full for avatars, dots, and pills.
  • Card padding is built in. Card already pads. Don't wrap its contents in another padded <div>, and don't nest a self-contained surface (List, SplitCard, another Card) inside a Card; that doubles the border and padding. Drop a Table straight into a Card, or use a standalone List with no Card.
  • 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

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

  • 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

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

geni resource validate checks the required files, app.json, credential wiring, and the design-system rules (no zinc/slate/stone/gray, no raw <button>/<input>/<textarea>, vendored primitives imported from the barrel). geni resource publish validates and triggers the Vite build, reporting whether it passed. The geni app tools then let you run and inspect handlers against the live app: run-handler, build-status, invocations, and errors.

On this page