> ## Documentation Index
> Fetch the complete documentation index at: https://domoinc-openapi-sync-dataflows.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Book Recommender

### Intro

***

This tutorial walks through building an AI-powered book recommender with React, TypeScript, Ant Design, and Domo's AI Service Layer. You'll learn how to:

* Scaffold a Vite + React + TypeScript app with the [DA CLI](/portal/Apps/App-Framework/Tools/da-cli)
* Search a public API (Open Library) with debounced queries as the user types
* Call Domo's AI text generation endpoint with a structured system prompt and parse the JSON response
* Build a polished Ant Design form and results view

The finished code is at [DomoApps/book-recommender-tutorial on GitHub](https://github.com/DomoApps/book-recommender-tutorial).

<Note>
  **Prerequisite:** Complete the [Setup and Installation](/portal/Apps/App-Framework/Quickstart/Setup-and-Installation) guide and run `domo login` before starting. Your Domo instance needs access to the AI Service Layer.
</Note>

### Step 1: Install the DA CLI and scaffold the app

***

The [DA CLI](/portal/Apps/App-Framework/Tools/da-cli) clones the [@domoinc/vite-react-template](https://www.npmjs.com/package/@domoinc/vite-react-template) — a Vite + React + TypeScript project preconfigured with the Domo proxy, ESLint, Prettier, Vitest, Storybook, and `da generate` scaffolding.

Install the CLI globally:

```bash theme={"dark"}
# pnpm (recommended)
pnpm add -g @domoinc/da

# or yarn
yarn global add @domoinc/da

# or npm
npm install -g @domoinc/da
```

Create the project:

```bash theme={"dark"}
da new book-recommender-tutorial
cd book-recommender-tutorial
```

`da new` prompts for a package manager (pick **pnpm**), clones the template, writes your app name, initializes git, and installs dependencies.

<Tip>
  App names must be lowercase with hyphens only. Capitals, underscores, and periods are rejected.
</Tip>

Add the runtime dependencies we'll use beyond the scaffold:

```bash theme={"dark"}
pnpm add antd ryuu.js
```

* **Ant Design** (`antd`) — the UI component library we'll use for the select inputs and buttons.
* **`ryuu.js`** — the Domo JS client for calling Domo platform APIs (the AI Service Layer in this case) through the dev-server proxy.

### Step 2: Add static assets

***

Drop the background image and divider graphic into `public/static/`:

* `bookshelf.jpeg` — full-bleed background
* `chapter_divider.png` — decorative divider under the heading

You can grab both from the [sample repo's `public/static/`](https://github.com/DomoApps/book-recommender-tutorial/tree/main/public/static), or swap in your own. Vite serves anything in `public/static/` at the `/static/` path at runtime.

### Step 3: Publish the initial design and wire the proxy

***

Calls to Domo APIs (including the AI Service Layer) go through the dev-server proxy, which needs a `proxyId` to authorize requests. That means we need to publish a skeleton, create a proxy card, and paste the IDs back into `manifest.json`.

First, set the app metadata. Replace `public/manifest.json` with:

```json theme={"dark"}
{
  "name": "Book Recommender",
  "version": "0.0.1",
  "size": { "width": 5, "height": 3 },
  "fullpage": true,
  "mapping": []
}
```

Then upload the skeleton:

```bash theme={"dark"}
pnpm upload
```

This runs `pnpm build` and `domo publish` from the `build/` folder. The output prints a link to the new App Design.

In the Domo UI:

1. Open the App Design link — or go to **More** → **Asset Library** and find your design.
2. Click **New Card**. This app doesn't use a dataset, so you can save the card without selecting one — we just need it to generate a `proxyId`.
3. Save the card.

Copy the App Design `id` and the card's `proxyId` from the design page, and add both to `public/manifest.json`:

```json theme={"dark"}
{
  "id": "8c5e598d-6c3a-48cb-86f2-71954602151f",
  "proxyId": "da3ee198-e9a9-4c8e-bb72-4f31581fe6cb"
}
```

<Tip>
  For multi-environment apps, keep `manifest.json` clean and use `da manifest` to add overrides to `src/manifestOverrides.json` instead of hand-editing.
</Tip>

### Step 4: Build the App component

***

The whole app lives in one component: `src/components/App/App.tsx`. It has three responsibilities:

1. **Search Open Library** as the user types — debounced, so we don't hammer the API.
2. **Collect preferences** — favorite books (multi-select), genre, mood, and length.
3. **Submit to Domo's AI service** and render a grid of recommendations.

The scaffold created `App.tsx` already — replace its contents with:

```tsx theme={"dark"}
import { Button, Select } from 'antd';
import { FC, useMemo, useRef, useState } from 'react';
import domo from 'ryuu.js';

import bookshelf from '/static/bookshelf.jpeg';
import chapterDivider from '/static/chapter_divider.png';

import styles from './App.module.scss';

const userPrompt = `Please generate a list of book recommendations based on the user's preferences.`;

const systemPrompt = `
You are a helpful, well-read literary assistant who gives thoughtful book recommendations.

The user will provide:
- A list of their favorite books (including author names if available)
- The genre(s) they're interested in
- The mood or tone they're looking for (e.g., uplifting, dark, relaxing, intense)
- Their preferred book length (e.g., short reads, medium, long epics)

Your task is to analyze the user's preferences and recommend **4 books** that:
- Match their genre, mood, and length preferences
- Share themes, tone, writing style, or emotional resonance with their favorite books
- Are not already listed in their favorites

For each recommendation, include:
- Title
- Author
- 1-2 sentence explanation of why it was chosen, referencing the user's input

Prioritize well-reviewed books, lesser-known gems, and avoid overly generic picks unless they are a perfect match.

If a user gives few inputs, do your best to infer recommendations from what's provided.

**Output format:**

Please return a JSON array of objects, each containing:
- "title": The title of the book
- "author": The author of the book
- "reason": A brief explanation of why this book was recommended

Do not include any additional text or explanations outside of this JSON format.`;

const genres = [
  { value: 'adventure', label: 'Adventure' },
  { value: 'fantasy', label: 'Fantasy' },
  { value: 'fiction', label: 'Fiction' },
  { value: 'historical', label: 'Historical' },
  { value: 'mystery', label: 'Mystery' },
  { value: 'non-fiction', label: 'Non-Fiction' },
  { value: 'romance', label: 'Romance' },
  { value: 'science-fiction', label: 'Science Fiction' },
  { value: 'thriller', label: 'Thriller' },
  // ...add as many as you like
];

const moods = [
  { value: 'dark', label: 'Dark' },
  { value: 'funny', label: 'Funny' },
  { value: 'inspirational', label: 'Inspirational' },
  { value: 'reflective', label: 'Reflective' },
  { value: 'relaxing', label: 'Relaxing' },
  { value: 'suspenseful', label: 'Suspenseful' },
  { value: 'uplifting', label: 'Uplifting' },
];

const bookLengths = [
  { value: 'short', label: 'Short (Less than 200 pages)' },
  { value: 'medium', label: 'Medium (200-400 pages)' },
  { value: 'long', label: 'Long (More than 400 pages)' },
  { value: 'epic', label: 'Epic (More than 600 pages)' },
];

interface OpenLibraryBook {
  key: string;
  title: string;
  author_name?: string[];
}

interface Recommendation {
  title: string;
  author: string;
  reason: string;
}
```

The full genre / mood / length lists in the [sample repo](https://github.com/DomoApps/book-recommender-tutorial) are longer — keep or trim them to taste.

**State and derived values.** Inside `export const App: FC = () => {`:

```tsx theme={"dark"}
const [favoriteBooks, setFavoriteBooks] = useState<OpenLibraryBook[]>([]);
const [genre, setGenre] = useState<string | undefined>(undefined);
const [mood, setMood] = useState<string | undefined>(undefined);
const [bookLength, setBookLength] = useState<string | undefined>(undefined);

const [allBooks, setAllBooks] = useState<OpenLibraryBook[]>([]);
const [matchingBooks, setMatchingBooks] = useState<OpenLibraryBook[]>([]);

const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(false);
const [bookSearchQuery, setBookSearchQuery] = useState('');
const [bookSearchLoading, setBookSearchLoading] = useState(false);

const bookOptions = useMemo(
  () =>
    matchingBooks.map((book) => ({
      value: book.key,
      label: `${book.title}, ${book.author_name?.join(', ') || 'Unknown Author'}`,
    })),
  [matchingBooks],
);
```

`matchingBooks` is what the dropdown currently shows; `allBooks` is a running union of every book we've fetched so far, so when the user selects one from an old search we can still resolve it by `key`.

**Debounced Open Library search.**

```tsx theme={"dark"}
const fetchBooks = async (query: string): Promise<OpenLibraryBook[]> => {
  const url = `https://openlibrary.org/search.json?title=${encodeURIComponent(query)}`;
  const response = await fetch(url);
  const data = await response.json();
  return data.docs;
};

const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

const onBookSearch = (value: string) => {
  setBookSearchQuery(value);
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
  if (!value.trim()) {
    setMatchingBooks([]);
    setBookSearchLoading(false);
    return;
  }
  setBookSearchLoading(true);
  debounceTimer.current = setTimeout(() => {
    void (async () => {
      const fetchedBooks = await fetchBooks(value);
      setMatchingBooks(fetchedBooks);
      setAllBooks((prev) => [
        ...prev,
        ...fetchedBooks.filter(
          (b) => !prev.some((existing) => existing.key === b.key),
        ),
      ]);
      setBookSearchLoading(false);
    })();
  }, 300);
};

const onBookChange = (value: string[]) => {
  setFavoriteBooks(allBooks.filter((book) => value.includes(book.key)));
};
```

The 300 ms debounce keeps Open Library happy and means the dropdown only updates when the user pauses. `fetch` here hits Open Library directly — it's a public API, no Domo proxy involved.

### Step 5: Call the Domo AI service

***

Domo's AI Service Layer exposes `/domo/ai/v1/text/generation` for text generation. We pass three things:

* **`input`** — the structured user query
* **`promptTemplate.template`** — wraps the input (the `${input}` placeholder is replaced server-side)
* **`system`** — the system prompt that forces JSON-only output

The model returns a `choices[0].output` string that we strip of any code-fence wrapping and parse as JSON.

````tsx theme={"dark"}
const getBookRecommendations = async (
  books: OpenLibraryBook[],
  genre: string | undefined,
  mood: string | undefined,
  bookLength: string | undefined,
): Promise<Recommendation[]> => {
  try {
    const bookInfo = books
      .map(
        (book) =>
          `**${book.title}** by ${book.author_name?.join(', ') || 'Unknown Author'}`,
      )
      .join(', ');

    const body = {
      input: `Favorite Books: ${bookInfo}, Genre: ${genre || 'Any'}, Mood: ${
        mood || 'Any'
      }, Length: ${bookLength || 'Any'}`,
      promptTemplate: {
        template: `${userPrompt} \`\`\`\${input}\`\`\``,
      },
      system: systemPrompt,
      outputWordLength: { max: 400 },
    };

    const data = (await domo.post('/domo/ai/v1/text/generation', body)) as {
      choices: { output: string }[];
    };
    const output = data.choices[0].output;
    const cleaned = output
      .replace(/^\s*```(?:json)?\s*/i, '')
      .replace(/\s*```\s*$/i, '')
      .trim();
    return JSON.parse(cleaned) as Recommendation[];
  } catch (error) {
    console.error('Error generating recommendations:', error);
    return [];
  }
};

const onSubmit = async () => {
  setLoading(true);
  const recs = await getBookRecommendations(
    favoriteBooks,
    genre,
    mood,
    bookLength,
  );
  setRecommendations(recs);
  setLoading(false);
};
````

The regex strip handles the case where the model still wraps output in \`\`\`json ... \`\`\` despite the system prompt telling it not to — cheaper than re-prompting.

### Step 6: Render the form and results

***

Still inside `App`:

```tsx theme={"dark"}
return (
  <div
    className={styles.app}
    style={{
      background: `url(${bookshelf}) no-repeat center center fixed`,
      backgroundSize: 'cover',
      height: '100vh',
    }}
  >
    <div className={styles.content}>
      {recommendations.length === 0 ? (
        <>
          <div className={styles.heading}>
            <h1>Chapter One</h1>
            <h2>Find your next favorite book</h2>
            <img
              style={{ marginTop: '30px' }}
              src={chapterDivider}
              width="40%"
              alt="divider"
            />
          </div>
          <div className={styles.form}>
            <Select
              mode="multiple"
              autoClearSearchValue
              filterOption={false}
              allowClear
              placeholder="Choose your favorite books"
              options={bookOptions}
              value={favoriteBooks.map((book) => book.key)}
              onSearch={onBookSearch}
              onChange={onBookChange}
              notFoundContent={
                bookSearchLoading
                  ? 'Searching…'
                  : bookSearchQuery.trim()
                    ? 'No books found'
                    : 'Start typing a book title to search'
              }
              style={{ flex: 1 }}
            />
          </div>
          <div className={styles.form}>
            <Select
              placeholder="Select a genre"
              value={genre}
              onChange={setGenre}
              options={genres}
              style={{ flex: 1 }}
            />
            <Select
              placeholder="Select a mood"
              value={mood}
              onChange={setMood}
              options={moods}
              style={{ flex: 1 }}
            />
            <Select
              placeholder="Select a book length"
              value={bookLength}
              onChange={setBookLength}
              options={bookLengths}
              style={{ flex: 1 }}
            />
          </div>
          <Button onClick={onSubmit} loading={loading}>
            Get Recommendations
          </Button>
        </>
      ) : (
        <div>
          <h1>Recommended Books</h1>
          <div className={styles.bookList}>
            {recommendations.map((rec, index) => (
              <div key={index} className={styles.bookItem}>
                <h4>{rec.title}</h4>
                <p className={styles.author}>{rec.author}</p>
                <p className={styles.reason}>{rec.reason}</p>
              </div>
            ))}
          </div>
          <div className={styles.actions}>
            <Button onClick={() => setRecommendations([])}>
              Edit preferences
            </Button>
            <Button type="primary" onClick={onSubmit} loading={loading}>
              Try again
            </Button>
          </div>
        </div>
      )}
    </div>
  </div>
);
```

The two branches of the ternary — form vs. results — keep the component simple: once we have recommendations, swap the view and offer **Edit preferences** (clear results, show the form again) or **Try again** (re-submit the same preferences).

**Styling.** The SCSS is short — grab it from [`App.module.scss` in the sample repo](https://github.com/DomoApps/book-recommender-tutorial/blob/main/src/components/App/App.module.scss) or write your own.

### Step 7: Test locally

***

```bash theme={"dark"}
pnpm start
```

The Vite dev server starts on port 3000 (or 3001/3002 if busy). Because `proxyId` is set, `domo.post('/domo/ai/v1/text/generation', ...)` is authenticated through the proxy to your real Domo instance — you should see real recommendations come back in a few seconds.

<Warning>
  **Warning:** If `/domo/ai/v1/text/generation` returns 401 or 403, your user or instance doesn't have access to the AI Service Layer. Reach out to your Domo admin. If it returns 404, check that `proxyId` in `manifest.json` matches the card you created in Step 3.
</Warning>

### Step 8: Publish

***

```bash theme={"dark"}
pnpm upload
```

The new build becomes the active design. Anyone instantiating the app from the Asset Library picks it up.

### Next steps

***

* Stream results incrementally by swapping `/domo/ai/v1/text/generation` for the streaming variant and parsing partial chunks.
* Persist favorite-book lists per user with an AppDB collection (see the [Todo App tutorial](/portal/Apps/App-Framework/Tutorials/React/Todo-App)).
* Swap Open Library for Google Books or any other search API — the debounce pattern is the same.
* Continue with [Mapbox World Map](/portal/Apps/App-Framework/Tutorials/React/Mapbox-World-App) or [Todo App with AppDB](/portal/Apps/App-Framework/Tutorials/React/Todo-App).
