{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quickstart",
  "title": "Fable UI Quickstart",
  "description": "Install a production-ready AI SDK chat at /fable-chat with env-driven provider configuration.",
  "dependencies": [
    "ai",
    "@ai-sdk/react",
    "@ai-sdk/anthropic",
    "@ai-sdk/deepseek",
    "@ai-sdk/google",
    "@ai-sdk/mistral",
    "@ai-sdk/openai",
    "@openrouter/ai-sdk-provider",
    "lucide-react",
    "react-markdown",
    "remark-gfm"
  ],
  "registryDependencies": [
    "button",
    "input-group",
    "textarea",
    "message-scroller",
    "message",
    "bubble",
    "marker",
    "shobky/fable-ui/core",
    "shobky/fable-ui/metric-card",
    "shobky/fable-ui/suggested-actions"
  ],
  "files": [
    {
      "path": "examples/quickstart/app/fable-chat/page.tsx",
      "content": "import { FableChat } from \"@/components/fable-ui/chat/fable-chat\"\n\nexport default function FableChatPage() {\n  return (\n    <main className=\"min-h-screen bg-background\">\n      <FableChat />\n    </main>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/fable-chat/page.tsx"
    },
    {
      "path": "examples/quickstart/app/api/fable-chat/route.ts",
      "content": "import {\n  convertToModelMessages,\n  createUIMessageStream,\n  createUIMessageStreamResponse,\n  stepCountIs,\n  streamText,\n} from \"ai\"\nimport type { UIMessage } from \"ai\"\n\nimport {\n  createFableAIModel,\n  getConfigurationMessage,\n  getFableAIConfigStatus,\n  getProviderLabel,\n} from \"@/lib/fable-ui/quickstart/provider-config\"\nimport { fableTools } from \"@/lib/fable-ui/quickstart/tools\"\n\ntype ChatRequestBody = {\n  messages?: UIMessage[]\n}\n\nconst noStoreHeaders = {\n  \"Cache-Control\": \"no-store, max-age=0\",\n  Pragma: \"no-cache\",\n}\n\nexport async function POST(req: Request) {\n  let body: ChatRequestBody\n\n  try {\n    body = (await req.json()) as ChatRequestBody\n  } catch {\n    return createTextOnlyResponse(\"Request error. The chat route received invalid JSON.\")\n  }\n\n  const messages = body.messages ?? []\n  const config = getFableAIConfigStatus()\n\n  if (!config.ok) {\n    return createTextOnlyResponse(\n      getConfigurationMessage({\n        missing: config.missing,\n        error: config.error,\n      }),\n      messages,\n    )\n  }\n\n  try {\n    const result = streamText({\n      model: createFableAIModel(config),\n      system: [\n        \"You are a concise assistant in a production Fable UI quickstart chat.\",\n        `The configured provider is ${getProviderLabel(config.provider)} and the model is ${config.model}.`,\n        \"Use Fable UI tools only when the user's request is better answered with a trusted UI surface.\",\n        \"Never call tools with generic placeholder data, fake metrics, or invented values.\",\n        \"If exact values or context are missing, ask a short follow-up question or answer in text.\",\n        \"When a metric is clearly available from the conversation, render it with show_metric.\",\n        \"When useful next steps are requested, render them with show_next_actions.\",\n      ].join(\"\\n\"),\n      messages: await convertToModelMessages(messages),\n      tools: fableTools,\n      toolChoice: \"auto\",\n      stopWhen: stepCountIs(3),\n    })\n\n    return result.toUIMessageStreamResponse({\n      headers: noStoreHeaders,\n      onError: getPublicChatError,\n    })\n  } catch (error) {\n    return createTextOnlyResponse(getPublicChatError(error), messages)\n  }\n}\n\nexport function GET() {\n  return createTextOnlyResponse(\"POST chat messages to this route from `/fable-chat`.\")\n}\n\nfunction createTextOnlyResponse(text: string, messages: UIMessage[] = []) {\n  const stream = createUIMessageStream({\n    originalMessages: messages,\n    execute: ({ writer }) => {\n      const id = \"fable-chat-text\"\n\n      writer.write({ type: \"text-start\", id })\n      writer.write({ type: \"text-delta\", id, delta: text })\n      writer.write({ type: \"text-end\", id })\n    },\n  })\n\n  return createUIMessageStreamResponse({ stream, headers: noStoreHeaders })\n}\n\nfunction getPublicChatError(error: unknown) {\n  const message = error instanceof Error ? error.message : String(error || \"\")\n\n  if (/api key|authentication|unauthorized|permission|forbidden|401|403/i.test(message)) {\n    return \"Provider authentication failed. Check `FABLE_AI_API_KEY` and confirm the key has access to the configured model.\"\n  }\n\n  if (/rate limit|quota|429/i.test(message)) {\n    return \"The configured AI provider is rate limited right now. Wait a moment, then try again.\"\n  }\n\n  if (/model|not found|unsupported|404/i.test(message)) {\n    return \"The configured model is unavailable for this provider or API key. Check `FABLE_AI_PROVIDER` and `FABLE_AI_MODEL`.\"\n  }\n\n  if (/timeout|network|fetch|econn|enotfound|socket/i.test(message)) {\n    return \"The chat route could not reach the AI provider. Check your network connection and provider status.\"\n  }\n\n  return \"The chat route failed while asking the AI provider. Check the server logs for details.\"\n}\n",
      "type": "registry:file",
      "target": "app/api/fable-chat/route.ts"
    },
    {
      "path": "examples/quickstart/components/fable-chat.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useMemo, useState } from \"react\"\nimport type { FormEvent, KeyboardEvent } from \"react\"\nimport { useChat } from \"@ai-sdk/react\"\nimport { DefaultChatTransport, type UIMessage } from \"ai\"\nimport { LoaderCircle, SendHorizontal } from \"lucide-react\"\n\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupTextarea,\n} from \"@/components/ui/input-group\"\nimport {\n  Marker,\n  MarkerContent,\n  MarkerIcon,\n} from \"@/components/ui/marker\"\nimport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n} from \"@/components/ui/message-scroller\"\nimport { FableMessage } from \"@/components/fable-ui/chat/fable-message\"\n\nfunction describeClientError(error: unknown) {\n  const message = error instanceof Error ? error.message : String(error || \"\")\n\n  if (/rate limit|quota|429/i.test(message)) {\n    return \"The AI provider is rate limited right now. Wait a moment and try again.\"\n  }\n\n  if (/api key|unauthorized|authentication|401|403/i.test(message)) {\n    return \"The AI provider rejected the request. Check `FABLE_AI_API_KEY` and model access.\"\n  }\n\n  if (/network|fetch|timeout|econn|enotfound/i.test(message)) {\n    return \"The chat request could not reach the server. Check your connection and try again.\"\n  }\n\n  return \"The chat request failed. Please try again.\"\n}\n\nfunction ThinkingRow() {\n  return (\n    <Marker role=\"status\" className=\"mx-auto w-full max-w-3xl px-4\">\n      <MarkerIcon>\n        <LoaderCircle className=\"animate-spin\" aria-hidden=\"true\" />\n      </MarkerIcon>\n      <MarkerContent>\n        <span className=\"inline-flex animate-pulse bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent\">\n          Thinking...\n        </span>\n      </MarkerContent>\n    </Marker>\n  )\n}\n\nexport function FableChat() {\n  const [input, setInput] = useState(\"\")\n  const [clientError, setClientError] = useState<string | null>(null)\n  const transport = useMemo(() => new DefaultChatTransport({ api: \"/api/fable-chat\" }), [])\n  const { messages, sendMessage, status, error, clearError } = useChat<UIMessage>({\n    transport,\n    messages: [],\n    experimental_throttle: 80,\n    onError: (nextError) => {\n      setClientError(describeClientError(nextError))\n    },\n  })\n  const isBusy = status === \"submitted\" || status === \"streaming\"\n  const visibleError = clientError || (error ? describeClientError(error) : null)\n\n  const sendText = useCallback(\n    async (text: string) => {\n      const prompt = text.trim()\n\n      if (!prompt || isBusy) {\n        return false\n      }\n\n      setClientError(null)\n      clearError()\n\n      try {\n        await sendMessage({ text: prompt })\n        return true\n      } catch (nextError) {\n        setClientError(describeClientError(nextError))\n        return false\n      }\n    },\n    [clearError, isBusy, sendMessage],\n  )\n\n  async function submitPrompt() {\n    const text = input.trim()\n\n    if (!text || isBusy) {\n      return\n    }\n\n    setInput(\"\")\n\n    const wasSent = await sendText(text)\n\n    if (!wasSent) {\n      setInput(text)\n    }\n  }\n\n  const handleSuggestedAction = useCallback(\n    (action: { prompt: string }) => {\n      void sendText(action.prompt)\n    },\n    [sendText],\n  )\n\n  async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n    event.preventDefault()\n    await submitPrompt()\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n    if (event.key === \"Enter\" && !event.shiftKey) {\n      event.preventDefault()\n      void submitPrompt()\n    }\n  }\n\n  return (\n    <MessageScrollerProvider\n      autoScroll\n      defaultScrollPosition=\"last-anchor\"\n      scrollPreviousItemPeek={64}\n    >\n      <section className=\"flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground\">\n        <header className=\"border-b px-4 py-3 sm:px-6\">\n          <div className=\"mx-auto flex max-w-3xl items-center justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <h1 className=\"truncate text-sm font-semibold\">Fable Chat</h1>\n              <p className=\"truncate text-xs text-muted-foreground\">\n                Live AI responses with Fable UI tool rendering\n              </p>\n            </div>\n          </div>\n        </header>\n\n        <MessageScroller className=\"min-h-0 flex-1\">\n          <MessageScrollerViewport>\n            <MessageScrollerContent className=\"mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end gap-4 px-4 py-6 sm:px-6\">\n              {messages.length === 0 && !isBusy ? (\n                <div className=\"flex flex-1 items-center justify-center text-center text-muted-foreground\">\n                  <p className=\"max-w-md text-balance text-sm leading-6\">\n                    Ask a question. If a Fable UI surface is useful, the model can render it inline.\n                  </p>\n                </div>\n              ) : null}\n\n              {messages.map((message) => (\n                <MessageScrollerItem\n                  key={message.id}\n                  messageId={message.id}\n                  scrollAnchor={message.role === \"user\"}\n                >\n                  <FableMessage\n                    message={message}\n                    onSuggestedAction={isBusy ? undefined : handleSuggestedAction}\n                  />\n                </MessageScrollerItem>\n              ))}\n\n              {isBusy ? (\n                <MessageScrollerItem messageId=\"assistant-thinking\">\n                  <ThinkingRow />\n                </MessageScrollerItem>\n              ) : null}\n            </MessageScrollerContent>\n          </MessageScrollerViewport>\n\n          <MessageScrollerButton />\n        </MessageScroller>\n\n        <form onSubmit={handleSubmit} className=\"border-t bg-background/95 px-4 py-4 sm:px-6\">\n          <div className=\"mx-auto max-w-3xl\">\n            {visibleError ? (\n              <div className=\"mb-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive\">\n                {visibleError}\n              </div>\n            ) : null}\n\n            <InputGroup className=\"min-h-24 rounded-3xl bg-card p-2 shadow-sm ring-1 ring-border\">\n              <InputGroupTextarea\n                value={input}\n                onChange={(event) => setInput(event.target.value)}\n                onKeyDown={handleKeyDown}\n                placeholder=\"Ask Fable Chat...\"\n                aria-label=\"Message\"\n                rows={1}\n                disabled={isBusy}\n                className=\"min-h-12 px-3 text-base\"\n              />\n              <InputGroupAddon align=\"block-end\" className=\"justify-end pt-2\">\n                <InputGroupButton\n                  type=\"submit\"\n                  variant=\"default\"\n                  size=\"icon-sm\"\n                  disabled={isBusy || input.trim().length === 0}\n                  aria-label=\"Send message\"\n                >\n                  {isBusy ? (\n                    <LoaderCircle className=\"animate-spin\" aria-hidden=\"true\" />\n                  ) : (\n                    <SendHorizontal aria-hidden=\"true\" />\n                  )}\n                </InputGroupButton>\n              </InputGroupAddon>\n            </InputGroup>\n          </div>\n        </form>\n      </section>\n    </MessageScrollerProvider>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/fable-ui/chat/fable-chat.tsx"
    },
    {
      "path": "examples/quickstart/components/fable-message.tsx",
      "content": "import type { UIMessage } from \"ai\"\nimport ReactMarkdown from \"react-markdown\"\nimport remarkGfm from \"remark-gfm\"\n\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\"\nimport { Message, MessageContent } from \"@/components/ui/message\"\nimport type { ToolRenderHandlers } from \"@/lib/fable-ui/core/definitions\"\nimport { cn } from \"@/lib/utils\"\nimport { FableToolPart } from \"@/components/fable-ui/chat/fable-tool-part\"\n\nfunction MarkdownResponse({ children }: { children: string }) {\n  return (\n    <div\n      className={cn(\n        \"flex max-w-none flex-col gap-3 text-sm leading-7\",\n        \"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4\",\n        \"[&_blockquote]:border-l [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground\",\n        \"[&_code]:rounded-md [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs\",\n        \"[&_li]:ml-5 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-muted [&_pre]:p-4 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:list-disc\",\n      )}\n    >\n      <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>\n    </div>\n  )\n}\n\nexport function FableMessage({\n  message,\n  onSuggestedAction,\n}: {\n  message: UIMessage\n  onSuggestedAction?: ToolRenderHandlers[\"onSuggestedAction\"]\n}) {\n  const isUser = message.role === \"user\"\n  const align = isUser ? \"end\" : \"start\"\n\n  return (\n    <Message align={align}>\n      <MessageContent className={cn(\"gap-3\", isUser ? \"items-end\" : \"items-stretch\")}>\n        {(message.parts ?? []).map((part, index) => {\n          if (part.type === \"text\") {\n            return (\n              <Bubble\n                key={`${message.id}-text-${index}`}\n                align={align}\n                variant={isUser ? \"tinted\" : \"ghost\"}\n                className={isUser ? \"w-fit max-w-[75%]\" : \"w-full max-w-full\"}\n              >\n                <BubbleContent\n                  className={cn(\n                    \"border-none\",\n                    isUser ? \"rounded-tr-sm\" : \"w-full max-w-3xl px-0\",\n                  )}\n                >\n                  <MarkdownResponse>{part.text}</MarkdownResponse>\n                </BubbleContent>\n              </Bubble>\n            )\n          }\n\n          if (part.type.startsWith(\"tool-\")) {\n            return (\n              <div key={`${message.id}-tool-${index}`} className=\"w-full max-w-5xl\">\n                <FableToolPart part={part} onSuggestedAction={onSuggestedAction} />\n              </div>\n            )\n          }\n\n          return null\n        })}\n      </MessageContent>\n    </Message>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/fable-ui/chat/fable-message.tsx"
    },
    {
      "path": "examples/quickstart/components/fable-tool-part.tsx",
      "content": "\"use client\"\n\nimport type { ToolPartLike, ToolRenderHandlers } from \"@/lib/fable-ui/core/definitions\"\nimport { FableToolPart as RenderFableToolPart } from \"@/lib/fable-ui/core/tool-renderer\"\nimport { fableToolRegistry } from \"@/lib/fable-ui/quickstart/tools\"\n\nexport function FableToolPart({\n  part,\n  onSuggestedAction,\n}: {\n  part: ToolPartLike\n  onSuggestedAction?: ToolRenderHandlers[\"onSuggestedAction\"]\n}) {\n  return (\n    <RenderFableToolPart\n      part={part}\n      registry={fableToolRegistry}\n      handlers={{ onSuggestedAction }}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/fable-ui/chat/fable-tool-part.tsx"
    },
    {
      "path": "examples/quickstart/lib/tools.ts",
      "content": "import { showMetric } from \"@/lib/fable-ui/tools/show-metric-tool\"\nimport { showNextActions } from \"@/lib/fable-ui/tools/show-next-actions-tool\"\n\nexport const fableToolRegistry = {\n  show_metric: showMetric,\n  show_next_actions: showNextActions,\n}\n\nexport const fableTools = Object.fromEntries(\n  Object.entries(fableToolRegistry).map(([name, def]) => [name, def.tool]),\n)\n",
      "type": "registry:lib",
      "target": "@lib/fable-ui/quickstart/tools.ts"
    },
    {
      "path": "examples/quickstart/lib/provider-config.ts",
      "content": "import { createAnthropic } from \"@ai-sdk/anthropic\"\nimport { createDeepSeek } from \"@ai-sdk/deepseek\"\nimport { createGoogleGenerativeAI } from \"@ai-sdk/google\"\nimport { createMistral } from \"@ai-sdk/mistral\"\nimport { createOpenAI } from \"@ai-sdk/openai\"\nimport { createOpenRouter } from \"@openrouter/ai-sdk-provider\"\nimport type { LanguageModel } from \"ai\"\n\nexport type FableAIProvider =\n  | \"google\"\n  | \"anthropic\"\n  | \"openai\"\n  | \"openrouter\"\n  | \"mistral\"\n  | \"deepseek\"\n\nconst providerLabels: Record<FableAIProvider, string> = {\n  google: \"Google\",\n  anthropic: \"Anthropic\",\n  openai: \"OpenAI\",\n  openrouter: \"OpenRouter\",\n  mistral: \"Mistral\",\n  deepseek: \"DeepSeek\",\n}\n\nconst providerIds = Object.keys(providerLabels) as FableAIProvider[]\n\nexport function getFableAIConfigStatus() {\n  const provider = process.env.FABLE_AI_PROVIDER?.trim().toLowerCase()\n  const model = process.env.FABLE_AI_MODEL?.trim()\n  const apiKey = process.env.FABLE_AI_API_KEY?.trim()\n  const missing = ([\n    [\"FABLE_AI_PROVIDER\", provider],\n    [\"FABLE_AI_MODEL\", model],\n    [\"FABLE_AI_API_KEY\", apiKey],\n  ] satisfies Array<[string, string | undefined]>)\n    .filter(([, value]) => !value)\n    .map(([name]) => name)\n\n  if (missing.length > 0) {\n    return { ok: false as const, missing }\n  }\n\n  if (!isFableAIProvider(provider)) {\n    return {\n      ok: false as const,\n      missing: [],\n      error: `FABLE_AI_PROVIDER must be one of: ${providerIds.join(\", \")}.`,\n    }\n  }\n\n  return {\n    ok: true as const,\n    provider,\n    model: model!,\n    apiKey: apiKey!,\n  }\n}\n\nexport function createFableAIModel(input: {\n  provider: FableAIProvider\n  model: string\n  apiKey: string\n}): LanguageModel {\n  switch (input.provider) {\n    case \"anthropic\":\n      return createAnthropic({ apiKey: input.apiKey })(input.model)\n    case \"deepseek\":\n      return createDeepSeek({ apiKey: input.apiKey })(input.model)\n    case \"mistral\":\n      return createMistral({ apiKey: input.apiKey })(input.model)\n    case \"openai\":\n      return createOpenAI({ apiKey: input.apiKey })(input.model)\n    case \"openrouter\":\n      return createOpenRouter({ apiKey: input.apiKey })(input.model)\n    case \"google\":\n    default:\n      return createGoogleGenerativeAI({ apiKey: input.apiKey })(input.model)\n  }\n}\n\nexport function getProviderLabel(provider: FableAIProvider) {\n  return providerLabels[provider]\n}\n\nexport function getConfigurationMessage(input: {\n  missing?: string[]\n  error?: string\n}) {\n  const missing = input.missing ?? []\n  const missingText =\n    missing.length > 0\n      ? `\\n\\nMissing variables:\\n${missing.map((name) => `- ${name}`).join(\"\\n\")}`\n      : \"\"\n\n  return [\n    \"Fable Chat is almost ready. Complete the AI provider configuration and restart your dev server.\",\n    input.error ? `\\n\\n${input.error}` : \"\",\n    missingText,\n    \"\\n\\nAdd these to `.env.local`:\",\n    \"```env\",\n    \"FABLE_AI_PROVIDER=google\",\n    \"FABLE_AI_MODEL=gemini-3-flash-preview\",\n    \"FABLE_AI_API_KEY=your-provider-api-key\",\n    \"```\",\n  ].join(\"\\n\")\n}\n\nfunction isFableAIProvider(value: unknown): value is FableAIProvider {\n  return providerIds.includes(value as FableAIProvider)\n}\n",
      "type": "registry:lib",
      "target": "@lib/fable-ui/quickstart/provider-config.ts"
    },
    {
      "path": "examples/quickstart/README.md",
      "content": "# Fable UI quickstart\n\nThis item installs a production-ready AI SDK chat at `/fable-chat` with a route handler at `/api/fable-chat`.\n\n## Configure the model\n\nAdd three server-only environment variables to `.env.local`:\n\n```env\nFABLE_AI_PROVIDER=google\nFABLE_AI_MODEL=gemini-3-flash-preview\nFABLE_AI_API_KEY=your-provider-api-key\n```\n\nSupported `FABLE_AI_PROVIDER` values are `google`, `anthropic`, `openai`, `openrouter`, `mistral`, and `deepseek`.\n\nRestart the dev server after changing `.env.local`, then open `/fable-chat` and start chatting. If the variables are missing, the chat will respond with a setup message listing the values to add.\n\n## What it installs\n\n```txt\napp/fable-chat/page.tsx\napp/api/fable-chat/route.ts\ncomponents/fable-ui/chat/*\nlib/fable-ui/quickstart/*\n```\n\nThe quickstart includes `metric-card` and `suggested-actions` tool rendering. Suggested action clicks send the action prompt through the same chat route as a manually typed message. Add more Fable UI registry items when your assistant needs more surfaces, or configure a data source driver when tool calls should read from host-owned data.\n",
      "type": "registry:file",
      "target": "~/docs/fable-ui/quickstart.md"
    }
  ],
  "type": "registry:block"
}