Files
helix-engage/docs/superpowers/plans/2026-04-05-website-widget.md
saridsa2 82ec843c6c docs: website widget spec + implementation plan
- Widget design spec (embeddable AI chat + booking + lead capture)
- Implementation plan (6 tasks)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:49:59 +05:30

32 KiB

Website Widget — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build an embeddable website widget (AI chat + appointment booking + lead capture) served from the sidecar, with HMAC-signed site keys, captcha protection, and theme integration.

Architecture: Sidecar gets a new widget module with endpoints for init, chat, booking, leads, and key management. A separate Preact-based widget bundle is built with Vite in library mode, served as a static file from the sidecar. The widget renders in a shadow DOM for CSS isolation and fetches theme/config via the site key.

Tech Stack: NestJS (sidecar endpoints), Preact + Vite (widget bundle), Shadow DOM, HMAC-SHA256 (key signing), reCAPTCHA v3 (captcha)

Spec: docs/superpowers/specs/2026-04-05-website-widget-design.md


File Map

Sidecar (helix-engage-server)

File Action Responsibility
src/widget/widget.module.ts Create NestJS module
src/widget/widget.controller.ts Create REST endpoints: init, chat, doctors, slots, book, lead
src/widget/widget.service.ts Create Lead creation, appointment booking, doctor queries
src/widget/widget-keys.service.ts Create HMAC key generation, validation, CRUD via Redis
src/widget/widget-key.guard.ts Create NestJS guard for key + origin validation
src/widget/captcha.guard.ts Create reCAPTCHA v3 token verification
src/widget/widget.types.ts Create Types for widget requests/responses
src/auth/session.service.ts Modify Add setCachePersistent() method
src/app.module.ts Modify Import WidgetModule
src/main.ts Modify Serve static widget.js file

Widget Bundle (new package)

File Action Responsibility
packages/helix-engage-widget/package.json Create Package config
packages/helix-engage-widget/vite.config.ts Create Library mode, IIFE output
packages/helix-engage-widget/tsconfig.json Create TypeScript config
packages/helix-engage-widget/src/main.ts Create Entry: read data-key, init widget
packages/helix-engage-widget/src/api.ts Create HTTP client for widget endpoints
packages/helix-engage-widget/src/widget.tsx Create Shadow DOM mount, theming, tab routing
packages/helix-engage-widget/src/chat.tsx Create AI chatbot with streaming
packages/helix-engage-widget/src/booking.tsx Create Appointment booking flow
packages/helix-engage-widget/src/contact.tsx Create Lead capture form
packages/helix-engage-widget/src/captcha.ts Create reCAPTCHA v3 integration
packages/helix-engage-widget/src/styles.ts Create CSS-in-JS for shadow DOM
packages/helix-engage-widget/src/types.ts Create Shared types

Task 1: Widget Types + Key Service (Sidecar)

Files:

  • Create: helix-engage-server/src/widget/widget.types.ts

  • Create: helix-engage-server/src/widget/widget-keys.service.ts

  • Modify: helix-engage-server/src/auth/session.service.ts

  • Step 1: Add setCachePersistent to SessionService

Add a method that sets a Redis key without TTL:

async setCachePersistent(key: string, value: string): Promise<void> {
    await this.redis.set(key, value);
}
  • Step 2: Create widget.types.ts
// src/widget/widget.types.ts

export type WidgetSiteKey = {
    siteId: string;
    hospitalName: string;
    allowedOrigins: string[];
    active: boolean;
    createdAt: string;
};

export type WidgetInitResponse = {
    brand: { name: string; logo: string };
    colors: { primary: string; primaryLight: string; text: string; textLight: string };
    captchaSiteKey: string;
};

export type WidgetBookRequest = {
    departmentId: string;
    doctorId: string;
    scheduledAt: string;
    patientName: string;
    patientPhone: string;
    age?: string;
    gender?: string;
    chiefComplaint?: string;
    captchaToken: string;
};

export type WidgetLeadRequest = {
    name: string;
    phone: string;
    interest?: string;
    message?: string;
    captchaToken: string;
};

export type WidgetChatRequest = {
    messages: Array<{ role: string; content: string }>;
    captchaToken?: string;
};
  • Step 3: Create widget-keys.service.ts
// src/widget/widget-keys.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
import { SessionService } from '../auth/session.service';
import type { WidgetSiteKey } from './widget.types';

const KEY_PREFIX = 'widget:keys:';

@Injectable()
export class WidgetKeysService {
    private readonly logger = new Logger(WidgetKeysService.name);
    private readonly secret: string;

    constructor(
        private config: ConfigService,
        private session: SessionService,
    ) {
        this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
    }

    generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
        const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
        const signature = this.sign(siteId);
        const key = `${siteId}.${signature}`;

        const siteKey: WidgetSiteKey = {
            siteId,
            hospitalName,
            allowedOrigins,
            active: true,
            createdAt: new Date().toISOString(),
        };

        return { key, siteKey };
    }

    async saveKey(siteKey: WidgetSiteKey): Promise<void> {
        await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
        this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
    }

    async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
        const dotIndex = rawKey.indexOf('.');
        if (dotIndex === -1) return null;

        const siteId = rawKey.substring(0, dotIndex);
        const signature = rawKey.substring(dotIndex + 1);

        // Verify HMAC
        const expected = this.sign(siteId);
        try {
            if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
        } catch {
            return null;
        }

        // Fetch from Redis
        const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
        if (!data) return null;

        const siteKey: WidgetSiteKey = JSON.parse(data);
        if (!siteKey.active) return null;

        return siteKey;
    }

    validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
        if (!origin) return false;
        if (siteKey.allowedOrigins.length === 0) return true;
        return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
    }

    async listKeys(): Promise<WidgetSiteKey[]> {
        const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
        const results: WidgetSiteKey[] = [];
        for (const key of keys) {
            const data = await this.session.getCache(key);
            if (data) results.push(JSON.parse(data));
        }
        return results;
    }

    async revokeKey(siteId: string): Promise<boolean> {
        const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
        if (!data) return false;
        const siteKey: WidgetSiteKey = JSON.parse(data);
        siteKey.active = false;
        await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
        this.logger.log(`Widget key revoked: ${siteId}`);
        return true;
    }

    private sign(data: string): string {
        return createHmac('sha256', this.secret).update(data).digest('hex');
    }
}
  • Step 4: Commit
cd helix-engage-server
git add src/widget/widget.types.ts src/widget/widget-keys.service.ts src/auth/session.service.ts
git commit -m "feat: widget types + HMAC key service with Redis storage"

Task 2: Widget Guards (Key + Captcha)

Files:

  • Create: helix-engage-server/src/widget/widget-key.guard.ts

  • Create: helix-engage-server/src/widget/captcha.guard.ts

  • Step 1: Create widget-key.guard.ts

// src/widget/widget-key.guard.ts

import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
import { WidgetKeysService } from './widget-keys.service';

@Injectable()
export class WidgetKeyGuard implements CanActivate {
    constructor(private readonly keys: WidgetKeysService) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = context.switchToHttp().getRequest();
        const key = request.query?.key ?? request.headers['x-widget-key'];

        if (!key) throw new HttpException('Widget key required', 401);

        const siteKey = await this.keys.validateKey(key);
        if (!siteKey) throw new HttpException('Invalid widget key', 403);

        const origin = request.headers.origin ?? request.headers.referer;
        if (!this.keys.validateOrigin(siteKey, origin)) {
            throw new HttpException('Origin not allowed', 403);
        }

        // Attach to request for downstream use
        request.widgetSiteKey = siteKey;
        return true;
    }
}
  • Step 2: Create captcha.guard.ts
// src/widget/captcha.guard.ts

import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';

const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';

@Injectable()
export class CaptchaGuard implements CanActivate {
    private readonly logger = new Logger(CaptchaGuard.name);
    private readonly secretKey: string;

    constructor() {
        this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
    }

    async canActivate(context: ExecutionContext): Promise<boolean> {
        if (!this.secretKey) {
            this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
            return true;
        }

        const request = context.switchToHttp().getRequest();
        const token = request.body?.captchaToken;

        if (!token) throw new HttpException('Captcha token required', 400);

        try {
            const res = await fetch(RECAPTCHA_VERIFY_URL, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: `secret=${this.secretKey}&response=${token}`,
            });
            const data = await res.json();

            if (!data.success || (data.score != null && data.score < 0.3)) {
                this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`);
                throw new HttpException('Captcha verification failed', 403);
            }

            return true;
        } catch (err: any) {
            if (err instanceof HttpException) throw err;
            this.logger.error(`Captcha verification error: ${err.message}`);
            return true; // Fail open if captcha service is down
        }
    }
}
  • Step 3: Commit
git add src/widget/widget-key.guard.ts src/widget/captcha.guard.ts
git commit -m "feat: widget guards — HMAC key validation + reCAPTCHA v3"

Task 3: Widget Controller + Service + Module (Sidecar)

Files:

  • Create: helix-engage-server/src/widget/widget.service.ts

  • Create: helix-engage-server/src/widget/widget.controller.ts

  • Create: helix-engage-server/src/widget/widget.module.ts

  • Modify: helix-engage-server/src/app.module.ts

  • Modify: helix-engage-server/src/main.ts

  • Step 1: Create widget.service.ts

// src/widget/widget.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { ConfigService } from '@nestjs/config';
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
import { ThemeService } from '../config/theme.service';

@Injectable()
export class WidgetService {
    private readonly logger = new Logger(WidgetService.name);
    private readonly apiKey: string;

    constructor(
        private platform: PlatformGraphqlService,
        private theme: ThemeService,
        private config: ConfigService,
    ) {
        this.apiKey = config.get<string>('platform.apiKey') ?? '';
    }

    getInitData(): WidgetInitResponse {
        const t = this.theme.getTheme();
        return {
            brand: { name: t.brand.hospitalName, logo: t.brand.logo },
            colors: {
                primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
                primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
                text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
                textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
            },
            captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
        };
    }

    async getDoctors(): Promise<any[]> {
        const auth = `Bearer ${this.apiKey}`;
        const data = await this.platform.queryWithAuth<any>(
            `{ doctors(first: 50) { edges { node {
                id name fullName { firstName lastName } department specialty visitingHours
                consultationFeeNew { amountMicros currencyCode }
                clinic { clinicName }
            } } } }`,
            undefined, auth,
        );
        return data.doctors.edges.map((e: any) => e.node);
    }

    async getSlots(doctorId: string, date: string): Promise<any> {
        const auth = `Bearer ${this.apiKey}`;
        const data = await this.platform.queryWithAuth<any>(
            `{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
            undefined, auth,
        );
        const booked = data.appointments.edges.map((e: any) => {
            const dt = new Date(e.node.scheduledAt);
            return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
        });

        const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
        return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
    }

    async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
        const auth = `Bearer ${this.apiKey}`;
        const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);

        // Create or find patient
        let patientId: string | null = null;
        try {
            const existing = await this.platform.queryWithAuth<any>(
                `{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
                undefined, auth,
            );
            patientId = existing.patients.edges[0]?.node?.id ?? null;
        } catch { /* continue */ }

        if (!patientId) {
            const created = await this.platform.queryWithAuth<any>(
                `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
                { data: {
                    fullName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
                    phones: { primaryPhoneNumber: `+91${phone}` },
                    patientType: 'NEW',
                } },
                auth,
            );
            patientId = created.createPatient.id;
        }

        // Create appointment
        const appt = await this.platform.queryWithAuth<any>(
            `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
            { data: {
                scheduledAt: req.scheduledAt,
                durationMin: 30,
                appointmentType: 'CONSULTATION',
                status: 'SCHEDULED',
                doctorId: req.doctorId,
                department: req.departmentId,
                reasonForVisit: req.chiefComplaint ?? '',
                patientId,
            } },
            auth,
        );

        // Create lead
        await this.platform.queryWithAuth<any>(
            `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
            { data: {
                name: req.patientName,
                contactName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
                contactPhone: { primaryPhoneNumber: `+91${phone}` },
                source: 'WEBSITE',
                status: 'APPOINTMENT_SET',
                interestedService: req.chiefComplaint ?? 'Appointment Booking',
                patientId,
            } },
            auth,
        ).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));

        const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
        this.logger.log(`Widget booking: ${req.patientName}${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);

        return { appointmentId: appt.createAppointment.id, reference };
    }

    async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
        const auth = `Bearer ${this.apiKey}`;
        const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);

        const data = await this.platform.queryWithAuth<any>(
            `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
            { data: {
                name: req.name,
                contactName: { firstName: req.name.split(' ')[0], lastName: req.name.split(' ').slice(1).join(' ') || '' },
                contactPhone: { primaryPhoneNumber: `+91${phone}` },
                source: 'WEBSITE',
                status: 'NEW',
                interestedService: req.interest ?? 'Website Enquiry',
            } },
            auth,
        );

        this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
        return { leadId: data.createLead.id };
    }
}
  • Step 2: Create widget.controller.ts
// src/widget/widget.controller.ts

import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';
import { WidgetService } from './widget.service';
import { WidgetKeysService } from './widget-keys.service';
import { WidgetKeyGuard } from './widget-key.guard';
import { CaptchaGuard } from './captcha.guard';
import { AiChatController } from '../ai/ai-chat.controller';
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';

@Controller('api/widget')
export class WidgetController {
    private readonly logger = new Logger(WidgetController.name);

    constructor(
        private readonly widget: WidgetService,
        private readonly keys: WidgetKeysService,
    ) {}

    @Get('init')
    @UseGuards(WidgetKeyGuard)
    init() {
        return this.widget.getInitData();
    }

    @Get('doctors')
    @UseGuards(WidgetKeyGuard)
    async doctors() {
        return this.widget.getDoctors();
    }

    @Get('slots')
    @UseGuards(WidgetKeyGuard)
    async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
        if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
        return this.widget.getSlots(doctorId, date);
    }

    @Post('book')
    @UseGuards(WidgetKeyGuard, CaptchaGuard)
    async book(@Body() body: WidgetBookRequest) {
        if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
            throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
        }
        return this.widget.bookAppointment(body);
    }

    @Post('lead')
    @UseGuards(WidgetKeyGuard, CaptchaGuard)
    async lead(@Body() body: WidgetLeadRequest) {
        if (!body.name || !body.phone) {
            throw new HttpException('name and phone required', 400);
        }
        return this.widget.createLead(body);
    }

    // Key management (admin only — no widget key guard, requires JWT)
    @Post('keys/generate')
    async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
        if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
        const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
        await this.keys.saveKey(siteKey);
        return { key, siteKey };
    }

    @Get('keys')
    async listKeys() {
        return this.keys.listKeys();
    }

    @Delete('keys/:siteId')
    async revokeKey(@Param('siteId') siteId: string) {
        const revoked = await this.keys.revokeKey(siteId);
        if (!revoked) throw new HttpException('Key not found', 404);
        return { status: 'revoked' };
    }
}
  • Step 3: Create widget.module.ts
// src/widget/widget.module.ts

import { Module } from '@nestjs/common';
import { WidgetController } from './widget.controller';
import { WidgetService } from './widget.service';
import { WidgetKeysService } from './widget-keys.service';
import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module';
import { ConfigThemeModule } from '../config/config-theme.module';

@Module({
    imports: [PlatformModule, AuthModule, ConfigThemeModule],
    controllers: [WidgetController],
    providers: [WidgetService, WidgetKeysService],
    exports: [WidgetKeysService],
})
export class WidgetModule {}
  • Step 4: Register in app.module.ts

Add import:

import { WidgetModule } from './widget/widget.module';

Add to imports array:

WidgetModule,
  • Step 5: Serve static widget.js from main.ts

In src/main.ts, after the NestJS app bootstrap, add static file serving for the widget bundle:

import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express';

// After app.listen():
app.useStaticAssets(join(__dirname, '..', 'public'), { prefix: '/' });

Create helix-engage-server/public/ directory for the widget bundle output.

  • Step 6: Build and verify
cd helix-engage-server && npm run build
  • Step 7: Commit
git add src/widget/ src/app.module.ts src/main.ts public/
git commit -m "feat: widget module — endpoints, service, key management, captcha"

Task 4: Widget Bundle — Project Setup + Entry Point

Files:

  • Create: packages/helix-engage-widget/package.json

  • Create: packages/helix-engage-widget/vite.config.ts

  • Create: packages/helix-engage-widget/tsconfig.json

  • Create: packages/helix-engage-widget/src/types.ts

  • Create: packages/helix-engage-widget/src/api.ts

  • Create: packages/helix-engage-widget/src/main.ts

  • Step 1: Create package.json

{
  "name": "helix-engage-widget",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "preact": "^10.25.0"
  },
  "devDependencies": {
    "@preact/preset-vite": "^2.9.0",
    "typescript": "^5.7.0",
    "vite": "^7.0.0"
  }
}
  • Step 2: Create vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
    plugins: [preact()],
    build: {
        lib: {
            entry: 'src/main.ts',
            name: 'HelixWidget',
            fileName: () => 'widget.js',
            formats: ['iife'],
        },
        outDir: '../../helix-engage-server/public',
        emptyOutDir: false,
        minify: 'terser',
        rollupOptions: {
            output: {
                inlineDynamicImports: true,
            },
        },
    },
});
  • Step 3: Create tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "jsxImportSource": "preact",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["src"]
}
  • Step 4: Create types.ts
// src/types.ts

export type WidgetConfig = {
    brand: { name: string; logo: string };
    colors: { primary: string; primaryLight: string; text: string; textLight: string };
    captchaSiteKey: string;
};

export type Doctor = {
    id: string;
    name: string;
    fullName: { firstName: string; lastName: string };
    department: string;
    specialty: string;
    visitingHours: string;
    consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
    clinic: { clinicName: string } | null;
};

export type TimeSlot = {
    time: string;
    available: boolean;
};

export type ChatMessage = {
    role: 'user' | 'assistant';
    content: string;
};
  • Step 5: Create api.ts
// src/api.ts

import type { WidgetConfig, Doctor, TimeSlot } from './types';

let baseUrl = '';
let widgetKey = '';

export const initApi = (url: string, key: string) => {
    baseUrl = url;
    widgetKey = key;
};

const headers = () => ({
    'Content-Type': 'application/json',
    'X-Widget-Key': widgetKey,
});

export const fetchInit = async (): Promise<WidgetConfig> => {
    const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
    if (!res.ok) throw new Error('Widget init failed');
    return res.json();
};

export const fetchDoctors = async (): Promise<Doctor[]> => {
    const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
    if (!res.ok) throw new Error('Failed to load doctors');
    return res.json();
};

export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
    const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
    if (!res.ok) throw new Error('Failed to load slots');
    return res.json();
};

export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
    const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
        method: 'POST', headers: headers(), body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error('Booking failed');
    return res.json();
};

export const submitLead = async (data: any): Promise<{ leadId: string }> => {
    const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
        method: 'POST', headers: headers(), body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error('Submission failed');
    return res.json();
};

export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
    const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
        method: 'POST', headers: headers(),
        body: JSON.stringify({ messages, captchaToken }),
    });
    if (!res.ok || !res.body) throw new Error('Chat failed');
    return res.body;
};
  • Step 6: Create main.ts
// src/main.ts

import { render } from 'preact';
import { initApi, fetchInit } from './api';
import { Widget } from './widget';
import type { WidgetConfig } from './types';

const init = async () => {
    const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
    if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }

    const key = script.getAttribute('data-key') ?? '';
    const baseUrl = script.src.replace(/\/widget\.js.*$/, '');

    initApi(baseUrl, key);

    let config: WidgetConfig;
    try {
        config = await fetchInit();
    } catch (err) {
        console.error('[HelixWidget] Init failed:', err);
        return;
    }

    // Create shadow DOM host
    const host = document.createElement('div');
    host.id = 'helix-widget-host';
    host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;';
    document.body.appendChild(host);

    const shadow = host.attachShadow({ mode: 'open' });
    const mountPoint = document.createElement('div');
    shadow.appendChild(mountPoint);

    render(<Widget config={config} shadow={shadow} />, mountPoint);
};

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
} else {
    init();
}
  • Step 7: Install dependencies and verify
cd packages/helix-engage-widget && npm install
  • Step 8: Commit
git add packages/helix-engage-widget/
git commit -m "feat: widget bundle — project setup, API client, entry point"

Task 5: Widget UI Components (Preact)

Files:

  • Create: packages/helix-engage-widget/src/styles.ts
  • Create: packages/helix-engage-widget/src/widget.tsx
  • Create: packages/helix-engage-widget/src/chat.tsx
  • Create: packages/helix-engage-widget/src/booking.tsx
  • Create: packages/helix-engage-widget/src/contact.tsx
  • Create: packages/helix-engage-widget/src/captcha.ts

These are the Preact components rendered inside the shadow DOM. Each component is self-contained.

  • Step 1: Create styles.ts — CSS string injected into shadow DOM
  • Step 2: Create widget.tsx — Main shell with bubble, panel, tab routing
  • Step 3: Create chat.tsx — AI chat with streaming, quick actions, lead capture fallback
  • Step 4: Create booking.tsx — Step-by-step appointment booking
  • Step 5: Create contact.tsx — Simple lead capture form
  • Step 6: Create captcha.ts — Load reCAPTCHA script, get token

Each component follows the pattern: fetch data from API, render form/chat, submit with captcha token.

  • Step 7: Build the widget
cd packages/helix-engage-widget && npm run build
# Output: ../../helix-engage-server/public/widget.js
  • Step 8: Commit
git add packages/helix-engage-widget/src/
git commit -m "feat: widget UI — chat, booking, contact, theming, shadow DOM"

Task 6: Integration Test + Key Generation

Files:

  • None new — testing the full flow

  • Step 1: Generate a site key

curl -s -X POST http://localhost:4100/api/widget/keys/generate \
  -H "Content-Type: application/json" \
  -d '{"hospitalName":"Global Hospital","allowedOrigins":["http://localhost:3000","http://localhost:5173"]}' | python3 -m json.tool

Save the returned key value.

  • Step 2: Test init endpoint
curl -s "http://localhost:4100/api/widget/init?key=SITE_KEY_HERE" | python3 -m json.tool

Should return theme config with brand name, colors, captcha site key.

  • Step 3: Test widget.js serving
curl -s -o /dev/null -w "%{http_code}" http://localhost:4100/widget.js

Should return 200.

  • Step 4: Create a test HTML page

Create packages/helix-engage-widget/test.html:

<!DOCTYPE html>
<html>
<head><title>Widget Test</title></head>
<body>
<h1>Hospital Website</h1>
<p>This is a test page for the Helix Engage widget.</p>
<script src="http://localhost:4100/widget.js" data-key="SITE_KEY_HERE"></script>
</body>
</html>

Open in browser, verify the floating bubble appears, themed correctly.

  • Step 5: Test booking flow end-to-end

Click Book tab → select department → doctor → date → slot → fill name + phone → submit. Verify appointment + lead created in platform.

  • Step 6: Build sidecar and commit all
cd helix-engage-server && npm run build
git add -A && git commit -m "feat: website widget — full integration (chat + booking + lead capture)"

Execution Notes

  • The widget bundle builds into helix-engage-server/public/widget.js — Vite outputs directly to the sidecar's public dir
  • The sidecar serves it via Express static middleware
  • Site keys use HMAC-SHA256 with WIDGET_SECRET env var
  • Captcha is gated by RECAPTCHA_SECRET_KEY env var — if not set, captcha is disabled (dev mode)
  • All widget endpoints use the server-side API key for platform queries (not the visitor's JWT)
  • The widget has no dependency on the main helix-engage frontend — completely standalone
  • Task 5 steps are intentionally less detailed — the UI components follow standard Preact patterns and depend on the API client from Task 4