| name | sesion-clinic-workflow-platform |
| description | Mental health practice management platform with intelligent scheduling, WhatsApp automation, AFIP billing, video consultations, and Claude AI integration for Argentine psychologists |
| triggers | ["set up sesion clinic workflow platform","configure sesion appointment scheduling and whatsapp automation","integrate claude ai with sesion mental health platform","implement afip compliant invoicing with sesion","configure livekit video consultations in sesion","work with sesion psychology practice management","set up mercadopago billing in sesion","build sesion whatsapp patient communication workflows"] |
Sesión Clinic Workflow 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 patient communication, AFIP-compliant electronic invoicing, secure video consultations, and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6 models.
Architecture Overview
Sesión is built as a microservices ecosystem with:
- Frontend: SvelteKit 5 with TypeScript and TailwindCSS
- Backend: NestJS microservices
- Database: PostgreSQL with Prisma ORM
- Cache/Sessions: Redis
- Search: Elasticsearch
- Storage: MinIO (S3-compatible)
- Messaging: Apache Kafka for event-driven workflows
- Video: LiveKit WebRTC infrastructure
- WhatsApp: Baileys library for WhatsApp Web API
- AI: Anthropic Claude (Opus 4.6 for deep analysis, Sonnet 4.6 for real-time assistance)
- Payments: Stripe (international), Mercado Pago (Argentina)
Installation and Setup
Prerequisites
node >= 20.x
pnpm >= 8.x
docker >= 24.x
docker-compose >= 2.x
postgresql >= 15.x
redis >= 7.x
Clone and Install Dependencies
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
pnpm install
cp .env.example .env
Environment Configuration
Create a .env file with the following variables:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/sesion_db"
REDIS_URL="redis://localhost:6379"
ELASTICSEARCH_URL="http://localhost:9200"
# MinIO Storage
MINIO_ENDPOINT="localhost"
MINIO_PORT=9000
MINIO_ACCESS_KEY="${MINIO_ACCESS_KEY}"
MINIO_SECRET_KEY="${MINIO_SECRET_KEY}"
MINIO_BUCKET="sesion-documents"
# Authentication
JWT_SECRET="${JWT_SECRET}"
JWT_EXPIRES_IN="15m"
REFRESH_TOKEN_SECRET="${REFRESH_TOKEN_SECRET}"
REFRESH_TOKEN_EXPIRES_IN="7d"
# Anthropic Claude AI
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
CLAUDE_MAX_TOKENS=4096
# LiveKit Video
LIVEKIT_API_KEY="${LIVEKIT_API_KEY}"
LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET}"
LIVEKIT_WS_URL="wss://your-livekit-instance.com"
# WhatsApp (Baileys)
WHATSAPP_SESSION_PATH="./whatsapp-sessions"
WHATSAPP_WEBHOOK_SECRET="${WHATSAPP_WEBHOOK_SECRET}"
# Payment Gateways
STRIPE_SECRET_KEY="${STRIPE_SECRET_KEY}"
STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET}"
MERCADOPAGO_ACCESS_TOKEN="${MERCADOPAGO_ACCESS_TOKEN}"
MERCADOPAGO_PUBLIC_KEY="${MERCADOPAGO_PUBLIC_KEY}"
# AFIP (Argentina Tax Authority)
AFIP_CUIT="${AFIP_CUIT}"
AFIP_CERT_PATH="./certs/afip.crt"
AFIP_KEY_PATH="./certs/afip.key"
AFIP_PRODUCTION=false
# Kafka
KAFKA_BROKERS="localhost:9092"
KAFKA_CLIENT_ID="sesion-clinic"
KAFKA_CONSUMER_GROUP="sesion-workers"
# Application
NODE_ENV="development"
PORT=3000
API_PORT=4000
FRONTEND_URL="http://localhost:3000"
BACKEND_URL="http://localhost:4000"
Database Setup
pnpm prisma generate
pnpm prisma migrate deploy
pnpm prisma db seed
Start Services
docker-compose up -d postgres redis elasticsearch kafka minio
cd backend
pnpm run start:dev
cd frontend
pnpm run dev
Key Components and Usage
1. Agenda Management (Appointment Scheduling)
Prisma Schema for Appointments
model Appointment {
id String @id @default(cuid())
patientId String
practitionerId String
sessionType SessionType
startTime DateTime
endTime DateTime
status AppointmentStatus @default(SCHEDULED)
roomId String?
notes String?
reminderSent24h Boolean @default(false)
reminderSent2h Boolean @default(false)
confirmationToken String? @unique
patient Patient @relation(fields: [patientId], references: [id])
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
room Room? @relation(fields: [roomId], references: [id])
invoice Invoice?
videoSession VideoSession?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([practitionerId, startTime])
@@index([patientId, startTime])
}
{
}
{
}
Appointment Service (NestJS)
import { Injectable, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Appointment, SessionType } from '@prisma/client';
import { addMinutes, isBefore, isAfter } from 'date-fns';
@Injectable()
export class AppointmentsService {
constructor(
private prisma: PrismaService,
private eventEmitter: EventEmitter2,
) {}
async createAppointment(data: {
patientId: string;
practitionerId: string;
sessionType: SessionType;
startTime: Date;
roomId?: string;
}): Promise<Appointment> {
duration = .(data.);
endTime = (data., duration);
.(
data.,
data.,
endTime,
data.,
);
appointment = ...({
: {
...data,
endTime,
: .(),
},
: {
: ,
: ,
},
});
..(, appointment);
appointment;
}
(: ): {
durations = {
: ,
: ,
: ,
: ,
: ,
};
durations[sessionType] || ;
}
(
: ,
: ,
: ,
?: ,
): <> {
practitionerConflicts = ...({
: {
practitionerId,
: { : [, ] },
: [
{
: { : startTime },
: { : startTime },
},
{
: { : endTime },
: { : endTime },
},
],
},
});
(practitionerConflicts. > ) {
(
,
);
}
(roomId) {
roomConflicts = ...({
: {
roomId,
: { : [, ] },
: [
{
: { : startTime },
: { : startTime },
},
{
: { : endTime },
: { : endTime },
},
],
},
});
(roomConflicts. > ) {
();
}
}
}
(): {
.().().(, );
}
(
: ,
: ,
: ,
): <[]> {
duration = .(sessionType);
: [] = [];
workingHours = ...({
: { practitionerId },
});
suggestions;
}
}
2. WhatsApp Automation with Baileys
WhatsApp Service
import { Injectable, Logger } from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WAMessage,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import { OnEvent } from '@nestjs/event-emitter';
import { Appointment } from '@prisma/client';
import { format, subHours } from 'date-fns';
import { es } from 'date-fns/locale';
@Injectable()
export class WhatsAppService {
private sock: any;
private readonly logger = new Logger(WhatsAppService.name);
async initialize() {
const { state, saveCreds } = await useMultiFileAuthState(
process.env.WHATSAPP_SESSION_PATH,
);
this. = ({
: state,
: ,
});
...(, saveCreds);
...(, {
{ connection, lastDisconnect } = update;
(connection === ) {
shouldReconnect =
(lastDisconnect?. )?.?. !==
.;
(shouldReconnect) {
.();
}
} (connection === ) {
..();
}
});
...(, ({ messages }: ) => {
.(messages[]);
});
}
() {
messageText = message.?. || ;
= message..;
crisisKeywords = [
,
,
,
,
,
];
isCrisis = crisisKeywords.(
messageText.().(keyword),
);
(isCrisis) {
..();
}
(messageText.().()) {
.(, message);
}
}
()
() {
reminderTime24h = (appointment., );
}
() {
formattedDate = (appointment., , {
: es,
});
formattedTime = (appointment., );
message = ;
.(appointment.., message);
}
() {
message = ;
.(invoiceData.., message);
(invoiceData.) {
..(invoiceData.., {
: invoiceData.,
: ,
: ,
});
}
}
() {
{
..(to, { text });
..();
} (error) {
..(, error);
}
}
() {
}
}
3. AFIP Electronic Invoicing
AFIP Service
import { Injectable } from '@nestjs/common';
import { Afip } from '@afipsdk/afip.js';
import * as fs from 'fs';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AfipService {
private afip: any;
constructor(private prisma: PrismaService) {
this.afip = new Afip({
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_PRODUCTION === 'true',
});
}
async generateInvoice(appointmentId: string) {
appointment = ...({
: { : appointmentId },
: {
: ,
: ,
},
});
invoiceType = .(
appointment..,
);
lastInvoice = ...(
,
invoiceType,
);
invoiceNumber = lastInvoice + ;
amount = appointment..;
taxableAmount = amount / ;
vatAmount = amount - taxableAmount;
data = {
: ,
: ,
: invoiceType,
: ,
: ,
: appointment..,
: invoiceNumber,
: invoiceNumber,
: .( ()),
: amount,
: ,
: taxableAmount,
: ,
: vatAmount,
: ,
: ,
: ,
: [
{
: ,
: taxableAmount,
: vatAmount,
},
],
};
result = ...(data);
invoice = ...({
: {
appointmentId,
: ,
invoiceType,
: result.,
: .(result.),
amount,
taxableAmount,
vatAmount,
: ,
},
});
invoice;
}
(: ): {
types = {
: ,
: ,
: ,
};
types[fiscalCategory] || ;
}
(: ): {
date.().()[].(, );
}
(: ): {
(
,
);
}
() {
invoices = ...({
: {
: {
practitionerId,
},
: {
: (year, month - , ),
: (year, month, ),
},
},
});
totalAmount = invoices.( sum + inv., );
totalVat = invoices.( sum + inv., );
{
: ,
: invoices.,
totalAmount,
totalVat,
: totalAmount - totalVat,
invoices,
};
}
}
4. LiveKit Video Consultations
Video Session Service
import { Injectable } from '@nestjs/common';
import { AccessToken } from 'livekit-server-sdk';
import { PrismaService } from '../prisma/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 },
});
const roomName = `session-${appointmentId}`;
const videoSession = await this.prisma.videoSession.create({
data: {
appointmentId,
roomName,
: ,
},
});
videoSession;
}
(
: ,
: ,
: | ,
): <> {
videoSession = ...({
: { appointmentId },
});
(!videoSession) {
();
}
at = (
process..,
process..,
{
: userId,
: ,
},
);
at.({
: ,
: videoSession.,
: ,
: ,
: ,
...(userType === && {
: ,
: ,
}),
});
at.();
}
() {
...({
: { appointmentId },
: { : },
});
}
() {
videoSession = ...({
: { appointmentId },
: { : , : () },
});
...({
: { : appointmentId },
: { : },
});
videoSession;
}
}
Frontend Video Component (Svelte)
<!-- frontend/src/lib/components/VideoConsultation.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Room, RoomEvent, Track } from 'livekit-client';
export let token: string;
export let serverUrl: string;
let videoElement: HTMLVideoElement;
let remoteVideoElement: HTMLVideoElement;
let room: Room;
let isConnected = false;
let isMuted = false;
let isVideoOff = false;
onMount(async () => {
room = new Room({
adaptiveStream: true,
dynacast: true,
videoCaptureDefaults: {
resolution: { width: 1280, height: 720, frameRate: 24 }
}
});
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed);
room.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed);
room.on(RoomEvent.Disconnected, handleDisconnect);
try {
await room.connect(serverUrl, token);
isConnected = true;
// Publish local tracks
await room.localParticipant.enableCameraAndMicrophone();
// Attach local video
const videoTrack = room.localParticipant.getTrackPublication(Track.Source.Camera);
if (videoTrack?.track) {
videoTrack.track.attach(videoElement);
}
} catch (error) {
console.error('Failed to connect to room:', error);
}
});
function handleTrackSubscribed(track: any, publication: any, participant: any) {
if (track.kind === Track.Kind.Video) {
track.attach(remoteVideoElement);
}
}
function handleTrackUnsubscribed(track: any) {
track.detach();
}
function handleDisconnect() {
isConnected = false;
}
async function toggleMute() {
if (room.localParticipant) {
isMuted = !isMuted;
await room.localParticipant.setMicrophoneEnabled(!isMuted);
}
}
async function toggleVideo() {
if (room.localParticipant) {
isVideoOff = !isVideoOff;
await room.localParticipant.setCameraEnabled(!isVideoOff);
}
}
async function endCall() {
await room.disconnect();
// Notify backend
await fetch(`/api/video/end-session`, { method: 'POST' });
}
onDestroy(() => {
if (room) {
room.disconnect();
}
});
</script>
<div class="video-consultation">
<div class="video-grid">
<div class="remote-video">
<video bind:this={remoteVideoElement} autoplay playsinline />
{#if !isConnected}
<div class="waiting-room">
<p>Esperando al terapeuta...</p>
</div>
{/if}
</div>
<div class="local-video">
<video bind:this={videoElement} autoplay playsinline muted />
</div>
</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>
</div>
<style>
.video-consultation {
position: relative;
width: 100%;
height: 100vh;
background: #1a1a1a;
}
.video-grid {
width: 100%;
height: calc(100% - 80px);
position: relative;
}
.remote-video {
width: 100%;
height: 100%;
}
.remote-video video {
width: 100%;
height: 100%;
object-fit: cover;
}
.local-video {
position: absolute;
bottom: 20px;
right: 20px;
width: 240px;
height: 180px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.local-video video {
width: 100%;
height: 100%;
object-fit: cover;
}
.waiting-room {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.8);
color: white;
}
.controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
background: rgba(0,0,0,0.6);
}
.controls button {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
font-size: 24px;
cursor: pointer;
background: #333;
color: white;
transition: all 0.2s;
}
.controls button:hover {
background: #444;
}
.controls button.active {
background: #4CAF50;
}
.controls button.end-call {
background: #f44336;
width: auto;
padding: 0 24px;
border-radius: 28px;
}
</style>
5. Claude AI Integration
AI Orchestration Service
import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ClaudeService {
private anthropic: Anthropic;
constructor(private prisma: PrismaService) {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
}
async summarizeClinicalNotes(
appointmentIds: string[],
): Promise<string> {