Custom Runtime Apps
By default, a deployed agent uses Amodal's shared chat shell. A repo can instead ship its own single-page app and have Amodal serve that app from the agent's URL.
This is the pattern used by app-like agents such as an investor-relations workspace: the repo contains both the agent definition directories and a React app under src/.
Minimal Manifest
Enable the custom app in amodal.json:
{
"name": "investor-relations",
"version": "0.1.0",
"runtimeApp": {
"custom": true
}
}Optional fields:
{
"runtimeApp": {
"custom": true,
"build": "npm run build",
"dist": "dist"
}
}| Field | Default | Description |
|---|---|---|
custom | required | Set to true to build and serve the repo's SPA. |
build | npm run build | Build command run from the repo root after dependencies install. |
dist | dist | Directory uploaded as the SPA artifact. |
Expected Repo Shape
my-agent/
├── amodal.json
├── package.json
├── index.html
├── vite.config.ts
├── src/
│ └── main.tsx
└── amodal/
├── stores/
├── skills/
├── intents/
└── connections/The app should use same-origin API calls. In browser code, derive the runtime base from window.location.origin; only use VITE_RUNTIME_URL for local development against a remote runtime. Requests such as /chat/stream and /api/stores/investors then hit the agent's own origin through the edge worker.
Build And Serve Flow
When runtimeApp.custom is enabled:
- The build server clones the repo.
- It installs dependencies with
npm install. - It loads and validates the agent definition.
- It uploads server artifacts and
repo.tar.gzto R2. - It runs the SPA build command.
- It uploads the
distdirectory to R2 under:
runtime-app/{agentId}/{deployId}/spaThe Cloudflare Worker reads deploy metadata from the platform API. If the deploy's UI kind is custom-spa, it serves files from that R2 prefix. Browser routes return index.html; asset routes return the concrete asset with long-lived immutable caching.
Failure Behavior
If runtimeApp.custom is set and the SPA build fails, the deploy fails. Amodal does not silently fall back to the default chat shell, because that would hide a broken app release.
If runtimeApp.custom is omitted or false, the deploy uses the shared default runtime app artifact.
Iteration Loop
Build and test the SPA locally with the same command listed in runtimeApp.build. Commit the frontend source, push to the connected GitHub branch, and redeploy the agent through Amodal, CI, or the Platform API.
When a deploy fails, inspect build status and logs:
curl "$AMODAL_API_BASE/api/builds/$AMODAL_BUILD_ID/status" \
-H "Authorization: Bearer $AMODAL_API_KEY"
curl "$AMODAL_API_BASE/api/builds/$AMODAL_BUILD_ID/logs" \
-H "Authorization: Bearer $AMODAL_API_KEY"Fix the repo and redeploy. Do not patch the uploaded SPA artifact directly; the repo remains the source of truth.
SDK Use
Custom apps normally use @amodalai/react for runtime APIs, sessions, stores, events, and chat. Template repos may alias a local @amodalai/sdk wrapper to keep domain-specific hooks and types close to the app.
The key rule is that the browser app is not a separate service. It is an artifact of the agent deploy and is served from the same deployed agent URL as the runtime APIs.
Minimal Wiring
Install the SDK and React:
npm install @amodalai/react react react-domWrap the app in AmodalProvider and render AmodalChat. Two things are easy to miss:
- Import the stylesheet (
@amodalai/react/style.css) — without it the chat input and send/reset buttons render unstyled. - Pass an absolute same-origin
runtimeUrl— do not pass an empty string. The agent calls its own origin through the edge worker, but an emptyruntimeUrlends up as the literal string"undefined"by the time the SDK builds request URLs, producing requests to/undefined/chat/stream(which the SPA fallback answers withindex.html, so chat silently never responds). Usewindow.location.origin. Only setVITE_RUNTIME_URLfor local dev against a remote runtime. - For hosted cookie auth, omit
getToken. A gated (user_auth) agent redirects the browser to login, sets an HttpOnly session cookie, and the edge worker injects runtime authorization server-side. The SPA should not fetch/auth/sessionor try to read a token.
// src/main.tsx
import { createRoot } from "react-dom/client";
import "@amodalai/react/style.css"; // required, or the chat UI is unstyled
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);// src/App.tsx
import { AmodalProvider, AmodalChat } from "@amodalai/react";
// Absolute same-origin base. Never "" — that becomes /undefined/chat/stream.
const env = import.meta.env.VITE_RUNTIME_URL;
const runtimeUrl = env && env !== "undefined" ? env : window.location.origin;
export function App() {
return (
<AmodalProvider runtimeUrl={runtimeUrl}>
<AmodalChat serverUrl={runtimeUrl} />
</AmodalProvider>
);
}Wiring Agent Capabilities
A custom SPA only changes the browser shell. The chat loop still uses the deployed agent definition. If the app should demonstrate tools, stores, skills, or connections, wire those names into session_types.default in amodal.json:
{
"session_types": {
"default": {
"prompt": "Use the demo capabilities when they help.",
"skills": ["Craft Greeting"],
"connections": ["advice"],
"stores": {
"visitors": "rw"
},
"tools": ["shout"]
}
}
}Without that session profile, the files may deploy successfully while the model still cannot see or call them from chat.
Authenticating Chat From a Custom App
The runtime requires authentication on chat requests — end_user_auth.type selects who the caller is, but it does not make runtime auth optional.
- Team-internal hosted app (
hosted_auth_mode: "user_auth"): the visitor logs in through the agent's/auth/loginas a full-page redirect. The edge worker stores an HttpOnlyamodal_sessioncookie and injects the bearer header when it proxies same-origin runtime calls. The SPA should not read or store that token. Use a same-originruntimeUrland omitgetTokenunless you have a separate bearer-token flow. - External bearer-token app: if another backend or identity system owns auth, pass a
getTokencallback that returns a bearer token for the runtime. The SDK only sendsAuthorizationwhengetTokenreturns a non-empty value. - Anonymous (
type: "none") public agents: public access only opens the app shell. Chat still requires an authenticated runtime caller. Browser-only anonymous token spending is intentionally not supported; use a server-side broker, an agent API key, or a short-lived runtime token minted byPOST /api/agents/{agent_id}/tokensfor CI/server smoke tests.
See Authentication for the full access/identity model and the getToken contract, and Embedding & Multi-tenancy for the external-product embedding case.