DOCS / SUPERPOWERS / PLANS / 2026 05 16 HUMANHOURS DEMO ACCOUNT
VIEW RAW

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. Needs NEXT_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 from login/actions.ts:77). Default plan='free', default_hourly_rate=45.00, default_currency='EUR'.
  • Plan bump: organizations.plan CHECK 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 format hh_live_<8prefix>_<48secret>. Production hashes with its own API_KEY_PEPPER — so creating it in the live UI avoids any secret extraction.
  • Ingest: POST https://humanhours.dev/api/v1/track, header Authorization: Bearer hh_live_..., optional Idempotency-Key. Body schema (packages/types/src/index.ts:41-69, .strict()): required agent_id (slug ^[a-z0-9_-], 2-64), task_type (key ^[a-z0-9_], 2-64), outcome (success|failure|needs_review); optional human_baseline_minutes, agent_duration_seconds, agent_cost, metadata, occurred_at (ISO8601 +offset), audit_sample.
  • Hard window: occurred_at must be >= now-90d and <= now+24h (route.ts:42-64). Older events are rejected.
  • Savings: events.hours_saved, cost_saved, net_saved are Postgres generated-stored columns computed at insert (0020:46-56). Dashboard reads events directly (apps/web/lib/dashboard.ts:62-66). No post-seed rollup action required.
  • Baseline authenticity: if human_baseline_minutes is 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_classification 4.0, first_line_support_response 9.0, ticket_routing 2.5, knowledge_article_lookup 5.0, refund_eligibility_check 6.0, status_update_outbound 3.0, sentiment_tagging 1.5, escalation_decision 4.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 pro before seeding.

File structure

  • Create: packages/db/scripts/seed-demo-events.ts — standalone operational backfill script (consistent with existing packages/db/scripts/* like e2e-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.ts to the repo (matches scripts/ convention) or keep it uncommitted/throwaway. Default in this plan: commit it on the existing demo/humanhours-demo-account branch, 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.local is 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.nl no 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_invites from our side; Ralf invites Michael manually → Task 8 Step 1. ✓
  • No app/schema/feature changes → only file is an operational scripts/ backfill; no apps/web edits. ✓
  • Teardown via single org_id cascade → 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.


Found a typo or want to suggest an edit? Email support@triadagency.ai.