Vertesia Documentation

Building an Application UI

Applications extend Vertesia with custom React pages. Each app is a unified project containing a standalone React UI (frontend) and a Hono tool server (backend for custom tools, skills, and interactions), built and deployed as a single unit. Studio embeds customer UIs in sandboxed iframes, so the app owns React and all other browser dependencies.

Prerequisites

  • Node.js 24.x and pnpm 11.8.0 through Corepack
  • Vertesia CLI installed and authenticated (vertesia auth login)

1. Scaffold Your Plugin

pnpm dlx @vertesia/create-plugin my-plugin

You will be prompted for the app name (kebab-case), version, and description. The generated project includes everything needed: React UI, Hono tool server, Vite build configuration, Vercel deployment configuration, and example tools/skills.

2. Develop Locally

cd my-plugin
npm install
npm run dev

The generated dev script runs Vite in app mode (vite dev --mode app), so local development reads .env.app and .env.app.local.

Open https://localhost:5173. You'll see:

  • /app/* -- your plugin UI with hot module replacement
  • /* -- the tool server admin UI (manage tools, skills, interactions)
  • /api -- the tool server API endpoint

HTTPS is required for authentication. The dev server uses a self-signed certificate.

The generated project includes an .env.app file:

VITE_APP_NAME=my-plugin

This value must match the name field in your Vertesia app manifest. It is public Vite build-time configuration, so it is safe to commit. Use .env.app.local for local overrides. Generic Vite development env files such as .env.local are not required by the generated template.

3. Register Your App in Vertesia

Create a manifest.json:

{
  "name": "my-plugin",
  "title": "My Plugin",
  "description": "What this plugin does",
  "publisher": "your-org",
  "visibility": "private",
  "status": "beta"
}

Register and install in one step:

vertesia apps create --install -f manifest.json

This creates the app manifest, installs it in your current project, and grants you access.

Verify that .env.app uses the same app name:

VITE_APP_NAME=my-plugin

Restart npm run dev and navigate to /app/ to see your plugin running with full Vertesia authentication.

4. Deploy to Vercel

Vercel is the easiest way to deploy your plugin — its generous free tier is more than enough for development and small-scale production.

npm i -g vercel
vercel --prod

The template includes a vercel.json and api/index.js adapter that handles routing: the standalone app is served at /app, / redirects to /app, and API requests go through the serverless function.

Vercel builds with vite build --mode app, so it reads .env.app and .env.app.local if present. Do not rely on .env.local for deployed builds. If you override VITE_APP_NAME in Vercel project settings, keep it equal to the manifest name.

After deploying, update your app manifest with the production endpoint:

vertesia apps update my-plugin --manifest '{
  "endpoint": "https://my-plugin.vercel.app/api"
}'

The endpoint URL tells Vertesia where to find your plugin's tools, skills, interactions, and UI configuration. It replaces the older ui.src and tool_collections fields.

Important: Disable deployment protection in Vercel project settings for the plugin to be publicly accessible.

Isolation Strategies

The manifest ui.isolation field controls how Studio hosts the application:

  • iframe (recommended for customer apps) -- runs the standalone app with its own dependencies and document
  • shadow -- legacy host-React plugin mode with Shadow DOM style isolation
  • css -- legacy host-React plugin mode in the Studio document; styles may conflict with the host

Iframe apps do not receive Studio React contexts. Use Vertesia APIs and the app's own router normally. The iframe bridge keeps the embedded route, active account/project, locale, and light/dark theme synchronized with the Composite App without reloading the iframe. Newly generated apps are available from both the App Portal and Composite Apps; set ui.available_in explicitly if an app should appear in only one surface.

Studio supplies and refreshes the active user session through an in-memory, source- and origin-checked handshake. The host origin is passed explicitly so authentication survives hard reloads and sign-in redirects. The credential is the viewing user's Studio JWT, not an app-specific reduced-scope token. Consequently, iframe isolation protects Studio's DOM and JavaScript dependency graph, but it is not a credential trust boundary: only configure ui.src to an application origin you trust with the viewing user's Vertesia access. Tokens are never placed in the URL or persisted by the bridge.

The iframe must be hosted on an origin separate from Studio. This is security-critical because the sandbox enables both scripts and same-origin behavior for a normal standalone application; a same-origin frame could otherwise escape the intended DOM boundary.

Using Vertesia UI Components

The @vertesia/ui package provides ready-to-use components. Import from subpaths:

// Core components and hooks
import { Button, Card, Input, Spinner, VModal, VTabs, useFetch, useToast } from '@vertesia/ui/core';

// Router
import { useNavigate, useParams, NavLink, NestedRouterProvider } from '@vertesia/ui/router';

// Session and auth
import { useUserSession } from '@vertesia/ui/session';

// Layout
import { FullHeightLayout } from '@vertesia/ui/layout';

Fetching Data

Use the useFetch hook with the Vertesia client:

import { useFetch, Spinner } from '@vertesia/ui/core';
import { useUserSession } from '@vertesia/ui/session';

function MyPage() {
    const { client } = useUserSession();

    const { data, error } = useFetch(
        () => client.store.collections.list(),
        []
    );

    if (error) return <div>Failed to load</div>;
    if (!data) return <Spinner />;

    return <div>{data.map(item => ...)}</div>;
}

Using the Vertesia Client

const { client } = useUserSession();

// Collections and objects
const collections = await client.store.collections.list();
await client.store.objects.create(
    { content: file, name: file.name },
    { collection_id: collectionId }
);

// Launch an agent
await client.runs.create({
    interaction: 'app:my_interaction',
    data: { /* payload */ },
    tags: ['my-tag'],
});

Styling

Use Tailwind CSS with Vertesia's semantic colors: primary, secondary, success, attention, destructive, done, info, muted.

Write the bare name — never a -foreground or -background suffix. @vertesia/ui/css/utilities.css overrides Tailwind's bg-* and text-* so each bare name resolves to the right role on its own. bg-success is not the dark green --success variable it appears to name; it resolves to --success-background. Reading color.css alone will make a correct class look wrong, so read this table instead:

You writeResolves toRenders
bg-success--success-backgroundlight green surface
text-success--success (no --success-foreground exists)dark green ink
border-success, fill-success, stroke-success, ring-success--successdark green ink
bg-primary--primary (no --color-primary-background is registered in @theme)solid dark blue
text-primary, text-link--primary-foregroundwhite

The rule behind the table: bg-X prefers --X-background and falls back to --X; text-X prefers --X-foreground and falls back to --X; every other color utility takes --X directly.

bg-success-background and text-muted-foreground still compile, but they bypass the convention and most *-foreground variables no longer exist. The bare name is the whole API.

Pair the same base name. A surface and the text on it take the same semantic. Each utility resolves its own side, so the pair is always legible even though both classes name one token:

<div className="bg-success text-success border-success" />
<div className="bg-destructive text-destructive" />
<span className="text-muted" />

Blue is info, not primary. primary is only the solid brand treatment: bg-primary text-primary is the primary button — a dark blue block with white text. Neither half stands on its own. text-primary and text-link render white, so blue text on an ordinary background is text-info. bg-primary is a solid block rather than a tinted surface, so a readable blue background is bg-info paired with text-info. Both mistakes fail silently: white on white, no build error.

Available utilities are bg-*, bg-mixer-*/[n], text-*, text-disabled-*, text-mixer-*/[n], border-*, border-disabled-*, border-mixer-*/[n], plus decoration-*, outline-*, shadow-*, ring-*, accent-*, caret-*, fill-* and stroke-*. The *-mixer-*/[n] forms blend the token toward white (light mode) or black (dark mode) by n%. There is no bg-disabled-* — utilities.css defines only text-disabled-* and border-disabled-*.

For these classes to be generated by Tailwind, your plugin must pull the Vertesia design tokens into its main Tailwind entry CSS so the @theme directives are visible at compile time:

src/styles/tailwind.css

@import 'tailwindcss';

/*
 * Pull in the Vertesia design tokens so the Tailwind compilation knows
 * about the semantic palette (--color-primary, --color-foreground, etc.).
 * Without this, classes like `bg-primary` and `text-muted` never get
 * generated as utilities.
 *
 * Skip @vertesia/ui/css/base.css if you have your own base layer — it
 * body-applies selection styles that may conflict with your palette.
 */
@import '@vertesia/ui/css/color.css';
@import '@vertesia/ui/css/theme.css';
@import '@vertesia/ui/css/utilities.css';
@import '@vertesia/ui/css/custom-tooltips.css';

If the import lives inside a component file (e.g. import '@vertesia/ui/css/index.css' from a TSX module) instead of the Tailwind entry CSS, the CSS variables are bundled but Tailwind never sees the @theme block at compile time, so the semantic utility classes are silently missing.

Next Steps

The generated project includes a comprehensive README with detailed guides for:

  • Creating resources -- tools, skills, interactions, content types, templates
  • Tool server configuration -- registering collections, settings schema, org restrictions
  • Build system -- dual Rollup/Vite architecture, import hooks
  • Debugging with the platform -- Cloudflare tunnel for local testing with real agents
  • Theme customization -- overriding CSS custom properties in index.css

Was this page helpful?