Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ make yappybara-launch

- `YAPPY_BILLING_MODE=subscription` (default): subscription-first mode. If `ANTHROPIC_API_KEY` is set, Yappybara ignores it to avoid accidental API charges.
- `YAPPY_BILLING_MODE=api`: usage-based API billing (requires `ANTHROPIC_API_KEY`).
- `YAPPY_BILLING_MODE=user_api`: parent BYOK mode. Server ignores its own API key and requires each family to provide their own key in-browser.
- `YAPPY_BILLING_MODE=auto`: uses API billing when a key is present, otherwise Claude login/subscription.

### Claude Code Plugin
Expand Down
11 changes: 5 additions & 6 deletions docs/fly-api-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This is the path to move Yappybara from local-only to a hosted service parents c

- Native app deployment and config (`fly deploy`, `fly.toml`): [Fly Launch](https://fly.io/docs/launch/), [App Config](https://fly.io/docs/reference/configuration/).
- Persistent storage with volumes: [Fly Volumes](https://fly.io/docs/volumes/overview/).
- Encrypted runtime secrets (`ANTHROPIC_API_KEY`): [Fly Secrets](https://fly.io/docs/apps/secrets/).
- Optional encrypted runtime secrets (only if you choose server-owned billing): [Fly Secrets](https://fly.io/docs/apps/secrets/).
- Custom subdomains + certs: [Fly Custom Domains](https://fly.io/docs/networking/custom-domain/).
- Pricing reference: [Fly Pricing](https://fly.io/docs/about/pricing/).

Expand All @@ -35,12 +35,11 @@ fly apps create yappybara-api
fly volumes create yappybara_data --region ord --size 3
```

Set required secrets:
Set required runtime env:

```bash
fly secrets set ANTHROPIC_API_KEY=... \
YAPPY_MODE=claude \
YAPPY_BILLING_MODE=api \
fly secrets set YAPPY_MODE=claude \
YAPPY_BILLING_MODE=user_api \
YAPPY_DEPLOY_DOMAIN=yappybara.dev \
YAPPY_DEPLOY_PRICE_USD=9
```
Expand Down Expand Up @@ -121,7 +120,7 @@ Practical launch strategy:
2. Persist all queue/build data to `/data` volume.
3. Add rate limiting before public launch.
4. Keep parent-supervision disclaimers visible in hosted UI.
5. Keep API key only in Fly secrets, never in client code.
5. For `YAPPY_BILLING_MODE=user_api`, do not set a server Anthropic key; each family supplies their own key in browser.

## Next technical increments

Expand Down
2 changes: 1 addition & 1 deletion fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ primary_region = "ord"
[env]
PORT = "8080"
YAPPY_MODE = "claude"
YAPPY_BILLING_MODE = "api"
YAPPY_BILLING_MODE = "user_api"
YAPPY_STORAGE_DIR = "/data"
YAPPY_DEPLOY_DOMAIN = "yappybara.dev"
YAPPY_DEPLOY_PRICE_USD = "9"
Expand Down
6 changes: 3 additions & 3 deletions interview-app/.env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Minimal env for hosted API mode (Fly.io)
PORT=8080
YAPPY_MODE=claude
YAPPY_BILLING_MODE=api
YAPPY_BILLING_MODE=user_api
YAPPY_STORAGE_DIR=/data
YAPPY_DEPLOY_DOMAIN=yappybara.dev
YAPPY_DEPLOY_PRICE_USD=9
FORCE_HTTP=true
AUTO_GENERATE_CERTS=false

# Required for Claude API mode
ANTHROPIC_API_KEY=your-api-key-here
# Optional for server-owned billing mode only (NOT needed in user_api mode)
# ANTHROPIC_API_KEY=your-api-key-here
7 changes: 6 additions & 1 deletion interview-app/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export async function runBuilder({ spec, conversation, appId }) {
export async function runBuilder({ spec, conversation, appId, runtimeApiKey }) {
// Resolve absolute paths so there's zero ambiguity for Dash
const buildsDir = path.join(__dirname, 'public', 'builds', appId || 'latest');
const buildFile = path.join(buildsDir, 'index.html');
Expand Down Expand Up @@ -74,6 +74,10 @@ Be FAST and BOLD. Build something they'll be excited to show their friends.`;

let resultText = '';
try {
const env = { ...process.env };
if (runtimeApiKey) {
env.ANTHROPIC_API_KEY = runtimeApiKey;
}
const result = query({
prompt: `Build the app prototype now. Write the HTML file to: ${buildFile}`,
options: {
Expand All @@ -83,6 +87,7 @@ Be FAST and BOLD. Build something they'll be excited to show their friends.`;
allowDangerouslySkipPermissions: true,
maxTurns: 15,
cwd: __dirname,
env,
}
});

Expand Down
2 changes: 1 addition & 1 deletion interview-app/fly.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ primary_region = "ord"
[env]
PORT = "8080"
YAPPY_MODE = "claude"
YAPPY_BILLING_MODE = "api"
YAPPY_BILLING_MODE = "user_api"
YAPPY_STORAGE_DIR = "/data"
YAPPY_DEPLOY_DOMAIN = "yappybara.dev"
YAPPY_DEPLOY_PRICE_USD = "9"
Expand Down
7 changes: 6 additions & 1 deletion interview-app/mechanic.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Note: action log entries with type "dad_feedback" are observations from the kid'
- Keep last-fix.md short and in kid-friendly language
- Sign off in last-fix.md as "— Wrench" because that's your name`;

export async function runMechanic({ feedback, conversation, actionLog }) {
export async function runMechanic({ feedback, conversation, actionLog, runtimeApiKey }) {
// Build the conversation summary (last 10 messages)
let conversationText = 'No conversation available';
if (conversation && conversation.length > 0) {
Expand All @@ -56,6 +56,10 @@ export async function runMechanic({ feedback, conversation, actionLog }) {
console.log('');

let resultText = '';
const env = { ...process.env };
if (runtimeApiKey) {
env.ANTHROPIC_API_KEY = runtimeApiKey;
}
const result = query({
prompt: 'Fix the issue described in my system prompt. Start by reading the relevant files.',
options: {
Expand All @@ -66,6 +70,7 @@ export async function runMechanic({ feedback, conversation, actionLog }) {
allowedTools: ['Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob'],
maxTurns: 10,
cwd: __dirname,
env,
}
});

Expand Down
163 changes: 130 additions & 33 deletions interview-app/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2234,6 +2234,9 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
deployPriceUsd: 5,
lastGuide: null,
fallbackNotified: false,
requiresUserApiKey: false,
apiKeyHeader: 'x-anthropic-api-key',
anthropicApiKey: '',
};

// ===== DOM refs =====
Expand Down Expand Up @@ -2268,6 +2271,58 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
const $parentGateCheckbox = document.getElementById('parent-gate-checkbox');
const $parentGateContinue = document.getElementById('parent-gate-continue');
const PARENT_GATE_STORAGE_KEY = 'yappybara-parent-supervision-v1';
const API_KEY_STORAGE_KEY = 'yappybara-anthropic-key-v1';

function loadStoredApiKey() {
try {
return String(localStorage.getItem(API_KEY_STORAGE_KEY) || '').trim();
} catch (e) {
return '';
}
}

function isPlausibleAnthropicApiKey(value) {
const key = String(value || '').trim();
return key.length >= 24 && key.startsWith('sk-ant-');
}

function setStoredApiKey(value) {
const key = String(value || '').trim();
state.anthropicApiKey = key;
try {
if (key) {
localStorage.setItem(API_KEY_STORAGE_KEY, key);
} else {
localStorage.removeItem(API_KEY_STORAGE_KEY);
}
} catch (e) {}
}

function authHeaders(extra = {}) {
const headers = { ...extra };
const key = String(state.anthropicApiKey || '').trim();
if (key) headers[state.apiKeyHeader || 'x-anthropic-api-key'] = key;
return headers;
}

async function ensureParentApiKeyIfRequired() {
if (!state.requiresUserApiKey || state.assistMode === 'demo') return true;
if (isPlausibleAnthropicApiKey(state.anthropicApiKey)) return true;

const entered = window.prompt(
'Parent setup: paste your Anthropic API key (starts with sk-ant-). It is stored only in this browser/device.'
);
if (!entered) return false;
if (!isPlausibleAnthropicApiKey(entered)) {
window.alert('That key format looks invalid. Please paste a valid Anthropic API key that starts with sk-ant-.');
return false;
}
setStoredApiKey(entered);
updateModeHint(state.assistMode, state.assistModeReason);
return true;
}

setStoredApiKey(loadStoredApiKey());

function updateModeHint(mode, reason) {
if (!mode || !$modeHint) return;
Expand All @@ -2278,7 +2333,10 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
const lead = mode === 'demo'
? 'Practice mode: works without Claude login.'
: 'Full AI mode: powered by Claude.';
$modeHint.textContent = state.assistModeReason ? `${lead} ${state.assistModeReason}` : lead;
const keyHint = (mode !== 'demo' && state.requiresUserApiKey)
? (state.anthropicApiKey ? 'Parent API key is connected on this device.' : 'Parent API key required before chat can start.')
: '';
$modeHint.textContent = [lead, keyHint, state.assistModeReason].filter(Boolean).join(' ');
}

function sanitizeSubdomain(value) {
Expand Down Expand Up @@ -3724,6 +3782,10 @@ <h3>Meet Your Build Crew!</h3>
async function sendMessage(text, imageInfo) {
if (state.isWaiting) return;
if (!text && !imageInfo) return;
if (!(await ensureParentApiKeyIfRequired())) {
addSystemMessage('Parent Anthropic API key is required before full AI mode can start.');
return;
}

state.isWaiting = true;
$sendBtn.disabled = true;
Expand Down Expand Up @@ -3759,7 +3821,7 @@ <h3>Meet Your Build Crew!</h3>

const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
});

Expand All @@ -3768,6 +3830,9 @@ <h3>Meet Your Build Crew!</h3>

if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'Server error' }));
if (err.requiresUserApiKey) {
setStoredApiKey('');
}
addSystemMessage(err.error || 'Oops, something went wrong. Try again!');
console.error('API error:', err);
state.isWaiting = false;
Expand Down Expand Up @@ -4317,6 +4382,8 @@ <h3>Meet Your Build Crew!</h3>
const r = await fetch('/api/session-info');
if (r.ok) {
const info = await r.json();
state.requiresUserApiKey = Boolean(info.requiresUserApiKey);
if (info.apiKeyHeader) state.apiKeyHeader = String(info.apiKeyHeader);
if (info.mode) {
updateModeHint(info.mode, info.modeReason || '');
} else {
Expand Down Expand Up @@ -4702,14 +4769,21 @@ <h2>What You Built</h2>
`;

// Fetch AI-generated questions, fall back to hardcoded
fetch('/api/trivia?topic=' + encodeURIComponent(topic))
.then(r => r.json())
fetch('/api/trivia?topic=' + encodeURIComponent(topic), { headers: authHeaders() })
.then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw data;
return data;
})
.then(data => {
const questions = (data.questions && data.questions.length >= 3) ? data.questions : fallbackQuestions;
const displayTopic = data.topic || topic;
startTriviaGame(questions, displayTopic);
})
.catch(() => {
.catch((err) => {
if (err && err.requiresUserApiKey) {
addSystemMessage('Parent Anthropic API key required for AI trivia. Using local trivia set for now.');
}
startTriviaGame(fallbackQuestions, 'Capybara Mix');
});

Expand Down Expand Up @@ -5246,34 +5320,45 @@ <h2>What You Built</h2>
await activateApp(data.id);
enterAppMode();
switchTab('chat');
if (!(await ensureParentApiKeyIfRequired())) {
addSystemMessage('Parent Anthropic API key is required before full AI mode can start.');
return;
}
// Greet
addSystemMessage(quickStart ? 'Quick start enabled. Waking up Bart...' : 'Waking up Bart...');
showTyping();
const firstMsg = { role: 'user', content: "Hey Bart! I'm here to tell you about my app idea!" };
state.messages.push(firstMsg);
const chatResp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ messages: state.messages })
});
hideTyping();
if (chatResp.ok) {
const chatData = await chatResp.json();
if (chatData.mode) updateModeHint(chatData.mode, chatData.modeReason || '');
if (chatData.fallbackUsed && !state.fallbackNotified) {
addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.');
state.fallbackNotified = true;
}
refreshParentGuide();
state.messages.push({ role: 'assistant', content: chatData.response });
const sysMsg = document.querySelector('#chat .message.system');
if (sysMsg) sysMsg.remove();
addMessage('ai', chatData.response);
await speak(chatData.response);
if (state.recognition) {
state.micActive = true;
startListening();
if (!chatResp.ok) {
const err = await chatResp.json().catch(() => ({ error: 'Server error' }));
if (err.requiresUserApiKey) {
setStoredApiKey('');
}
state.messages.pop();
addSystemMessage(err.error || 'Could not start chat yet. Please try again.');
return;
}
const chatData = await chatResp.json();
if (chatData.mode) updateModeHint(chatData.mode, chatData.modeReason || '');
if (chatData.fallbackUsed && !state.fallbackNotified) {
addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.');
state.fallbackNotified = true;
}
refreshParentGuide();
state.messages.push({ role: 'assistant', content: chatData.response });
const sysMsg = document.querySelector('#chat .message.system');
if (sysMsg) sysMsg.remove();
addMessage('ai', chatData.response);
await speak(chatData.response);
if (state.recognition) {
state.micActive = true;
startListening();
}
} catch(e) {
console.error('Failed to create app:', e);
Expand Down Expand Up @@ -5609,28 +5694,40 @@ <h2>What You Built</h2>

// Helper to send a message programmatically
async function sendMessageAuto(text) {
if (!(await ensureParentApiKeyIfRequired())) {
addSystemMessage('Parent Anthropic API key is required before full AI mode can start.');
return;
}
state.isWaiting = true;
showTyping();
setBartMood('thinking');
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ messages: state.messages })
});
hideTyping();
if (resp.ok) {
const data = await resp.json();
if (data.mode) updateModeHint(data.mode, data.modeReason || '');
if (data.fallbackUsed && !state.fallbackNotified) {
addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.');
state.fallbackNotified = true;
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'Server error' }));
if (err.requiresUserApiKey) {
setStoredApiKey('');
}
refreshParentGuide();
state.messages.push({ role: 'assistant', content: data.response });
addMessage('ai', data.response);
await speak(data.response);
addSystemMessage(err.error || 'Oops, something went wrong. Try again!');
state.isWaiting = false;
setBartMood('idle');
return;
}
const data = await resp.json();
if (data.mode) updateModeHint(data.mode, data.modeReason || '');
if (data.fallbackUsed && !state.fallbackNotified) {
addSystemMessage('Claude is unavailable right now, so Bart switched to local practice mode.');
state.fallbackNotified = true;
}
refreshParentGuide();
state.messages.push({ role: 'assistant', content: data.response });
addMessage('ai', data.response);
await speak(data.response);
} catch(e) { hideTyping(); }
state.isWaiting = false;
setBartMood('idle');
Expand Down
Loading