HumanHours Demo Account — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. This plan has production side-effects and human-in-the-loop checkpoints; it is NOT suitable for autonomous subagent execution. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Seed a believable, current-state demo account (ralf+demo@triad.nl) in HumanHours production so Ralf can show Michael Burian (NDI) how the product works today, with zero app/schema changes.
Architecture: Use the app's real flows wherever possible. The only privileged DB writes are (1) creating a confirmed auth user via the existing create-test-user.ts script and (2) one UPDATE organizations SET plan='pro'. The org, owner membership, and API key are produced by the live app's own flows. ~90 days of events are POSTed through the public /api/v1/track ingest API exactly like a real integration; the DB's generated columns compute savings automatically.
Tech Stack: Supabase (Postgres, EU project esekswemujghuuagiwwt), Next.js 16 app at humanhours.dev, @agent-metrics/core, pnpm + tsx scripts, Supabase CLI, Vercel CLI.
Sanctioned-exception note
The "never seed demo data into prod" rule (feedback_no_demo_data_in_prod) was explicitly overridden by Ralf on 2026-05-16 for this single +demo account, after the conflict and the rule's verbatim strength were surfaced. Spec: docs/superpowers/specs/2026-05-16-humanhours-demo-account-design.md.
Confirmed facts (from codebase investigation)
- Auth user:
packages/db/scripts/create-test-user.ts <email> <password>→auth.admin.createUser({ email_confirm: true }), no email sent, idempotent. NeedsNEXT_PUBLIC_SUPABASE_URL+SUPABASE_SERVICE_ROLE_KEY. - Org + owner membership: created by the app via
ensureOrgForUser()on first login (apps/web/lib/orgs.ts:15-59, called fromlogin/actions.ts:77). Defaultplan='free',default_hourly_rate=45.00,default_currency='EUR'. - Plan bump:
organizations.planCHECK allows'free'|'pro'|'agency'|'enterprise'(migration 0031). No Stripe columns required. - API key: created in the live dashboard at
/api-keys(apps/web/app/(app)/api-keys/actions.ts:50-77). Raw formathh_live_<8prefix>_<48secret>. Production hashes with its ownAPI_KEY_PEPPER— so creating it in the live UI avoids any secret extraction. - Ingest:
POST https://humanhours.dev/api/v1/track, headerAuthorization: Bearer hh_live_..., optionalIdempotency-Key. Body schema (packages/types/src/index.ts:41-69,.strict()): requiredagent_id(slug^[a-z0-9_-], 2-64),task_type(key^[a-z0-9_], 2-64),outcome(success|failure|needs_review); optionalhuman_baseline_minutes,agent_duration_seconds,agent_cost,metadata,occurred_at(ISO8601 +offset),audit_sample. - Hard window:
occurred_atmust be>= now-90dand<= now+24h(route.ts:42-64). Older events are rejected. - Savings:
events.hours_saved,cost_saved,net_savedare Postgres generated-stored columns computed at insert (0020:46-56). Dashboard readseventsdirectly (apps/web/lib/dashboard.ts:62-66). No post-seed rollup action required. - Baseline authenticity: if
human_baseline_minutesis omitted, the API resolves it from the seeded builtin task_types (resolved_baseline_source='builtin') — showcases the benchmark library. Builtin support task types and baseline minutes:email_classification4.0,first_line_support_response9.0,ticket_routing2.5,knowledge_article_lookup5.0,refund_eligibility_check6.0,status_update_outbound3.0,sentiment_tagging1.5,escalation_decision4.0 (migrations 0018/0022,is_builtin=true,org_id=null). - Quota: Free=500 events/mo (excess silently dropped); Pro=10,000/mo. Plan must be
probefore seeding.
File structure
- Create:
packages/db/scripts/seed-demo-events.ts— standalone operational backfill script (consistent with existingpackages/db/scripts/*likee2e-track.ts; this is NOT app code and changes no app behavior/schema). One responsibility: generate + POST a realistic 90-day event stream for one agent to the public ingest API. - No other files created or modified. No changes under
apps/web.
Open decision for the operator at handoff: commit
seed-demo-events.tsto the repo (matchesscripts/convention) or keep it uncommitted/throwaway. Default in this plan: commit it on the existingdemo/humanhours-demo-accountbranch, do not push.
Task 1: Obtain Supabase write access
Files: none (environment setup)
- Step 1: Link the Supabase CLI to the prod project
Run:
cd /Users/ralf/projects/agent-metrics && supabase link --project-ref esekswemujghuuagiwwt
Expected: "Finished supabase link." (CLI is already authenticated as the project owner.)
- Step 2: Pull env from the linked Vercel project
Run:
cd /Users/ralf/projects/agent-metrics && vercel env pull .env.demo.local --environment=production
Expected: .env.demo.local written.
- Step 3: Verify the two secrets we actually need are non-empty
Run:
cd /Users/ralf/projects/agent-metrics && grep -E '^(NEXT_PUBLIC_SUPABASE_URL|SUPABASE_SERVICE_ROLE_KEY)=' .env.demo.local | sed 's/=.*/=<set>/'
Expected: both lines present. If SUPABASE_SERVICE_ROLE_KEY is empty (it is a Sensitive var — see memory feedback_vercel_sensitive_env_pull, vercel env pull returns "" for these): CHECKPOINT — ask Ralf to copy SUPABASE_SERVICE_ROLE_KEY and the project URL from the Supabase dashboard (Project Settings → API) into .env.demo.local. Do not guess or proceed without it.
- Step 4: Confirm
.env.demo.localis gitignored
Run:
cd /Users/ralf/projects/agent-metrics && git check-ignore .env.demo.local && echo IGNORED || echo "NOT IGNORED - add to .gitignore"
Expected: IGNORED. If not, add .env.demo.local to .gitignore (this is config, not app code) before any commit.
Task 2: Create the confirmed demo auth user
Files: none (uses existing packages/db/scripts/create-test-user.ts)
- Step 1: Choose a strong password and create the user
Run (replace the password with a freshly generated one; record it for Ralf):
cd /Users/ralf/projects/agent-metrics && set -a && . ./.env.demo.local && set +a && \
pnpm exec tsx packages/db/scripts/create-test-user.ts ralf+demo@triad.nl "$(openssl rand -base64 18)"
Expected: script prints the created/updated user and its UUID, no email is sent (email_confirm: true). Capture both the password used and the printed user UUID — hand the password to Ralf at the final checkpoint.
Note: the script is idempotent; re-running resets the password.
- Step 2: Verify the user exists and is confirmed
Run:
cd /Users/ralf/projects/agent-metrics && set -a && . ./.env.demo.local && set +a && \
pnpm exec node -e "const{createClient}=require('@supabase/supabase-js');const a=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY);a.auth.admin.listUsers({perPage:1000}).then(r=>{const u=r.data.users.find(x=>x.email==='ralf+demo@triad.nl');console.log(u?{'id':u.id,'confirmed':!!u.email_confirmed_at}:'NOT FOUND')})"
Expected: { id: '<uuid>', confirmed: true }.
Task 3: Provision org + owner membership via the real app flow (CHECKPOINT)
Files: none (the live app creates these rows)
- Step 1: CHECKPOINT — Ralf logs into the live app once
Ask Ralf to sign in at https://humanhours.dev with ralf+demo@triad.nl and the password from Task 2. This triggers ensureOrgForUser() which creates the organizations row + org_members owner row through the app's own code path. Wait for Ralf to confirm he reached the dashboard.
- Step 2: Read back the created org id
Run:
cd /Users/ralf/projects/agent-metrics && set -a && . ./.env.demo.local && set +a && \
pnpm exec node -e "const{createClient}=require('@supabase/supabase-js');const a=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY);(async()=>{const u=(await a.auth.admin.listUsers({perPage:1000})).data.users.find(x=>x.email==='ralf+demo@triad.nl');const m=(await a.from('org_members').select('org_id,role').eq('user_id',u.id)).data;const o=(await a.from('organizations').select('id,name,slug,plan').in('id',m.map(x=>x.org_id))).data;console.log(JSON.stringify({membership:m,orgs:o},null,2))})()"
Expected: exactly one membership with role: 'owner' and one organization, plan: 'free'. Record the org_id. If more than one org exists, stop and reconcile before continuing (we must seed into exactly one).
Task 4: Set the demo org to the Pro plan
Files: none (single field update on existing row)
- Step 1: Update plan='pro' (no Stripe fields touched)
Run (substitute the recorded <ORG_ID>):
cd /Users/ralf/projects/agent-metrics && set -a && . ./.env.demo.local && set +a && \
pnpm exec node -e "const{createClient}=require('@supabase/supabase-js');const a=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY);a.from('organizations').update({plan:'pro'}).eq('id','<ORG_ID>').select('id,plan,name').single().then(r=>console.log(r.data||r.error))"
Expected: { id: '<ORG_ID>', plan: 'pro', name: '...' }. No stripe_* or subscription_* columns are written.
- Step 2: (Optional) Give the org a clearly-demo display name
Only if Ralf wants it relabeled. Run (substitute name):
cd /Users/ralf/projects/agent-metrics && set -a && . ./.env.demo.local && set +a && \
pnpm exec node -e "const{createClient}=require('@supabase/supabase-js');const a=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY);a.from('organizations').update({name:'Acme Front Office (Demo)'}).eq('id','<ORG_ID>').select('id,name').single().then(r=>console.log(r.data||r.error))"
Expected: updated name echoed.
Task 5: Create the ingest API key via the live dashboard (CHECKPOINT)
Files: none (created through the real app UI; avoids needing the prod API_KEY_PEPPER)
- Step 1: CHECKPOINT — Ralf creates a key in the UI
Ask Ralf, still signed in as ralf+demo@triad.nl on https://humanhours.dev, to go to API keys → Create key, name it e.g. demo-seed, and paste the full hh_live_..._... raw key back. It is shown only once.
- Step 2: Smoke-test the key with one real event
Run (substitute <RAW_KEY>; this posts ONE event ~2 days ago):
ISO=$(node -e "console.log(new Date(Date.now()-2*864e5).toISOString())") && \
curl -sS -X POST https://humanhours.dev/api/v1/track \
-H "Authorization: Bearer <RAW_KEY>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: demo-smoke-1" \
-d "{\"agent_id\":\"frontdesk-bot\",\"task_type\":\"first_line_support_response\",\"outcome\":\"success\",\"agent_duration_seconds\":18,\"agent_cost\":0.012,\"occurred_at\":\"$ISO\"}" | python3 -m json.tool
Expected: HTTP 201 JSON containing "resolved_baseline_source": "builtin", "resolved_baseline_minutes": 9, and a non-zero "cost_saved". If you get missing_authorization/invalid_api_key, the key is wrong — re-do Step 1. This single event is intentionally kept (idempotency key demo-smoke-1) so it is not duplicated by the bulk run.
Task 6: Write the 90-day backfill script
Files:
-
Create:
packages/db/scripts/seed-demo-events.ts -
Step 1: Create the script with full content
Create packages/db/scripts/seed-demo-events.ts:
/**
* One-off demo backfill: posts ~90 days of realistic front-office chatbot
* events for a single agent to the public ingest API. No app/DB changes.
*
* Usage:
* API_KEY=hh_live_... pnpm exec tsx packages/db/scripts/seed-demo-events.ts [--dry-run]
*
* Env:
* API_KEY (required) raw hh_live_ key for the demo org
* BASE_URL (optional) default https://humanhours.dev
* AGENT_ID (optional) default frontdesk-bot
* SEED (optional) default 42 (deterministic volumes)
*/
const BASE_URL = process.env.BASE_URL ?? "https://humanhours.dev";
const API_KEY = process.env.API_KEY ?? "";
const AGENT_ID = process.env.AGENT_ID ?? "frontdesk-bot";
const DRY_RUN = process.argv.includes("--dry-run");
let seed = Number(process.env.SEED ?? 42);
if (!API_KEY) {
console.error("API_KEY env var required (raw hh_live_ key).");
process.exit(1);
}
// Deterministic PRNG (mulberry32) so volumes are reproducible.
function rand(): number {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(rand() * arr.length)]!;
}
function randint(lo: number, hi: number): number {
return lo + Math.floor(rand() * (hi - lo + 1));
}
// Builtin support task types (org_id=null, is_builtin=true). We deliberately
// omit human_baseline_minutes so the API resolves it from the builtin library
// (resolved_baseline_source='builtin') — showcases the benchmark moat.
const TASK_TYPES = [
"first_line_support_response",
"email_classification",
"ticket_routing",
"knowledge_article_lookup",
"refund_eligibility_check",
"status_update_outbound",
"sentiment_tagging",
"escalation_decision",
] as const;
const CHANNELS = ["whatsapp", "web_chat", "email", "voice_ivr"] as const;
const MODELS = ["gpt-4o-mini", "gpt-4o", "claude-haiku"] as const;
type Ev = {
agent_id: string;
task_type: string;
outcome: "success" | "failure" | "needs_review";
agent_duration_seconds: number;
agent_cost: number;
metadata: Record<string, unknown>;
occurred_at: string;
};
function buildEvents(): Ev[] {
const events: Ev[] = [];
const now = Date.now();
const DAY = 864e5;
// 89 days back to be safely inside the API's >= now-90d window.
for (let d = 89; d >= 0; d--) {
const date = new Date(now - d * DAY);
const dow = date.getUTCDay(); // 0 Sun .. 6 Sat
const weekend = dow === 0 || dow === 6;
// Adoption ramp: ~45/day at start growing to ~110/day, weekends ~35% volume.
const ramp = 45 + Math.round((89 - d) * 0.75);
let count = randint(ramp - 12, ramp + 12);
if (weekend) count = Math.round(count * 0.35);
for (let i = 0; i < count; i++) {
// Working-hours-weighted timestamp (07:00-20:00 UTC heavier).
const hour = rand() < 0.82 ? randint(7, 20) : randint(0, 23);
const ts = new Date(date);
ts.setUTCHours(hour, randint(0, 59), randint(0, 59), 0);
// Clamp strictly inside (now-90d, now).
const tms = Math.min(Math.max(ts.getTime(), now - 89.5 * DAY), now - 60_000);
const roll = rand();
const outcome: Ev["outcome"] =
roll < 0.86 ? "success" : roll < 0.96 ? "needs_review" : "failure";
events.push({
agent_id: AGENT_ID,
task_type: pick(TASK_TYPES),
outcome,
agent_duration_seconds: randint(4, 38),
agent_cost: Math.round((0.004 + rand() * 0.05) * 1e4) / 1e4,
metadata: { channel: pick(CHANNELS), model: pick(MODELS) },
occurred_at: new Date(tms).toISOString(),
});
}
}
return events;
}
async function postOne(ev: Ev, idem: string): Promise<number> {
const res = await fetch(`${BASE_URL}/api/v1/track`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idem,
},
body: JSON.stringify(ev),
});
if (res.status !== 201 && res.status !== 200) {
const body = await res.text();
console.error(` ! ${res.status} ${body.slice(0, 200)}`);
}
return res.status;
}
async function main() {
const events = buildEvents();
console.log(`Built ${events.length} events over 90 days for agent "${AGENT_ID}" → ${BASE_URL}`);
if (DRY_RUN) {
console.log("DRY RUN — sample:", JSON.stringify(events.slice(0, 3), null, 2));
const byMonth: Record<string, number> = {};
for (const e of events) {
const k = e.occurred_at.slice(0, 7);
byMonth[k] = (byMonth[k] ?? 0) + 1;
}
console.log("per month:", byMonth, "(Pro cap = 10000/mo)");
return;
}
const CONC = 8;
let ok = 0;
let fail = 0;
for (let i = 0; i < events.length; i += CONC) {
const slice = events.slice(i, i + CONC);
const codes = await Promise.all(slice.map((e, j) => postOne(e, `demo-seed-${i + j}`)));
for (const c of codes) c === 201 || c === 200 ? ok++ : fail++;
if ((i / CONC) % 25 === 0) console.log(` ${ok + fail}/${events.length} ...`);
}
console.log(`Done. ok=${ok} fail=${fail}`);
if (fail > 0) process.exitCode = 1;
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
- Step 2: Type/lint check the script
Run:
cd /Users/ralf/projects/agent-metrics && pnpm exec tsc --noEmit -p packages/db 2>/dev/null || pnpm exec tsx --check packages/db/scripts/seed-demo-events.ts && echo "script parses"
Expected: no type errors for the new file (or script parses).
- Step 3: Commit the script (local only, no push)
Run:
cd /Users/ralf/projects/agent-metrics && git add packages/db/scripts/seed-demo-events.ts && \
git commit -q -m "chore: one-off demo backfill script for HumanHours prospect demo
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" && git log --oneline -1
Expected: commit created on branch demo/humanhours-demo-account.
Task 7: Dry-run, then seed, then verify
Files: none
- Step 1: Dry-run to sanity-check volumes vs the Pro quota
Run:
cd /Users/ralf/projects/agent-metrics && API_KEY="<RAW_KEY>" pnpm exec tsx packages/db/scripts/seed-demo-events.ts --dry-run
Expected: total ~6,000-7,500 events; each month's count well under 10,000 (Pro cap). If any month exceeds ~9,000, lower the ramp constants and re-run the dry-run.
- Step 2: Run the real backfill
Run:
cd /Users/ralf/projects/agent-metrics && API_KEY="<RAW_KEY>" pnpm exec tsx packages/db/scripts/seed-demo-events.ts
Expected: Done. ok=<N> fail=0. Investigate any non-zero fail (printed status + body) before proceeding.
- Step 3: Verify the data landed and savings computed
Run (substitute <ORG_ID>):
cd /Users/ralf/projects/agent-metrics && set -a && . ./.env.demo.local && set +a && \
pnpm exec node -e "const{createClient}=require('@supabase/supabase-js');const a=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY);(async()=>{const{count}=await a.from('events').select('*',{count:'exact',head:true}).eq('org_id','<ORG_ID>');const{data}=await a.from('events').select('hours_saved,cost_saved,net_saved,resolved_baseline_source').eq('org_id','<ORG_ID>').limit(1000);const sum=data.reduce((s,e)=>({h:s.h+Number(e.hours_saved),c:s.c+Number(e.cost_saved)}),{h:0,c:0});console.log({count,sample_baseline_src:data[0]?.resolved_baseline_source,first1000_hours:Math.round(sum.h),first1000_cost:Math.round(sum.c)})})()"
Expected: count ≈ the script's ok, sample_baseline_src: 'builtin', and clearly non-zero first1000_hours / first1000_cost.
- Step 4: CHECKPOINT — Ralf eyeballs the live dashboard
Ask Ralf to refresh https://humanhours.dev (signed in as the demo user) and confirm the dashboard shows ~90 days of hours/money saved for frontdesk-bot that look credible. If numbers look off (too round, too huge, too flat), adjust the script constants in Task 6 and re-run Task 7 (idempotency keys demo-seed-* make re-runs safe — duplicates are deduped per org).
Task 8: Hand off + record teardown
Files: none
- Step 1: Give Ralf the access details
Provide: demo email ralf+demo@triad.nl, the generated password (from Task 2), the org id, and a reminder that he invites Michael as viewer himself via the in-app invite UI (out of our scope per the spec).
- Step 2: Document the one-command teardown
Record (do NOT run unless asked) — full removal is a single cascade delete plus the auth user:
# Substitute <ORG_ID> and <USER_ID>. organizations FKs cascade to
# org_members, api_keys, agents, events, audit_samples.
pnpm exec node -e "const{createClient}=require('@supabase/supabase-js');const a=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY);(async()=>{await a.from('organizations').delete().eq('id','<ORG_ID>');await a.auth.admin.deleteUser('<USER_ID>');console.log('demo torn down')})()"
- Step 3: Clean up local secrets
Run:
cd /Users/ralf/projects/agent-metrics && rm -f .env.demo.local && echo "local prod secrets removed"
Self-Review
1. Spec coverage (spec: 2026-05-16-humanhours-demo-account-design.md):
- Auth user
ralf+demo@triad.nlno confirmation mail → Task 2. ✓ - Org
pro, no Stripe → Task 3 (app creates) + Task 4 (plan bump, no stripe cols). ✓ - Owner membership → Task 3 (app
ensureOrgForUser). ✓ - One API key via existing mechanism (not hand-hacked hash) → Task 5 (live UI). ✓
- One front-office chatbot agent → auto-created by first event (
agent_id=frontdesk-bot), Task 5/7. ✓ - ~90 days events over builtin task types via real ingest API; app rolls up savings → Task 6/7; generated columns, no rollup step. ✓
- No
org_invitesfrom our side; Ralf invites Michael manually → Task 8 Step 1. ✓ - No app/schema/feature changes → only file is an operational
scripts/backfill; noapps/webedits. ✓ - Teardown via single
org_idcascade → Task 8 Step 2. ✓ - Out of scope (Stripe, trust layer, agency tree, multi-agent) → none present. ✓
2. Placeholder scan: <ORG_ID>, <USER_ID>, <RAW_KEY> are runtime values produced by earlier tasks, not unspecified design gaps — each has an explicit producing step. The seed script is given in full. No "TODO/handle errors/similar to" placeholders. ✓
3. Consistency: agent_id is frontdesk-bot in Task 5 smoke test and the script default. Task types match the verified builtin keys/baselines. Idempotency-Key namespace demo-smoke-1 vs demo-seed-<n> deliberately disjoint so the smoke event is not double-counted and bulk re-runs dedupe. The 89-day loop with a now-89.5d clamp stays strictly inside the API's now-90d rejection boundary. ✓
Known risk surfaced (not a gap): vercel env pull may return empty SUPABASE_SERVICE_ROLE_KEY (Sensitive var) — Task 1 Step 3 is an explicit checkpoint to get it from the Supabase dashboard rather than silently failing.