| name | sesion-mental-health-clinic-platform |
| description | AI-powered practice management platform for psychologists with scheduling, WhatsApp automation, AFIP billing, video calls, and Claude AI integration |
| triggers | ["set up sesion mental health platform","configure sesion for psychology clinic","integrate claude ai with sesion","configure whatsapp automation for clinic","set up afip electronic invoicing","configure livekit video consultations","deploy sesion workflow orchestration","configure mercadopago billing integration"] |
Sesión Mental Health Clinic Platform
Skill by ara.so — Devtools Skills collection.
Sesión is a comprehensive SaaS platform for psychology clinics in Argentina that orchestrates appointment scheduling, WhatsApp automation (via Baileys), AFIP-compliant electronic invoicing, secure video consultations (LiveKit), and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6.
Architecture Overview
Sesión uses a microservices architecture with:
- Backend: NestJS (TypeScript) with Prisma ORM
- Frontend: SvelteKit 5 with Tailwind CSS
- Database: PostgreSQL with Prisma
- AI: Anthropic Claude (Opus 4.6 for deep analysis, Sonnet 4.6 for real-time)
- Video: LiveKit WebRTC infrastructure
- Messaging: Baileys (WhatsApp Web API)
- Payments: Stripe (international), MercadoPago (Argentina)
- Caching: Redis for sessions and real-time data
Installation & Setup
Prerequisites
node >= 20.x
postgresql >= 15.x
redis >= 7.x
Clone and Install
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
cd backend
npm install
cd ../frontend
npm install
Environment Configuration
Create .env files in both backend and frontend directories:
Backend .env:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/sesion_db"
# Redis
REDIS_URL="redis://localhost:6379"
# Anthropic Claude AI
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
# WhatsApp (Baileys)
WHATSAPP_SESSION_DIR="./whatsapp-sessions"
WHATSAPP_WEBHOOK_SECRET="${WHATSAPP_WEBHOOK_SECRET}"
# LiveKit Video
LIVEKIT_API_KEY="${LIVEKIT_API_KEY}"
LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET}"
LIVEKIT_WS_URL="wss://your-livekit-server.com"
# AFIP (Argentina Tax Authority)
AFIP_CUIT="${AFIP_CUIT}"
AFIP_CERT_PATH="./certs/afip-cert.pem"
AFIP_KEY_PATH="./certs/afip-key.pem"
AFIP_ENVIRONMENT="testing" # or "production"
# Payment Providers
STRIPE_SECRET_KEY="${STRIPE_SECRET_KEY}"
STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET}"
MERCADOPAGO_ACCESS_TOKEN="${MERCADOPAGO_ACCESS_TOKEN}"
MERCADOPAGO_PUBLIC_KEY="${MERCADOPAGO_PUBLIC_KEY}"
# Application
JWT_SECRET="${JWT_SECRET}"
APP_URL="http://localhost:3000"
API_URL="http://localhost:4000"
Frontend .env:
PUBLIC_API_URL="http://localhost:4000"
PUBLIC_LIVEKIT_WS_URL="wss://your-livekit-server.com"
Database Setup
cd backend
npx prisma generate
npx prisma migrate deploy
npx prisma db seed
Start Development Servers
cd backend
npm run start:dev
cd frontend
npm run dev
redis-server
Core Module Usage
1. Appointment Scheduling Module
Creating an appointment with conflict detection:
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { AppointmentConflictError } from './errors';
@Injectable()
export class AppointmentService {
constructor(private prisma: PrismaService) {}
async createAppointment(data: {
patientId: string;
practitionerId: string;
startTime: Date;
duration: number; // minutes
type: 'presencial' | 'virtual' | 'evaluacion';
}) {
const endTime = new Date(data.startTime.getTime() + data.duration * 60000);
const conflicts = await this.prisma.appointment.findMany({
where: {
practitionerId: data.practitionerId,
OR: [
{
: { : data. },
: { : data. },
},
{
: { : endTime },
: { : endTime },
},
],
: { : },
},
});
(conflicts. > ) {
(
);
}
appointment = ...({
: {
...data,
endTime,
: ,
},
: {
: ,
: ,
},
});
..(appointment);
appointment;
}
() {
startOfDay = (preferredDate);
startOfDay.(, , , );
endOfDay = (preferredDate);
endOfDay.(, , , );
bookedSlots = ...({
: {
practitionerId,
: { : startOfDay, : endOfDay },
: { : },
},
: { : , : },
: { : },
});
availability = ...({
: { practitionerId, : preferredDate.() },
});
: [] = [];
( slot availability) {
slotStart = (preferredDate);
slotStart.(slot., slot., , );
slotEnd = (preferredDate);
slotEnd.(slot., slot., , );
currentTime = slotStart;
(currentTime.() + duration * <= slotEnd.()) {
isFree = !bookedSlots.(
currentTime >= booked. && currentTime < booked.
);
(isFree) {
suggestions.( (currentTime));
}
currentTime = (currentTime.() + * );
}
}
suggestions.(, );
}
}
2. WhatsApp Automation (Baileys Integration)
Setting up WhatsApp session and sending automated reminders:
import { Injectable, OnModuleInit } from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WAMessage,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as cron from 'node-cron';
@Injectable()
export class WhatsAppService implements OnModuleInit {
private socket: ReturnType<typeof makeWASocket>;
async onModuleInit() {
await this.initializeWhatsApp();
this.scheduleReminders();
}
private async initializeWhatsApp() {
const { state, saveCreds } = await useMultiFileAuthState(
process.env.WHATSAPP_SESSION_DIR || './whatsapp-sessions'
);
this.socket = makeWASocket({
auth: state,
: ,
});
...(, saveCreds);
...(, {
{ connection, lastDisconnect } = update;
(connection === ) {
shouldReconnect =
(lastDisconnect?. )?.?. !==
.;
(shouldReconnect) {
.();
}
}
});
...(, (m) => {
.(m.[]);
});
}
() {
message = +
+
+
+
+
;
..(
.(appointment..),
{ : message }
);
}
() {
cron.(, () => {
now = ();
in24Hours = (now.() + * * * );
in2Hours = (now.() + * * * );
upcoming24h = ...({
: {
: {
: (in24Hours.() - * * ),
: (in24Hours.() + * * ),
},
: ,
: ,
},
: { : },
});
( apt upcoming24h) {
.(apt, );
...({
: { : apt. },
: { : },
});
}
upcoming2h = ...({
: {
: {
: (in2Hours.() - * * ),
: (in2Hours.() + * * ),
},
: ,
: ,
},
: { : },
});
( apt upcoming2h) {
.(apt, );
...({
: { : apt. },
: { : },
});
}
});
}
() {
message = +
+
+
;
..(
.(appointment..),
{ : message }
);
}
() {
(!message. || message..) ;
text = message.. ||
message..?. || ;
= message..;
crisisKeywords = [, , , , ];
(crisisKeywords.( text.().(keyword))) {
.(, text);
;
}
(text.().()) {
.();
}
}
() {
patient = ...({
: { : .(phone) },
: { : },
});
(patient?.) {
..(
.(patient..),
{
: ,
}
);
}
..(phone, {
: +
,
});
}
(: ): {
;
}
(: ): {
jid.()[];
}
}
3. AFIP Electronic Invoicing
Generating AFIP-compliant invoices:
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import * as AfipSDK from '@afipsdk/afip.js';
import * as fs from 'fs';
@Injectable()
export class BillingService {
private afip: any;
constructor(private prisma: PrismaService) {
this.afip = new AfipSDK({
CUIT: process.env.AFIP_CUIT,
cert: fs.readFileSync(process.env.AFIP_CERT_PATH),
key: fs.readFileSync(process.env.AFIP_KEY_PATH),
production: process.env.AFIP_ENVIRONMENT === 'production',
});
}
async generateInvoiceForSession(sessionId: string) {
session = ...({
: { : sessionId },
: {
: ,
: {
: { : },
},
},
});
(!session) ();
practitionerFiscal = session..;
invoiceType = .(
practitionerFiscal.,
session..
);
invoiceData = {
: ,
: practitionerFiscal.,
: invoiceType,
: ,
: ,
: session.. || session..,
: .(practitionerFiscal., invoiceType),
: .(practitionerFiscal., invoiceType),
: .( ()),
: session.,
: ,
: invoiceType === ? session. / : session.,
: ,
: invoiceType === ? session. - (session. / ) : ,
: ,
: ,
: ,
: invoiceType === ? [
{
: ,
: session. / ,
: session. - (session. / ),
}
] : ,
};
result = ...(invoiceData);
(result.) {
invoice = ...({
: {
sessionId,
: session.,
: session.,
: invoiceType,
: invoiceData.,
: session.,
: result.,
: .(result.),
: .(result),
},
});
..(invoice);
invoice;
} {
();
}
}
(
: ,
?:
): {
(practitionerCategory === ) {
(patientCategory === ) ;
(patientCategory === ) ;
;
}
(practitionerCategory === ) ;
;
}
(
: ,
:
): <> {
lastInvoice = ...({
: { : { : fiscalConfigId }, : invoiceType },
: { : },
});
(lastInvoice?. || ) + ;
}
(: ): {
date.().()[].(, );
}
(: ): {
year = (dateStr.(, ));
month = (dateStr.(, )) - ;
day = (dateStr.(, ));
(year, month, day);
}
() {
startDate = (year, month - , );
endDate = (year, month, );
invoices = ...({
: {
practitionerId,
: { : startDate, : endDate },
},
: { : },
});
summary = {
: invoices.( sum + inv., ),
: {
: invoices.( inv. === ).,
: invoices.( inv. === ).,
: invoices.( inv. === ).,
},
: invoices
.( inv. === )
.( sum + (inv. - inv. / ), ),
};
summary;
}
}
4. LiveKit Video Consultations
Creating and managing video sessions:
import { Injectable } from '@nestjs/common';
import { AccessToken } from 'livekit-server-sdk';
import { PrismaService } from './prisma.service';
@Injectable()
export class VideoService {
constructor(private prisma: PrismaService) {}
async createVideoSession(appointmentId: string) {
const appointment = await this.prisma.appointment.findUnique({
where: { id: appointmentId },
include: { patient: true, practitioner: true },
});
if (!appointment) throw new Error('Appointment not found');
if (appointment.type !== 'virtual') {
throw new Error('Appointment is not virtual');
}
const roomName = ;
videoSession = ...({
: {
appointmentId,
roomName,
: ,
},
});
{
: videoSession.,
roomName,
: .(roomName, appointment.., ),
: .(
roomName,
appointment..,
),
};
}
(: , : , : ): {
at = (
process..,
process..,
{
: userId,
: .({ role }),
}
);
at.({
: ,
: roomName,
: ,
: ,
: ,
: role === ,
});
at.();
}
() {
session = ...({
: { : sessionId },
: { : },
});
(session.. !== practitionerId) {
();
}
consent = ...({
: {
: sessionId,
: ,
: ,
},
});
(!consent) {
();
}
...({
: { : sessionId },
: { : () },
});
{ : };
}
() {
session = ...({
: { : sessionId },
: {
: ,
: (),
},
});
session;
}
}
Frontend Svelte component for video session:
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Room, RoomEvent, Track } from 'livekit-client';
export let token: string;
export let wsUrl: string;
export let role: 'patient' | 'practitioner';
let room: Room;
let videoElement: HTMLVideoElement;
let remoteVideoElement: HTMLVideoElement;
let isConnected = false;
let isMuted = false;
let isVideoOff = false;
onMount(async () => {
room = new Room({
adaptiveStream: true,
dynacast: true,
videoCaptureDefaults: {
resolution: { width: 1280, height: 720 },
},
});
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Video) {
track.attach(remoteVideoElement);
}
});
room.on(RoomEvent.Disconnected, () => {
isConnected = false;
});
await room.connect(wsUrl, token);
// Publish local tracks
await room.localParticipant.enableCameraAndMicrophone();
const videoTrack = room.localParticipant.videoTrackPublications.values().next().value;
if (videoTrack) {
videoTrack.track?.attach(videoElement);
}
isConnected = true;
});
onDestroy(() => {
room?.disconnect();
});
async function toggleMute() {
await room.localParticipant.setMicrophoneEnabled(isMuted);
isMuted = !isMuted;
}
async function toggleVideo() {
await room.localParticipant.setCameraEnabled(isVideoOff);
isVideoOff = !isVideoOff;
}
async function endCall() {
await fetch(`/api/video/end-session`, { method: 'POST' });
room.disconnect();
}
</script>
<div class="video-container">
<div class="remote-video">
<video bind:this={remoteVideoElement} autoplay playsinline />
</div>
<div class="local-video">
<video bind:this={videoElement} autoplay playsinline muted />
</div>
<div class="controls">
<button on:click={toggleMute} class:active={!isMuted}>
{isMuted ? '🔇' : '🎤'}
</button>
<button on:click={toggleVideo} class:active={!isVideoOff}>
{isVideoOff ? '📹' : '📷'}
</button>
<button on:click={endCall} class="end-call">Finalizar</button>
</div>
{#if !isConnected}
<div class="loading">Conectando...</div>
{/if}
</div>
<style>
.video-container {
position: relative;
width: 100%;
height: 100vh;
background: #000;
}
.remote-video {
width: 100%;
height: 100%;
}
.remote-video video {
width: 100%;
height: 100%;
object-fit: cover;
}
.local-video {
position: absolute;
bottom: 80px;
right: 20px;
width: 200px;
height: 150px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #fff;
}
.local-video video {
width: 100%;
height: 100%;
object-fit: cover;
}
.controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12px;
}
.controls button {
padding: 12px 24px;
border-radius: 24px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
transition: background 0.2s;
}
.controls button:hover {
background: rgba(255, 255, 255, 0.3);
}
.controls button.active {
background: #4CAF50;
}
.controls button.end-call {
background: #f44336;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
}
</style>
5. Claude AI Integration
Clinical note summarization with Claude Opus:
import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { PrismaService } from './prisma.service';
@Injectable()
export class AIService {
private anthropic: Anthropic;
constructor(private prisma: PrismaService)