Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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"
  }
}
FieldDefaultDescription
customrequiredSet to true to build and serve the repo's SPA.
buildnpm run buildBuild command run from the repo root after dependencies install.
distdistDirectory 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:

  1. The build server clones the repo.
  2. It installs dependencies with npm install.
  3. It loads and validates the agent definition.
  4. It uploads server artifacts and repo.tar.gz to R2.
  5. It runs the SPA build command.
  6. It uploads the dist directory to R2 under:
runtime-app/{agentId}/{deployId}/spa

The 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-dom

Wrap 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 empty runtimeUrl ends 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 with index.html, so chat silently never responds). Use window.location.origin. Only set VITE_RUNTIME_URL for 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/session or 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/login as a full-page redirect. The edge worker stores an HttpOnly amodal_session cookie and injects the bearer header when it proxies same-origin runtime calls. The SPA should not read or store that token. Use a same-origin runtimeUrl and omit getToken unless you have a separate bearer-token flow.
  • External bearer-token app: if another backend or identity system owns auth, pass a getToken callback that returns a bearer token for the runtime. The SDK only sends Authorization when getToken returns 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 by POST /api/agents/{agent_id}/tokens for 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.