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 library 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(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*.tsxhandlers/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
| 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 loading, error, and 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 loading, error, and empty states 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'] }),
})
<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
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
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. - Any
@/lib/apisymbol other thanuseHandler/useHandlerMutation. The rest (e.g.callHandler) is private and importing it fails the build.
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.
- 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.tsxusesmx-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; addsm:grid-cols-2only 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-0on flex children andtruncateorline-clamp-2on long values (names, emails, urls). Wide tabular data goes in its ownoverflow-x-autowrapper so the table scrolls; the page itself must never scroll sideways.
Style
You design the app. Two rules are machine-enforced by the validator, the rest is judgment:
- Grayscale on
neutral. Neverzinc,slate,stone, orgray— the validator rejects them. - No gradient surfaces.
bg-gradient-to-*on panels, tiles, or banners is rejected; gradient belongs in a chart'slinearGradientfill only. - Status colors signal state, not decoration.
green-600/green-50success,red-600/red-50danger,yellow-700/yellow-50warning. - 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 style rules (no zinc/slate/stone/gray, no bg-gradient-to-* surfaces). 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.