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
- The user opens the app. The iframe loads, the React bundle hydrates, and the providers (TanStack Query, Router) wire up.
- Pages call handlers with
useHandler('name', input)(reads) oruseHandlerMutation('name')(writes). - 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. - A handler error is logged to the platform; the iframe shows the user a generic error. Errors are never cached.
- 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.jsindex.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(theuseHandleranduseHandlerMutationhooks)src/lib/HandlerView.tsx,src/lib/utils.ts,src/lib/telemetry.ts,src/lib/ErrorBoundary*.tsxsrc/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, outsidesrc/components/ui/)src/lib/*.ts(your own shared helpers, outside the platform-owned files above)handlers/*.ts(server-side handlers)
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
{
"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
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
inputSchemaand parserawInputfirst. The platform generates client-side types from the schema. A handler without it fails validation. - One handler per file. No branching on an
actionfield. - 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 fromcontext, neverprocess.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/useHandlerMutationresolve 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.alland returns enriched rows. Never call a per-rowuseHandlerfrom 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.
| Need | Library |
|---|---|
| Framework | React 19 + Vite |
| Data fetching | TanStack Query 5 (wired in the scaffold) |
| Routing | React Router 7 (declarative) |
| Styling | Tailwind 4 |
| Components | @/components/ui (vendored primitives) |
| Icons | @phosphor-icons/react (square variants) |
| Charts | Recharts |
| Validation | Zod |
| Dates | date-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
fetchto handler URLs from React. UseuseHandler/useHandlerMutation. - Per-file or relative imports of vendored primitives (
'@/components/ui/Button'). Import every primitive from the@/components/uibarrel in one statement. - Any
@/lib/apisymbol other thanuseHandler/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 appliesmax-w-2xl mx-autoplus responsive padding. If you write your own<main>or container, content stretches and looks wrong. Don't add anothermax-w-*ormx-autoinside it. - Single column is the baseline. Use
grid-cols-1; addsm:grid-cols-2only as progressive enhancement. Neverlg:orxl:; 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
SegmentedControlshowing one group at a time. - Prevent text overflow. Put
min-w-0on flex children andtruncateorline-clamp-2on long values (names, emails, urls). Wide tabular data goes in the vendoredTable, 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. Neverzinc,slate,stone, orgray. Primary action:bg-neutral-900 text-white. - Status colors signal state, not decoration.
green-600/green-50success,red-600/red-50danger,yellow-700/yellow-50warning,blue-600/blue-50info. - Rounded to match the primitives:
rounded-xlfor surfaces,rounded-lgfor controls,rounded-fullfor avatars, dots, and pills. - Card padding is built in.
Cardalready pads. Don't wrap its contents in another padded<div>, and don't nest a self-contained surface (List,SplitCard, anotherCard) inside aCard; that doubles the border and padding. Drop aTablestraight into aCard, or use a standaloneListwith noCard. - 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
useStateor 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 isuseHandleron mount with static input. - No N+1 fetches. A list followed by per-item details in a sequential
awaitloop is the most common cause of a slow handler. Use a batch endpoint orPromise.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_moreis false without a ceiling. Cap at roughly 10 pages / 2k rows / 2MB and return{ rows, nextCursor }; the React side loads more viauseInfiniteQuery. - 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.
Agent workflows
The full contract for an agent workflow. An LLM agent runs instructions.md as its task, invoking reusable scripts via bash, with its runtime budget shaped by agent.json.
Skills
The full contract for a skill. A SKILL.md instruction bundle that agents load as context to learn how to do a kind of work — authored and published through the same resource lifecycle as workflows and apps.