Skip to main content

Documentation Index

Fetch the complete documentation index at: https://domoinc-arun-raj-connectors-domo-480626-update-new-field-mi.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

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
  • 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.
Prerequisite: Complete the Setup and Installation guide and run domo login before starting. Your Domo instance needs access to the AI Service Layer.

Step 1: Install the DA CLI and scaffold the app


The DA CLI clones the @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:
# pnpm (recommended)
pnpm add -g @domoinc/da

# or yarn
yarn global add @domoinc/da

# or npm
npm install -g @domoinc/da
Create the project:
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.
App names must be lowercase with hyphens only. Capitals, underscores, and periods are rejected.
Add the runtime dependencies we’ll use beyond the scaffold:
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/, 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:
{
  "name": "Book Recommender",
  "version": "0.0.1",
  "size": { "width": 5, "height": 3 },
  "fullpage": true,
  "mapping": []
}
Then upload the skeleton:
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 MoreAsset 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:
{
  "id": "8c5e598d-6c3a-48cb-86f2-71954602151f",
  "proxyId": "da3ee198-e9a9-4c8e-bb72-4f31581fe6cb"
}
For multi-environment apps, keep manifest.json clean and use da manifest to add overrides to src/manifestOverrides.json instead of hand-editing.

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:
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 are longer — keep or trim them to taste. State and derived values. Inside export const App: FC = () => {:
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.
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.
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:
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 or write your own.

Step 7: Test locally


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: 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.

Step 8: Publish


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).
  • Swap Open Library for Google Books or any other search API — the debounce pattern is the same.
  • Continue with Mapbox World Map or Todo App with AppDB.