Skip to content

Build Your First Full App with Habits

This guide walks you through building a complete AI-powered application, the Resume Analyzer, from understanding the workflows to packaging for distribution across all platforms: Android, iOS, macOS, Windows, and Linux.

Complete Example

This tutorial uses the Resume Analyzer showcase, a production-ready app with multiple AI workflows, a mobile-first frontend, and local database storage.

Environment Setup Checklist

Install Node Version Manager (nvm)

  • [ ] macOS/Linux: Install nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
  • [ ] Windows: Use nvm-windows or download Node.js directly from nodejs.org
  • [ ] Restart terminal or run: source ~/.bashrc (or ~/.zshrc)
  • [ ] Verify nvm: nvm --version

Install Node.js 24

  • [ ] Install Node.js 24: nvm install 24
  • [ ] Set as default: nvm alias default 24
  • [ ] Verify Node.js: node --version (should show v24.x.x)

What You'll Build

The Resume Analyzer demonstrates building a multi-habit application with:

  • 4 AI-powered workflows — Analyze resumes, generate cover letters, list history, retrieve saved analyses
  • OpenAI Vision integration — Extract text from resume images
  • Local database storage — PouchDB for storing analyses without external dependencies
  • Mobile-first frontend — Responsive UI ready for packaging as native app

Workflow Visualization

Explore the 4 habits that power the Resume Analyzer:

Source Files

yaml
version: "1.0"

workflows:
  - id: analyze-resume
    path: ./habits/analyze-resume.yaml
    enabled: true

  - id: generate-cover-letter
    path: ./habits/generate-cover-letter.yaml
    enabled: true

  - id: list-analyses
    path: ./habits/list-analyses.yaml
    enabled: true

  - id: get-analysis
    path: ./habits/get-analysis.yaml
    enabled: true

server:
  port: 13000
  host: "0.0.0.0"
  frontend: ./frontend
  openapi: true

logging:
  level: info
  outputs: [console]
  format: text
  colorize: true
yaml
id: analyze-resume
name: Analyze Resume
description: Analyze resume image against job description and provide improvement suggestions

input:
  - id: resumeImage
    type: string
    required: true
    description: Resume image content (base64 encoded PNG/JPG/WebP)
  - id: jobDescription
    type: string
    required: false
    description: Target job description
  - id: targetRole
    type: string
    required: false
    description: Target job title/role

nodes:
  # Extract text from image using OpenAI Vision
  - id: extract-text
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: vision_prompt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        image: "{{habits.input.resumeImage}}"
        temperature: 0.1
        maxTokens: 4000
        prompt: |
          Extract all text content from this resume image.
          
          Return ONLY the extracted text content, preserving the structure and formatting as plain text.
          Include all sections: contact info, summary, experience, education, skills, certifications.

  # Parse and structure resume
  - id: parse-resume
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.3
        maxTokens: 1000
        prompt: |
          Parse this resume and extract structured information:
          
          {{extract-text}}
          
          Return JSON:
          {
            "name": "Full name",
            "email": "Email",
            "phone": "Phone",
            "location": "Location",
            "summary": "Professional summary",
            "experience": [{"title": "", "company": "", "dates": "", "bullets": []}],
            "education": [{"degree": "", "school": "", "year": ""}],
            "skills": ["skill1", "skill2"],
            "certifications": [],
            "yearsExperience": number
          }

  # Score ATS compatibility
  - id: ats-score
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.4
        maxTokens: 600
        prompt: |
          Analyze this resume for ATS (Applicant Tracking System) compatibility:
          
          Resume: {{extract-text}}
          Target Job: {{habits.input.jobDescription}}
          
          Evaluate:
          1. Keyword optimization (does it include relevant keywords?)
          2. Format compatibility (is it ATS-friendly?)
          3. Contact info completeness
          4. Section structure
          5. Quantifiable achievements
          
          Return JSON:
          {
            "overallScore": number (0-100),
            "keywordScore": number (0-100),
            "formatScore": number (0-100),
            "contentScore": number (0-100),
            "missingKeywords": ["keyword1", "keyword2"],
            "formatIssues": ["issue1", "issue2"],
            "suggestions": ["suggestion1", "suggestion2"]
          }

  # Analyze experience quality
  - id: experience-analysis
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.5
        maxTokens: 800
        prompt: |
          Analyze the work experience section of this resume:
          
          {{extract-text}}
          
          For each position, evaluate:
          1. Action verbs usage
          2. Quantifiable results (numbers, percentages)
          3. Achievement vs. responsibility focus
          4. Relevance to target role: {{habits.input.targetRole}}
          
          Provide:
          - Strengths of each position description
          - Weaknesses and areas to improve
          - Specific rewrite suggestions for 2-3 weak bullets
          - Example of how to add metrics where missing

  # Job match analysis
  - id: job-match
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.4
        maxTokens: 700
        prompt: |
          Compare this resume to the job requirements:
          
          Resume: {{extract-text}}
          
          Job Description: {{habits.input.jobDescription}}
          
          Target Role: {{habits.input.targetRole}}
          
          Return JSON:
          {
            "matchScore": number (0-100),
            "matchingSkills": ["skill1", "skill2"],
            "missingSkills": ["skill1", "skill2"],
            "experienceGaps": ["gap1", "gap2"],
            "strengthsForRole": ["strength1", "strength2"],
            "recommendations": ["rec1", "rec2"],
            "likelyOutcome": "Strong match / Moderate match / Weak match"
          }

  # Generate improved summary
  - id: generate-summary
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.7
        maxTokens: 300
        roles:
          - role: system
            content: You are an expert resume writer who crafts compelling professional summaries.
        prompt: |
          Write an improved professional summary for this resume:
          
          Current Resume: {{extract-text}}
          
          Target Role: {{habits.input.targetRole}}
          
          Create a 3-4 sentence summary that:
          - Opens with years of experience and specialty
          - Highlights 2-3 key achievements
          - Includes relevant skills for the target role
          - Shows unique value proposition

  # Interview questions to prepare
  - id: interview-prep
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.6
        maxTokens: 600
        prompt: |
          Based on this resume and job, generate interview preparation:
          
          Resume: {{extract-text}}
          Job: {{habits.input.jobDescription}}
          Role: {{habits.input.targetRole}}
          
          Provide:
          1. 5 likely interview questions based on resume gaps
          2. 3 behavioral questions (STAR format prep)
          3. 3 technical questions based on listed skills
          4. Suggested talking points for strengths
          5. How to address any weaknesses/gaps

  # Save analysis document using PouchDB
  - id: save-analysis
    type: bits
    data:
      framework: bits
      source: local
      module: "@ha-bits/bit-pouch"
      operation: insert
      params:
        collection: "resume_analyses"
        document:
          targetRole: "{{habits.input.targetRole}}"
          extractedText: "{{extract-text}}"
          parsed: "{{parse-resume}}"
          atsScore: "{{ats-score}}"
          experienceAnalysis: "{{experience-analysis}}"
          jobMatch: "{{job-match}}"
          improvedSummary: "{{generate-summary}}"
          interviewPrep: "{{interview-prep}}"
          createdAt: "{{habits.now}}"

  # Save image as attachment to the analysis document
  - id: save-attachment
    type: bits
    data:
      framework: bits
      source: local
      module: "@ha-bits/bit-pouch"
      operation: putAttachment
      params:
        collection: "resume_analyses"
        documentId: "{{save-analysis.id}}"
        attachmentName: "resume.webp"
        attachmentData: "{{habits.input.resumeImage}}"
        contentType: "image/webp"

edges:
  - source: extract-text
    target: parse-resume
  - source: parse-resume
    target: ats-score
  - source: parse-resume
    target: experience-analysis
  - source: parse-resume
    target: job-match
  - source: parse-resume
    target: generate-summary
  - source: job-match
    target: interview-prep
  - source: ats-score
    target: save-analysis
  - source: experience-analysis
    target: save-analysis
  - source: job-match
    target: save-analysis
  - source: generate-summary
    target: save-analysis
  - source: interview-prep
    target: save-analysis
  - source: save-analysis
    target: save-attachment

output:
  analysisId: "{{save-analysis.id}}"
  extractedText: "{{extract-text}}"
  parsed: "{{parse-resume}}"
  atsScore: "{{ats-score}}"
  experienceAnalysis: "{{experience-analysis}}"
  jobMatch: "{{job-match}}"
  improvedSummary: "{{generate-summary}}"
  interviewPrep: "{{interview-prep}}"
  attachmentSaved: "{{save-attachment.success}}"
yaml
id: generate-cover-letter
name: Generate Cover Letter
description: Generate a customized cover letter for a specific job

input:
  - id: resumeImage
    type: string
    required: true
    description: Resume image content (base64 encoded PNG/JPG/WebP)
  - id: jobDescription
    type: string
    required: true
    description: Target job description
  - id: companyName
    type: string
    required: true
    description: Company name
  - id: hiringManager
    type: string
    required: false
    description: Hiring manager name if known
  - id: tone
    type: string
    required: false
    description: formal, friendly, enthusiastic

nodes:
  # Extract text from image using OpenAI Vision
  - id: extract-text
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: vision_prompt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        image: "{{habits.input.resumeImage}}"
        temperature: 0.1
        maxTokens: 4000
        prompt: |
          Extract all text content from this resume image.
          
          Return ONLY the extracted text content, preserving the structure and formatting as plain text.

  - id: generate-letter
    type: bits
    data:
      framework: bits
      source: npm
      module: "@ha-bits/bit-openai"
      operation: ask_chatgpt
      credentials:
        openai:
          apiKey: "{{habits.env.OPENAI_API_KEY}}"
      params:
        model: gpt-4o
        temperature: 0.7
        maxTokens: 800
        roles:
          - role: system
            content: You are an expert cover letter writer who crafts compelling, personalized letters that get interviews.
        prompt: |
          Write a cover letter for this application:
          
          Resume:
          {{extract-text}}
          
          Job Description:
          {{habits.input.jobDescription}}
          
          Company: {{habits.input.companyName}}
          Hiring Manager: {{habits.input.hiringManager}}
          Tone: {{habits.input.tone}}
          
          Structure:
          1. Opening hook that shows enthusiasm and mentions the specific role
          2. Paragraph connecting your experience to their needs (use specific examples)
          3. Paragraph showing knowledge of the company and cultural fit
          4. Strong closing with call to action
          
          Keep it to about 300-350 words. Be specific, not generic.

edges:
  - source: extract-text
    target: generate-letter

output:
  coverLetter: "{{generate-letter}}"
  extractedText: "{{extract-text}}"
yaml
id: list-analyses
name: List Analyses
description: Get all resume analyses

input:
  - id: limit
    type: number
    required: false

nodes:
  - id: query-analyses
    type: bits
    data:
      framework: bits
      source: local
      module: "@ha-bits/bit-pouch"
      operation: query
      params:
        collection: "resume_analyses"
        limit: "{{habits.input.limit}}"

edges: []

output:
  analyses: "{{query-analyses.results}}"
  count: "{{query-analyses.count}}"
yaml
id: get-analysis
name: Get Analysis
description: Get a specific resume analysis with optional PDF attachment

input:
  - id: id
    type: string
    required: true
  - id: includeAttachment
    type: boolean
    required: false
    description: Whether to include the PDF attachment

nodes:
  - id: fetch-analysis
    type: bits
    data:
      framework: bits
      source: local
      module: "@ha-bits/bit-pouch"
      operation: query
      params:
        collection: "resume_analyses"
        filter:
          _id: "{{habits.input.id}}"
        limit: 1

  - id: fetch-attachment
    type: bits
    data:
      framework: bits
      source: local
      module: "@ha-bits/bit-pouch"
      operation: getAttachment
      params:
        collection: "resume_analyses"
        documentId: "{{habits.input.id}}"
        attachmentName: "resume.pdf"

edges:
  - source: fetch-analysis
    target: fetch-attachment

output:
  analysis: "{{fetch-analysis.results[0]}}"
  found: "{{fetch-analysis.count > 0}}"
  attachment: "{{fetch-attachment}}"
html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="theme-color" content="#1a1a1a">
    <title>Resume Analyzer</title>
    <style>
        :root {
            --safe-top: env(safe-area-inset-top, 0px);
            --safe-bottom: env(safe-area-inset-bottom, 0px);
            --primary: #3b82f6;
            --primary-dark: #2563eb;
            --success: #10b981;
            --warning: #f59e0b;
            --danger: #ef4444;
            --bg-dark: #1a1a1a;
            --bg-card: #2a2a2a;
            --text-primary: #ffffff;
            --text-secondary: rgba(255,255,255,0.7);
            --text-muted: rgba(255,255,255,0.5);
        }
        
        * { 
            box-sizing: border-box; 
            margin: 0; 
            padding: 0;
            -webkit-tap-highlight-color: transparent;
            -webkit-touch-callout: none;
        }
        
        html, body {
            height: 100%;
            overflow: hidden;
        }
        
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
            background: #1a1a1a;
            color: var(--text-primary);
            font-size: 16px;
            line-height: 1.5;
        }
        
        /* App Container */
        .app {
            display: flex;
            flex-direction: column;
            height: 100%;
            padding-top: var(--safe-top);
            padding-bottom: var(--safe-bottom);
        }
        
        /* App Header */
        .app-header {
            flex-shrink: 0;
            padding: 16px 20px;
            background: #222222;
            border-bottom: 1px solid rgba(255,255,255,0.1);
            position: relative;
            z-index: 100;
        }
        
        .app-header h1 {
            font-size: 1.5rem;
            font-weight: 700;
            text-align: center;
        }
        
        .header-back {
            position: absolute;
            left: 16px;
            top: 50%;
            transform: translateY(-50%);
            background: none;
            border: none;
            color: var(--primary);
            font-size: 1rem;
            padding: 8px;
            cursor: pointer;
            display: none;
        }
        
        .header-back.visible { display: block; }
        
        .header-action {
            position: absolute;
            right: 16px;
            top: 50%;
            transform: translateY(-50%);
            background: none;
            border: none;
            color: var(--primary);
            font-size: 1.2rem;
            padding: 8px;
            cursor: pointer;
            display: none;
        }
        
        .header-action.visible { display: block; }
        
        /* App Content */
        .app-content {
            flex: 1;
            overflow-y: auto;
            overflow-x: hidden;
            -webkit-overflow-scrolling: touch;
            scroll-behavior: smooth;
        }
        
        /* Screens */
        .screen {
            display: none;
            min-height: 100%;
            padding: 20px;
            animation: fadeIn 0.3s ease;
        }
        
        .screen.active { display: block; }
        
        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
        }
        
        @keyframes slideUp {
            from { opacity: 0; transform: translateY(100%); }
            to { opacity: 1; transform: translateY(0); }
        }
        
        /* Cards */
        .card {
            background: var(--bg-card);
            border: 1px solid rgba(255,255,255,0.1);
            border-radius: 20px;
            padding: 24px;
            margin-bottom: 16px;
        }
        
        .card-title {
            font-size: 1.1rem;
            font-weight: 600;
            color: var(--primary);
            margin-bottom: 20px;
        }
        
        /* Form Elements */
        .form-group {
            margin-bottom: 20px;
        }
        
        .form-label {
            display: block;
            font-size: 0.9rem;
            font-weight: 500;
            color: var(--text-secondary);
            margin-bottom: 10px;
        }
        
        .form-input {
            width: 100%;
            padding: 16px;
            background: rgba(255,255,255,0.06);
            border: 2px solid rgba(255,255,255,0.1);
            border-radius: 14px;
            font-size: 1rem;
            color: var(--text-primary);
            font-family: inherit;
            transition: border-color 0.2s;
        }
        
        .form-input:focus {
            outline: none;
            border-color: var(--primary);
        }
        
        .form-input::placeholder { color: var(--text-muted); }
        
        textarea.form-input {
            resize: none;
            min-height: 120px;
        }
        
        /* File Upload */
        .upload-zone {
            border: 2px dashed rgba(255,255,255,0.2);
            border-radius: 20px;
            padding: 40px 20px;
            text-align: center;
            transition: all 0.3s;
            cursor: pointer;
        }
        
        .upload-zone.active {
            border-color: var(--primary);
            background: rgba(59, 130, 246, 0.1);
        }
        
        .upload-zone.has-file {
            border-color: var(--success);
            background: rgba(16, 185, 129, 0.1);
        }
        
        .upload-zone input { display: none; }
        
        .upload-icon {
            font-size: 3.5rem;
            margin-bottom: 12px;
        }
        
        .upload-text {
            font-size: 1rem;
            color: var(--text-secondary);
        }
        
        .upload-filename {
            margin-top: 12px;
            font-weight: 600;
            color: var(--success);
            word-break: break-all;
        }
        
        /* Primary Button */
        .btn-primary {
            width: 100%;
            padding: 18px 24px;
            background: var(--primary);
            border: none;
            border-radius: 16px;
            font-size: 1.1rem;
            font-weight: 600;
            color: white;
            cursor: pointer;
            transition: transform 0.2s, opacity 0.2s;
        }
        
        .btn-primary:active {
            transform: scale(0.98);
        }
        
        .btn-primary:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
        
        /* Score Ring */
        .score-ring {
            display: flex;
            justify-content: center;
            gap: 12px;
            flex-wrap: wrap;
            margin-bottom: 24px;
        }
        
        .score-item {
            text-align: center;
            flex: 1;
            min-width: 70px;
            padding: 16px 8px;
            background: rgba(255,255,255,0.05);
            border-radius: 16px;
        }
        
        .score-value {
            font-size: 1.8rem;
            font-weight: 700;
            color: var(--primary);
        }
        
        .score-value.high { color: var(--success); }
        .score-value.medium { color: var(--warning); }
        .score-value.low { color: var(--danger); }
        
        .score-label {
            font-size: 0.75rem;
            color: var(--text-muted);
            margin-top: 4px;
        }
        
        /* Segment Control (Tabs) */
        .segment-control {
            display: flex;
            background: rgba(255,255,255,0.06);
            border-radius: 12px;
            padding: 4px;
            margin-bottom: 20px;
            overflow-x: auto;
            -webkit-overflow-scrolling: touch;
            scrollbar-width: none;
        }
        
        .segment-control::-webkit-scrollbar { display: none; }
        
        .segment-btn {
            flex: 1;
            min-width: max-content;
            padding: 12px 16px;
            background: transparent;
            border: none;
            border-radius: 10px;
            font-size: 0.85rem;
            font-weight: 500;
            color: var(--text-secondary);
            cursor: pointer;
            transition: all 0.2s;
            white-space: nowrap;
        }
        
        .segment-btn.active {
            background: var(--primary);
            color: white;
        }
        
        /* Tab Content */
        .tab-panel {
            display: none;
            animation: fadeIn 0.3s ease;
        }
        
        .tab-panel.active { display: block; }
        
        /* Content Section */
        .content-section {
            background: rgba(255,255,255,0.04);
            border-radius: 16px;
            padding: 20px;
            margin-bottom: 16px;
        }
        
        .section-title {
            font-size: 1rem;
            font-weight: 600;
            color: var(--primary);
            margin-bottom: 12px;
        }
        
        .section-text {
            font-size: 0.95rem;
            line-height: 1.7;
            color: var(--text-secondary);
            white-space: pre-wrap;
        }
        
        /* Skills Tags */
        .skills-row {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin-bottom: 16px;
        }
        
        .skill-chip {
            padding: 8px 14px;
            border-radius: 20px;
            font-size: 0.85rem;
            font-weight: 500;
        }
        
        .skill-chip.match {
            background: rgba(16, 185, 129, 0.2);
            border: 1px solid var(--success);
            color: var(--success);
        }
        
        .skill-chip.missing {
            background: rgba(239, 68, 68, 0.2);
            border: 1px solid var(--danger);
            color: var(--danger);
        }
        
        /* History List */
        .history-list {
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        
        .history-item {
            display: flex;
            align-items: center;
            padding: 16px;
            background: rgba(255,255,255,0.05);
            border: 1px solid rgba(255,255,255,0.08);
            border-radius: 14px;
            cursor: pointer;
            transition: background 0.2s;
        }
        
        .history-item:active {
            background: rgba(255,255,255,0.1);
        }
        
        .history-icon {
            width: 44px;
            height: 44px;
            background: var(--primary);
            border-radius: 12px;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 1.3rem;
            margin-right: 14px;
            flex-shrink: 0;
        }
        
        .history-info { flex: 1; }
        
        .history-title {
            font-weight: 600;
            margin-bottom: 2px;
        }
        
        .history-meta {
            font-size: 0.85rem;
            color: var(--text-muted);
        }
        
        .history-arrow {
            color: var(--text-muted);
            font-size: 1.2rem;
        }
        
        /* Loading Overlay */
        .loading-overlay {
            position: fixed;
            inset: 0;
            background: rgba(26, 26, 26, 0.95);
            display: none;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            z-index: 1000;
        }
        
        .loading-overlay.active { display: flex; }
        
        .loading-spinner {
            width: 56px;
            height: 56px;
            border: 4px solid rgba(255,255,255,0.1);
            border-top-color: var(--primary);
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin-bottom: 20px;
        }
        
        @keyframes spin { to { transform: rotate(360deg); } }
        
        .loading-text {
            font-size: 1.1rem;
            color: var(--text-secondary);
        }
        
        /* Bottom Nav */
        .bottom-nav {
            flex-shrink: 0;
            display: flex;
            background: #222222;
            border-top: 1px solid rgba(255,255,255,0.1);
            padding: 8px 0;
            padding-bottom: calc(8px + var(--safe-bottom));
        }
        
        .nav-item {
            flex: 1;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 8px;
            background: none;
            border: none;
            color: var(--text-muted);
            font-size: 0.7rem;
            cursor: pointer;
            transition: color 0.2s;
        }
        
        .nav-item.active { color: var(--primary); }
        
        .nav-icon {
            font-size: 1.5rem;
            margin-bottom: 4px;
        }
        
        /* Action Button */
        .action-btn {
            display: inline-flex;
            align-items: center;
            gap: 8px;
            padding: 14px 20px;
            background: rgba(255,255,255,0.08);
            border: 1px solid rgba(255,255,255,0.15);
            border-radius: 12px;
            font-size: 0.95rem;
            color: var(--text-primary);
            cursor: pointer;
            transition: background 0.2s;
        }
        
        .action-btn:active { background: rgba(255,255,255,0.15); }
        
        .action-btn.success {
            background: rgba(16, 185, 129, 0.15);
            border-color: var(--success);
            color: var(--success);
        }
        
        /* Copy Toast */
        .toast {
            position: fixed;
            bottom: 100px;
            left: 50%;
            transform: translateX(-50%) translateY(20px);
            background: rgba(0,0,0,0.9);
            color: white;
            padding: 12px 24px;
            border-radius: 25px;
            font-size: 0.9rem;
            opacity: 0;
            transition: all 0.3s;
            z-index: 1001;
        }
        
        .toast.visible {
            opacity: 1;
            transform: translateX(-50%) translateY(0);
        }
        
        /* Empty State */
        .empty-state {
            text-align: center;
            padding: 40px 20px;
            color: var(--text-muted);
        }
        
        .empty-icon {
            font-size: 3rem;
            margin-bottom: 12px;
            opacity: 0.5;
        }
    </style>
</head>
<body>
    <div class="app">
        <!-- Header -->
        <header class="app-header">
            <button class="header-back" id="headerBack" onclick="goBack()">← Back</button>
            <h1 id="headerTitle">Resume Analyzer</h1>
            <button class="header-action" id="headerAction" onclick="downloadPdf()">📥</button>
        </header>
        
        <!-- Content -->
        <main class="app-content">
            <!-- Home Screen -->
            <div class="screen active" id="homeScreen">
                <div class="card">
                    <div class="card-title">Upload Resume</div>
                    <form id="analyzeForm">
                        <div class="form-group">
                            <div class="upload-zone" id="uploadZone">
                                <input type="file" id="fileInput" accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp">
                                <div class="upload-icon">📄</div>
                                <div class="upload-text">Tap to upload resume image (PNG, JPG, WebP)</div>
                                <div class="upload-filename" id="fileName"></div>
                            </div>
                        </div>
                        
                        <div class="form-group">
                            <label class="form-label">Target Role</label>
                            <input type="text" class="form-input" id="targetRole" placeholder="e.g., Senior Software Engineer">
                        </div>
                        
                        <div class="form-group">
                            <label class="form-label">Job Description (optional)</label>
                            <textarea class="form-input" id="jobDescription" placeholder="Paste the job description for better analysis..."></textarea>
                        </div>
                        
                        <button type="submit" class="btn-primary" id="analyzeBtn">
                            Analyze Resume
                        </button>
                    </form>
                </div>
            </div>
            
            <!-- Results Screen -->
            <div class="screen" id="resultsScreen">
                <div class="score-ring" id="scoreRing"></div>
                
                <div class="segment-control" id="tabControl">
                    <button class="segment-btn active" data-tab="ats">📊 ATS</button>
                    <button class="segment-btn" data-tab="exp">💼 Experience</button>
                    <button class="segment-btn" data-tab="match">🎯 Match</button>
                    <button class="segment-btn" data-tab="summary">✍️ Summary</button>
                    <button class="segment-btn" data-tab="interview">🎤 Interview</button>
                </div>
                
                <div class="tab-panel active" id="atsPanel">
                    <div class="content-section">
                        <div class="section-title">ATS Compatibility</div>
                        <div class="section-text" id="atsContent"></div>
                    </div>
                </div>
                
                <div class="tab-panel" id="expPanel">
                    <div class="content-section">
                        <div class="section-title">Experience Analysis</div>
                        <div class="section-text" id="expContent"></div>
                    </div>
                </div>
                
                <div class="tab-panel" id="matchPanel">
                    <div class="content-section">
                        <div class="section-title">✅ Matching Skills</div>
                        <div class="skills-row" id="matchingSkills"></div>
                        
                        <div class="section-title">❌ Missing Skills</div>
                        <div class="skills-row" id="missingSkills"></div>
                    </div>
                    <div class="content-section">
                        <div class="section-title">Job Match Details</div>
                        <div class="section-text" id="matchContent"></div>
                    </div>
                </div>
                
                <div class="tab-panel" id="summaryPanel">
                    <div class="content-section">
                        <div class="section-title">Improved Summary</div>
                        <div class="section-text" id="summaryContent"></div>
                        <div style="margin-top: 16px;">
                            <button class="action-btn" onclick="copyText('summaryContent')">📋 Copy to Clipboard</button>
                        </div>
                    </div>
                </div>
                
                <div class="tab-panel" id="interviewPanel">
                    <div class="content-section">
                        <div class="section-title">Interview Prep</div>
                        <div class="section-text" id="interviewContent"></div>
                    </div>
                </div>
            </div>
            
            <!-- History Screen -->
            <div class="screen" id="historyScreen">
                <div class="history-list" id="historyList">
                    <div class="empty-state">
                        <div class="empty-icon">📋</div>
                        <div>Loading history...</div>
                    </div>
                </div>
            </div>
        </main>
        
        <!-- Bottom Nav -->
        <nav class="bottom-nav">
            <button class="nav-item active" data-screen="home" onclick="showScreen('home')">
                <span class="nav-icon">➕</span>
                <span>New</span>
            </button>
            <button class="nav-item" data-screen="history" onclick="showScreen('history')">
                <span class="nav-icon">📋</span>
                <span>History</span>
            </button>
        </nav>
        
        <!-- Loading Overlay -->
        <div class="loading-overlay" id="loadingOverlay">
            <div class="loading-spinner"></div>
            <div class="loading-text">Analyzing resume...</div>
        </div>
        
        <!-- Toast -->
        <div class="toast" id="toast">Copied!</div>
    </div>

    <script>
        // State
        let fileBase64 = null;
        let currentPdfData = null;
        let currentScreen = 'home';
        
        // Elements
        const uploadZone = document.getElementById('uploadZone');
        const fileInput = document.getElementById('fileInput');
        const fileName = document.getElementById('fileName');
        const analyzeForm = document.getElementById('analyzeForm');
        const loadingOverlay = document.getElementById('loadingOverlay');
        const headerBack = document.getElementById('headerBack');
        const headerAction = document.getElementById('headerAction');
        const headerTitle = document.getElementById('headerTitle');
        
        // File Upload
        uploadZone.addEventListener('click', () => fileInput.click());
        
        uploadZone.addEventListener('dragover', (e) => {
            e.preventDefault();
            uploadZone.classList.add('active');
        });
        
        uploadZone.addEventListener('dragleave', () => {
            uploadZone.classList.remove('active');
        });
        
        uploadZone.addEventListener('drop', (e) => {
            e.preventDefault();
            uploadZone.classList.remove('active');
            const file = e.dataTransfer.files[0];
            if (file?.type.startsWith('image/')) {
                handleFile(file);
            }
        });
        
        fileInput.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (file) handleFile(file);
        });
        
        function handleFile(file) {
            fileName.textContent = file.name;
            uploadZone.classList.add('has-file');
            
            const reader = new FileReader();
            reader.onload = () => {
                fileBase64 = reader.result.split(',')[1];
            };
            reader.readAsDataURL(file);
        }
        
        // Form Submit
        analyzeForm.addEventListener('submit', async (e) => {
            e.preventDefault();
            
            if (!fileBase64) {
                showToast('Please upload an image first');
                return;
            }
            
            loadingOverlay.classList.add('active');
            
            try {
                const response = await fetch('/api/analyze-resume', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        resumeImage: fileBase64,
                        targetRole: document.getElementById('targetRole').value || undefined,
                        jobDescription: document.getElementById('jobDescription').value || undefined
                    })
                });
                
                const data = await response.json();
                displayResults(data);
                showScreen('results');
                loadHistory();
            } catch (error) {
                showToast('Error: ' + error.message);
            } finally {
                loadingOverlay.classList.remove('active');
            }
        });
        
        // Tab Control
        document.getElementById('tabControl').addEventListener('click', (e) => {
            const btn = e.target.closest('.segment-btn');
            if (!btn) return;
            
            document.querySelectorAll('.segment-btn').forEach(b => b.classList.remove('active'));
            document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
            
            btn.classList.add('active');
            document.getElementById(btn.dataset.tab + 'Panel').classList.add('active');
        });
        
        // Screen Navigation
        function showScreen(screen) {
            currentScreen = screen;
            
            document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
            document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
            
            if (screen === 'home') {
                document.getElementById('homeScreen').classList.add('active');
                document.querySelector('[data-screen="home"]').classList.add('active');
                headerTitle.textContent = 'Resume Analyzer';
                headerBack.classList.remove('visible');
                headerAction.classList.remove('visible');
            } else if (screen === 'history') {
                document.getElementById('historyScreen').classList.add('active');
                document.querySelector('[data-screen="history"]').classList.add('active');
                headerTitle.textContent = 'History';
                headerBack.classList.remove('visible');
                headerAction.classList.remove('visible');
                loadHistory();
            } else if (screen === 'results') {
                document.getElementById('resultsScreen').classList.add('active');
                headerTitle.textContent = 'Analysis';
                headerBack.classList.add('visible');
                if (currentPdfData) {
                    headerAction.classList.add('visible');
                }
            }
        }
        
        function goBack() {
            showScreen('home');
            currentPdfData = null;
        }
        
        // Display Results
        function displayResults(data) {
            const ats = parseJSON(data.atsScore);
            const match = parseJSON(data.jobMatch);
            
            // Scores
            const scores = [
                { label: 'ATS', value: ats?.overallScore },
                { label: 'Keywords', value: ats?.keywordScore },
                { label: 'Format', value: ats?.formatScore },
                { label: 'Match', value: match?.matchScore }
            ];
            
            document.getElementById('scoreRing').innerHTML = scores.map(s => {
                const val = s.value ?? '-';
                const cls = typeof val === 'number' ? (val >= 70 ? 'high' : val >= 50 ? 'medium' : 'low') : '';
                return `
                    <div class="score-item">
                        <div class="score-value ${cls}">${val}${typeof val === 'number' ? '%' : ''}</div>
                        <div class="score-label">${s.label}</div>
                    </div>
                `;
            }).join('');
            
            // ATS Tab
            let atsText = '';
            if (ats) {
                atsText += `Missing Keywords:\n${(ats.missingKeywords || []).join(', ') || 'None detected'}\n\n`;
                atsText += `Format Issues:\n${(ats.formatIssues || []).join(', ') || 'None detected'}\n\n`;
                atsText += `Suggestions:\n${(ats.suggestions || []).map(s => '• ' + s).join('\n')}`;
            }
            document.getElementById('atsContent').textContent = atsText || data.atsScore || '';
            
            // Experience Tab
            document.getElementById('expContent').textContent = data.experienceAnalysis || '';
            
            // Match Tab
            if (match) {
                document.getElementById('matchingSkills').innerHTML = 
                    (match.matchingSkills || []).map(s => `<span class="skill-chip match">${s}</span>`).join('') || 
                    '<span style="color: var(--text-muted)">None detected</span>';
                    
                document.getElementById('missingSkills').innerHTML = 
                    (match.missingSkills || []).map(s => `<span class="skill-chip missing">${s}</span>`).join('') || 
                    '<span style="color: var(--text-muted)">None detected</span>';
                
                let matchText = `Outcome: ${match.likelyOutcome || 'Unknown'}\n\n`;
                matchText += `Strengths:\n${(match.strengthsForRole || []).map(s => '• ' + s).join('\n')}\n\n`;
                matchText += `Gaps:\n${(match.experienceGaps || []).map(s => '• ' + s).join('\n')}\n\n`;
                matchText += `Recommendations:\n${(match.recommendations || []).map(s => '• ' + s).join('\n')}`;
                document.getElementById('matchContent').textContent = matchText;
            }
            
            // Summary Tab
            document.getElementById('summaryContent').textContent = data.improvedSummary || '';
            
            // Interview Tab
            document.getElementById('interviewContent').textContent = data.interviewPrep || '';
        }
        
        // History
        async function loadHistory() {
            const list = document.getElementById('historyList');
            
            try {
                const response = await fetch('/api/list-analyses?limit=10');
                const data = await response.json();
                
                if (data.analyses?.length > 0) {
                    list.innerHTML = data.analyses.map(a => {
                        const ats = parseJSON(a.atsScore);
                        return `
                            <div class="history-item" onclick="viewAnalysis('${a.customId || a._id}')">
                                <div class="history-icon">📄</div>
                                <div class="history-info">
                                    <div class="history-title">${a.targetRole || 'Resume Analysis'}</div>
                                    <div class="history-meta">ATS Score: ${ats?.overallScore ?? '-'}%</div>
                                </div>
                                <div class="history-arrow">›</div>
                            </div>
                        `;
                    }).join('');
                } else {
                    list.innerHTML = `
                        <div class="empty-state">
                            <div class="empty-icon">📋</div>
                            <div>No analyses yet</div>
                        </div>
                    `;
                }
            } catch (error) {
                list.innerHTML = `
                    <div class="empty-state">
                        <div class="empty-icon">⚠️</div>
                        <div>Error loading history</div>
                    </div>
                `;
            }
        }
        
        async function viewAnalysis(id) {
            loadingOverlay.classList.add('active');
            
            try {
                const response = await fetch(`/api/get-analysis?id=${id}&includeAttachment=true`);
                const data = await response.json();
                
                if (data.analysis) {
                    displayResults(data.analysis);
                    currentPdfData = data.attachment?.data || null;
                    showScreen('results');
                }
            } catch (error) {
                showToast('Error loading analysis');
            } finally {
                loadingOverlay.classList.remove('active');
            }
        }
        
        // Utils
        function parseJSON(data) {
            if (typeof data === 'object') return data;
            try {
                return JSON.parse(data.replace(/```json\n?/g, '').replace(/```\n?/g, ''));
            } catch { return null; }
        }
        
        function copyText(elementId) {
            const text = document.getElementById(elementId).textContent;
            navigator.clipboard.writeText(text);
            showToast('Copied to clipboard!');
        }
        
        function showToast(message) {
            const toast = document.getElementById('toast');
            toast.textContent = message;
            toast.classList.add('visible');
            setTimeout(() => toast.classList.remove('visible'), 2000);
        }
        
        function downloadPdf() {
            if (currentPdfData) {
                const link = document.createElement('a');
                link.href = 'data:image/webp;base64,' + currentPdfData;
                link.download = 'resume.webp';
                link.click();
            } else {
                showToast('No image attached');
            }
        }
        
        // Init
        loadHistory();
    </script>
</body>
</html>
example
OPENAI_API_KEY=

Running Locally

Run directly using Cortex package, recommended for production runs, does not inlcude base or extra depdencies.

# First, download the example files
npx @ha-bits/cortex@latest server --config ./resume-analyzer/stack.yaml

Understanding the Architecture

For detailed explanations of how habits work, see:


Packaging Your App

Once your app is working locally, you can package it for distribution. See the Binary Export guide for full details.

Full App vs Client App

Before packaging, decide on your deployment architecture:

ApproachBest ForProsCons
Full App (Standalone)Internal tools, demos, kiosk appsDoesn't require a backend, single packageAPI keys bundled in app
Client App (Remote Backend)Production apps, public distributionSecure key storage, scalable, updatableRequires deployed API

Security Warning: Full App Builds

When you package with --execution-mode full, your .env file (including API keys) is bundled inside the app. Anyone with the app can extract these keys.

For distributable apps with sensitive keys:

  1. Use a remote backend: Deploy your habits server and have the app connect via API
  2. Use @codenteam/intersect: Secrets vault and management for standalone deployments

Only use full app builds for:

  • Development and testing
  • Internal tools with non-sensitive credentials
  • Demos where key exposure is acceptable
  • Internal/Kiosk Micro-Apps that would be only used internally

Full App (Standalone)

Package your entire stack as a standalone native app. Doesn't require a separate backend deployment.

Creates a standalone macOS app. Doesn't require a backend.

npx habits pack --config ./stack.yaml --format desktop-full --desktop-platform dmg --output ./ResumeAnalyzer.dmg

After packaging:

bash
# The app runs standalone: no server needed
# To override bundled environment variables, place a .env file beside the app

Client App (Remote Backend)

Package the frontend as a native app that connects to your deployed backend. This keeps API keys secure on your server.

Creates a macOS app that connects to your deployed backend.

npx habits pack --config ./stack.yaml --format desktop --backend-url https://your-api.example.com --desktop-platform dmg --output ./ResumeAnalyzer.dmg

Requirements:

  • Node.js 24+
  • Backend deployed and accessible at the provided URL
  • Desktop: For Tauri builds, Rust and Cargo installed
  • Android: Java, Gradle, Android SDK (ANDROID_HOME or ANDROID_SDK_ROOT set)
  • iOS: macOS with Xcode (iOS builds only work on macOS)

Using Base UI for Export

You can also export from the Base UI:

  1. Open Base UI: npx habits base
  2. Load your stack
  3. Go to Export tab
  4. Select export type (Binary, Desktop, or Mobile)
  5. Configure target platform
  6. Click Export to download

Binary Export UI


Habits Stack Preparation Checklist

Basic Stack Requirements

  • [ ] Stack has a name field in stack.yaml
  • [ ] Each habit has a unique name field
  • [ ] Each habit has at least one node
  • [ ] All API keys stored in .env file (not in habit files)
  • [ ] .env is in .gitignore if you have a version control
  • [ ] .env.example exists with required variable names

If using Server-Side or Full-Stack Logic

  • [ ] All habits have clear inputs defined
  • [ ] All habits have clear outputs defined
  • [ ] UI points to correct backend endpoints
  • [ ] CORS configured if frontend/backend on different origins

Troubleshooting: Cannot Find stack.yaml Error

  • [ ] Verify stack.yaml exists in current directory
  • [ ] Or provide full path: --config /path/to/stack.yaml

Troubleshooting: Missing Environment Variable Error

  • [ ] Verify .env file exists
  • [ ] Check variable names match references in habits (e.g., ${OPENAI_API_KEY})
  • [ ] Ensure .env is in same directory as stack.yaml

Exporting for Production Checklist

Note: We are deprecating support for capacitor, cordova, and electron. Future releases will rely exclusively on tauri for desktop and mobile exports. Please migrate any existing projects to use tauri for best support and compatibility.

  • [ ] Stack tested locally and working
  • [ ] Export via Base UI: Export → Docker
  • [ ] Download {name}-docker.zip
  • [ ] Unzip and run: docker-compose up -d

If exporting Server-Side (Alternative: Single Executable)

  • [ ] Stack tested locally and working
  • [ ] Export via Base UI → Export tab → Binary
  • [ ] Download binary for target platform
  • [ ] Run executable on target machine

If exporting Desktop App (Experimental)

  • [ ] Stack tested locally and working
  • [ ] Backend URL configured (where app will connect)
  • [ ] Choose framework: tauri (recommended) or electron
  • [ ] Choose platform: windows, mac, linux, or all
  • [ ] Check build tools: curl http://localhost:3000/habits/base/api/export/binary/support or in UI
  • [ ] For Tauri: Rust, Cargo installed
  • [ ] Export via Base UI → Export tab → Desktop
  • [ ] If first time: Download scaffold (buildBinary: false)
  • [ ] If ready for binary: Enable buildBinary: true
  • [ ] Download and test on target platform

If exporting Mobile App (Experimental)

  • [ ] Stack tested locally and working
  • [ ] Backend URL configured (must be accessible from mobile device)
  • [ ] Choose framework: tauri (recommended)
  • [ ] Choose target: ios, android, or both
  • [ ] Check build tools: curl http://localhost:3000/habits/base/api/export/binary/support
    • [ ] For Android: Java, Gradle, Android SDK installed
    • [ ] For iOS: macOS with Xcode installed (iOS builds only work on macOS)
  • [ ] Set environment variables:
  • [ ] ANDROID_HOME or ANDROID_SDK_ROOT for Android
  • [ ] Export via Base UI → Export tab → Mobile
  • [ ] If first time: Download scaffold (buildBinary: false)
  • [ ] If ready for binary: Enable buildBinary: true
  • [ ] Download APK (Android) or IPA (iOS)
  • [ ] Test on real device or emulator

Troubleshooting: Desktop/Mobile Build Fails

  • [ ] Check mobile or desktop section for missing tools
  • [ ] Install missing dependencies
  • [ ] Try scaffold export first (buildBinary: false) to verify config
  • [ ] Check logs for specific error messages
  • [ ] Via API, Run: curl http://localhost:3000/habits/base/api/export/binary/support

Troubleshooting: iOS Build Fails

  • [ ] Verify you're on macOS (iOS builds require macOS)
  • [ ] Verify Xcode is installed: xcodebuild -version
  • [ ] Open Xcode at least once to accept license agreements

Troubleshooting: Android Build Fails

  • [ ] Verify ANDROID_HOME/ANDROID_SDK_ROOT is set
  • [ ] Verify Java and Gradle versions are compatible
  • [ ] Check compatibility in support endpoint response
  • [ ] Install Android SDK build tools if missing

Next Steps

Released under the Apache 2.0 License.