Guides

Add an AI design chat

The conversation that powers snowcone.app/create, on your own site: a user types "retro style cow design", gets artwork on a product, and keeps going — "now put it on a mug", "make it red". One proxy route + useChat.

How it works

The whole conversation runs server-side in Snowcone’s chat orchestrator: it interprets the message, asks clarifying questions when it needs to, and chains the tools — generate art, remove backgrounds, search the catalog, render mockups. Your app ships none of that logic. You ship two small pieces:

1. A proxy route on your server that forwards to POST /ai-generations/chat/stream with your key. 2. A chat UI built on the AI SDK’s useChat — the endpoint speaks the standard UI Message Stream over SSE, so useChat consumes it with zero adaptation. Multi-turn works automatically: the client sends the conversation history each turn, which is why “now put it on a mug” knows what “it” is.

Rich results stream back as typed data parts — generated images, product suggestions, mockup cards — typed by @snowcone-app/chat-contracts, so your renderer is a discriminated switch, not JSON guesswork.

Build it

1

Get a key

Chat takes a shop-scoped key with the ai:generate scope — the same credential as image generation. A sandbox shop mint returns one (api_key), or issue one at snowcone.app/studio/api-keys. Server-side only — never in the browser.

2

Add the proxy route

The AI SDK client posts to your server; your server forwards to Snowcone and pipes the SSE response straight back:

tsx
// app/api/chat/route.ts — your server. The key never reaches the browser.
export async function POST(req: Request) {
  // Optional but recommended: gate per-user here (session check, daily cap)
  // — only you know who your user is. Same two-tier metering pattern as
  // image generation: https://developers.snowcone.app/ai-generation

  // Forward the AI SDK request body untouched and return the upstream
  // Response as-is — the SSE stream pipes straight through to useChat.
  return fetch("https://api.snowcone.app/ai-generations/chat/stream", {
    method: "POST",
    headers: {
      "x-api-key": process.env.SNOWCONE_API_KEY!, // shop-scoped, ai:generate
      "content-type": "application/json",
    },
    body: await req.text(),
  });
}
This is exactly how snowcone.app/create works — our storefront is the first consumer of this same public endpoint, through a proxy route just like this one.
3

Wire up useChat

Install ai, @ai-sdk/react, and @snowcone-app/chat-contracts, then point useChat at your route:

tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import type { SnowconeChatDataParts } from "@snowcone-app/chat-contracts";

// Typed messages: every data part below is discriminated for free.
type SnowconeMessage = UIMessage<never, SnowconeChatDataParts>;

export function DesignChat() {
  const [input, setInput] = useState("");
  const [stage, setStage] = useState<string | null>(null);

  const { messages, sendMessage, status } = useChat<SnowconeMessage>({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
    // Progress labels ("generating image", "rendering mockup") stream as
    // TRANSIENT parts — they arrive here, not in message.parts.
    onData: (part) => {
      if (part.type === "data-stage") setStage(part.data.label);
    },
    onFinish: () => setStage(null),
  });

  return (
    <div>
      {messages.map((m) => (
        <Message key={m.id} message={m} onPick={(text) => sendMessage({ text })} />
      ))}
      {stage && <p>{stage}…</p>}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim()) return;
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder='Try "retro style cow design on a sticker"'
          disabled={status !== "ready"}
        />
      </form>
    </div>
  );
}

Render the rich parts

Each assistant message is a list of parts: text plus the typed Snowcone data parts. Render what you care about and skip the rest:

tsx
function Message({
  message,
  onPick,
}: {
  message: SnowconeMessage;
  onPick: (text: string) => void;
}) {
  return (
    <div data-role={message.role}>
      {message.parts.map((part, i) => {
        switch (part.type) {
          case "text":
            return <p key={i}>{part.text}</p>;

          // Generated artwork this turn — show it, let the user react to it.
          case "data-images":
            return part.data.images.map(({ imageUrl }) => (
              <img key={imageUrl} src={imageUrl} alt="" width={160} />
            ));

          // Catalog suggestions — quick-pick chips that feed the next turn.
          case "data-products":
            return (
              <div key={i}>
                {part.data.products.map((p) => (
                  <button key={p.id} onClick={() => onPick(`Put it on the ${p.name}`)}>
                    {p.thumbnailUrl && <img src={p.thumbnailUrl} alt="" width={48} />}
                    {p.name}
                  </button>
                ))}
              </div>
            );

          // The money shot: artwork on a product, as a ready-to-show image URL.
          case "data-mockup":
            return (
              <figure key={i}>
                <img src={part.data.mockupImageUrl} alt={part.data.productName} />
                <figcaption>{part.data.productName}</figcaption>
              </figure>
            );

          default:
            return null;
        }
      })}
    </div>
  );
}
Preview vs. commit. A data-mockup part is a stateless preview — nothing is persisted until the user explicitly saves. viewUrl/editorUrl appear only on a committed design, so their absence is your cue to show a Save affordance rather than pretend one exists. The artworkUrl on the part is the raw generated art.
Contracts your renderer should respect. price and lowestPrice in data-products are integer cents (3772 = $37.72) — divide by 100 to display. The products are suggestions: the first is the one the mockup was rendered on; the rest are alternatives to steer to (“put it on the tote instead”). And data-images length varies by pipeline — several images for generation, sometimes a single one for an edit or background removal — so don’t assume a fixed grid.
The URLs are yours. Streamed mockup and thumbnail URLs are keyed to your shop and directly fetchable — safe to hotlink in your UI, persist, or feed into a buy link.

To make a result buyable, take the part’s productId and artworkUrl and build a checkout link — that’s two URLs, covered in Add a buy button.

Save it (commit)

A preview becomes a real, persisted design through one explicit call — the backend behind your Save (or Add-to-cart) button:

bash
# The Save button's backend — commit the previewed design. Same
# ai:generate key as chat; committing is free (not a metered generation).
curl -X POST https://api.snowcone.app/ai-generations/commit \
  -H "x-api-key: $SNOWCONE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productId": "BEEB77",
    "artworkUrl": "https://…",
    "variantId": "…"
  }'
# productId + artworkUrl come straight off the data-mockup part;
# variantId (optional) keeps the previewed color/variant.
# → {
#   "mockup": { "mockupImageUrl": …, "productId": …, "productName": …,
#               "viewUrl": …, "editorUrl": … },
#   "design": { "artworkId": …, "artworkSlug": …, "designId": …, "pairingSlug": … }
# }

The response is the committed version of the same card: patch it over your data-mockup part and the viewUrl (product page with the design applied) and editorUrl (same page, editor open) light up. The design block is the persisted identity — keep pairingSlug if you link back to the design later.

Nothing is written until this call — browsing and previewing never create rows. If your app has its own user accounts, forward your user’s identity alongside the key the same way you would for generation; otherwise the design is owned by a fresh guest identity that the response returns as guestToken (persist it as a cookie to keep later commits under the same guest).

Production notes

Resumable streams. Generation takes tens of seconds. If the connection drops, resume by job id instead of restarting:

bash
# The start chunk's messageId is the job id. If the connection drops
# mid-generation, resume the same stream — data parts carry stable ids,
# so a replay updates the UI instead of duplicating it.
curl -N https://api.snowcone.app/ai-generations/chat/stream/$JOB_ID \
  -H "x-api-key: $SNOWCONE_API_KEY"

History is the steering wheel. The orchestrator derives conversation context from the replayed text and data-images parts you send back each turn, and honors the most recent data-mockup part’s artworkUrl as the working artwork — that’s why “make it red” edits the art the user is looking at, not an earlier one. To switch artwork explicitly, send Use this artwork instead: <url> as a message — the orchestrator adopts that URL as the working artwork (and chains background removal when the image needs it).

Trim the history you send. The orchestrator only needs the recent tail of a long conversation — cap what you forward (our storefront sends the last 10 messages via the transport’s prepareSendMessagesRequest) so request bodies don’t grow without bound.

Cost & limits. Chat turns bill like image generation (each generated image draws on your plan budget) and 429 when the budget is exhausted — details on AI art generation. Gate your own users in the proxy route; Snowcone meters your org, but only you can meter your users.

Prefer raw HTTP? The same orchestrator is also available as plain start/poll endpoints (chat/start + chat/jobs/:id) — documented on AI art generation.