vibes.diy/docs Home Discord
creator docs · reference · generated

CallAI Helper Function

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/callai.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

The callAI function asks an AI model and returns a string. Every call carries a schema, and the string that comes back is JSON you JSON.parse(). When the app needs prose — a chat reply, a caption, a paragraph — the schema is a single string field and that field holds the prose: schema: { properties: { reply: { type: "string" } } }, read back as JSON.parse(response).reply.

The browser runtime validates each answer against your schema. A schema mismatch uses the existing fallback attempt; an exhausted fallback rejects the call. Keep the person's input available when showing a retry action. App normalization still owns trusted ids, sharing fields, and meaningful values such as a nonempty title.

Use this capability when an app turns words into structured records or provides requested model-generated text. Direct controls handle known values, and a simple message saves directly when its text is the whole record.

Name what the app does in the app's own words. The function name callAI stays in implementation context — it never becomes a label, a badge, or a credit line in the UI (a roast board says what it makes, not what it is powered by). One plain sentence about AI is right where it helps the person understand the experience; the function name is not.

Text to records

When the person's own words describe input in the shape they already have it — typing entries in their own words, pasting a chat, replies or a list, a photo, adding things however they like — the sentence composer is the front door they asked for. When a specified request names its fields or features and describes no such input, the front door is a plain Add form for those fields; adding by sentence is the first of the closing ▸ options, phrased for this app, such as "Add plants by typing a sentence like 'basil and two tomatoes, bed 3'", and Ask is an option too. An open-ended request may open with the composer as one of its one or two alive defaults. Keep quantity steppers, claim buttons, and toggles on existing records as direct controls; they complement sentence creation. A name-only list, a simple message, and a timer stay direct because their input already is the whole record or an explicit setting.

When the composer is on screen, wire these visible actions in the same build, using the app's own record names. One composer has Add and Ask actions. Add extracts and normalizes facts, saves each record immediately, and shows saved records with edit actions; one item and a pasted batch share this action. Each saved row has Change by sentence: selecting it changes Add to Apply correction, which saves onto that same record id. Ask sends the typed question with the relevant saved records and displays a read-only answer beside the composer. Edit opens the selected row’s populated fields. One parent-owned Undo restores the latest successful creation batch or the previous document from either a sentence correction or a field edit.

A record the person describes in their own words starts as a sentence. For that intake, use one text box or the app's chat composer, extract the person's facts into the document's fields, normalize them, and save immediately in that same submit action. Show the saved record with editing controls. A pasted list becomes individual records through an array schema. Field-by-field controls belong to the edit view of a saved record. One new record and a pasted batch use the same composer. Opening a saved row’s Edit action reveals its populated field controls. A message whose text IS the record can save directly; extraction is useful when the person would otherwise fill several fields. Requested AI generation remains available for creative work.

The extraction schema names the same editable fields the app writes to its documents. Normalize the parsed output into that shape before saving or rendering: check strings, finite numbers, enum values and arrays (including their members), and project only the fields the app owns. Keep _id, authorship, parent ids and sharing fields under app control. Submitting the sentence commits the normalized records automatically. The saved card shows the understood facts and is the confirmation; editing follows persistence. For an edit by sentence, pass the current editable fields plus the correction through the same single-record schema, then write the normalized fields onto that saved record's trusted identity.

After each successful creation, sentence correction or field edit, the parent owns one Undo action. Keep the created ids or the previous document until the next successful write replaces them; a detail editor reports its previous document to that parent after its save succeeds. Undo deletes the created records or puts the previous document back. An explicit setting — a number, a date, a time, a choice, an on/off state — stays a direct control. The schema can extract ordinary quantities the person describes, such as two tents or six servings, while a saved row keeps its quantity control. Critical values (a dose, a price, a deadline, a timer, a recipient) are set directly. A multi-field record the person describes in their own words starts with the composer.

This complete example covers one sentence → one task, pasted list → many tasks, a selected task + correction → the same task, questions from the same box, and Undo after a save. Every new submission uses the array schema, whether it names one task or several. The person sets a due date in the saved task’s date control. A malformed response leaves the typed sentence and saved records available; the waiting state ends when the request settles.

App.jsx

jsximport React, { useState } from "react";
import { callAI } from "call-ai";
import { useFireproof } from "use-fireproof";
import { useVibe } from "use-vibes";
import { Button, Field, Heading, Icon, IconButton, Input, Row, RowCard, Stack, Text, Textarea } from "@vibes.diy/pouf";

const taskSchema = {
  type: "object",
  properties: {
    title: { type: "string", description: "The task in the person's words" },
    details: { type: "string", description: "Supporting details in the person’s words" },
  },
  required: ["title", "details"],
};

function normalizeTask(raw) {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
  const title = typeof raw.title === "string" ? raw.title.trim() : "";
  const details = typeof raw.details === "string" ? raw.details.trim() : "";
  return title.length > 0 ? { title, details } : undefined;
}

// Single-record extraction and edit-by-sentence share a schema and normalizer.
async function readTask(sentence, current) {
  const facts = current === undefined ? {} : { title: current.title, details: current.details };
  const response = await callAI(
    `Extract one task’s title and supporting details from the person's words. Update only what the correction mentions when a current task is supplied. Today: ${new Date().toISOString().slice(0, 10)}. Current: ${JSON.stringify(facts)}. Person: ${sentence}`,
    { schema: taskSchema }
  );
  const task = normalizeTask(JSON.parse(response));
  if (task === undefined) throw new Error("Add a task in your own words and try again.");
  return task;
}

// A pasted list uses an array of the same flat document fields.
async function readTasks(sentence) {
  const response = await callAI(
    `Extract every task from these words as a separate item, including when the person names just one task. Keep their title and supporting details. Today: ${new Date().toISOString().slice(0, 10)}. List: ${sentence}`,
    { schema: { properties: { items: { type: "array", items: taskSchema } }, required: ["items"] } }
  );
  const raw = JSON.parse(response);
  const items = Array.isArray(raw?.items) ? raw.items.map(normalizeTask) : [];
  if (items.length === 0 || items.some((item) => item === undefined)) {
    throw new Error("Describe the tasks you want to add and try again.");
  }
  return items;
}

function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
  const { useDocument } = useFireproof(database);
  const { doc, merge, save } = useDocument(task);
  const [editing, setEditing] = useState(false);
  return <RowCard><Stack gap={2}>
    <Text>{task.title}</Text><Text muted>{task.details}</Text><Text muted>{task.dueDate}</Text>
    {can.edit(task).ok && <>
      <Row gap={2} wrap>
        <Button type="button" size="sm" variant="quiet" disabled={disabled} onClick={() => onCorrect(task)}>Change by sentence</Button>
        <Button type="button" size="sm" variant="quiet" disabled={disabled} onClick={() => setEditing(true)}>Edit details</Button>
      </Row>
      {editing && <form onSubmit={async (event) => { event.preventDefault(); if (disabled) return; await save(); onSaved(task); setEditing(false); }}>
        <Stack gap={3}>
          <Field label="Task">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={disabled} value={doc.title} onChange={(title) => merge({ title })} />}</Field>
          <Field label="Details">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} disabled={disabled} value={doc.details} onChange={(details) => merge({ details })} />}</Field>
          <Field label="Due date">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={disabled} type="date" value={doc.dueDate} onChange={(dueDate) => merge({ dueDate })} />}</Field>
          <Row><Button type="submit" size="sm" variant="quiet" disabled={disabled}>Save details</Button></Row>
        </Stack>
      </form>}
    </>}
  </Stack></RowCard>;
}

export default function App() {
  const { database, useLiveQuery } = useFireproof("tasks");
  const { me, can, ready } = useVibe("tasks");
  const { docs } = useLiveQuery("type", { key: "task" });
  const [sentence, setSentence] = useState("");
  const [current, setCurrent] = useState(undefined);
  const [pending, setPending] = useState(undefined);
  const [answer, setAnswer] = useState("");
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState("");
  // Creation, sentence correction and detail editing replace this one Undo receipt after a successful write.
  const [lastWrite, setLastWrite] = useState(undefined);
  async function submitSentence(event) {
    event.preventDefault();
    if (busy || !ready) return;
    setBusy(true); setMessage("");
    try {
      // A failed local batch resumes its unsaved tail without extracting or duplicating earlier records.
      let operation = pending;
      if (operation === undefined) {
        const target = current === undefined ? undefined : docs.find((doc) => doc._id === current._id);
        if (current !== undefined && target === undefined) throw new Error("Choose a saved task to correct.");
        const items = target === undefined ? await readTasks(sentence) : [await readTask(sentence, target)];
        const remaining = items.map((item) => target === undefined
          ? { _id: crypto.randomUUID(), type: "task", title: item.title, details: item.details, dueDate: "", authorHandle: me?.userHandle }
          : { ...target, title: item.title, details: item.details });
        operation = { remaining, created: [], replaced: target };
      }
      if (operation.remaining.some((doc) => operation.replaced === undefined ? can.create(doc).ok === false : can.edit(doc).ok === false)) {
        throw new Error("This task is not editable right now.");
      }
      setPending(operation);
      while (operation.remaining.length > 0) {
        const doc = operation.remaining[0];
        await database.put(doc);
        operation = { ...operation, remaining: operation.remaining.slice(1), created: operation.replaced === undefined ? [...operation.created, doc._id] : [] };
        setPending(operation);
        setLastWrite({ created: operation.created, replaced: operation.replaced });
      }
      setPending(undefined);
      setSentence(""); setCurrent(undefined);
    } catch (error) {
      // Preserve the words and any unsaved tail on local errors. Synced refusals converge through useLiveQuery.
      setMessage(error.reason === "refusal" ? "Try describing the task another way." : error.message);
    } finally { setBusy(false); }
  }
  async function askRecords() {
    if (busy || !ready || pending !== undefined || sentence.trim().length === 0) return;
    setBusy(true); setMessage("");
    try {
      const response = await callAI(
        `Answer from these records. Today: ${new Date().toISOString().slice(0, 10)}. Records: ${JSON.stringify(docs.map(({ title, details, dueDate }) => ({ title, details, dueDate })))}. Question: ${sentence}`,
        { schema: { properties: { answer: { type: "string" } }, required: ["answer"] } }
      );
      const raw = JSON.parse(response);
      if (typeof raw?.answer !== "string") throw new Error("Ask your question again.");
      setAnswer(raw.answer); setSentence("");
    } catch (error) {
      setMessage(error.message);
    } finally { setBusy(false); }
  }
  // The composer's sparkle: one example sentence in the box, which Add then extracts like typed words.
  async function suggestSentence() {
    if (busy || !ready || pending !== undefined) return;
    setBusy(true); setMessage("");
    try {
      const response = await callAI(
        `Write one example sentence a person might type to add tasks: two or three everyday tasks, each with a day or a deadline, different from these titles: ${JSON.stringify(docs.map(({ title }) => title))}.`,
        { schema: { properties: { sentence: { type: "string" } }, required: ["sentence"] } }
      );
      const raw = JSON.parse(response);
      if (typeof raw?.sentence !== "string" || raw.sentence.trim().length === 0) throw new Error("Press the sparkle again for another example.");
      setSentence(raw.sentence.trim()); setCurrent(undefined);
    } catch (error) {
      setMessage(error.message);
    } finally { setBusy(false); }
  }
  // A new record is one del away; a corrected one is one put of its previous version away.
  async function undoLast() {
    if (busy || lastWrite === undefined) return;
    setBusy(true); setMessage("");
    try {
      for (const id of lastWrite.created) await database.del(id);
      if (lastWrite.replaced !== undefined) await database.put(lastWrite.replaced);
      setLastWrite(undefined); setPending(undefined); setCurrent(undefined);
    } catch (error) {
      setMessage(error.message);
    } finally { setBusy(false); }
  }
  return <main className="min-h-screen bg-background text-foreground font-sans p-6"><Stack gap={5}>
    <Heading level={1}>Tasks in your own words</Heading>
    <form onSubmit={submitSentence}>
      <Stack gap={3}>
        <Field label="Describe tasks, a correction, or ask a question">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} value={sentence} disabled={busy || pending !== undefined} onChange={setSentence} />}</Field>
        {current !== undefined && <Row gap={2} align="center" wrap><Text muted>Changing: {current.title}</Text><Button type="button" size="sm" variant="quiet" disabled={busy || pending !== undefined} onClick={() => { setCurrent(undefined); setSentence(""); }}>Add a new task</Button></Row>}
        <Row gap={2} wrap>
          <Button type="submit" disabled={!ready || busy || sentence.trim().length === 0}>{busy ? "Adding your changes…" : pending !== undefined ? "Retry remaining tasks" : current === undefined ? "Add tasks" : "Apply correction"}</Button>
          <Button type="button" variant="quiet" disabled={!ready || busy || pending !== undefined || sentence.trim().length === 0} onClick={askRecords}>Ask about tasks</Button>
          <IconButton type="button" variant="quiet" tone="purple" label="Suggest tasks" disabled={!ready || busy || pending !== undefined} icon={<Icon name="sparkle" size="sm" />} onClick={suggestSentence} />
        </Row>
      </Stack>
    </form>
    <Text>{answer}</Text>
    <p role="status">{message}</p>
    <Row gap={2} wrap>
      {lastWrite !== undefined && <Button type="button" size="sm" variant="quiet" disabled={busy} onClick={undoLast}>Undo</Button>}
      {pending !== undefined && !busy && <Button type="button" size="sm" variant="quiet" tone="down" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</Button>}
    </Row>
    {docs.map((task) => <TaskCard key={task._id} task={task} database={database} can={can} disabled={busy || pending !== undefined} onSaved={(previous) => setLastWrite({ created: [], replaced: previous })} onCorrect={(doc) => { if (busy || pending !== undefined) return; setCurrent(doc); setSentence(""); }} />)}
  </Stack></main>;
}

For nested document shapes, normalize every collection at its boundary: Array.isArray(raw.days) before mapping days, and the same check for each day's stops. Persist the normalized records in the submit handler, then render their saved cards. Extend the schema and normalizer together as the app's editable fields evolve.

Ask about your records

In a sentence-intake app, the same box answers a question about the records the app holds: send the records the answer could need with the question, and ask for the answer in a schema with one string field. Bound what you send (the visible list, or the records a query selected), name today's date when the question could involve time, and render the answer as text beside the box, never as a saved record.

jsxconst { docs } = useLiveQuery("type", { key: "task" });
async function askRecords(question) {
  const response = await callAI(
    `Answer the question from these records only; say so when they do not answer it. Today: ${new Date().toISOString().slice(0, 10)}. Records: ${JSON.stringify(docs.map(({ title, dueDate }) => ({ title, dueDate })))}. Question: ${question}`,
    { schema: { properties: { answer: { type: "string" } } } }
  );
  return JSON.parse(response).answer;
}

Fill a form with a suggestion

Every form in the app carries one sparkle button that fills its fields with a suggestion from callAI, so the person can save without typing. It is a quiet IconButton with icon={<Icon name="sparkle" size="sm" />} and a label in the app's own words ("Suggest an offer"), placed beside the form's submit action, one per form: the Add form, the edit view of a saved record and the sentence composer each carry their own. The press asks callAI for one plausible, specific example through the form's own schema (the same editable fields the form saves) and writes the normalized answer into the form's field state; the sparkle fills and the submit action saves. The person reads the suggestion, changes anything, and presses the form's own save; while a suggestion or a save is in flight the fields, the submit and the sparkle wait together, so a suggestion never lands over fresh typing or under a clear. A one-field form carries its sparkle too, and so does an app with no other use for callAI: a form that asks only for a name, a title or a label saves that value directly with no extraction, and it is still one press away from a specific suggested name, through a one-field schema and the same normalizer the form saves through. A suggestion is sized to its field: the prompt names the length the field expects (a name of two to four words, a note of one or two sentences, a number in the field's range), and the normalizer keeps it there, so a short box never fills with a paragraph. For a sentence composer the suggestion is one example sentence written into the box, which the Add action then extracts like typed words. Ask for variety so each press suggests something new, and reuse the form's schema and normalizer rather than writing a second pair for suggestions.

App.jsx

jsximport React, { useState } from "react";
import { callAI } from "call-ai";
import { useFireproof } from "use-fireproof";
import { useVibe } from "use-vibes";
import { Button, Field, Heading, Icon, IconButton, Input, Row, RowCard, Stack, Text } from "@vibes.diy/pouf";

const offerSchema = {
  type: "object",
  properties: {
    name: { type: "string", description: "Who is bringing it" },
    plant: { type: "string", description: "The specific plant they bring" },
    pots: { type: "number", description: "How many pots of it" },
  },
  required: ["name", "plant", "pots"],
};

// The form and the sparkle share one normalizer, so a suggestion lands in exactly the shape the form saves.
function normalizeOffer(raw) {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
  const name = typeof raw.name === "string" ? raw.name.trim() : "";
  // Sized to the field: a plant name is a few words, pots are a small count.
  const plant = typeof raw.plant === "string" ? raw.plant.trim().split(/\s+/).slice(0, 4).join(" ") : "";
  const pots = Number.isFinite(Number(raw.pots)) ? Math.min(6, Math.max(1, Math.round(Number(raw.pots)))) : 1;
  return name.length > 0 && plant.length > 0 ? { name, plant, pots } : undefined;
}

const EMPTY = { name: "", plant: "", pots: 1 };

export default function App() {
  const { database, useLiveQuery } = useFireproof("garden-swap");
  const { me, can, ready } = useVibe("garden-swap");
  const { docs } = useLiveQuery("type", { key: "offer" });
  const [draft, setDraft] = useState(EMPTY);
  const [suggesting, setSuggesting] = useState(false);
  const [saving, setSaving] = useState(false);
  const [message, setMessage] = useState("");

  // The sparkle fills the same fields the form saves and leaves saving to the form's own submit.
  async function suggestOffer() {
    if (suggesting) return;
    setSuggesting(true);
    setMessage("");
    try {
      const response = await callAI(
        `Invent one plausible neighbor for a garden swap table: a first name (one word), the specific plant they bring (two or three words), and how many pots of it (one to six). Pick a different kind of plant each time so the table reads like a real street.`,
        { schema: offerSchema }
      );
      const offer = normalizeOffer(JSON.parse(response));
      if (offer === undefined) throw new Error("Press the sparkle again for another suggestion.");
      setDraft(offer);
    } catch (error) {
      setMessage(error.message);
    } finally {
      setSuggesting(false);
    }
  }

  async function addOffer(event) {
    event.preventDefault();
    const offer = normalizeOffer(draft);
    if (!ready || offer === undefined) return;
    const doc = { _id: crypto.randomUUID(), type: "offer", ...offer, authorHandle: me?.userHandle };
    if (can.create(doc).ok === false) {
      setMessage("This table is read-only for you right now.");
      return;
    }
    setSaving(true);
    try {
      await database.put(doc);
      setDraft(EMPTY);
    } catch (error) {
      setMessage(error.message);
    } finally {
      setSaving(false);
    }
  }

  const complete = normalizeOffer(draft) !== undefined;
  return <main className="min-h-screen bg-background text-foreground font-sans p-6"><Stack gap={5}>
    <Heading level={1}>Garden swap table</Heading>
    <form onSubmit={addOffer}>
      <Stack gap={3}>
        <Field label="Name">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={saving || suggesting} value={draft.name} onChange={(name) => setDraft({ ...draft, name })} />}</Field>
        <Field label="Plant">{(id, describedBy) => <Input id={id} describedBy={describedBy} disabled={saving || suggesting} value={draft.plant} onChange={(plant) => setDraft({ ...draft, plant })} />}</Field>
        <Field label="How many pots">{(id, describedBy) => <Input id={id} describedBy={describedBy} type="number" disabled={saving || suggesting} value={String(draft.pots)} onChange={(pots) => setDraft({ ...draft, pots: Number(pots) })} />}</Field>
        <Row gap={2} align="center">
          <Button type="submit" loading={saving} disabled={!ready || saving || suggesting || !complete}>Add to the table</Button>
          <IconButton type="button" variant="quiet" tone="purple" label="Suggest an offer" loading={suggesting} disabled={saving} icon={<Icon name="sparkle" size="sm" />} onClick={suggestOffer} />
        </Row>
      </Stack>
    </form>
    <p role="status">{message}</p>
    {docs.map((offer) => <RowCard key={offer._id}><Text>{offer.name} brings {offer.plant}, {offer.pots} pots</Text></RowCard>)}
  </Stack></main>;
}

Reading an Image

Send an image by passing the prompt as an array of content parts: an image_url part carrying the picture beside a text part asking the question.

The prompt is the content of one user message, so a plain string means exactly one text part and the array is that same message's parts. Read the picture the user picked with a FileReader and hand over the data URL it produces, straight from the camera or photo library — callAI shrinks a large photo to a model-friendly size (1568px on its long edge) before sending it, so the app passes the whole picture and leaves the resizing to the platform:

App.jsx

javascriptimport { callAI } from "call-ai";

function readAsDataUrl(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(file);
  });
}

async function readFlyer(file) {
  const dataUrl = await readAsDataUrl(file);
  const response = await callAI(
    [
      { type: "text", text: "Read this event flyer and pull out the event details." },
      { type: "image_url", image_url: { url: dataUrl } },
    ],
    {
      schema: {
        properties: {
          title: { type: "string", description: "Name of the event" },
          date: { type: "string", description: "Date as printed on the flyer" },
          time: { type: "string" },
          venue: { type: "string" },
          description: { type: "string" },
        },
      },
    }
  );
  return JSON.parse(response);
}

A string prompt stays the common path for text-only work and behaves exactly as it always has. A model that reads text alone answers an image request with an error naming that model, so an app that asks about a picture hears when the picture went unread — surface that message the way you surface any other callAI error.

An image that stays too large even after shrinking is answered at once with an error whose reason is "image-too-large"; show its message and ask the person to pick a smaller photo.

Inside backend.js, ctx.callAI takes a string prompt.

Choosing a model

listModels({ kind: "image" }) returns the image models this app can use, the app's own choice first; listModels({ kind: "text" }) returns the text models callAI can answer with, in the same order. Import it beside callAI: import { callAI, listModels } from "call-ai". Each entry is { id, name, description, kind, source, isDefault }:

  • id — what you hand back: callAI(prompt, { schema, model: id }), or <ImgGen model={id} /> for a picture.
  • name and description — the words to show a person who is choosing.
  • isDefault — true on the one model the app uses when it names none. Start a picker there.
  • source — where the entry comes from: "account-setting" (the signed-in person's own pick for their account), "app-setting" (the pick this app's maker made), "owner-account-setting" (the maker's pick for all their apps, when this app has none of its own), "platform-default" (the platform's pick for this app), or "catalog" (the platform's curated models).

callAI(prompt, { schema, model }) answers with the model you name from that list, and leaving model out keeps the app's default. Pass an id the list returned: any other name comes back as an error message you can show beside the picker. When the app's maker saved their own provider key in settings, that key covers the model they chose there; other models run on the person's credits. Let a person choose with a Select built from the list:

App.jsx

jsximport React, { useEffect, useState } from "react";
import { callAI, listModels } from "call-ai";
import { Button, Stack, Text } from "@vibes.diy/pouf";
import { Select } from "@vibes.diy/pouf/interactive";

export default function App() {
  const [models, setModels] = useState([]);
  const [model, setModel] = useState("");
  const [haiku, setHaiku] = useState("");
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState("");
  useEffect(() => {
    listModels({ kind: "text" })
      .then((list) => {
        setModels(list);
        setModel(list.find((m) => m.isDefault)?.id ?? list[0]?.id ?? "");
      })
      .catch((error) => setMessage(error.message));
  }, []);
  async function writeHaiku() {
    setBusy(true);
    setMessage("");
    try {
      const response = await callAI("Write a haiku about the first snow of the year.", {
        model,
        schema: { properties: { haiku: { type: "string" } }, required: ["haiku"] },
      });
      setHaiku(JSON.parse(response).haiku);
    } catch (error) {
      setMessage(error.message);
    } finally {
      setBusy(false);
    }
  }
  return (
    <Stack>
      <Select label="Model" value={model} onChange={setModel} options={models.map((m) => ({ value: m.id, label: m.name }))} />
      <Button onClick={writeHaiku} loading={busy} disabled={model === ""}>Write a haiku</Button>
      <Text>{haiku}</Text>
      <Text>{message}</Text>
    </Stack>
  );
}

For pictures, the image-gen skill's "Choosing a Model" section shows one <ImgGen> per image model, side by side.

Error Handling

javascriptimport { callAI } from "call-ai";

try {
  const response = await callAI("Generate some content", {
    schema: {
      properties: {
        result: { type: "string" },
      },
    },
  });
  console.log(JSON.parse(response));
} catch (error) {
  console.error("API error:", error.message);
}

Let the request finish. Reading an image takes several seconds, so a slow answer is still an answer: show a waiting state ("reading your photo…") and keep awaiting the call. The catch above is what tells the app a request actually failed.