| name | sesion-mental-health-platform |
| description | AI-powered mental health practice management platform for psychologists with scheduling, WhatsApp automation, AFIP billing, video consultations, and Claude AI integration |
| triggers | ["how do I set up the Sesión mental health platform","configure WhatsApp automation for patient appointments","integrate AFIP electronic invoicing in Sesión","set up video consultations with LiveKit","use Claude AI for clinical notes in Sesión","configure MercadoPago payment integration","implement patient journey workflows","troubleshoot Sesión WhatsApp connection"] |
Sesión Mental Health Platform
Skill by ara.so — Devtools Skills collection.
Sesión is a comprehensive SaaS platform for psychology clinics and independent practitioners in Argentina. It orchestrates appointment scheduling, automated WhatsApp messaging (via Baileys), AFIP-compliant electronic invoicing, secure video consultations (LiveKit), and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6. Built with SvelteKit 5, NestJS, Prisma, and TypeScript.
Installation
Prerequisites
node >= 18.0.0
pnpm >= 8.0.0
postgresql >= 14.0
redis >= 7.0
Clone and Setup
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
pnpm install
cp .env.example .env
Environment Configuration
DATABASE_URL="postgresql://user:password@localhost:5432/sesion"
REDIS_URL="redis://localhost:6379"
ANTHROPIC_API_KEY="sk-ant-..."
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
WHATSAPP_SESSION_DIR="./sessions"
WHATSAPP_QR_TIMEOUT=60000
LIVEKIT_API_KEY="${LIVEKIT_API_KEY}"
LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET}"
LIVEKIT_WS_URL="wss://your-livekit.cloud.livekit.cloud"
MERCADOPAGO_ACCESS_TOKEN="${MERCADOPAGO_ACCESS_TOKEN}"
STRIPE_SECRET_KEY="${STRIPE_SECRET_KEY}"
AFIP_CUIT="${AFIP_CUIT}"
AFIP_CERT_PATH="./certs/afip.crt"
AFIP_KEY_PATH="./certs/afip.key"
AFIP_PRODUCTION=false
JWT_SECRET="${JWT_SECRET}"
APP_URL="http://localhost:5173"
API_URL="http://localhost:3000"
Database Setup
pnpm prisma migrate dev
pnpm prisma db seed
Start Development Servers
pnpm dev
pnpm api:dev
pnpm dev:full
Architecture Overview
sesion/
├── apps/
│ ├── web/ # SvelteKit 5 frontend
│ ├── api/ # NestJS backend
│ └── worker/ # Background job processor
├── packages/
│ ├── db/ # Prisma schema & migrations
│ ├── ai/ # Claude AI orchestration
│ ├── whatsapp/ # Baileys integration
│ ├── video/ # LiveKit wrapper
│ ├── billing/ # AFIP invoicing
│ └── shared/ # Common types & utils
└── prisma/
└── schema.prisma
Core Modules
1. Appointment Scheduling
Create Appointment (NestJS Controller)
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AppointmentsService } from './appointments.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
@Controller('appointments')
@UseGuards(JwtAuthGuard)
export class AppointmentsController {
constructor(private readonly appointmentsService: AppointmentsService) {}
@Post()
async create(@Body() dto: CreateAppointmentDto) {
return this.appointmentsService.create(dto);
}
}
Appointment Service with Conflict Detection
import { Injectable, ConflictException } from '@nestjs/common';
import { PrismaService } from '@sesion/db';
import { addMinutes, isWithinInterval } from 'date-fns';
@Injectable()
export class AppointmentsService {
constructor(private prisma: PrismaService) {}
async create(dto: CreateAppointmentDto) {
const conflicts = await this.prisma.appointment.findMany({
where: {
practitionerId: dto.practitionerId,
status: { not: 'CANCELLED' },
scheduledAt: {
gte: dto.scheduledAt,
lt: addMinutes(dto.scheduledAt, dto.durationMinutes || 45)
}
}
});
if (conflicts.length > ) {
();
}
appointment = ...({
: {
...dto,
:
},
: {
: ,
:
}
});
..(appointment);
appointment;
}
() {
...({
: {
practitionerId,
: { : () },
: { : [, ] }
},
: { : },
: { : }
});
}
}
Frontend: Appointment Calendar (Svelte 5)
<!-- apps/web/src/routes/agenda/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let appointments = $state([]);
let selectedDate = $state(new Date());
let loading = $state(false);
async function loadAppointments() {
loading = true;
const res = await fetch('/api/appointments/upcoming', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
appointments = await res.json();
loading = false;
}
async function createAppointment(event: CustomEvent) {
const res = await fetch('/api/appointments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(event.detail)
});
if (res.ok) {
await loadAppointments();
}
}
onMount(loadAppointments);
</script>
<div class="agenda-container">
<header>
<h1>Agenda</h1>
<button onclick={() => goto('/appointments/new')}>
Nueva Cita
</button>
</header>
{#if loading}
<div class="skeleton"></div>
{:else}
<div class="appointments-grid">
{#each appointments as apt}
<div class="appointment-card" class:urgent={apt.isUrgent}>
<time>{new Date(apt.scheduledAt).toLocaleString('es-AR')}</time>
<h3>{apt.patient.name}</h3>
<span class="type">{apt.type}</span>
<span class="status">{apt.status}</span>
</div>
{/each}
</div>
{/if}
</div>
2. WhatsApp Automation (Baileys)
WhatsApp Connection Service
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
makeInMemoryStore
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class WhatsAppService {
private sock: any;
private readonly logger = new Logger(WhatsAppService.name);
async connect() {
const { state, saveCreds } = await useMultiFileAuthState(
process.env.WHATSAPP_SESSION_DIR || './sessions'
);
this.sock = makeWASocket({
auth: state,
printQRInTerminal: true
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.(, {
{ connection, lastDisconnect, qr } = update;
(qr) {
..();
}
(connection === ) {
shouldReconnect =
(lastDisconnect?. )?.?. !==
.;
(shouldReconnect) {
..();
.();
}
} (connection === ) {
..();
}
});
...(, ({ messages }) => {
.(messages[]);
});
}
() {
patientNumber = appointment...(, ) + ;
message = +
+
+
+
;
..(patientNumber, { : message });
...({
: {
: appointment.,
: patientNumber,
: message,
: ,
:
}
});
}
() {
(!message.?.) ;
text = message...();
= message..;
([, , ].(text)) {
.();
} ([, ].(text)) {
.();
}
(.(text)) {
.(, text);
}
}
(: ): {
crisisKeywords = [
, , ,
, ,
];
crisisKeywords.( text.(kw));
}
}
Automated Reminder Scheduler
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '@sesion/db';
import { WhatsAppService } from '@sesion/whatsapp';
import { subHours } from 'date-fns';
@Injectable()
export class ReminderJob {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsAppService
) {}
@Cron(CronExpression.EVERY_HOUR)
async send24HourReminders() {
const tomorrow = new Date();
tomorrow.setHours(tomorrow.getHours() + 24);
const appointments = await this.prisma.appointment.findMany({
: {
: {
: tomorrow,
: (tomorrow.() + * * )
},
: ,
:
},
: { : , : }
});
( apt appointments) {
..(apt);
...({
: { : apt. },
: { : }
});
}
}
}
3. AFIP Electronic Invoicing
Invoice Generation Service
import { Injectable } from '@nestjs/common';
import Afip from '@afipsdk/afip.js';
import { PrismaService } from '@sesion/db';
@Injectable()
export class AfipService {
private afip: Afip;
constructor(private prisma: PrismaService) {
this.afip = new Afip({
CUIT: process.env.AFIP_CUIT,
cert: process.env.AFIP_CERT_PATH,
key: process.env.AFIP_KEY_PATH,
production: process.env.AFIP_PRODUCTION === 'true'
});
}
async generateInvoice(sessionId: string) {
const session = await this.prisma.session.findUnique({
: { : sessionId },
: { : , : }
});
lastVoucher = ...(
session..,
);
invoiceData = {
: ,
: session..,
: ,
: ,
: ,
: session..,
: lastVoucher + ,
: lastVoucher + ,
: .(.() / ),
: session.,
: ,
: session.,
: ,
: ,
: ,
: ,
:
};
response = ...(invoiceData);
invoice = ...({
: {
: session.,
: session.,
: session.,
: response.,
: (response.),
: ,
: session.,
: ,
:
}
});
invoice;
}
(: ): <> {
invoice = ...({
: { : invoiceId }
});
pdfBuffer = .(invoice);
url = .(pdfBuffer, invoiceId);
url;
}
}
4. Video Consultations (LiveKit)
Video Room Service
import { Injectable } from '@nestjs/common';
import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
@Injectable()
export class LiveKitService {
private roomService: RoomServiceClient;
constructor() {
this.roomService = new RoomServiceClient(
process.env.LIVEKIT_WS_URL!,
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!
);
}
async createRoom(appointmentId: string) {
const roomName = `session-${appointmentId}`;
await this.roomService.createRoom({
name: roomName,
emptyTimeout: 60 * 10,
maxParticipants: 2
});
return roomName;
}
async generateToken(
: ,
: ,
: |
): <> {
token = (
process..!,
process..!,
{
: participantName,
: participantName
}
);
token.({
: ,
: roomName,
: ,
: ,
: role ===
});
token.();
}
() {
..(roomName);
}
}
Video Room Component (Svelte)
<!-- apps/web/src/lib/components/VideoRoom.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Room, VideoPresets } from 'livekit-client';
let { appointmentId } = $props();
let room = $state<Room | null>(null);
let videoElement = $state<HTMLVideoElement>();
let audioElement = $state<HTMLAudioElement>();
let isConnected = $state(false);
async function connect() {
const res = await fetch(`/api/video/token/${appointmentId}`);
const { token, roomName } = await res.json();
room = new Room({
adaptiveStream: true,
dynacast: true,
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution
}
});
room.on('trackSubscribed', (track, publication, participant) => {
if (track.kind === 'video' && videoElement) {
track.attach(videoElement);
} else if (track.kind === 'audio' && audioElement) {
track.attach(audioElement);
}
});
await room.connect(process.env.PUBLIC_LIVEKIT_WS_URL!, token);
await room.localParticipant.enableCameraAndMicrophone();
isConnected = true;
}
async function disconnect() {
await room?.disconnect();
isConnected = false;
}
onMount(connect);
onDestroy(disconnect);
</script>
<div class="video-container">
<video bind:this={videoElement} autoplay playsinline></video>
<audio bind:this={audioElement} autoplay></audio>
<div class="controls">
<button onclick={disconnect}>Finalizar Sesión</button>
</div>
</div>
<style>
video {
width: 100%;
max-height: 80vh;
border-radius: 8px;
}
</style>
5. Claude AI Integration
Clinical Notes Assistant
import Anthropic from '@anthropic-ai/sdk';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ClaudeService {
private anthropic: Anthropic;
constructor() {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
}
async summarizeSession(sessionNotes: string): Promise<string> {
const message = await this.anthropic.messages.create({
model: process.env.CLAUDE_OPUS_MODEL || 'claude-opus-4',
max_tokens: 2000,
messages: [{
role: 'user',
content: `Como psicólogo clínico en Argentina, resume las siguientes notas de sesión en formato estructurado:
Notas originales:
${sessionNotes}
Genera un resumen con:
- Motivo de consulta
- Observaciones principales
- Intervenciones realizadas
- Plan de tratamiento
- Próximos pasos
Usa terminología clínica argentina y respeta la confidencialidad.`
}],
:
});
message.[]. ===
? message.[].
: ;
}
(: , : ): <> {
message = ...({
: process.. || ,
: ,
: [{
: ,
: query
}],
:
});
message.[]. ===
? message.[].
: ;
}
(: []): <{ : , : [] }> {
historyText = .(patientHistory, , );
message = ...({
: process.. || ,
: ,
: [{
: ,
:
}]
});
text = message.[]. === ? message.[]. : ;
.(text);
}
}
AI-Assisted Note Taking (Frontend)
<!-- apps/web/src/routes/session/[id]/notes/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
let { data } = $props();
let notes = $state('');
let summary = $state('');
let loading = $state(false);
async function generateSummary() {
loading = true;
const res = await fetch(`/api/ai/summarize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ notes })
});
const { summary: generated } = await res.json();
summary = generated;
loading = false;
}
async function saveNotes() {
await fetch(`/api/sessions/${data.session.id}/notes`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ notes, summary })
});
}
</script>
<div class="notes-editor">
<textarea
bind:value={notes}
placeholder="Notas de sesión..."
rows="15"
></textarea>
<button onclick={generateSummary} disabled={loading}>
{loading ? 'Generando...' : 'Generar Resumen con IA'}
</button>
{#if summary}
<div class="summary-panel">
<h3>Resumen Clínico</h3>
<div>{summary}</div>
</div>
{/if}
<button onclick={saveNotes}>Guardar Notas</button>
</div>
Prisma Schema Highlights
// packages/db/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Practitioner {
id String @id @default(cuid())
email String @unique
name String
license String @unique
puntoVenta Int
fiscalCategory String // Monotributo, Responsable Inscripto
cuit String @unique
appointments Appointment[]
patients Patient[]
invoices Invoice[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Patient {
id String @id @default(cuid())
name String
dni String @unique
phone String
email String?
practitionerId String
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
appointments Appointment[]
sessions Session[]
invoices Invoice[]
consentGiven Boolean @default(false)
consentDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Appointment {
id String @id @default(cuid())
scheduledAt DateTime
durationMinutes Int @default(45)
type String // PRESENCIAL, VIRTUAL, EVALUACION
status String // SCHEDULED, CONFIRMED, CANCELLED, COMPLETED
patientId String
patient Patient @relation(fields: [patientId], references: [id])
practitionerId String
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
reminderSent24h Boolean @default(false)
reminderSent2h Boolean @default(false)
session Session?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([practitionerId, scheduledAt])
}
model Session {
id String @id @default(cuid())
appointmentId String @unique
appointment Appointment @relation(fields: [appointmentId], references: [id])
notes String?
aiSummary String?
recordingUrl String?
amount Float
paid Boolean @default(false)
patientId String
patient Patient @relation(fields: [patientId], references: [id])
invoice Invoice?
startedAt DateTime?
endedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Invoice {
id String @id @default(cuid())
invoiceNumber String @unique
type String // FACTURA_A, FACTURA_B, FACTURA_C
amount Float
status String // ISSUED, PAID, CANCELLED
afipCae String
afipCaeExpiration DateTime
sessionId String @unique
session Session @relation(fields: [sessionId], references: [id])
patientId String
patient Patient @relation(fields: [patientId], references: [id])
practitionerId String
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
pdfUrl String?
sentViaWhatsApp Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model WhatsAppMessage {
id String @id @default(cuid())
to String
content String
type String // REMINDER, CONFIRMATION, INVOICE, CRISIS_ALERT
status String // SENT, DELIVERED, READ, FAILED
appointmentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([to, createdAt])
}
Common Workflows
Patient Onboarding Flow
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@sesion/db';
import { WhatsAppService } from '@sesion/whatsapp';
@Injectable()
export class OnboardingService {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsAppService
) {}
async onboardNewPatient(data: {
name: string;
dni: string;
phone: string;
practitionerId: string;
}) {
const patient = await this.prisma.patient.create({
data: {
...data,