How it works Examples Docs Pricing Blog WP Blocks Install free

Blog /

Build a recipe ranker that uses the AI bridge

The recipe ranker is the smallest possible app that exercises the AI bridge end-to-end. The base example (examples/recipe-ranker) is a block-embed widget that lists recipes (a custom post type) and ranks them by visitor view count, persisted via app-scoped storage. This post extends it with one extra feature: an AI-generated “why this is trending” caption under each recipe, asked of the site’s configured Connector with the recipes’ titles and excerpts as context.

Two reasons this is a good first AI tutorial:

  1. The base ranker already works without AI. The bridge surface for AI is additive on top.
  2. The AI call is bounded (one round-trip per render, capped explicitly in the manifest), so you can see the whole inference budget on the page.

What the bridge does for you

dsgo.ai.prompt is the read-only AI surface. You hand it a list of messages, optionally a system prompt, optionally a few constraints (max tokens, temperature). The bridge:

  • Routes the call through wp_ai_client_prompt.
  • Inherits the site’s configured Connector (Anthropic, OpenAI, Google, or anything else a Connector ships for).
  • Enforces the per-app limits in your manifest (max_tool_calls, timeout_seconds).
  • Returns a structured { text, usage, model } object.

You never touch a key. You never pay for inference. If the site admin changes their Connector on Tuesday, your app keeps working on Wednesday. The architectural rationale is in WP 7.0 changed what a plugin can be.

The manifest

{
  "manifest_version": 1,
  "id": "recipe-ranker",
  "name": "Recipe ranker",
  "description": "Ranks recipes by visitor views and adds an AI-generated trending caption.",
  "version": "0.2.0",
  "entry": "index.html",
  "isolation": "iframe",
  "display": { "modes": ["block"], "default": "block" },
  "permissions": { "read": ["posts", "ai"], "write": [] },
  "ai": { "max_tool_calls": 1, "timeout_seconds": 30 },
  "runtime": { "sandbox": "strict", "external_origins": [] }
}

Two notable pieces:

  • display.modes: ["block"]. This app is intended for embedding inside a Gutenberg block (rather than living at its own URL). The same bundle could run as a page mode app at /apps/recipe-ranker/; the manifest just declares which mounts are valid.
  • ai.max_tool_calls: 1. The app calls the model exactly once per render. The cap is an explicit guardrail; if a future version of the app tries to chain calls, the bridge enforces the manifest limit, not whatever the model decides. Setting this to 1 means a runaway prompt cannot rack up bills on the site admin’s Connector.

The install dialog shows two buckets: “Read content” (for posts) and “AI” (with a footer note explaining that calls go through the site’s configured Connector and do not consume any DSGo-side budget).

The base ranker (no AI yet)

This is straight out of the example bundle. Listing recipes by visitor view count:

const dsgo = window.dsgo;

async function getViews() {
  try {
    const v = await dsgo.storage.app.get('views');
    return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
  } catch {
    return {};
  }
}

async function bumpView(id) {
  try {
    const views = await getViews();
    views[id] = (views[id] ?? 0) + 1;
    await dsgo.storage.app.set('views', views);
  } catch {
    /* non-fatal; view count just won't persist this round */
  }
}

async function load() {
  const result = await dsgo.posts.list({
    type: 'recipe',
    orderby: 'date',
    order: 'desc',
    per_page: 10,
  });
  const recipes = result.items ?? result;

  const views = await getViews();

  const ranked = recipes
    .map((p) => ({ ...p, viewCount: Number(views[p.id] ?? 0) }))
    .sort(
      (a, b) =>
        b.viewCount - a.viewCount ||
        new Date(b.date) - new Date(a.date)
    );

  renderList(ranked);
}

load();

Two things worth pointing at:

  • type: 'recipe' in the bridge call routes the request to /wp/v2/recipe, the demo site’s custom post type. The bridge passes WP REST query parameters through verbatim, so anything WP REST supports works here. Recipe sites tend to register recipes as a CPT to keep them out of the main blog feed; this filter respects that.
  • The defensive result.items ?? result is forwards-compatible: the canonical shape is { items, total, total_pages }, but if you ever hit an older bridge that returns a bare array, the accessor still works. New code can drop the fallback.

Ranking by view count then by date means recipes with the same view count surface the newest one first. Fresh recipes get a chance to climb without being instantly buried by old ones with accumulated views.

Adding the AI caption

This is the new piece. For each visible recipe, ask the model for a one-line “why this is trending” caption. The trick is that you want one prompt for the whole batch, not one prompt per recipe; the per-call overhead adds up fast in tokens and latency.

async function loadWithCaptions() {
  const ranked = await load(); // returns the array of recipes from above

  const top = ranked.slice(0, 5);
  if (top.length === 0) return;

  const promptBody = top
    .map((r, i) => {
      const title = r.title?.rendered ?? r.title ?? '(untitled)';
      const excerpt = (r.excerpt?.rendered ?? '')
        .replace(/<[^>]+>/g, '')
        .trim();
      return `${i + 1}. ${title}: ${excerpt}`;
    })
    .join('\n');

  let captions = [];
  try {
    const response = await dsgo.ai.prompt({
      messages: [
        {
          role: 'system',
          content:
            'You write very short trending captions for recipes. ' +
            'Return only the captions, one per line, numbered 1 to 5. ' +
            'Each caption is one sentence, max 12 words. No emoji.',
        },
        { role: 'user', content: promptBody },
      ],
      max_tokens: 200,
    });

    captions = response.content
      .split('\n')
      .map((line) => line.replace(/^\d+\.\s*/, '').trim())
      .filter(Boolean);
  } catch (err) {
    if (err.code === 'ai_not_configured') {
      // Site has no Connector. Skip captions, render the rest.
      return;
    }
    // Any other error: log and skip the caption layer.
    console.warn('Caption generation failed:', err);
    return;
  }

  for (let i = 0; i < top.length; i++) {
    const captionEl = document.querySelector(
      `[data-recipe="${top[i].id}"] .caption`
    );
    if (captionEl && captions[i]) captionEl.textContent = captions[i];
  }
}

A few things this does deliberately:

  • One round-trip for five recipes. The system prompt tells the model the format, the user message lists all five, the response splits cleanly. Five separate calls would work but waste a lot of tokens on the system prompt and add 5x the latency.
  • temperature: 0.4. Captions don’t need to be wildly creative; they need to be terse and consistent. Lower temperature gives the model less rope.
  • Strip the 1. prefix in post-processing. The model returns numbered output to make parsing reliable; you strip the numbering before display. Asking the model to skip the numbering tends to produce less reliable formatting.
  • Hard cap at five recipes. The manifest says max_tool_calls: 1. If the recipe list grew to twenty, you’d want to either raise the cap and paginate the captions or accept that only the top five get captioned. The cap is there to make those decisions explicit.
  • Render the rest of the page first. The loadWithCaptions function calls load() (which renders the recipe list immediately) before await dsgo.ai.prompt. The visitor sees the ranked recipes instantly; the captions fill in once inference returns. If the AI call takes 8 seconds because the Connector is slow, the recipes are still readable for those 8 seconds.

What you didn’t have to do

  • Manage an Anthropic / OpenAI / Google API key. The site admin did, once, at Settings → Connectors.
  • Pay for inference. The site admin pays whatever their provider charges. Your distribution does not absorb the cost. (See Why we don’t hold your API keys for why this matters at scale.)
  • Handle provider-specific request shapes. WP AI Client normalizes them. Your code looks the same whether the user is on Claude or GPT or Gemini.
  • Roll a per-user rate limit. The bridge enforces the per-app timeout_seconds and max_tool_calls. If you need per-visitor caps (e.g., “this visitor has triggered five generations today, that’s enough”), layer them via dsgo.storage.user.*.

Where this goes wrong, and what to do

A few honest failure modes worth budgeting for:

  • The site has no Connector configured. dsgo.ai.prompt rejects with ai_not_configured. Your fallback should hide the captions and show the recipes plain. Don’t show a broken UI to a visitor; the admin’s configuration is not the visitor’s problem.
  • The Connector returns slowly. Default timeout is 30 seconds (your manifest sets it). Don’t await the AI call before rendering the rest of the page; render the recipe list first, then async-fill the captions. The pattern in loadWithCaptions above does this.
  • The model returns garbage. Your post-processing should tolerate empty captions. If captions[i] is undefined or whitespace, the loop above leaves the caption div empty rather than rendering “1.” or undefined. Empty caption beats wrong caption.
  • The Connector hits its own rate limit. Bridge returns a rate_limited error with a retry_after field. For a UI like this, you’d skip the caption layer and not retry; for a more important AI call you’d back off and retry once.

A note on cost framing

I want to flag something about the cost framing for AI features. When you build features like this and tell users about them, “this uses your site’s AI” is the right framing, not “this is AI-powered.” The first sentence sets the right expectation: the user is paying for the inference (via their Connector), and your feature consumes that budget on their behalf. The second sentence sounds like marketing; the first sounds like accounting. Users prefer accounting when they’re going to be billed, even when the bill goes to a third party.

Where to go next