diff --git a/CHAINING.md b/CHAINING.md
new file mode 100644
index 000000000..38e19712f
--- /dev/null
+++ b/CHAINING.md
@@ -0,0 +1,70 @@
+# Sparkle Pills Chaining Contract
+
+This repository implements an interview-first chaining flow for sparkle-pill use cases in `POST /api/chat`.
+
+## Goal
+
+For sparkle-pill requests, avoid jumping straight to a plan. First collect concrete use-case constraints, then emit an actionable system plan.
+
+## Runtime behavior
+
+1. Detect sparkle-pill intent in `query`/`message` (`/\bsparkle\s*pills?\b/i`) or explicit `sparkleIntent: true` for interview follow-up turns.
+2. If required interview answers are missing, return interview questions and stop.
+3. If interview answers are present and complete, return a deterministic system plan.
+4. Do not call model providers for this branch (no Gateway or Grok dependency).
+5. If the request is pack-bound (`video_id`/`pack_id`), stay on the existing pack-grounded chat path instead of deterministic sparkle planning.
+
+## Request payload
+
+```json
+{
+ "query": "Build me a system for using sparkle pills",
+ "sparkleIntent": true,
+ "sparkleInterview": {
+ "useCase": "I need to manage daily focus sessions across work and school.",
+ "trigger": "Start each weekday at 08:30",
+ "tools": ["Google Calendar", "Slack"],
+ "constraints": "No notifications after 21:00",
+ "successSignal": "5 sessions completed per week"
+ }
+}
+```
+
+## Interview-first response shape
+
+```json
+{
+ "answer": "Before I design the system...",
+ "requiresInterview": true,
+ "interviewQuestions": [
+ "What exact chore or workflow should sparkle pills automate?",
+ "What should trigger each run (time, event, or manual action)?",
+ "Which tools and systems must it update (for example Calendar, Slack, email)?",
+ "What rules or constraints must never be violated?",
+ "How will you measure success each week?"
+ ]
+}
+```
+
+## Plan response shape
+
+```json
+{
+ "answer": "Sparkle Pills System Plan\n...",
+ "requiresInterview": false,
+ "provider": "sparkle-pills-planner",
+ "interview": {
+ "useCase": "...",
+ "trigger": "...",
+ "tools": ["..."],
+ "constraints": "...",
+ "successSignal": "..."
+ }
+}
+```
+
+## Files
+
+- `apps/web/src/lib/sparkle-pills.ts`
+- `apps/web/src/app/api/chat/route.ts`
+- `apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts`
diff --git a/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts b/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts
new file mode 100644
index 000000000..eff8df095
--- /dev/null
+++ b/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts
@@ -0,0 +1,176 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { generateText, aiGateway } = vi.hoisted(() => ({
+ generateText: vi.fn(),
+ aiGateway: vi.fn((model: string) => model),
+}));
+
+const { grokChatCompletion } = vi.hoisted(() => ({
+ grokChatCompletion: vi.fn(),
+}));
+
+vi.mock('ai', () => ({
+ generateText,
+}));
+
+vi.mock('@/lib/ai-gateway', () => ({
+ aiGateway,
+ GATEWAY_CHAT_MODEL: 'openai/gpt-4o',
+}));
+
+vi.mock('@/lib/billing/grok-client', () => ({
+ grokChatCompletion,
+}));
+
+import { POST } from '@/app/api/chat/route';
+import { resetEntitlementStoreForTests } from '@/lib/billing/entitlement-store';
+import { resetChatQuotaForTests } from '@/lib/billing/chat-quota';
+import { buildIdentityPack } from '@/lib/video-pack';
+import { QJ_TRANSCRIPT, QJ_VIDEO_ID } from '@/lib/__fixtures__/qj-z5ohr7sga-emit';
+import * as store from '@/lib/video-pack-store';
+
+describe('POST /api/chat sparkle pills interview-first flow', () => {
+ beforeEach(() => {
+ process.env.AI_GATEWAY_API_KEY = 'vck_test';
+ resetEntitlementStoreForTests();
+ resetChatQuotaForTests();
+ generateText.mockResolvedValue({ text: 'gateway reply' });
+ });
+
+ afterEach(() => {
+ delete process.env.AI_GATEWAY_API_KEY;
+ vi.clearAllMocks();
+ });
+
+ it('asks clarifying interview questions before producing a sparkle-pill system plan', async () => {
+ const request = new Request('http://localhost/api/chat', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ query: 'Build me a system for using sparkle pills',
+ }),
+ });
+
+ const response = await POST(request);
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.requiresInterview).toBe(true);
+ expect(body.interviewQuestions).toEqual(expect.arrayContaining([expect.any(String)]));
+ expect(body.answer).toContain('Before I design the system');
+ expect(generateText).not.toHaveBeenCalled();
+ expect(grokChatCompletion).not.toHaveBeenCalled();
+ });
+
+ it('returns a concrete sparkle-pill workflow after interview answers are provided', async () => {
+ const request = new Request('http://localhost/api/chat', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ query: 'Build me a system for using sparkle pills',
+ sparkleInterview: {
+ useCase: 'I need to manage daily focus sessions across work and school.',
+ trigger: 'Start each weekday at 08:30',
+ tools: ['Google Calendar', 'Slack'],
+ constraints: 'No notifications after 21:00',
+ successSignal: '5 sessions completed per week',
+ },
+ }),
+ });
+
+ const response = await POST(request);
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.requiresInterview).toBe(false);
+ expect(body.provider).toBe('sparkle-pills-planner');
+ expect(body.answer).toContain('Sparkle Pills System Plan');
+ expect(body.answer).toContain('Start each weekday at 08:30');
+ expect(body.answer).toContain('5 sessions completed per week');
+ expect(generateText).not.toHaveBeenCalled();
+ expect(grokChatCompletion).not.toHaveBeenCalled();
+ });
+
+ it('keeps pack-bound sparkle requests on grounded chat instead of deterministic planner', async () => {
+ const pack = buildIdentityPack(QJ_VIDEO_ID);
+ pack.transcript = QJ_TRANSCRIPT;
+ pack.id = `vp:v0:${QJ_VIDEO_ID}`;
+ vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({
+ outcome: 'hit',
+ record: { state: 'ready', pack },
+ store: { backend: 'memory', ok: true },
+ });
+
+ const request = new Request('http://localhost/api/chat', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ query: 'Build me a system for using sparkle pills',
+ video_id: QJ_VIDEO_ID,
+ pack_id: `vp:v0:${QJ_VIDEO_ID}`,
+ sparkleInterview: {
+ useCase: 'Weekday focus sessions',
+ trigger: 'Weekdays at 08:30',
+ tools: ['Calendar', 'Slack'],
+ constraints: 'No messages after 21:00',
+ successSignal: '5 sessions weekly',
+ },
+ }),
+ });
+
+ const response = await POST(request);
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.provider).toBe('vercel-ai-gateway');
+ expect(body.requiresInterview).toBeUndefined();
+ expect(body.answer).toBe('gateway reply');
+ expect(generateText).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps pack-bound sparkle requests fail-closed when no ready pack exists', async () => {
+ vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({
+ outcome: 'miss',
+ store: { backend: 'memory', ok: true },
+ });
+
+ const request = new Request('http://localhost/api/chat', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ query: 'Build me a system for using sparkle pills',
+ video_id: QJ_VIDEO_ID,
+ pack_id: `vp:v0:${QJ_VIDEO_ID}`,
+ }),
+ });
+
+ const response = await POST(request);
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.code).toBe('pack_not_found');
+ expect(generateText).not.toHaveBeenCalled();
+ });
+
+ it('continues sparkle interview without repeating sparkle phrase by using sparkleIntent', async () => {
+ const request = new Request('http://localhost/api/chat', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ query: 'Use case: automate weekday focus sessions.',
+ sparkleIntent: true,
+ sparkleInterview: {
+ useCase: 'weekday focus sessions',
+ },
+ }),
+ });
+
+ const response = await POST(request);
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.requiresInterview).toBe(true);
+ expect(body.interviewQuestions).toEqual(expect.arrayContaining([expect.any(String)]));
+ expect(generateText).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts
index 8eb126de6..4df1c2afc 100644
--- a/apps/web/src/app/api/chat/route.ts
+++ b/apps/web/src/app/api/chat/route.ts
@@ -14,6 +14,7 @@ import {
parseChatPackBinding,
resolveChatPackGrounding,
} from '@/lib/chat-pack-grounding';
+import { resolveSparklePillsResponse } from '@/lib/sparkle-pills';
type ChatHistoryMessage = { role: 'user' | 'assistant'; content: string };
const rawBackendUrl = process.env.BACKEND_URL || '';
@@ -136,6 +137,17 @@ export async function POST(request: Request) {
: [];
const query = body.query || body.message || '';
+ if (!packBinding) {
+ const sparkleResponse = resolveSparklePillsResponse({
+ query: typeof query === 'string' ? query : String(query),
+ sparkleIntent: body.sparkleIntent,
+ sparkleInterview: body.sparkleInterview,
+ });
+ if (sparkleResponse) {
+ return NextResponse.json(sparkleResponse);
+ }
+ }
+
if (isPro) {
const leadScore = await scoreLeadWithJev({ query, history });
const scoredPrompt =
diff --git a/apps/web/src/components/__tests__/StudioIdeChat.sparkle.test.tsx b/apps/web/src/components/__tests__/StudioIdeChat.sparkle.test.tsx
new file mode 100644
index 000000000..adccbcbf0
--- /dev/null
+++ b/apps/web/src/components/__tests__/StudioIdeChat.sparkle.test.tsx
@@ -0,0 +1,124 @@
+// @vitest-environment jsdom
+import React from 'react';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import StudioIdeChat from '@/components/studio/StudioIdeChat';
+
+describe('StudioIdeChat sparkle interview chaining', () => {
+ beforeEach(() => {
+ vi.stubGlobal('React', React);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders interview questions and keeps sparkle intent/interview payload on follow-up turn', async () => {
+ const network = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ answer:
+ 'Before I design the system, I need a short use-case interview so the workflow stays concrete and executable.',
+ requiresInterview: true,
+ interviewQuestions: [
+ 'What exact chore or workflow should sparkle pills automate?',
+ 'What should trigger each run (time, event, or manual action)?',
+ 'Which tools and systems must it update (for example Calendar, Slack, email)?',
+ 'What rules or constraints must never be violated?',
+ 'How will you measure success each week?',
+ ],
+ }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ answer: 'Sparkle Pills System Plan',
+ requiresInterview: false,
+ provider: 'sparkle-pills-planner',
+ }),
+ });
+ vi.stubGlobal('fetch', network);
+
+ render();
+
+ fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
+ target: { value: 'Build me a system for using sparkle pills' },
+ });
+ fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));
+
+ await waitFor(() => expect(network).toHaveBeenCalledTimes(1));
+ await screen.findByText(/What exact chore or workflow should sparkle pills automate\?/i);
+
+ fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
+ target: {
+ value: [
+ 'useCase: Manage weekday focus sessions',
+ 'trigger: Start each weekday at 08:30',
+ 'tools: Google Calendar, Slack, Notion',
+ 'constraints: No notifications after 21:00',
+ 'successSignal: 5 sessions completed per week',
+ ].join('\n'),
+ },
+ });
+ fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));
+
+ await waitFor(() => expect(network).toHaveBeenCalledTimes(2));
+ const secondBody = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string) as {
+ sparkleIntent?: boolean;
+ sparkleInterview?: {
+ useCase?: string;
+ trigger?: string;
+ tools?: string[];
+ constraints?: string;
+ successSignal?: string;
+ };
+ };
+
+ expect(secondBody.sparkleIntent).toBe(true);
+ expect(secondBody.sparkleInterview).toEqual({
+ useCase: 'Manage weekday focus sessions',
+ trigger: 'Start each weekday at 08:30',
+ tools: ['Google Calendar', 'Slack', 'Notion'],
+ constraints: 'No notifications after 21:00',
+ successSignal: '5 sessions completed per week',
+ });
+ await screen.findByText('Sparkle Pills System Plan');
+ });
+
+ it('does not treat the request that starts the interview as its first answer', async () => {
+ const interview = {
+ answer: 'Before I design the system, I need a short use-case interview.',
+ requiresInterview: true,
+ interviewQuestions: ['What exact chore or workflow should sparkle pills automate?'],
+ };
+ const network = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => interview,
+ });
+ vi.stubGlobal('fetch', network);
+
+ render();
+ fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
+ target: { value: 'Build me a system for using sparkle pills' },
+ });
+ fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));
+ await screen.findByText(/What exact chore or workflow should sparkle pills automate\?/i);
+
+ fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
+ target: { value: 'Manage weekday focus sessions' },
+ });
+ fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));
+ await waitFor(() => expect(network).toHaveBeenCalledTimes(2));
+
+ const secondBody = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string);
+ expect(secondBody.sparkleIntent).toBe(true);
+ expect(secondBody.sparkleInterview).toEqual({ useCase: 'Manage weekday focus sessions' });
+ });
+});
diff --git a/apps/web/src/components/studio/StudioIdeChat.tsx b/apps/web/src/components/studio/StudioIdeChat.tsx
index dd15bdf45..b50beaf24 100644
--- a/apps/web/src/components/studio/StudioIdeChat.tsx
+++ b/apps/web/src/components/studio/StudioIdeChat.tsx
@@ -2,6 +2,7 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUp, Sparkles } from 'lucide-react';
+import type { SparkleInterviewAnswers } from '@/lib/sparkle-pills';
type ChatMessage = {
id: string;
@@ -16,6 +17,143 @@ type StudioIdeChatProps = {
};
const FREE_QUOTA_MESSAGE = 'Free plan includes 5 AI chat messages per day.';
+const SPARKLE_FIELDS = ['useCase', 'trigger', 'tools', 'constraints', 'successSignal'] as const;
+type SparkleField = (typeof SPARKLE_FIELDS)[number];
+type SparkleInterviewDraft = Partial;
+type SparkleChatResponse = {
+ answer?: string;
+ requiresInterview?: boolean;
+ interviewQuestions?: string[];
+ provider?: string;
+ sparkleInterview?: unknown;
+ [key: string]: unknown;
+};
+
+function parseTools(value: string): string[] {
+ return value
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+}
+
+function normalizeInterviewKey(value: string): SparkleField | null {
+ const key = value.replace(/[^a-z]/gi, '').toLowerCase();
+ if (key === 'usecase' || key === 'workflow' || key === 'objective') return 'useCase';
+ if (key === 'trigger' || key === 'schedule') return 'trigger';
+ if (key === 'tools' || key === 'integrations' || key === 'systems') return 'tools';
+ if (key === 'constraints' || key === 'guardrails' || key === 'rules') return 'constraints';
+ if (key === 'successsignal' || key === 'successmetric' || key === 'success') return 'successSignal';
+ return null;
+}
+
+function parseStructuredInterviewAnswer(text: string): SparkleInterviewDraft | null {
+ const labelPattern =
+ /(useCase|workflow|objective|trigger|schedule|tools|integrations|systems|constraints|guardrails|rules|successSignal|successMetric|success)\s*:/gi;
+ const labelMatches = Array.from(text.matchAll(labelPattern));
+ if (labelMatches.length > 0) {
+ const fromLabels: SparkleInterviewDraft = {};
+ for (let index = 0; index < labelMatches.length; index += 1) {
+ const current = labelMatches[index];
+ const next = labelMatches[index + 1];
+ if (!current || current.index == null) continue;
+ const rawKey = current[1];
+ const key = rawKey ? normalizeInterviewKey(rawKey) : null;
+ if (!key) continue;
+ const valueStart = current.index + current[0].length;
+ const valueEnd = next?.index ?? text.length;
+ const rawValue = text.slice(valueStart, valueEnd).trim().replace(/[|,;]+$/, '').trim();
+ if (!rawValue) continue;
+ if (key === 'tools') {
+ fromLabels.tools = parseTools(rawValue);
+ } else {
+ fromLabels[key] = rawValue;
+ }
+ }
+ if (Object.keys(fromLabels).length > 0) return fromLabels;
+ }
+
+ const lines = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ const fromLabels: SparkleInterviewDraft = {};
+ let matchedLabel = false;
+ for (const line of lines) {
+ const idx = line.indexOf(':');
+ if (idx < 1) continue;
+ const key = normalizeInterviewKey(line.slice(0, idx).trim());
+ if (!key) continue;
+ const rawValue = line.slice(idx + 1).trim();
+ if (!rawValue) continue;
+ matchedLabel = true;
+ if (key === 'tools') {
+ fromLabels.tools = parseTools(rawValue);
+ } else {
+ fromLabels[key] = rawValue;
+ }
+ }
+ if (matchedLabel) return fromLabels;
+
+ const parts = text
+ .split('|')
+ .map((part) => part.trim())
+ .filter((part) => part.length > 0);
+ if (parts.length !== 5) return null;
+ return {
+ useCase: parts[0],
+ trigger: parts[1],
+ tools: parseTools(parts[2]),
+ constraints: parts[3],
+ successSignal: parts[4],
+ };
+}
+
+function nextMissingInterviewField(interview: SparkleInterviewDraft): SparkleField | null {
+ for (const field of SPARKLE_FIELDS) {
+ if (field === 'tools') {
+ if (!Array.isArray(interview.tools) || interview.tools.length === 0) return field;
+ continue;
+ }
+ if (typeof interview[field] !== 'string' || interview[field]?.trim().length === 0) {
+ return field;
+ }
+ }
+ return null;
+}
+
+function mergeInterviewDraft(base: SparkleInterviewDraft, patch: SparkleInterviewDraft): SparkleInterviewDraft {
+ return {
+ ...base,
+ ...patch,
+ ...(patch.tools ? { tools: patch.tools } : {}),
+ };
+}
+
+function withSequentialAnswer(base: SparkleInterviewDraft, answer: string): SparkleInterviewDraft {
+ const field = nextMissingInterviewField(base);
+ if (!field) return base;
+ const normalized = answer.trim();
+ if (!normalized) return base;
+ if (field === 'tools') {
+ return { ...base, tools: parseTools(normalized) };
+ }
+ return { ...base, [field]: normalized };
+}
+
+function composeInterviewAssistantMessage(answer: string, questions: string[]): string {
+ return [
+ answer.trim(),
+ '',
+ ...questions.map((question, index) => `${index + 1}. ${question}`),
+ '',
+ 'Reply in one message with:',
+ 'useCase: ...',
+ 'trigger: ...',
+ 'tools: tool1, tool2',
+ 'constraints: ...',
+ 'successSignal: ...',
+ ].join('\n');
+}
/**
* Center-pane chat for the Studio IDE. Wired to the real POST /api/chat
@@ -30,6 +168,8 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh
const [sending, setSending] = useState(false);
const [quotaHit, setQuotaHit] = useState(false);
const [error, setError] = useState(null);
+ const [sparkleInterviewQuestions, setSparkleInterviewQuestions] = useState(null);
+ const [sparkleInterviewDraft, setSparkleInterviewDraft] = useState({});
const messagesRef = useRef(null);
const idCounter = useRef(0);
@@ -49,6 +189,8 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh
setMessages([]);
setQuotaHit(false);
setError(null);
+ setSparkleInterviewQuestions(null);
+ setSparkleInterviewDraft({});
}, [videoId, packId]);
const send = async (event?: FormEvent) => {
@@ -70,13 +212,21 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh
const body: Record = { query, history };
if (videoId) body.video_id = videoId;
if (packId) body.pack_id = packId;
+ if (sparkleInterviewQuestions) {
+ const structured = parseStructuredInterviewAnswer(query);
+ const draftPayload = structured
+ ? mergeInterviewDraft(sparkleInterviewDraft, structured)
+ : withSequentialAnswer(sparkleInterviewDraft, query);
+ body.sparkleIntent = true;
+ body.sparkleInterview = draftPayload;
+ }
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
- const data = await res.json();
+ const data = (await res.json()) as SparkleChatResponse;
if (res.status === 402 && data.upgradeRequired) {
setQuotaHit(true);
@@ -92,13 +242,43 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh
}
if (!res.ok) {
- throw new Error(data.answer || data.error || `Chat failed (${res.status}).`);
+ const failure =
+ typeof data.answer === 'string'
+ ? data.answer
+ : typeof data.error === 'string'
+ ? data.error
+ : `Chat failed (${res.status}).`;
+ throw new Error(failure);
}
+ const interviewQuestions = Array.isArray(data.interviewQuestions)
+ ? data.interviewQuestions.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ : [];
+ const assistantContent =
+ data.requiresInterview && interviewQuestions.length > 0
+ ? composeInterviewAssistantMessage(data.answer || '(empty response)', interviewQuestions)
+ : data.answer || '(empty response)';
+
setMessages((prev) => [
...prev,
- { id: `a-${idCounter.current++}`, role: 'assistant', content: data.answer || '(empty response)' },
+ { id: `a-${idCounter.current++}`, role: 'assistant', content: assistantContent },
]);
+
+ if (data.requiresInterview) {
+ const structured = parseStructuredInterviewAnswer(query);
+ const nextDraft = structured
+ ? mergeInterviewDraft(sparkleInterviewDraft, structured)
+ : sparkleInterviewQuestions
+ ? withSequentialAnswer(sparkleInterviewDraft, query)
+ : sparkleInterviewDraft;
+ setSparkleInterviewQuestions(interviewQuestions.length > 0 ? interviewQuestions : []);
+ setSparkleInterviewDraft(nextDraft);
+ } else if (data.provider === 'sparkle-pills-planner') {
+ setSparkleInterviewQuestions(null);
+ setSparkleInterviewDraft({});
+ } else if (!data.requiresInterview) {
+ setSparkleInterviewQuestions(null);
+ }
} catch (err) {
const text = err instanceof Error ? err.message : 'Chat failed.';
setError(text);
diff --git a/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts b/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts
index ba0472083..9e6588a30 100644
--- a/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts
+++ b/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts
@@ -310,6 +310,8 @@ describe('emitAppBuilderSandbox (ingest→App Builder sandbox emit)', () => {
expect(main).toContain('bindOutlineNav');
expect(main).toContain("fetch('/api/chat'");
expect(main).toContain('postLiveChat');
+ expect(main).toContain('sparkleIntent');
+ expect(main).toContain('sparkleInterview');
expect(html).toContain('data-testid="shell-chat-send">Send');
expect(html).not.toContain('Send (preview)');
expect(html).not.toContain('Sign in required');
diff --git a/apps/web/src/lib/__tests__/sparkle-pills.test.ts b/apps/web/src/lib/__tests__/sparkle-pills.test.ts
new file mode 100644
index 000000000..2ef8c701e
--- /dev/null
+++ b/apps/web/src/lib/__tests__/sparkle-pills.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { resolveSparklePillsResponse } from '@/lib/sparkle-pills';
+
+describe('sparkle-pills', () => {
+ it('retains every supplied tool instead of truncating after eight entries', () => {
+ const response = resolveSparklePillsResponse({
+ query: 'Build me a system for using sparkle pills',
+ sparkleInterview: {
+ useCase: 'focus sessions',
+ trigger: 'weekdays at 08:30',
+ tools: ['Tool-1', 'Tool-2', 'Tool-3', 'Tool-4', 'Tool-5', 'Tool-6', 'Tool-7', 'Tool-8', 'Tool-9'],
+ constraints: 'No notifications after 21:00',
+ successSignal: '5 sessions each week',
+ },
+ });
+
+ expect(response).not.toBeNull();
+ expect(response?.requiresInterview).toBe(false);
+ if (response && response.requiresInterview === false && 'interview' in response) {
+ expect(response.interview.tools).toHaveLength(9);
+ expect(response.interview.tools[8]).toBe('Tool-9');
+ }
+ });
+
+ it('supports explicit sparkle intent without sparkle phrase in query', () => {
+ const response = resolveSparklePillsResponse({
+ query: 'Use case details only',
+ sparkleIntent: true,
+ });
+
+ expect(response).not.toBeNull();
+ expect(response?.requiresInterview).toBe(true);
+ });
+});
diff --git a/apps/web/src/lib/emit-app-builder-sandbox.ts b/apps/web/src/lib/emit-app-builder-sandbox.ts
index d31f3b12a..2202873eb 100644
--- a/apps/web/src/lib/emit-app-builder-sandbox.ts
+++ b/apps/web/src/lib/emit-app-builder-sandbox.ts
@@ -1573,8 +1573,150 @@ function bindChatRail(): void {
const prompts = Array.from(document.querySelectorAll('button[data-chat-prompt]'));
const chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [];
+ const sparkleFields = ['useCase', 'trigger', 'tools', 'constraints', 'successSignal'] as const;
+ type SparkleField = (typeof sparkleFields)[number];
+ type SparkleInterviewDraft = Partial<{
+ useCase: string;
+ trigger: string;
+ tools: string[];
+ constraints: string;
+ successSignal: string;
+ }>;
+ type ChatResponse = {
+ answer?: string;
+ requiresInterview?: boolean;
+ interviewQuestions?: string[];
+ provider?: string;
+ };
+
+ let sparkleInterviewQuestions: string[] | null = null;
+ let sparkleInterviewDraft: SparkleInterviewDraft = {};
let inFlight = false;
+ function parseTools(value: string): string[] {
+ return value
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+ }
+
+ function normalizeInterviewKey(value: string): SparkleField | null {
+ const key = value.replace(/[^a-z]/gi, '').toLowerCase();
+ if (key === 'usecase' || key === 'workflow' || key === 'objective') return 'useCase';
+ if (key === 'trigger' || key === 'schedule') return 'trigger';
+ if (key === 'tools' || key === 'integrations' || key === 'systems') return 'tools';
+ if (key === 'constraints' || key === 'guardrails' || key === 'rules') return 'constraints';
+ if (key === 'successsignal' || key === 'successmetric' || key === 'success') return 'successSignal';
+ return null;
+ }
+
+ function parseStructuredInterviewAnswer(text: string): SparkleInterviewDraft | null {
+ const labelPattern =
+ /(useCase|workflow|objective|trigger|schedule|tools|integrations|systems|constraints|guardrails|rules|successSignal|successMetric|success)\s*:/gi;
+ const labelMatches = Array.from(text.matchAll(labelPattern));
+ if (labelMatches.length > 0) {
+ const fromLabels: SparkleInterviewDraft = {};
+ for (let index = 0; index < labelMatches.length; index += 1) {
+ const current = labelMatches[index];
+ const next = labelMatches[index + 1];
+ if (!current || current.index == null) continue;
+ const rawKey = current[1];
+ const key = rawKey ? normalizeInterviewKey(rawKey) : null;
+ if (!key) continue;
+ const valueStart = current.index + current[0].length;
+ const valueEnd = next?.index ?? text.length;
+ const rawValue = text.slice(valueStart, valueEnd).trim().replace(/[|,;]+$/, '').trim();
+ if (!rawValue) continue;
+ if (key === 'tools') {
+ fromLabels.tools = parseTools(rawValue);
+ } else {
+ fromLabels[key] = rawValue;
+ }
+ }
+ if (Object.keys(fromLabels).length > 0) return fromLabels;
+ }
+
+ const lines = text
+ .split(/\\r?\\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ const fromLabels: SparkleInterviewDraft = {};
+ let matchedLabel = false;
+ for (const line of lines) {
+ const idx = line.indexOf(':');
+ if (idx < 1) continue;
+ const key = normalizeInterviewKey(line.slice(0, idx).trim());
+ if (!key) continue;
+ const rawValue = line.slice(idx + 1).trim();
+ if (!rawValue) continue;
+ matchedLabel = true;
+ if (key === 'tools') {
+ fromLabels.tools = parseTools(rawValue);
+ } else {
+ fromLabels[key] = rawValue;
+ }
+ }
+ if (matchedLabel) return fromLabels;
+
+ const parts = text
+ .split('|')
+ .map((part) => part.trim())
+ .filter((part) => part.length > 0);
+ if (parts.length !== 5) return null;
+ return {
+ useCase: parts[0],
+ trigger: parts[1],
+ tools: parseTools(parts[2]),
+ constraints: parts[3],
+ successSignal: parts[4],
+ };
+ }
+
+ function mergeInterviewDraft(base: SparkleInterviewDraft, patch: SparkleInterviewDraft): SparkleInterviewDraft {
+ return {
+ ...base,
+ ...patch,
+ ...(patch.tools ? { tools: patch.tools } : {}),
+ };
+ }
+
+ function nextMissingInterviewField(interview: SparkleInterviewDraft): SparkleField | null {
+ for (const field of sparkleFields) {
+ if (field === 'tools') {
+ if (!Array.isArray(interview.tools) || interview.tools.length === 0) return field;
+ continue;
+ }
+ if (typeof interview[field] !== 'string' || interview[field]?.trim().length === 0) {
+ return field;
+ }
+ }
+ return null;
+ }
+
+ function withSequentialAnswer(base: SparkleInterviewDraft, answer: string): SparkleInterviewDraft {
+ const field = nextMissingInterviewField(base);
+ if (!field) return base;
+ const normalized = answer.trim();
+ if (!normalized) return base;
+ if (field === 'tools') return { ...base, tools: parseTools(normalized) };
+ return { ...base, [field]: normalized };
+ }
+
+ function composeInterviewAssistantMessage(answer: string, questions: string[]): string {
+ return [
+ answer.trim(),
+ '',
+ ...questions.map((question, index) => \`\${index + 1}. \${question}\`),
+ '',
+ 'Reply in one message with:',
+ 'useCase: ...',
+ 'trigger: ...',
+ 'tools: tool1, tool2',
+ 'constraints: ...',
+ 'successSignal: ...',
+ ].join('\\n');
+ }
+
function appendBubble(text: string, kind: 'user' | 'assistant' | 'system'): void {
if (!messages) return;
const bubble = document.createElement('div');
@@ -1609,6 +1751,13 @@ function bindChatRail(): void {
async function postLiveChat(text: string): Promise {
setComposerBusy(true);
try {
+ let sparklePayload: SparkleInterviewDraft | null = null;
+ if (sparkleInterviewQuestions) {
+ const structured = parseStructuredInterviewAnswer(text);
+ sparklePayload = structured
+ ? mergeInterviewDraft(sparkleInterviewDraft, structured)
+ : withSequentialAnswer(sparkleInterviewDraft, text);
+ }
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1619,18 +1768,35 @@ function bindChatRail(): void {
pack_id: pack.packId,
video_url: pack.sourceUrl,
history: chatHistory,
+ ...(sparklePayload
+ ? { sparkleIntent: true, sparkleInterview: sparklePayload }
+ : {}),
}),
signal: AbortSignal.timeout(60_000),
});
- const data = (await response.json()) as { answer?: string };
+ const data = (await response.json()) as ChatResponse;
+ const interviewQuestions = Array.isArray(data.interviewQuestions)
+ ? data.interviewQuestions.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
+ : [];
const answer =
- typeof data.answer === 'string' && data.answer.trim()
- ? data.answer.trim()
+ typeof data.answer === 'string' && data.answer.trim().length > 0
+ ? (
+ data.requiresInterview && interviewQuestions.length > 0
+ ? composeInterviewAssistantMessage(data.answer, interviewQuestions)
+ : data.answer.trim()
+ )
: 'No assistant reply returned.';
if (!response.ok) {
appendBubble(answer, 'system');
return;
}
+ if (data.requiresInterview) {
+ sparkleInterviewQuestions = interviewQuestions;
+ sparkleInterviewDraft = sparklePayload ?? sparkleInterviewDraft;
+ } else if (data.provider === 'sparkle-pills-planner') {
+ sparkleInterviewQuestions = null;
+ sparkleInterviewDraft = {};
+ }
chatHistory.push({ role: 'user', content: text });
chatHistory.push({ role: 'assistant', content: answer });
appendBubble(answer, 'assistant');
diff --git a/apps/web/src/lib/sparkle-pills.ts b/apps/web/src/lib/sparkle-pills.ts
new file mode 100644
index 000000000..1776e565e
--- /dev/null
+++ b/apps/web/src/lib/sparkle-pills.ts
@@ -0,0 +1,98 @@
+export type SparkleInterviewAnswers = {
+ useCase: string;
+ trigger: string;
+ tools: string[];
+ constraints: string;
+ successSignal: string;
+};
+
+const SPARKLE_RE = /\bsparkle\s*pills?\b/i;
+
+function compact(value: unknown): string {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function compactList(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
+ .filter((entry) => entry.length > 0);
+}
+
+function parseAnswers(value: unknown): SparkleInterviewAnswers | null {
+ if (!value || typeof value !== 'object') return null;
+ const source = value as Record;
+ const useCase = compact(source.useCase);
+ const trigger = compact(source.trigger);
+ const tools = compactList(source.tools);
+ const constraints = compact(source.constraints);
+ const successSignal = compact(source.successSignal);
+ if (!useCase || !trigger || tools.length === 0 || !constraints || !successSignal) {
+ return null;
+ }
+ return { useCase, trigger, tools, constraints, successSignal };
+}
+
+export function isSparklePillsRequest(query: string): boolean {
+ return SPARKLE_RE.test(query);
+}
+
+export function sparklePillsInterviewQuestions(): string[] {
+ return [
+ 'What exact chore or workflow should sparkle pills automate?',
+ 'What should trigger each run (time, event, or manual action)?',
+ 'Which tools and systems must it update (for example Calendar, Slack, email)?',
+ 'What rules or constraints must never be violated?',
+ 'How will you measure success each week?',
+ ];
+}
+
+export function resolveSparklePillsResponse(input: {
+ query: string;
+ sparkleIntent?: unknown;
+ sparkleInterview?: unknown;
+}):
+ | {
+ answer: string;
+ requiresInterview: boolean;
+ interviewQuestions: string[];
+ }
+ | {
+ answer: string;
+ requiresInterview: false;
+ provider: 'sparkle-pills-planner';
+ interview: SparkleInterviewAnswers;
+ }
+ | null {
+ const intentActive =
+ isSparklePillsRequest(input.query)
+ || input.sparkleIntent === true;
+ if (!intentActive) return null;
+
+ const interview = parseAnswers(input.sparkleInterview);
+ if (!interview) {
+ return {
+ answer:
+ 'Before I design the system, I need a short use-case interview so the workflow stays concrete and executable.',
+ requiresInterview: true,
+ interviewQuestions: sparklePillsInterviewQuestions(),
+ };
+ }
+
+ return {
+ answer: [
+ 'Sparkle Pills System Plan',
+ '',
+ `1. Objective: ${interview.useCase}`,
+ `2. Trigger: ${interview.trigger}`,
+ `3. Integrations: ${interview.tools.join(', ')}`,
+ `4. Guardrails: ${interview.constraints}`,
+ `5. Success metric: ${interview.successSignal}`,
+ '6. Execution loop: intake -> validate context -> run actions -> confirm outputs -> log weekly feedback.',
+ '7. First rollout: ship one low-risk automation path, then expand after two stable weekly cycles.',
+ ].join('\n'),
+ requiresInterview: false,
+ provider: 'sparkle-pills-planner',
+ interview,
+ };
+}