> ## Documentation Index
> Fetch the complete documentation index at: https://cadenya.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Widget quickstart

> Build a travel concierge with Cadenya Widgets, Next.js, and the React UI kit, then ship it to Vercel.

By the end of this page, you have a travel concierge running on Vercel. It asks travelers questions with buttons, reads their home airport from the page, and drops destination cards into the conversation. It takes about 30 minutes (packing not included).

Before you start, you need:

* A Cadenya account and the CLI, logged in to your **Development** workspace. The [introduction](/docs/index) covers both.
* An enabled model that supports tool calling. Run `cadenya models list` to see yours.
* Node.js 22.12 or later.
* A [Vercel](https://vercel.com) account and the Vercel CLI: `npm install -g vercel`.

Want to see where you're headed first? The [travel concierge demo](https://widgets-demo.cadenya.com/travel-agent) is a bigger version of what you build here.

## What a Widget is

A Widget puts a Cadenya Agent inside your web app. It binds one Agent to its own host (something like `adbtaawrmh4h.widgets.cadenya.com`) and to a list of web origins allowed to talk to it.

Your backend has one job: mint a Widget Session. It calls Cadenya with your private API key and hands the browser a short-lived token and the Widget's host. From then on, the browser and the Widget host talk to each other. Messages, tool calls, and the live event stream never pass through your server.

**Your Cadenya API key never leaves your server.**

The interesting part is the tools. A Bare tool has no HTTP endpoint and no MCP server behind it. It waits for your application to supply the result. In a Widget, that application is the browser, so the page itself can answer the agent: a button the traveler clicks, a value sitting in React state, a card that renders and says "done". That's the whole trick of this guide.

The [Widgets guide](/docs/guides/the-basics/widgets) goes deeper on sessions, lifetimes, and revocation.

## The project

Create a Next.js app and install three packages: the server SDK, the React UI kit, and Radix Themes (the kit builds on it).

```bash theme={null}
npx create-next-app@latest travel-concierge --yes
cd travel-concierge
npm install @cadenya/cadenya @cadenya/widgets-ui-react @radix-ui/themes
```

By the end, the project looks like this:

```text theme={null}
travel-concierge/
  app/
    api/session/route.ts    Mints a Widget Session (server only)
    page.tsx                Renders the concierge
  components/
    concierge.tsx           Session, provider, panel, and Widget tools
  cadenya/
    agent.json              The Agent
    variation.json          Its prompt (the model comes from the CLI)
  .env.local                Your Cadenya credentials (never committed)
```

The `cadenya/` folder isn't part of the app. It holds the JSON you feed the CLI, so the agent's configuration lives in Git next to the code that depends on it.

## Deploy to Vercel

Deploy now, before there's anything to deploy. Why so early? Because a Widget only answers requests from origins on its allowlist, and **the allowlist takes exact origins, not wildcards.** You need your production URL before you create the Widget.

```bash theme={null}
vercel --prod
```

Accept the defaults. When the deploy finishes, Vercel prints the production URL, something like `https://travel-concierge.vercel.app`. Copy it. You use it in the next section.

<Note>
  Every Vercel preview deployment gets its own URL, and each one is a separate origin. Add the ones you want to test against to the Widget's allowlist, or test previews against `localhost` and keep the allowlist to production.
</Note>

## Create the agent and Widget

The concierge needs four things in Cadenya: a Tool Set the page can answer, an Agent, a variation, and a Widget.

### Add the Widgets Tools template

You don't have to write the tools. The **Widgets Tools** template is a Bare Tool Set built for Widget pages, and the React UI kit already knows how to render it. Open it in your workspace:

[app.cadenya.com/w/default/toolSets/templates/widgets-tools](https://app.cadenya.com/w/default/toolSets/templates/widgets-tools)

That `default` in the URL isn't a typo. Cadenya swaps it for your default workspace (pick yours under **Preferences** on your profile page), so one link in the docs opens the page in *your* workspace.

Keep the defaults and click **Add to workspace**. You get a Tool Set with the external ID `widget-tools` and three tools, one for each way a page can answer an agent:

1. `cdy_widget_ask_user` asks the traveler up to four questions and waits. Each question is radio buttons, checkboxes, or a text field, and the answers go back as `{"answers": {"<question id>": ...}}`.
2. `cdy_widget_display_details` shows a card: a title, fields, markdown, an image, and link buttons. Nobody needs to answer a card, so the tool answers the agent the moment it's called.
3. `cdy_widget_get_page_context` reads whatever the page chooses to share. The traveler doesn't click anything: the page answers on its own.

The template also turns on widget argument exposure for the whole Tool Set. **Without it, the browser never sees the arguments the agent passes to a tool,** and the question card has nothing to render. (The `cdy_widget_` prefix keeps the names from colliding with your own tools.)

### Create the Agent and the rest

The Agent is the role, and its variation carries the prompt. Put both in the `cadenya/` folder:

```json cadenya/agent.json theme={null}
{
  "metadata": { "name": "Travel concierge", "externalId": "travel-concierge" },
  "spec": {
    "description": "Plans trips with questions, the traveler's home airport, and destination cards",
    "variationSelectionMode": "VARIATION_SELECTION_MODE_RANDOM"
  }
}
```

```json cadenya/variation.json theme={null}
{
  "metadata": { "name": "Travel concierge", "externalId": "travel-concierge-v1" },
  "spec": {
    "systemPromptTemplate": "You are a friendly travel concierge. Before you suggest anything, call cdy_widget_get_page_context to learn the traveler's home airport, so your ideas make sense from where they start. When you need preferences, such as budget, season, or pace, ask with cdy_widget_ask_user instead of in prose. Show each destination with cdy_widget_display_details: put the flight time, best season, and rough budget in fields. You plan trips. You don't book them."
  }
}
```

Create everything in order. Every command refers to resources by their external IDs, including the `widget-tools` Tool Set from the template, so you never copy a generated ID around. Set `MODEL_ID` to a model from `cadenya models list`, and put your Vercel URL in the last command:

```bash theme={null}
MODEL_ID=your-model-id

cadenya agents create -f cadenya/agent.json
cadenya agents variations create external_id:travel-concierge \
  -f cadenya/variation.json --model-id "$MODEL_ID"
cadenya agents variations add-assignment external_id:travel-concierge \
  external_id:travel-concierge-v1 --tool-set-id external_id:widget-tools
cadenya agents publish external_id:travel-concierge

cadenya widgets create --name "Travel concierge" --external-id travel-concierge \
  --agent-id external_id:travel-concierge \
  --origin-allowlist http://localhost:3000 \
  --origin-allowlist https://travel-concierge.vercel.app
```

A Widget only binds to a published Agent, which is why `agents publish` comes before `widgets create`.

## Mint a Widget Session

The browser can't hold your API key, so it asks your server for a session instead. First, give the server its credentials. Create an API key in your **Development** workspace with the `widget_sessions:manage` scope, then fill in `.env.local`:

```bash .env.local theme={null}
CADENYA_API_KEY=your-api-key
CADENYA_WORKSPACE_ID=your-workspace-id
CADENYA_WIDGET_ID=external_id:travel-concierge
```

`cadenya auth status` prints your workspace ID. The SDK reads all three variables on its own.

Now the route. It mints a session for the Widget and returns the two values the browser needs:

```typescript app/api/session/route.ts theme={null}
import Cadenya from "@cadenya/cadenya";

export const runtime = "nodejs"; // The server SDK needs Node, not the Edge runtime.
export const dynamic = "force-dynamic"; // Never cache a minted token.

// Module scope lets every request reuse one client.
const client = new Cadenya();

export async function POST() {
  const session = await client.widgetSessions.create({
    spec: {
      widgetId: process.env.CADENYA_WIDGET_ID!,
      // Every session names a tenant and a subject, and sessions with the same
      // pair share conversation history. Swap in your signed-in user's IDs.
      tenant: { id: "travel-concierge", name: "Travel concierge" },
      subject: { id: "quickstart-traveler" },
    },
  });

  if (!session.info?.host) {
    return Response.json({ error: "The Widget Session came back without a host" }, { status: 502 });
  }

  return Response.json(
    { token: session.spec.token, host: session.info.host },
    { headers: { "Cache-Control": "no-store" } },
  );
}
```

This route mints a session for anyone who asks, and everyone is the same traveler, so everyone shares one conversation history. That's fine for a quickstart. In your real app, check who's asking first and use their IDs for the tenant and subject.

Start the dev server and mint one:

```bash theme={null}
npm run dev
curl -X POST http://localhost:3000/api/session
```

You should see a token and a host (trimmed here):

```json theme={null}
{ "token": "eyJhbGciOiJFZERTQSIs...", "host": "adbtaawrmh4h.widgets.cadenya.com" }
```

**Always use the `host` from the response.** Don't build it from the Widget ID.

Then give Vercel the same three variables, so production can mint sessions too:

```bash theme={null}
vercel env add CADENYA_API_KEY production
vercel env add CADENYA_WORKSPACE_ID production
vercel env add CADENYA_WIDGET_ID production
```

## Give the session to the Widget

`CadenyaWidgetProvider` takes the host and token and builds the browser client. `ConversationsPanel` is the whole chat: the conversation list, the message thread, the composer, and the live event stream.

```tsx components/concierge.tsx theme={null}
"use client";

import "@radix-ui/themes/styles.css";
import "@cadenya/widgets-ui-react/styles.css";
import { useCallback, useEffect, useState } from "react";
import { Theme } from "@radix-ui/themes";
import { CadenyaWidgetProvider, ConversationsPanel } from "@cadenya/widgets-ui-react";

type Session = { token: string; host: string };

async function mintSession(): Promise<Session> {
  const response = await fetch("/api/session", { method: "POST" });
  if (!response.ok) throw new Error("Could not start a Widget Session");
  return response.json();
}

export function Concierge() {
  const [session, setSession] = useState<Session | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    mintSession().then(setSession, (err: Error) => setError(err.message));
  }, []);

  // Tokens last about 15 minutes. On a 401, the provider calls this and
  // retries the request once with the new token.
  const getToken = useCallback(async () => (await mintSession()).token, []);

  if (error) return <p role="alert">{error}</p>;
  if (!session) return <p>Packing your bags…</p>;

  return (
    <Theme accentColor="teal" grayColor="slate" radius="large">
      <CadenyaWidgetProvider host={session.host} token={session.token} getToken={getToken}>
        <ConversationsPanel />
      </CadenyaWidgetProvider>
    </Theme>
  );
}
```

The Widget host has no refresh endpoint. `getToken` goes back to your server, which is the point: your app decides every time whether this visitor still gets a token.

Replace `app/page.tsx` with a page that gives the panel room. **The panel fills its container's height, so the container needs a definite one.** Without it, the panel grows with the conversation and pushes the composer off the screen.

```tsx app/page.tsx theme={null}
import { Concierge } from "@/components/concierge";

export default function Home() {
  return (
    <main style={{ height: "100dvh", padding: 16, boxSizing: "border-box" }}>
      <Concierge />
    </main>
  );
}
```

Open `http://localhost:3000` and say hi. The agent answers.

Now ask it to plan a weekend away. It calls `cdy_widget_get_page_context`, and then... nothing. A Bare tool waits until something supplies its result, and nothing on the page does yet. Time to fix that.

## Register frontend tool handlers

The UI kit gives you two ways to answer a tool from the browser:

1. **Tool components** render a tool call as a React component. The component gets the call's arguments and a `submit` function. Use them when the traveler answers.
2. **Page tools** are plain handlers. The panel runs them the moment the agent calls the tool and sends back whatever they return. Use them when the page answers.

The Widgets Tools template needs both, and `<WidgetTools>` registers all three in one element: the question card and the details card as tool components, and `cdy_widget_get_page_context` as a page tool. It lives in its own entry point, so apps that don't use the template don't bundle it.

Here's the finished `components/concierge.tsx`, with a home airport picker whose value becomes the page context:

```tsx components/concierge.tsx theme={null}
"use client";

import "@radix-ui/themes/styles.css";
import "@cadenya/widgets-ui-react/styles.css";
import { useCallback, useEffect, useState } from "react";
import { Box, Flex, Select, Text, Theme } from "@radix-ui/themes";
import {
  CadenyaWidgetProvider,
  ConversationsPanel,
  PageToolsProvider,
} from "@cadenya/widgets-ui-react";
import { WidgetTools } from "@cadenya/widgets-ui-react/widget-tools";

type Session = { token: string; host: string };

async function mintSession(): Promise<Session> {
  const response = await fetch("/api/session", { method: "POST" });
  if (!response.ok) throw new Error("Could not start a Widget Session");
  return response.json();
}

export function Concierge() {
  const [session, setSession] = useState<Session | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [airport, setAirport] = useState("PDX");

  useEffect(() => {
    mintSession().then(setSession, (err: Error) => setError(err.message));
  }, []);

  const getToken = useCallback(async () => (await mintSession()).token, []);

  if (error) return <p role="alert">{error}</p>;
  if (!session) return <p>Packing your bags…</p>;

  return (
    <Theme accentColor="teal" grayColor="slate" radius="large" style={{ height: "100%" }}>
      <Flex direction="column" gap="3" height="100%">
        <Flex align="center" gap="2">
          <Text as="label" size="2">
            Flying from
          </Text>
          <Select.Root value={airport} onValueChange={setAirport}>
            <Select.Trigger />
            <Select.Content>
              <Select.Item value="PDX">Portland (PDX)</Select.Item>
              <Select.Item value="JFK">New York (JFK)</Select.Item>
              <Select.Item value="LHR">London (LHR)</Select.Item>
              <Select.Item value="NRT">Tokyo (NRT)</Select.Item>
            </Select.Content>
          </Select.Root>
        </Flex>
        <Box flexGrow="1" minHeight="0">
          <CadenyaWidgetProvider host={session.host} token={session.token} getToken={getToken}>
            <PageToolsProvider>
              {/* The latest render's value answers, so the agent always reads the current pick. */}
              <WidgetTools pageContext={{ homeAirport: airport }} />
              <ConversationsPanel toolPlacement="inline" />
            </PageToolsProvider>
          </CadenyaWidgetProvider>
        </Box>
      </Flex>
    </Theme>
  );
}
```

**`<WidgetTools>` and the panel have to share one `PageToolsProvider`.** The provider is how the panel finds the renderers and handlers the element registers.

`pageContext` takes anything. A string goes to the agent as-is, and anything else gets JSON-encoded. Leave it out and the tool still answers, saying the page shares nothing, so the agent never waits on it.

`toolPlacement="inline"` keeps each tool call in the thread where it happened. The default, `"activity"`, shows a call above the composer and clears it when the agent replies, which suits a progress chip but not a card the traveler wants to scroll back to. Inline calls also come back when a conversation reopens. They don't run again: page tools only fire for calls that are still running.

### Bring your own tools

The template covers questions, cards, and page context. When you need something else, add your own Bare tool to a Tool Set and answer it with the same two hooks `<WidgetTools>` uses. Say the agent can save a destination to the traveler's shortlist with a `save_destination` tool:

```tsx components/shortlist-tool.tsx theme={null}
"use client";

import { usePageTool } from "@cadenya/widgets-ui-react";

export function ShortlistTool({ onSave }: { onSave: (city: string) => void }) {
  usePageTool("save_destination", ({ args }) => {
    // Arguments come from the model, so check them before you use them.
    const city = (args as { city?: unknown } | undefined)?.city;
    if (typeof city !== "string") throw new Error("save_destination needs a city");
    onSave(city);
    return { saved: city };
  });
  return null;
}
```

Render it next to `<WidgetTools>`, inside the same `PageToolsProvider`. Whatever the handler returns becomes the tool's result, and a thrown error reaches the agent as `{"error": "..."}`. For a tool the traveler answers, register a component with `useToolComponent` (or the panel's `toolComponents` prop) and call its `submit` prop with the answer. To restyle the template's own cards, pass your components to `<WidgetTools components={{ askUser, displayDetails }} />`.

### Watch it plan a trip

Pick **Tokyo (NRT)**, then ask:

```text theme={null}
I've got a long weekend in October and I don't want to look at a spreadsheet. Where should I go?
```

The agent reads your airport from the page context, asks a question or two (budget, pace, beach or city), and then shows destination cards within a short flight of Tokyo. Change the airport to **London (LHR)** and ask again. The suggestions follow you.

When it works locally, ship it:

```bash theme={null}
vercel --prod
```

## Styling

Styling comes in three layers. Start at the top and stop when it looks right.

### The Radix Theme

The surrounding `<Theme>` is the main styling API. The kit's CSS only references Radix tokens, so these five props restyle everything at once:

```tsx theme={null}
<Theme
  appearance="dark" // "inherit" | "light" | "dark"
  accentColor="amber"
  grayColor="sand"
  radius="full"
  scaling="105%"
>
```

`appearance="inherit"` follows a `light` or `dark` class on a parent element, which is what you want if your site already has a theme toggle. The [Radix Themes docs](https://www.radix-ui.com/themes/docs/theme/overview) list every accent and gray.

### Panel props

`ConversationsPanel` takes two props for its own look. `composer` picks the input style, and `bubbleColors` takes any CSS background, gradients included:

```tsx theme={null}
<ConversationsPanel
  toolPlacement="inline"
  toolComponents={toolComponents}
  composer="floating" // "bar" (default) | "pill" | "floating"
  bubbleColors={{
    user: "linear-gradient(135deg, #0d9488, #115e59)",
    userText: "white",
  }}
/>
```

`"pill"` fuses the input and send button into one rounded capsule. `"floating"` lifts that capsule onto a shadowed card and turns the agent's bubbles into cards.

### CSS

Everything else is CSS. The kit's class names all start with `cdny-` (`.cdny-panel`, `.cdny-bubble-user`, `.cdny-composer`, `.cdny-ask-user`, `.cdny-details`, and friends), and they're a stable contract. Bubble colors also exist as CSS variables:

```css app/globals.css theme={null}
.concierge-panel {
  --cdny-bubble-user-bg: #0d9488;
  --cdny-bubble-user-fg: white;
  --cdny-bubble-assistant-bg: var(--gray-a3);
  --cdny-scroll-shadow: var(--gray-a4);
}

/* Give the question and destination cards a little room to breathe. */
.concierge-panel .cdny-ask-user,
.concierge-panel .cdny-details {
  margin-block: var(--space-2);
}
```

Hand the class to the panel with `className="concierge-panel"`. The same prop is where sizing and placement go.

## Where to go next

Your concierge hands a session to anyone who asks for one. The [Widgets guide](/docs/guides/the-basics/widgets) shows how to pass trusted context from your own auth into every conversation: tenants, subjects, pinned parameters, and per-visitor secrets.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.